[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)}).
This commit is contained in:
Raphaël Bosi
2026-04-01 17:02:24 +02:00
committed by GitHub
parent 19dd4d6c1b
commit 9054d3aef6
9 changed files with 131 additions and 2 deletions
@@ -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,
};
};
@@ -38,6 +38,7 @@ const buildMockCommandMenuContextApi = (
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
objectMetadataLabel: '',
...overrides,
});
@@ -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 (
@@ -16,4 +16,5 @@ export type CommandMenuContextApi = {
targetObjectReadPermissions: Record<string, boolean>;
targetObjectWritePermissions: Record<string, boolean>;
objectMetadataItem: Record<string, unknown>;
objectMetadataLabel: string;
};
@@ -29,6 +29,7 @@ const buildContext = (
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
objectMetadataItem: {},
objectMetadataLabel: '',
...overrides,
});
@@ -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 ');
});
});
});
@@ -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');
});
});
@@ -0,0 +1,11 @@
export const resolveObjectMetadataLabel = ({
objectMetadataItem,
numberOfSelectedRecords,
}: {
objectMetadataItem: { labelSingular: string; labelPlural: string };
numberOfSelectedRecords: number;
}): string => {
return numberOfSelectedRecords === 1
? objectMetadataItem.labelSingular
: objectMetadataItem.labelPlural;
};
@@ -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';