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
@@ -38,7 +38,7 @@ export const CommandListItem = ({
id={action.key}
Icon={action.Icon}
label={getCommandMenuItemLabel(action.label)}
description={getCommandMenuItemLabel(action.description ?? '')}
description={getCommandMenuItemLabel(action.description)}
to={to}
onClick={disabled ? undefined : onClick}
hotKeys={action.hotKeys}
@@ -13,7 +13,7 @@ import { type MenuItemAccent } from 'twenty-ui/navigation';
export type CommandMenuItemDisplayProps = {
key: string;
label: MessageDescriptor | string;
label: Nullable<MessageDescriptor | string>;
shortLabel?: Nullable<MessageDescriptor | string>;
description?: MessageDescriptor | string;
Icon: IconComponent;
@@ -42,9 +42,7 @@ export const Default: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -59,7 +57,7 @@ export const WithLink: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const menuItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.shortLabel ?? ''),
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.shortLabel),
);
expect(menuItem).toBeVisible();
expect(canvas.getByRole('link')).toHaveAttribute('href', '/objects/people');
@@ -60,9 +60,7 @@ export const Default: Story = {
expect(
await canvas.findByText(
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
),
).toBeVisible();
},
@@ -65,9 +65,7 @@ export const AsButton: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(
addToFavoritesCommandMenuItem?.shortLabel ?? '',
),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -103,7 +101,7 @@ export const AsListItem: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -139,7 +137,7 @@ export const AsDropdownItem: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -53,7 +53,7 @@ export const Default: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -68,7 +68,7 @@ export const WithLink: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const dropdownItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label ?? ''),
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label),
);
expect(dropdownItem).toBeVisible();
},
@@ -1,7 +1,7 @@
import { CommandListItem } from '@/command-menu-item/display/components/CommandListItem';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
import { type Meta, type StoryObj } from '@storybook/react-vite';
@@ -53,7 +53,7 @@ export const Default: Story = {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByText(
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
),
);
expect(addToFavoritesMock).toHaveBeenCalled();
@@ -68,7 +68,7 @@ export const WithLink: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const listItem = await canvas.findByText(
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label ?? ''),
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label),
);
expect(listItem).toBeVisible();
},
@@ -1,5 +1,5 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { Command } from '@/command-menu-item/display/components/Command';
import { computeProgressText } from '@/command-menu-item/utils/computeProgressText';
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
@@ -78,9 +78,7 @@ export const DeleteMultipleRecordsCommand = () => {
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
const progressText = computeProgressText(progress);
@@ -85,9 +85,7 @@ export const DestroyMultipleRecordsCommand = () => {
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
const progressText = computeProgressText(progress);
@@ -49,9 +49,7 @@ export const ExportMultipleRecordsCommand = () => {
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
const originalShortLabel = getCommandMenuItemLabel(
actionConfig.shortLabel ?? '',
);
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
const progressText = computeProgressText(exportProgress);
@@ -16,6 +16,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { type CommandMenuContextApi } from 'twenty-shared/types';
import {
evaluateConditionalAvailabilityExpression,
interpolateCommandMenuItemLabel,
isDefined,
} from 'twenty-shared/utils';
import { type IconComponent, useIcons } from 'twenty-ui/display';
@@ -73,7 +74,15 @@ const buildCommandMenuItemFromFrontComponent = ({
mountContext,
commandMenuContextApi,
}: BuildCommandMenuItemFromFrontComponentParams) => {
const displayLabel = item.label;
const displayLabel = interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
});
const displayShortLabel = interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
});
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
@@ -85,7 +94,7 @@ const buildCommandMenuItemFromFrontComponent = ({
} else {
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel,
pageTitle: displayLabel ?? '',
pageIcon: Icon,
recordContext: isDefined(mountContext)
? {
@@ -102,7 +111,7 @@ const buildCommandMenuItemFromFrontComponent = ({
key: `command-menu-item-front-component-${item.id}`,
scope,
label: displayLabel,
shortLabel: item.shortLabel,
shortLabel: displayShortLabel,
position: item.position,
isPinned,
Icon,
@@ -160,8 +169,14 @@ const buildCommandItemFromEngineKey = ({
type,
key: `command-menu-item-engine-${item.id}`,
scope,
label: item.label,
shortLabel: item.shortLabel,
label: interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
}),
shortLabel: interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
}),
position: item.position,
isPinned,
Icon,
@@ -14,7 +14,7 @@ export type CommandMenuItemConfig = {
type: CommandMenuItemType;
scope: CommandMenuItemScope;
key: string;
label: MessageDescriptor | string;
label: Nullable<MessageDescriptor | string>;
shortLabel?: Nullable<MessageDescriptor | string>;
description?: MessageDescriptor | string;
position: number;
@@ -1,8 +1,14 @@
import { i18n, type MessageDescriptor } from '@lingui/core';
import { isString } from '@sniptt/guards';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const getCommandMenuItemLabel = (
label: string | MessageDescriptor,
label: Nullable<string | MessageDescriptor>,
): string => {
if (!isDefined(label)) {
return '';
}
return isString(label) ? label : i18n._(label);
};
@@ -1,6 +1,6 @@
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
import { styled } from '@linaria/react';
import { i18n, type MessageDescriptor } from '@lingui/core';
import { isString } from '@sniptt/guards';
import { type MessageDescriptor } from '@lingui/core';
import { type MouseEvent } from 'react';
import { type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -20,7 +20,7 @@ const StyledWrapper = styled.div`
export type CommandMenuButtonProps = {
command: {
key: string;
label: string | MessageDescriptor;
label: Nullable<string | MessageDescriptor>;
shortLabel?: Nullable<string | MessageDescriptor>;
Icon: IconComponent;
isPrimaryCTA?: boolean;
@@ -30,22 +30,16 @@ export type CommandMenuButtonProps = {
disabled?: boolean;
};
const getCommandMenuButtonLabel = (
label: string | MessageDescriptor,
): string => {
return isString(label) ? label : i18n._(label);
};
export const CommandMenuButton = ({
command,
onClick,
to,
disabled = false,
}: CommandMenuButtonProps) => {
const resolvedLabel = getCommandMenuButtonLabel(command.label);
const resolvedLabel = getCommandMenuItemLabel(command.label);
const resolvedShortLabel = isDefined(command.shortLabel)
? getCommandMenuButtonLabel(command.shortLabel)
? getCommandMenuItemLabel(command.shortLabel)
: undefined;
const buttonAccent = command.isPrimaryCTA ? 'blue' : 'default';
@@ -25,7 +25,7 @@ export const fromCommandMenuItemManifestToUniversalFlatCommandMenuItem = ({
universalIdentifier: commandMenuItemManifest.universalIdentifier,
applicationUniversalIdentifier,
label: commandMenuItemManifest.label,
shortLabel: null,
shortLabel: commandMenuItemManifest.shortLabel ?? null,
position: 0,
icon: commandMenuItemManifest.icon ?? null,
isPinned: commandMenuItemManifest.isPinned ?? false,
@@ -6,7 +6,7 @@ import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-ite
export const STANDARD_COMMAND_MENU_ITEMS = {
navigateToNextRecord: {
universalIdentifier: '3db2457d-8e96-4b8e-94c9-ed95d3f95738',
label: 'Navigate to next record',
label: 'Navigate to next ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: null,
icon: 'IconChevronDown',
position: 0,
@@ -21,7 +21,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
navigateToPreviousRecord: {
universalIdentifier: 'ec10f871-415b-420b-8150-7e09f6f04833',
label: 'Navigate to previous record',
label:
'Navigate to previous ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: null,
icon: 'IconChevronUp',
position: 1,
@@ -36,8 +37,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
createNewRecord: {
universalIdentifier: '08d255bf-58cd-47a5-bd82-78c5c58592f1',
label: 'Create new record',
shortLabel: 'New record',
label: 'Create new ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: 'New ${capitalize(objectMetadataItem.labelSingular)}',
icon: 'IconPlus',
position: 2,
isPinned: true,
@@ -66,7 +67,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
deleteMultipleRecords: {
universalIdentifier: 'cde86f1f-2c13-42b1-812b-f2b2b468cb83',
label: 'Delete records',
label: 'Delete ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Delete',
icon: 'IconTrash',
position: 4,
@@ -81,7 +82,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
restoreSingleRecord: {
universalIdentifier: '8b3a1cae-3e4d-43c1-a71f-48592b2e47ff',
label: 'Restore record',
label: 'Restore ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: 'Restore',
icon: 'IconRefresh',
position: 5,
@@ -96,7 +97,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
restoreMultipleRecords: {
universalIdentifier: '8b740c9d-d99a-45a8-812f-809caaf420ac',
label: 'Restore records',
label: 'Restore ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Restore',
icon: 'IconRefresh',
position: 6,
@@ -111,7 +112,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
destroySingleRecord: {
universalIdentifier: '44a78417-c394-4bc8-961f-98b503030ddb',
label: 'Permanently destroy record',
label:
'Permanently destroy ${capitalize(objectMetadataItem.labelSingular)}',
shortLabel: 'Destroy',
icon: 'IconTrashX',
position: 7,
@@ -126,7 +128,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
destroyMultipleRecords: {
universalIdentifier: 'c630b3fb-7920-40d1-9906-77d0aa797608',
label: 'Permanently destroy records',
label: 'Permanently destroy ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Destroy',
icon: 'IconTrashX',
position: 8,
@@ -141,7 +143,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
addToFavorites: {
universalIdentifier: '38bf80c3-bd55-4753-80ba-38aa66429a03',
label: 'Add to favorites',
label: 'Add to Favorites',
shortLabel: null,
icon: 'IconHeart',
position: 9,
@@ -156,7 +158,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
removeFromFavorites: {
universalIdentifier: '3ea42507-44fa-4895-a36d-cbfef7355a50',
label: 'Remove from favorites',
label: 'Remove from Favorites',
shortLabel: null,
icon: 'IconHeartOff',
position: 10,
@@ -214,7 +216,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
updateMultipleRecords: {
universalIdentifier: '2e080651-f098-4a78-bea9-7a70002dc57c',
label: 'Update records',
label: 'Update ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Update',
icon: 'IconEdit',
position: 14,
@@ -229,7 +231,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
mergeMultipleRecords: {
universalIdentifier: '6c14eb04-8e7e-4d47-93c0-8ec4834e2e60',
label: 'Merge records',
label: 'Merge ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Merge',
icon: 'IconArrowMerge',
position: 15,
@@ -244,7 +246,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
exportMultipleRecords: {
universalIdentifier: 'f71f68e5-7b6e-4c03-8161-c48434d7777c',
label: 'Export records',
label: 'Export ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 16,
@@ -258,7 +260,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
importRecords: {
universalIdentifier: 'a2dc9de7-4798-422e-bb55-bfad7b9bdbe8',
label: 'Import records',
label: 'Import ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Import',
icon: 'IconFileImport',
position: 17,
@@ -272,7 +274,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
exportView: {
universalIdentifier: '80680f2a-c426-48b3-a839-c63a6183dc4b',
label: 'Export view',
label: 'Export View',
shortLabel: 'Export',
icon: 'IconFileExport',
position: 18,
@@ -286,8 +288,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeDeletedRecords: {
universalIdentifier: 'd63c21c3-9785-4750-be87-5f36269b8e0d',
label: 'See deleted records',
shortLabel: 'Deleted records',
label: 'See deleted ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Deleted ${capitalize(objectMetadataItem.labelPlural)}',
icon: 'IconRotate2',
position: 19,
isPinned: false,
@@ -314,7 +316,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
hideDeletedRecords: {
universalIdentifier: '1420db7f-0fba-49e2-b23e-4b7caa0fafa0',
label: 'Hide deleted records',
label: 'Hide deleted ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Hide deleted',
icon: 'IconEyeOff',
position: 21,
@@ -584,8 +586,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeActiveVersionWorkflow: {
universalIdentifier: '31790508-75ff-4e4c-a768-83bd1b0718e0',
label: 'See active version',
shortLabel: 'See active version',
label: 'See Active Version',
shortLabel: 'See Active Version',
icon: 'IconVersions',
position: 46,
isPinned: false,
@@ -600,8 +602,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeRunsWorkflow: {
universalIdentifier: 'e57efc2d-00a2-493a-b76c-f2dabd23a5eb',
label: 'See runs',
shortLabel: 'See runs',
label: 'See Runs',
shortLabel: 'See Runs',
icon: 'IconHistoryToggle',
position: 47,
isPinned: true,
@@ -616,8 +618,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeVersionsWorkflow: {
universalIdentifier: '92781d24-b875-4282-8cdb-d127f04a5c7d',
label: 'See versions history',
shortLabel: 'See versions',
label: 'See Versions History',
shortLabel: 'See Versions',
icon: 'IconVersions',
position: 48,
isPinned: false,
@@ -632,8 +634,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
addNodeWorkflow: {
universalIdentifier: '818117fa-6cad-4ebc-83c1-40f4afc28d94',
label: 'Add a node',
shortLabel: 'Add a node',
label: 'Add a Node',
shortLabel: 'Add a Node',
icon: 'IconPlus',
position: 49,
isPinned: true,
@@ -648,7 +650,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
tidyUpWorkflow: {
universalIdentifier: '1f3a3cab-161a-4775-af47-11be4d0bf411',
label: 'Tidy up workflow',
label: 'Tidy up Workflow',
shortLabel: 'Tidy up',
icon: 'IconReorder',
position: 50,
@@ -680,8 +682,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
goToRuns: {
universalIdentifier: '1ba959da-ff49-4c1f-a517-2b78ee200508',
label: 'Go to runs',
shortLabel: 'See runs',
label: 'Go to Runs',
shortLabel: 'See Runs',
icon: 'IconHistoryToggle',
position: 52,
isPinned: false,
@@ -695,8 +697,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeVersionWorkflowRun: {
universalIdentifier: 'cc3a065c-c89e-40ac-9449-4272c55b1bb8',
label: 'See version',
shortLabel: 'See version',
label: 'See Version',
shortLabel: 'See Version',
icon: 'IconVersions',
position: 53,
isPinned: true,
@@ -710,8 +712,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeWorkflowWorkflowRun: {
universalIdentifier: '9d9cc62d-3543-45c3-93f3-23d2d8979f2b',
label: 'See workflow',
shortLabel: 'See workflow',
label: 'See Workflow',
shortLabel: 'See Workflow',
icon: 'IconSettingsAutomation',
position: 54,
isPinned: true,
@@ -741,8 +743,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeRunsWorkflowVersion: {
universalIdentifier: '44e305c7-4f0a-45ec-803f-6471b56455cb',
label: 'See runs',
shortLabel: 'See runs',
label: 'See Runs',
shortLabel: 'See Runs',
icon: 'IconHistoryToggle',
position: 56,
isPinned: true,
@@ -757,8 +759,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeWorkflowWorkflowVersion: {
universalIdentifier: 'b43052db-023e-4083-9b63-2c2dfbfd1320',
label: 'See workflow',
shortLabel: 'See workflow',
label: 'See Workflow',
shortLabel: 'See Workflow',
icon: 'IconSettingsAutomation',
position: 57,
isPinned: true,
@@ -773,8 +775,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
useAsDraftWorkflowVersion: {
universalIdentifier: '483c0c1d-ea4d-4a4d-8a59-2dcf9f8e38f6',
label: 'Use as draft',
shortLabel: 'Use as draft',
label: 'Use as Draft',
shortLabel: 'Use as Draft',
icon: 'IconPencil',
position: 58,
isPinned: true,
@@ -789,8 +791,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
seeVersionsWorkflowVersion: {
universalIdentifier: '1d4abeb7-2750-4af7-9a92-fbadd2a9e4ba',
label: 'See versions history',
shortLabel: 'See versions',
label: 'See Versions History',
shortLabel: 'See Versions',
icon: 'IconVersions',
position: 59,
isPinned: false,
@@ -805,7 +807,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
searchRecords: {
universalIdentifier: 'fa24e25e-68f8-4548-82ff-c7b5168b7c7d',
label: 'Search records',
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Search',
icon: 'IconSearch',
position: 60,
@@ -819,7 +821,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
},
searchRecordsFallback: {
universalIdentifier: 'c659890c-7266-46c9-bfe1-75cefff8b6d0',
label: 'Search records',
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
shortLabel: 'Search',
icon: 'IconSearch',
position: 61,
@@ -2,6 +2,7 @@ import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsT
export type CommandMenuItemManifest = SyncableEntityOptions & {
label: string;
shortLabel?: string;
icon?: string;
isPinned?: boolean;
availabilityType?: 'GLOBAL' | 'RECORD_SELECTION' | 'FALLBACK';
@@ -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';