From dedcf4e9b91692836f3da541a4af659699396381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:26:58 +0100 Subject: [PATCH] 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. --- .../display/components/CommandListItem.tsx | 2 +- .../components/CommandMenuItemDisplay.tsx | 2 +- .../CommandMenuItemButton.stories.tsx | 6 +- .../CommandMenuItemComponent.stories.tsx | 4 +- .../CommandMenuItemDisplay.stories.tsx | 8 +- .../CommandMenuItemDropdownItem.stories.tsx | 4 +- .../CommandMenuItemListItem.stories.tsx | 6 +- .../DeleteMultipleRecordsCommand.tsx | 6 +- .../DestroyMultipleRecordsCommand.tsx | 4 +- .../ExportMultipleRecordsCommand.tsx | 4 +- .../hooks/useCommandMenuItemsFromBackend.tsx | 25 +- .../types/CommandMenuItemConfig.ts | 2 +- .../utils/getCommandMenuItemLabel.ts | 8 +- .../components/CommandMenuButton.tsx | 16 +- ...o-universal-flat-command-menu-item.util.ts | 2 +- .../standard-command-menu-item.constant.ts | 90 ++--- .../application/frontComponentManifestType.ts | 1 + ...eConditionalAvailabilityExpression.test.ts | 0 .../interpolateCommandMenuItemLabel.test.ts | 316 ++++++++++++++++++ .../conditionalAvailabilityParser.ts | 120 +++++++ ...aluateConditionalAvailabilityExpression.ts | 21 ++ .../interpolateCommandMenuItemLabel.ts | 75 +++++ .../safeGetNestedProperty.ts | 39 +++ ...aluateConditionalAvailabilityExpression.ts | 143 -------- packages/twenty-shared/src/utils/index.ts | 5 +- 25 files changed, 673 insertions(+), 236 deletions(-) rename packages/twenty-shared/src/utils/{conditional-availability => command-menu-items}/__tests__/evaluateConditionalAvailabilityExpression.test.ts (100%) create mode 100644 packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemLabel.test.ts create mode 100644 packages/twenty-shared/src/utils/command-menu-items/conditionalAvailabilityParser.ts create mode 100644 packages/twenty-shared/src/utils/command-menu-items/evaluateConditionalAvailabilityExpression.ts create mode 100644 packages/twenty-shared/src/utils/command-menu-items/interpolateCommandMenuItemLabel.ts create mode 100644 packages/twenty-shared/src/utils/command-menu-items/safeGetNestedProperty.ts delete mode 100644 packages/twenty-shared/src/utils/conditional-availability/evaluateConditionalAvailabilityExpression.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandListItem.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandListItem.tsx index 1b002750fc9..afbeb395528 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandListItem.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandListItem.tsx @@ -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} diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemDisplay.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemDisplay.tsx index 5d306f0bc9a..c89c943fa76 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemDisplay.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/CommandMenuItemDisplay.tsx @@ -13,7 +13,7 @@ import { type MenuItemAccent } from 'twenty-ui/navigation'; export type CommandMenuItemDisplayProps = { key: string; - label: MessageDescriptor | string; + label: Nullable; shortLabel?: Nullable; description?: MessageDescriptor | string; Icon: IconComponent; diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemButton.stories.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemButton.stories.tsx index 62196af57f7..e7c32ef9b36 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemButton.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemButton.stories.tsx @@ -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'); diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemComponent.stories.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemComponent.stories.tsx index a96e98991cb..33ddca96e86 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemComponent.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemComponent.stories.tsx @@ -60,9 +60,7 @@ export const Default: Story = { expect( await canvas.findByText( - getCommandMenuItemLabel( - addToFavoritesCommandMenuItem?.shortLabel ?? '', - ), + getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel), ), ).toBeVisible(); }, diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemDisplay.stories.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemDisplay.stories.tsx index 075c92efc00..53575ed8027 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemDisplay.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemDisplay.stories.tsx @@ -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(); diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemDropdownItem.stories.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemDropdownItem.stories.tsx index 4d16a7be80c..78d3ea7c9e3 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemDropdownItem.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemDropdownItem.stories.tsx @@ -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(); }, diff --git a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemListItem.stories.tsx b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemListItem.stories.tsx index 76c1fd85eb2..66b465bbd47 100644 --- a/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemListItem.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/display/components/__stories__/CommandMenuItemListItem.stories.tsx @@ -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(); }, diff --git a/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/DeleteMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/DeleteMultipleRecordsCommand.tsx index 2c69b096900..b3a4ed0c2e7 100644 --- a/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/DeleteMultipleRecordsCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/DeleteMultipleRecordsCommand.tsx @@ -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); diff --git a/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/DestroyMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/DestroyMultipleRecordsCommand.tsx index aabce7ce26a..8f2d0a017f2 100644 --- a/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/DestroyMultipleRecordsCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/DestroyMultipleRecordsCommand.tsx @@ -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); diff --git a/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/ExportMultipleRecordsCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/ExportMultipleRecordsCommand.tsx index 317e85f0a02..ba5b03afba4 100644 --- a/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/ExportMultipleRecordsCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/record/multiple-records/components/ExportMultipleRecordsCommand.tsx @@ -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); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx index 0129c273e09..01e951f2e26 100644 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/command-menu-item/types/CommandMenuItemConfig.ts b/packages/twenty-front/src/modules/command-menu-item/types/CommandMenuItemConfig.ts index ff1ef7028c7..30ef106b00d 100644 --- a/packages/twenty-front/src/modules/command-menu-item/types/CommandMenuItemConfig.ts +++ b/packages/twenty-front/src/modules/command-menu-item/types/CommandMenuItemConfig.ts @@ -14,7 +14,7 @@ export type CommandMenuItemConfig = { type: CommandMenuItemType; scope: CommandMenuItemScope; key: string; - label: MessageDescriptor | string; + label: Nullable; shortLabel?: Nullable; description?: MessageDescriptor | string; position: number; diff --git a/packages/twenty-front/src/modules/command-menu-item/utils/getCommandMenuItemLabel.ts b/packages/twenty-front/src/modules/command-menu-item/utils/getCommandMenuItemLabel.ts index 8b2518de4a5..8885a62ae20 100644 --- a/packages/twenty-front/src/modules/command-menu-item/utils/getCommandMenuItemLabel.ts +++ b/packages/twenty-front/src/modules/command-menu-item/utils/getCommandMenuItemLabel.ts @@ -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 => { + if (!isDefined(label)) { + return ''; + } + return isString(label) ? label : i18n._(label); }; diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuButton.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuButton.tsx index cb77181d3dc..3ba35ef528e 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuButton.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuButton.tsx @@ -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; shortLabel?: Nullable; 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'; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util.ts index 27aa8571c9b..68b4fb3081f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util.ts @@ -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, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts index f68d8e63592..39703abb63e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts @@ -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, diff --git a/packages/twenty-shared/src/application/frontComponentManifestType.ts b/packages/twenty-shared/src/application/frontComponentManifestType.ts index 2a1c524866d..2a8093ab8dd 100644 --- a/packages/twenty-shared/src/application/frontComponentManifestType.ts +++ b/packages/twenty-shared/src/application/frontComponentManifestType.ts @@ -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'; diff --git a/packages/twenty-shared/src/utils/conditional-availability/__tests__/evaluateConditionalAvailabilityExpression.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts similarity index 100% rename from packages/twenty-shared/src/utils/conditional-availability/__tests__/evaluateConditionalAvailabilityExpression.test.ts rename to packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts diff --git a/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemLabel.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemLabel.test.ts new file mode 100644 index 00000000000..f96a5c8713e --- /dev/null +++ b/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemLabel.test.ts @@ -0,0 +1,316 @@ +import { + CommandMenuContextApiPageType, + type CommandMenuContextApi, +} from '@/types'; +import { interpolateCommandMenuItemLabel } from '../interpolateCommandMenuItemLabel'; + +const buildContext = ( + overrides: Partial = {}, +): 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'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/command-menu-items/conditionalAvailabilityParser.ts b/packages/twenty-shared/src/utils/command-menu-items/conditionalAvailabilityParser.ts new file mode 100644 index 00000000000..bcdfb02d312 --- /dev/null +++ b/packages/twenty-shared/src/utils/command-menu-items/conditionalAvailabilityParser.ts @@ -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), + ); diff --git a/packages/twenty-shared/src/utils/command-menu-items/evaluateConditionalAvailabilityExpression.ts b/packages/twenty-shared/src/utils/command-menu-items/evaluateConditionalAvailabilityExpression.ts new file mode 100644 index 00000000000..fef69cf2085 --- /dev/null +++ b/packages/twenty-shared/src/utils/command-menu-items/evaluateConditionalAvailabilityExpression.ts @@ -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; + } +}; diff --git a/packages/twenty-shared/src/utils/command-menu-items/interpolateCommandMenuItemLabel.ts b/packages/twenty-shared/src/utils/command-menu-items/interpolateCommandMenuItemLabel.ts new file mode 100644 index 00000000000..acdbbbcef1e --- /dev/null +++ b/packages/twenty-shared/src/utils/command-menu-items/interpolateCommandMenuItemLabel.ts @@ -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> = { + capitalize, + lowercase: (value: string) => value.toLowerCase(), +}; + +const resolveTemplateExpression = ({ + expression, + context, +}: { + expression: string; + context: Record; +}): 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; + context: Record; +}): Nullable => { + 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; + } + }); +}; diff --git a/packages/twenty-shared/src/utils/command-menu-items/safeGetNestedProperty.ts b/packages/twenty-shared/src/utils/command-menu-items/safeGetNestedProperty.ts new file mode 100644 index 00000000000..64b0162f324 --- /dev/null +++ b/packages/twenty-shared/src/utils/command-menu-items/safeGetNestedProperty.ts @@ -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)[part]; + } + + return currentObject; +}; diff --git a/packages/twenty-shared/src/utils/conditional-availability/evaluateConditionalAvailabilityExpression.ts b/packages/twenty-shared/src/utils/conditional-availability/evaluateConditionalAvailabilityExpression.ts deleted file mode 100644 index 4ee09a793f3..00000000000 --- a/packages/twenty-shared/src/utils/conditional-availability/evaluateConditionalAvailabilityExpression.ts +++ /dev/null @@ -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)[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; - } -}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index a18a7aaca11..190a3305d5c 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -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';