Support template variables in command menu item labels (#18707)

- Adds template variable interpolation (`${...}`) to command menu item
labels and short labels, enabling dynamic text like `Create new
${capitalize(objectMetadataItem.labelSingular)}` instead of static
`Create new record`.
- Supports `capitalize` and `lowercase` transform functions within
template expressions.
This commit is contained in:
Raphaël Bosi
2026-03-18 11:26:58 +01:00
committed by GitHub
parent 928194cee4
commit dedcf4e9b9
25 changed files with 673 additions and 236 deletions
@@ -0,0 +1,316 @@
import {
CommandMenuContextApiPageType,
type CommandMenuContextApi,
} from '@/types';
import { interpolateCommandMenuItemLabel } from '../interpolateCommandMenuItemLabel';
const buildContext = (
overrides: Partial<CommandMenuContextApi> = {},
): CommandMenuContextApi => ({
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
isInSidePanel: false,
isPageInEditMode: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
objectMetadataId: '',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
...overrides,
});
describe('interpolateCommandMenuItemLabel', () => {
describe('sequential invocations (global regex lastIndex)', () => {
it('should interpolate correctly when called multiple times in sequence', () => {
const context = buildContext({
numberOfSelectedRecords: 2,
objectMetadataItem: { labelPlural: 'people' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'First: ${numberOfSelectedRecords}',
context,
}),
).toBe('First: 2');
expect(
interpolateCommandMenuItemLabel({
label: 'Second: ${objectMetadataItem.labelPlural}',
context,
}),
).toBe('Second: people');
});
});
describe('plain strings without template variables', () => {
it('should return the label unchanged when no template variables are present', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({ label: 'Delete', context }),
).toBe('Delete');
});
it('should return null when label is null', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({ label: null, context }),
).toBeNull();
});
it('should return null when label is undefined', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({ label: undefined, context }),
).toBeNull();
});
it('should return an empty string for an empty label', () => {
const context = buildContext();
expect(interpolateCommandMenuItemLabel({ label: '', context })).toBe('');
});
});
describe('simple template variable interpolation', () => {
it('should interpolate a top-level context property', () => {
const context = buildContext({ numberOfSelectedRecords: 5 });
expect(
interpolateCommandMenuItemLabel({
label: 'Selected: ${numberOfSelectedRecords}',
context,
}),
).toBe('Selected: 5');
});
it('should interpolate objectMetadataItem.labelSingular', () => {
const context = buildContext({
objectMetadataItem: { labelSingular: 'person' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Create new ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new person');
});
it('should interpolate objectMetadataItem.labelPlural', () => {
const context = buildContext({
objectMetadataItem: { labelPlural: 'companies' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Delete ${objectMetadataItem.labelPlural}',
context,
}),
).toBe('Delete companies');
});
});
describe('multiple template variables', () => {
it('should interpolate multiple variables in one label', () => {
const context = buildContext({
numberOfSelectedRecords: 3,
objectMetadataItem: { labelPlural: 'people' },
});
expect(
interpolateCommandMenuItemLabel({
label:
'${numberOfSelectedRecords} ${objectMetadataItem.labelPlural} selected',
context,
}),
).toBe('3 people selected');
});
});
describe('nested property access', () => {
it('should resolve deeply nested properties', () => {
const context = buildContext({
objectMetadataItem: {
labelSingular: 'opportunity',
nameSingular: 'opportunity',
},
});
expect(
interpolateCommandMenuItemLabel({
label: 'New ${objectMetadataItem.nameSingular}',
context,
}),
).toBe('New opportunity');
});
});
describe('missing context values', () => {
it('should return empty string for undefined nested property', () => {
const context = buildContext({
objectMetadataItem: {},
});
expect(
interpolateCommandMenuItemLabel({
label: 'New ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('New ');
});
it('should return empty string for empty objectMetadataItem', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({
label: 'Create new ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new ');
});
});
describe('unresolvable property paths', () => {
it('should return empty string for a path that cannot be resolved', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({
label: 'Label ${nonExistent.deep.path}',
context,
}),
).toBe('Label ');
});
it('should not resolve inherited Object.prototype members', () => {
const context = buildContext();
expect(
interpolateCommandMenuItemLabel({
label: 'Val: ${toString}',
context,
}),
).toBe('Val: ');
expect(
interpolateCommandMenuItemLabel({
label: 'Val: ${objectMetadataItem.hasOwnProperty}',
context,
}),
).toBe('Val: ');
});
});
describe('boolean context values', () => {
it('should stringify boolean values', () => {
const context = buildContext({ isInSidePanel: true });
expect(
interpolateCommandMenuItemLabel({
label: 'Side panel: ${isInSidePanel}',
context,
}),
).toBe('Side panel: true');
});
it('should pass through string values as-is without forced lowercasing', () => {
const context = buildContext({
objectMetadataItem: { labelSingular: 'Person' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Create new ${objectMetadataItem.labelSingular}',
context,
}),
).toBe('Create new Person');
});
});
describe('capitalize transform', () => {
it('should capitalize the first character of a resolved value', () => {
const context = buildContext({
objectMetadataItem: { labelSingular: 'person' },
});
expect(
interpolateCommandMenuItemLabel({
label: '${capitalize(objectMetadataItem.labelSingular)} details',
context,
}),
).toBe('Person details');
});
it('should capitalize a mixed-case value', () => {
const context = buildContext({
objectMetadataItem: { labelPlural: 'companyRecords' },
});
expect(
interpolateCommandMenuItemLabel({
label: '${capitalize(objectMetadataItem.labelPlural)} selected',
context,
}),
).toBe('CompanyRecords selected');
});
it('should return empty string when the nested property is undefined', () => {
const context = buildContext({
objectMetadataItem: {},
});
expect(
interpolateCommandMenuItemLabel({
label: '${capitalize(objectMetadataItem.labelSingular)} details',
context,
}),
).toBe(' details');
});
});
describe('lowercase transform', () => {
it('should lowercase the resolved value', () => {
const context = buildContext({
objectMetadataItem: { labelSingular: 'Person' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Create ${lowercase(objectMetadataItem.labelSingular)}',
context,
}),
).toBe('Create person');
});
it('should lowercase all characters', () => {
const context = buildContext({
objectMetadataItem: { labelPlural: 'People' },
});
expect(
interpolateCommandMenuItemLabel({
label: 'Delete ${lowercase(objectMetadataItem.labelPlural)}',
context,
}),
).toBe('Delete people');
});
});
});
@@ -0,0 +1,120 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { Parser } from 'expr-eval-fork';
import { isDefined } from '../validation/isDefined';
import { safeGetNestedProperty } from './safeGetNestedProperty';
type ArrayMethod = 'every' | 'some';
const createArrayPropCheck = (
method: ArrayMethod,
predicate: (value: unknown) => boolean,
) => {
return (array: unknown, prop: string) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop)),
);
};
};
const createArrayPropValueCheck = (
method: ArrayMethod,
predicate: (value: unknown, target: unknown) => boolean,
) => {
return (array: unknown, prop: string, value: unknown) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop), value),
);
};
};
export const conditionalAvailabilityParser = new Parser();
conditionalAvailabilityParser.functions.isDefined = (value: unknown) =>
isDefined(value);
conditionalAvailabilityParser.functions.isNonEmptyString = (value: unknown) =>
isNonEmptyString(value);
conditionalAvailabilityParser.functions.includes = (
array: unknown,
value: unknown,
) => Array.isArray(array) && array.includes(value);
conditionalAvailabilityParser.functions.arrayLength = (value: unknown) =>
Array.isArray(value) ? value.length : 0;
conditionalAvailabilityParser.functions.every = createArrayPropCheck(
'every',
Boolean,
);
conditionalAvailabilityParser.functions.everyDefined = createArrayPropCheck(
'every',
isDefined,
);
conditionalAvailabilityParser.functions.some = createArrayPropCheck(
'some',
Boolean,
);
conditionalAvailabilityParser.functions.someDefined = createArrayPropCheck(
'some',
isDefined,
);
conditionalAvailabilityParser.functions.someNonEmptyString =
createArrayPropCheck('some', isNonEmptyString);
conditionalAvailabilityParser.functions.none = createArrayPropCheck(
'every',
(value) => !Boolean(value),
);
conditionalAvailabilityParser.functions.noneDefined = createArrayPropCheck(
'every',
(value) => !isDefined(value),
);
conditionalAvailabilityParser.functions.everyEquals = createArrayPropValueCheck(
'every',
(a, b) => a === b,
);
conditionalAvailabilityParser.functions.someEquals = createArrayPropValueCheck(
'some',
(a, b) => a === b,
);
conditionalAvailabilityParser.functions.noneEquals = createArrayPropValueCheck(
'every',
(a, b) => a !== b,
);
conditionalAvailabilityParser.functions.includesEvery =
createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && array.includes(value),
);
conditionalAvailabilityParser.functions.includesSome =
createArrayPropValueCheck(
'some',
(array, value) => Array.isArray(array) && array.includes(value),
);
conditionalAvailabilityParser.functions.includesNone =
createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && !array.includes(value),
);
@@ -0,0 +1,21 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type EvaluationContext } from 'expr-eval-fork';
import { conditionalAvailabilityParser } from './conditionalAvailabilityParser';
export const evaluateConditionalAvailabilityExpression = (
expression: string | null | undefined,
context: EvaluationContext,
): boolean => {
if (!isNonEmptyString(expression)) {
return true;
}
try {
const parsed = conditionalAvailabilityParser.parse(expression);
return parsed.evaluate(context) === true;
} catch {
return false;
}
};
@@ -0,0 +1,75 @@
import { type Nullable } from '@/types';
import { capitalize } from '../strings/capitalize';
import { isDefined } from '../validation/isDefined';
import { safeGetNestedProperty } from './safeGetNestedProperty';
const TEMPLATE_VARIABLE_REGEX = /\$\{([^{}]+)\}/g;
const HAS_TEMPLATE_VARIABLE_REGEX = /\$\{[^{}]+\}/;
const TRANSFORM_FUNCTION_CALL_REGEX = /^(\w+)\((.+)\)$/;
const LABEL_TRANSFORM_FUNCTIONS: Record<string, (value: string) => string> = {
capitalize,
lowercase: (value: string) => value.toLowerCase(),
};
const resolveTemplateExpression = ({
expression,
context,
}: {
expression: string;
context: Record<string, unknown>;
}): string => {
const trimmedExpression = expression.trim();
const transformFunctionMatch = trimmedExpression.match(
TRANSFORM_FUNCTION_CALL_REGEX,
);
const expressionToEvaluate = transformFunctionMatch
? transformFunctionMatch[2].trim()
: trimmedExpression;
const transformFunction = transformFunctionMatch
? LABEL_TRANSFORM_FUNCTIONS[transformFunctionMatch[1]]
: undefined;
const resolvedPropertyValue = safeGetNestedProperty(
context,
expressionToEvaluate,
);
if (!isDefined(resolvedPropertyValue)) {
return '';
}
const stringValue = String(resolvedPropertyValue);
return isDefined(transformFunction)
? transformFunction(stringValue)
: stringValue;
};
export const interpolateCommandMenuItemLabel = ({
label,
context,
}: {
label: Nullable<string>;
context: Record<string, unknown>;
}): Nullable<string> => {
if (!isDefined(label)) {
return null;
}
if (!HAS_TEMPLATE_VARIABLE_REGEX.test(label)) {
return label;
}
return label.replace(TEMPLATE_VARIABLE_REGEX, (match, expression: string) => {
try {
return resolveTemplateExpression({ expression, context });
} catch {
return match;
}
});
};
@@ -0,0 +1,39 @@
import { isObject, isString } from '@sniptt/guards';
import { isDefined } from '../validation/isDefined';
const BLOCKED_PROPERTY_NAMES = new Set([
'__proto__',
'constructor',
'prototype',
]);
export const safeGetNestedProperty = (
objectToEvaluate: unknown,
path: string,
): unknown => {
if (!isString(path)) {
return undefined;
}
const parts = path.split('.');
let currentObject: unknown = objectToEvaluate;
for (const part of parts) {
if (!isDefined(currentObject) || !isObject(currentObject)) {
return undefined;
}
if (
BLOCKED_PROPERTY_NAMES.has(part) ||
!Object.prototype.hasOwnProperty.call(currentObject, part)
) {
return undefined;
}
currentObject = (currentObject as Record<string, unknown>)[part];
}
return currentObject;
};
@@ -1,143 +0,0 @@
import {
isNonEmptyArray,
isNonEmptyString,
isObject,
isString,
} from '@sniptt/guards';
import { type EvaluationContext, Parser } from 'expr-eval-fork';
import { isDefined } from '../validation/isDefined';
const BLOCKED_PROPERTY_NAMES = new Set([
'__proto__',
'constructor',
'prototype',
]);
const safeGetNestedProperty = (
objectToEvaluate: unknown,
path: string,
): unknown => {
if (!isString(path)) {
return undefined;
}
const parts = path.split('.');
let currentObject: unknown = objectToEvaluate;
for (const part of parts) {
if (!isDefined(currentObject) || !isObject(currentObject)) {
return undefined;
}
if (BLOCKED_PROPERTY_NAMES.has(part)) {
return undefined;
}
currentObject = (currentObject as Record<string, unknown>)[part];
}
return currentObject;
};
type ArrayMethod = 'every' | 'some';
const createArrayPropCheck = (
method: ArrayMethod,
predicate: (value: unknown) => boolean,
) => {
return (array: unknown, prop: string) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop)),
);
};
};
const createArrayPropValueCheck = (
method: ArrayMethod,
predicate: (value: unknown, target: unknown) => boolean,
) => {
return (array: unknown, prop: string, value: unknown) => {
if (!isNonEmptyArray(array)) {
return false;
}
return array[method]((item) =>
predicate(safeGetNestedProperty(item, prop), value),
);
};
};
const parser = new Parser();
parser.functions.isDefined = (value: unknown) => isDefined(value);
parser.functions.isNonEmptyString = (value: unknown) => isNonEmptyString(value);
parser.functions.includes = (array: unknown, value: unknown) =>
Array.isArray(array) && array.includes(value);
parser.functions.arrayLength = (value: unknown) =>
Array.isArray(value) ? value.length : 0;
parser.functions.every = createArrayPropCheck('every', Boolean);
parser.functions.everyDefined = createArrayPropCheck('every', isDefined);
parser.functions.some = createArrayPropCheck('some', Boolean);
parser.functions.someDefined = createArrayPropCheck('some', isDefined);
parser.functions.someNonEmptyString = createArrayPropCheck(
'some',
isNonEmptyString,
);
parser.functions.none = createArrayPropCheck(
'every',
(value) => !Boolean(value),
);
parser.functions.noneDefined = createArrayPropCheck(
'every',
(value) => !isDefined(value),
);
parser.functions.everyEquals = createArrayPropValueCheck(
'every',
(a, b) => a === b,
);
parser.functions.someEquals = createArrayPropValueCheck(
'some',
(a, b) => a === b,
);
parser.functions.noneEquals = createArrayPropValueCheck(
'every',
(a, b) => a !== b,
);
parser.functions.includesEvery = createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && array.includes(value),
);
parser.functions.includesSome = createArrayPropValueCheck(
'some',
(array, value) => Array.isArray(array) && array.includes(value),
);
parser.functions.includesNone = createArrayPropValueCheck(
'every',
(array, value) => Array.isArray(array) && !array.includes(value),
);
export const evaluateConditionalAvailabilityExpression = (
expression: string | null | undefined,
context: EvaluationContext,
): boolean => {
if (!isNonEmptyString(expression)) {
return true;
}
try {
const parsed = parser.parse(expression);
return parsed.evaluate(context) === true;
} catch {
return false;
}
};
+4 -1
View File
@@ -23,8 +23,11 @@ export { upsertIntoArrayOfObjectsComparingId } from './array/upsertIntoArrayOfOb
export { upsertPropertiesOfItemIntoArrayOfObjectsComparingId } from './array/upsertPropertiesOfItemIntoArrayOfObjectsComparingId';
export { assertUnreachable } from './assertUnreachable';
export { base64UrlEncode } from './base64UrlEncode';
export { conditionalAvailabilityParser } from './command-menu-items/conditionalAvailabilityParser';
export { evaluateConditionalAvailabilityExpression } from './command-menu-items/evaluateConditionalAvailabilityExpression';
export { interpolateCommandMenuItemLabel } from './command-menu-items/interpolateCommandMenuItemLabel';
export { safeGetNestedProperty } from './command-menu-items/safeGetNestedProperty';
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
export { evaluateConditionalAvailabilityExpression } from './conditional-availability/evaluateConditionalAvailabilityExpression';
export { isPlainDateAfter } from './date/isPlainDateAfter';
export { isPlainDateBefore } from './date/isPlainDateBefore';
export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual';