From 9054d3aef62f12cbf50dfc70c9e8e41e21c888c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:02:24 +0200 Subject: [PATCH] [COMMAND MENU ITEMS] Resolve object metadata label in dynamic command menu item label (#19211) - Adds a resolveObjectMetadataLabel utility that returns the singular or plural label from object metadata based on the number of selected records (e.g., "person" vs "people"). - Exposes a pre-computed objectMetadataLabel string on CommandMenuContextApi, making it available for label interpolation (e.g., Delete ${capitalize(objectMetadataLabel)}). --- .../common/hooks/useCommandMenuContextApi.ts | 12 +++- ...nditional-availability-expressions.test.ts | 1 + .../conditional-availability-variables.ts | 3 + .../src/types/CommandMenuContextApi.ts | 1 + ...eConditionalAvailabilityExpression.test.ts | 1 + .../interpolateCommandMenuItemLabel.test.ts | 68 +++++++++++++++++++ .../resolveObjectMetadataLabel.test.ts | 35 ++++++++++ .../resolveObjectMetadataLabel.ts | 11 +++ packages/twenty-shared/src/utils/index.ts | 1 + 9 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-shared/src/utils/command-menu-items/__tests__/resolveObjectMetadataLabel.test.ts create mode 100644 packages/twenty-shared/src/utils/command-menu-items/resolveObjectMetadataLabel.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuContextApi.ts index b1eedbe80f..261e53abea 100644 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuContextApi.ts @@ -14,18 +14,18 @@ import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/r import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector'; import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { isNonEmptyArray } from '@sniptt/guards'; import { useStore } from 'jotai'; import { CommandMenuContextApiPageType, type CommandMenuContextApi, } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, resolveObjectMetadataLabel } from 'twenty-shared/utils'; export const useCommandMenuContextApi = (): CommandMenuContextApi => { const store = useStore(); @@ -138,6 +138,13 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { permissions.canUpdate; } + const objectMetadataLabel = isDefined(objectMetadataItem) + ? resolveObjectMetadataLabel({ + objectMetadataItem: objectMetadataItem, + numberOfSelectedRecords: contextStoreNumberOfSelectedRecords, + }) + : ''; + return { pageType, isInSidePanel, @@ -152,5 +159,6 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { targetObjectReadPermissions, targetObjectWritePermissions, objectMetadataItem: objectMetadataItem ?? {}, + objectMetadataLabel, }; }; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts b/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts index ae5344850f..44c0e61a6d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts @@ -38,6 +38,7 @@ const buildMockCommandMenuContextApi = ( targetObjectReadPermissions: {}, targetObjectWritePermissions: {}, objectMetadataItem: {}, + objectMetadataLabel: '', ...overrides, }); diff --git a/packages/twenty-sdk/src/sdk/front-component-api/conditional-availability/conditional-availability-variables.ts b/packages/twenty-sdk/src/sdk/front-component-api/conditional-availability/conditional-availability-variables.ts index 99247dc012..14f61237b8 100644 --- a/packages/twenty-sdk/src/sdk/front-component-api/conditional-availability/conditional-availability-variables.ts +++ b/packages/twenty-sdk/src/sdk/front-component-api/conditional-availability/conditional-availability-variables.ts @@ -25,6 +25,9 @@ export const targetObjectWritePermissions = export const objectMetadataItem = null as unknown as CommandMenuContextApi['objectMetadataItem']; +export const objectMetadataLabel = + null as unknown as CommandMenuContextApi['objectMetadataLabel']; + export const isDefined = null as unknown as (value: unknown) => boolean; export const isNonEmptyString = null as unknown as (value: unknown) => boolean; export const includes = null as unknown as ( diff --git a/packages/twenty-shared/src/types/CommandMenuContextApi.ts b/packages/twenty-shared/src/types/CommandMenuContextApi.ts index 00035ff5c2..d4b2ad6454 100644 --- a/packages/twenty-shared/src/types/CommandMenuContextApi.ts +++ b/packages/twenty-shared/src/types/CommandMenuContextApi.ts @@ -16,4 +16,5 @@ export type CommandMenuContextApi = { targetObjectReadPermissions: Record; targetObjectWritePermissions: Record; objectMetadataItem: Record; + objectMetadataLabel: string; }; diff --git a/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts index 29d38937a5..9c7f546030 100644 --- a/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts +++ b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts @@ -29,6 +29,7 @@ const buildContext = ( targetObjectReadPermissions: {}, targetObjectWritePermissions: {}, objectMetadataItem: {}, + objectMetadataLabel: '', ...overrides, }); 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 index f96a5c8713..e6bb7afae8 100644 --- 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 @@ -29,6 +29,7 @@ const buildContext = ( targetObjectReadPermissions: {}, targetObjectWritePermissions: {}, objectMetadataItem: {}, + objectMetadataLabel: '', ...overrides, }); @@ -313,4 +314,71 @@ describe('interpolateCommandMenuItemLabel', () => { ).toBe('Delete people'); }); }); + + describe('objectMetadataLabel interpolation', () => { + it('should resolve singular label with capitalize transform', () => { + const context = buildContext({ + objectMetadataLabel: 'person', + }); + + expect( + interpolateCommandMenuItemLabel({ + label: 'Delete ${capitalize(objectMetadataLabel)}', + context, + }), + ).toBe('Delete Person'); + }); + + it('should resolve plural label with capitalize transform', () => { + const context = buildContext({ + objectMetadataLabel: 'companies', + }); + + expect( + interpolateCommandMenuItemLabel({ + label: 'Export ${capitalize(objectMetadataLabel)}', + context, + }), + ).toBe('Export Companies'); + }); + + it('should resolve objectMetadataLabel without transform', () => { + const context = buildContext({ + objectMetadataLabel: 'people', + }); + + expect( + interpolateCommandMenuItemLabel({ + label: '${objectMetadataLabel} selected', + context, + }), + ).toBe('people selected'); + }); + + it('should resolve objectMetadataLabel with lowercase transform', () => { + const context = buildContext({ + objectMetadataLabel: 'Person', + }); + + expect( + interpolateCommandMenuItemLabel({ + label: 'Create ${lowercase(objectMetadataLabel)}', + context, + }), + ).toBe('Create person'); + }); + + it('should return empty segment when objectMetadataLabel is empty', () => { + const context = buildContext({ + objectMetadataLabel: '', + }); + + expect( + interpolateCommandMenuItemLabel({ + label: 'Delete ${capitalize(objectMetadataLabel)}', + context, + }), + ).toBe('Delete '); + }); + }); }); diff --git a/packages/twenty-shared/src/utils/command-menu-items/__tests__/resolveObjectMetadataLabel.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/resolveObjectMetadataLabel.test.ts new file mode 100644 index 0000000000..bc848aad40 --- /dev/null +++ b/packages/twenty-shared/src/utils/command-menu-items/__tests__/resolveObjectMetadataLabel.test.ts @@ -0,0 +1,35 @@ +import { resolveObjectMetadataLabel } from '../resolveObjectMetadataLabel'; + +describe('resolveObjectMetadataLabel', () => { + const objectMetadataItem = { + labelSingular: 'person', + labelPlural: 'people', + }; + + it('should return labelSingular when numberOfSelectedRecords is 1', () => { + expect( + resolveObjectMetadataLabel({ + objectMetadataItem, + numberOfSelectedRecords: 1, + }), + ).toBe('person'); + }); + + it('should return labelPlural when numberOfSelectedRecords is 0', () => { + expect( + resolveObjectMetadataLabel({ + objectMetadataItem, + numberOfSelectedRecords: 0, + }), + ).toBe('people'); + }); + + it('should return labelPlural when numberOfSelectedRecords is greater than 1', () => { + expect( + resolveObjectMetadataLabel({ + objectMetadataItem, + numberOfSelectedRecords: 5, + }), + ).toBe('people'); + }); +}); diff --git a/packages/twenty-shared/src/utils/command-menu-items/resolveObjectMetadataLabel.ts b/packages/twenty-shared/src/utils/command-menu-items/resolveObjectMetadataLabel.ts new file mode 100644 index 0000000000..ad52d4c3cf --- /dev/null +++ b/packages/twenty-shared/src/utils/command-menu-items/resolveObjectMetadataLabel.ts @@ -0,0 +1,11 @@ +export const resolveObjectMetadataLabel = ({ + objectMetadataItem, + numberOfSelectedRecords, +}: { + objectMetadataItem: { labelSingular: string; labelPlural: string }; + numberOfSelectedRecords: number; +}): string => { + return numberOfSelectedRecords === 1 + ? objectMetadataItem.labelSingular + : objectMetadataItem.labelPlural; +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 9221ba6f22..0026976fc5 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -26,6 +26,7 @@ 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 { resolveObjectMetadataLabel } from './command-menu-items/resolveObjectMetadataLabel'; export { safeGetNestedProperty } from './command-menu-items/safeGetNestedProperty'; export { computeDiffBetweenObjects } from './compute-diff-between-objects'; export { isPlainDateAfter } from './date/isPlainDateAfter';