[COMMAND MENU ITEMS] Add dynamic label and icon to command menu navigation items (#19452)

This commit is contained in:
Raphaël Bosi
2026-04-09 14:00:49 +02:00
committed by GitHub
parent 9b8cb610c3
commit 19f11b1216
14 changed files with 847 additions and 20 deletions
@@ -1,14 +1,18 @@
import { Injectable } from '@nestjs/common';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { enrichCommandMenuItemEventWithResolvedNavigation } from 'src/engine/subscriptions/metadata-event/utils/enrich-command-menu-item-event-with-resolved-navigation.util';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
import { sanitizeOverridableEntityEventBatch } from 'src/engine/subscriptions/metadata-event/utils/sanitize-overridable-entity-event-batch.util';
import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
@Injectable()
export class MetadataEventPublisher {
@@ -16,6 +20,7 @@ export class MetadataEventPublisher {
private readonly workspaceEventBroadcaster: WorkspaceEventBroadcaster,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
private readonly i18nService: I18nService,
) {}
async publish(metadataEventBatch: MetadataEventBatch): Promise<void> {
@@ -50,6 +55,10 @@ export class MetadataEventPublisher {
return this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch as MetadataEventBatch<'navigationMenuItem'>,
);
case 'commandMenuItem':
return this.enrichCommandMenuItemEventsWithResolvedNavigation(
metadataEventBatch as MetadataEventBatch<'commandMenuItem'>,
);
case 'objectMetadata':
return this.resolveObjectMetadataStandardOverrides(
metadataEventBatch as MetadataEventBatch<'objectMetadata'>,
@@ -96,6 +105,46 @@ export class MetadataEventPublisher {
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichCommandMenuItemEventsWithResolvedNavigation(
metadataEventBatch: MetadataEventBatch<'commandMenuItem'>,
): Promise<MetadataEventBatch<'commandMenuItem'>> {
const { flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: metadataEventBatch.workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const i18nInstance = this.i18nService.getI18nInstance(SOURCE_LOCALE);
const enrichedEvents = metadataEventBatch.events.map((event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const enrichedAfter = enrichCommandMenuItemEventWithResolvedNavigation({
record: event.properties.after as FlatCommandMenuItem,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance,
});
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch: MetadataEventBatch<'navigationMenuItem'>,
): Promise<MetadataEventBatch<'navigationMenuItem'>> {
@@ -0,0 +1,233 @@
import { type I18n } from '@lingui/core';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import {
NAVIGATION_INTERPOLATED_ICON,
NAVIGATION_INTERPOLATED_LABEL,
NAVIGATION_INTERPOLATED_SHORT_LABEL,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { enrichCommandMenuItemEventWithResolvedNavigation } from 'src/engine/subscriptions/metadata-event/utils/enrich-command-menu-item-event-with-resolved-navigation.util';
const mockI18nInstance = {
_: (messageId: string) => messageId,
} as unknown as I18n;
const OBJECT_METADATA_ID = 'obj-id-1';
const makeFlatObjectMetadata = (
overrides?: Partial<FlatObjectMetadata>,
): FlatObjectMetadata =>
({
id: OBJECT_METADATA_ID,
universalIdentifier: 'obj-uid-1',
workspaceId: 'ws-1',
applicationId: 'app-1',
labelPlural: 'People',
labelSingular: 'Person',
icon: 'IconUser',
isCustom: false,
standardOverrides: null,
...overrides,
}) as unknown as FlatObjectMetadata;
const makeFlatObjectMetadataMaps = (
flatObjectMetadata: FlatObjectMetadata,
): FlatEntityMaps<FlatObjectMetadata> => ({
byUniversalIdentifier: {
[flatObjectMetadata.universalIdentifier]: flatObjectMetadata,
},
universalIdentifierById: {
[flatObjectMetadata.id]: flatObjectMetadata.universalIdentifier,
},
universalIdentifiersByApplicationId: {},
});
const makeNavigationRecord = (
overrides?: Partial<FlatCommandMenuItem>,
): FlatCommandMenuItem =>
({
id: 'cmd-id-1',
engineComponentKey: EngineComponentKey.NAVIGATION,
label: NAVIGATION_INTERPOLATED_LABEL,
shortLabel: NAVIGATION_INTERPOLATED_SHORT_LABEL,
icon: NAVIGATION_INTERPOLATED_ICON,
payload: { objectMetadataItemId: OBJECT_METADATA_ID },
position: 1,
isPinned: false,
...overrides,
}) as unknown as FlatCommandMenuItem;
describe('enrichCommandMenuItemEventWithResolvedNavigation', () => {
it('should resolve label, shortLabel, and icon templates for NAVIGATION items', () => {
const flatObjectMetadata = makeFlatObjectMetadata();
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
const record = makeNavigationRecord();
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result.label).toBe('Go to People');
expect(result.shortLabel).toBe('People');
expect(result.icon).toBe('IconUser');
});
it('should return record unchanged for non-NAVIGATION items', () => {
const flatObjectMetadata = makeFlatObjectMetadata();
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
const record = makeNavigationRecord({
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
label: 'Create New Record',
shortLabel: undefined,
icon: 'IconPlus',
payload: undefined,
});
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result).toBe(record);
});
it('should return record unchanged when payload has no objectMetadataItemId', () => {
const flatObjectMetadata = makeFlatObjectMetadata();
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
const record = makeNavigationRecord({
payload: { path: '/settings' },
});
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result).toBe(record);
});
it('should return record unchanged when object metadata is not found in maps', () => {
const emptyMaps: FlatEntityMaps<FlatObjectMetadata> = {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
};
const record = makeNavigationRecord();
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps: emptyMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result).toBe(record);
});
it('should apply standard overrides when resolving templates', () => {
const flatObjectMetadata = makeFlatObjectMetadata({
labelPlural: 'People',
icon: 'IconUser',
standardOverrides: {
labelPlural: 'Contacts',
icon: 'IconContacts',
} as unknown as FlatObjectMetadata['standardOverrides'],
});
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
const record = makeNavigationRecord();
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result.label).toBe('Go to Contacts');
expect(result.shortLabel).toBe('Contacts');
expect(result.icon).toBe('IconContacts');
});
it('should use base values when standard overrides are null', () => {
const flatObjectMetadata = makeFlatObjectMetadata({
labelPlural: 'Companies',
icon: 'IconBuilding',
standardOverrides: null,
});
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
const record = makeNavigationRecord();
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result.label).toBe('Go to Companies');
expect(result.shortLabel).toBe('Companies');
expect(result.icon).toBe('IconBuilding');
});
it('should return record unchanged when payload is null', () => {
const flatObjectMetadata = makeFlatObjectMetadata();
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
const record = makeNavigationRecord({ payload: null });
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result).toBe(record);
});
it('should pass through already-resolved literal labels', () => {
const flatObjectMetadata = makeFlatObjectMetadata();
const flatObjectMetadataMaps =
makeFlatObjectMetadataMaps(flatObjectMetadata);
const record = makeNavigationRecord({
label: 'Go to People',
shortLabel: 'People',
icon: 'IconUser',
});
const result = enrichCommandMenuItemEventWithResolvedNavigation({
record,
flatObjectMetadataMaps,
locale: SOURCE_LOCALE,
i18nInstance: mockI18nInstance,
});
expect(result.label).toBe('Go to People');
expect(result.shortLabel).toBe('People');
expect(result.icon).toBe('IconUser');
});
});
@@ -0,0 +1,72 @@
import { type I18n } from '@lingui/core';
import { type APP_LOCALES } from 'twenty-shared/translations';
import {
interpolateCommandMenuItemTemplate,
isDefined,
} from 'twenty-shared/utils';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { buildNavigationInterpolationContext } from 'src/engine/metadata-modules/command-menu-item/utils/build-navigation-interpolation-context.util';
import { isObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util';
import { FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
type EnrichCommandMenuItemEventArgs = {
record: FlatCommandMenuItem;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
locale: keyof typeof APP_LOCALES | undefined;
i18nInstance: I18n;
};
export const enrichCommandMenuItemEventWithResolvedNavigation = ({
record,
flatObjectMetadataMaps,
locale,
i18nInstance,
}: EnrichCommandMenuItemEventArgs): FlatCommandMenuItem => {
if (record.engineComponentKey !== EngineComponentKey.NAVIGATION) {
return record;
}
const payload = record.payload;
if (!isObjectMetadataCommandMenuItemPayload(payload)) {
return record;
}
const objectMetadataItemId = payload.objectMetadataItemId;
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: objectMetadataItemId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(flatObjectMetadata)) {
return record;
}
const context = buildNavigationInterpolationContext({
objectMetadata: flatObjectMetadata,
locale,
i18nInstance,
});
const enriched = { ...record };
for (const field of ['label', 'shortLabel', 'icon'] as const) {
const rawValue = record[field];
const resolvedValue = interpolateCommandMenuItemTemplate({
label: rawValue,
context,
});
if (isDefined(resolvedValue)) {
enriched[field] = resolvedValue;
}
}
return enriched;
};
@@ -6,6 +6,7 @@ import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-neste
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
import { I18nModule } from 'src/engine/core-modules/i18n/i18n.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -32,6 +33,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NavigationMenuItemModule,
I18nModule,
],
providers: [
SubscriptionService,