From 19f11b12166d4c7ed1f7cf06e2c117b79a2920d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:00:49 +0200 Subject: [PATCH] [COMMAND MENU ITEMS] Add dynamic label and icon to command menu navigation items (#19452) --- ...00-refactor-navigation-commands.command.ts | 31 ++- .../command-menu-item.module.ts | 2 + .../command-menu-item.resolver.ts | 58 ++++- .../command-menu-item.service.ts | 65 +++++ ...ation-command-menu-item-field.util.spec.ts | 197 +++++++++++++++ ...d-navigation-interpolation-context.util.ts | 54 ++++ ...navigation-command-menu-item-field.util.ts | 57 +++++ ...metadata-command-menu-item-payload.util.ts | 9 + ...gation-flat-command-menu-item.util.test.ts | 19 +- ...-navigation-flat-command-menu-item.util.ts | 15 +- .../metadata-event-publisher.ts | 53 +++- ...vent-with-resolved-navigation.util.spec.ts | 233 ++++++++++++++++++ ...tem-event-with-resolved-navigation.util.ts | 72 ++++++ .../subscriptions/subscriptions.module.ts | 2 + 14 files changed, 847 insertions(+), 20 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/__tests__/interpolate-navigation-command-menu-item-field.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/build-navigation-interpolation-context.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/interpolate-navigation-command-menu-item-field.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util.ts create mode 100644 packages/twenty-server/src/engine/subscriptions/metadata-event/utils/__tests__/enrich-command-menu-item-event-with-resolved-navigation.util.spec.ts create mode 100644 packages/twenty-server/src/engine/subscriptions/metadata-event/utils/enrich-command-menu-item-event-with-resolved-navigation.util.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-1775500013000-refactor-navigation-commands.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-1775500013000-refactor-navigation-commands.command.ts index 819c99dc717..83f89ac58eb 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-1775500013000-refactor-navigation-commands.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-1775500013000-refactor-navigation-commands.command.ts @@ -15,10 +15,14 @@ import { addPayloadCheckConstraintToCommandMenuItem } from 'src/database/typeorm import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum'; +import { isObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util'; import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; import { buildNavigationFlatCommandMenuItem, NAVIGATION_COMMAND_UUID_NAMESPACE, + 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 { seedCompareObjectMetadataForNavigationPosition } from 'src/engine/metadata-modules/flat-command-menu-item/utils/seed-compare-object-metadata-for-navigation-position.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -239,19 +243,38 @@ export class RefactorNavigationCommandsCommand extends ActiveOrSuspendedWorkspac }); } + const staleNavigationItemsToUpdate = allCommandMenuItems + .filter( + (item) => + item.engineComponentKey === EngineComponentKey.NAVIGATION && + isObjectMetadataCommandMenuItemPayload(item.payload) && + (item.label !== NAVIGATION_INTERPOLATED_LABEL || + item.shortLabel !== NAVIGATION_INTERPOLATED_SHORT_LABEL || + item.icon !== NAVIGATION_INTERPOLATED_ICON), + ) + .map((item) => ({ + ...item, + label: NAVIGATION_INTERPOLATED_LABEL, + shortLabel: NAVIGATION_INTERPOLATED_SHORT_LABEL, + icon: NAVIGATION_INTERPOLATED_ICON, + updatedAt: now, + })); + if ( goToItemsToDelete.length === 0 && - flatCommandMenuItemsToCreate.length === 0 + flatCommandMenuItemsToCreate.length === 0 && + staleNavigationItemsToUpdate.length === 0 ) { this.logger.log( - `All NAVIGATION commands already exist for workspace ${workspaceId}, skipping`, + `All NAVIGATION commands already exist and use interpolation for workspace ${workspaceId}, skipping`, ); return; } this.logger.log( - `${isDryRun ? '[DRY RUN] Would create' : 'Creating'} ${flatCommandMenuItemsToCreate.length} NAVIGATION command(s) for workspace ${workspaceId}`, + `${isDryRun ? '[DRY RUN] Would create' : 'Creating'} ${flatCommandMenuItemsToCreate.length} NAVIGATION command(s) ` + + `and update ${staleNavigationItemsToUpdate.length} stale item(s) for workspace ${workspaceId}`, ); if (isDryRun) { @@ -265,7 +288,7 @@ export class RefactorNavigationCommandsCommand extends ActiveOrSuspendedWorkspac commandMenuItem: { flatEntityToCreate: flatCommandMenuItemsToCreate, flatEntityToDelete: goToItemsToDelete, - flatEntityToUpdate: [], + flatEntityToUpdate: staleNavigationItemsToUpdate, }, }, workspaceId, diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts index 82cfd879b8d..951b24364ec 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { I18nModule } from 'src/engine/core-modules/i18n/i18n.module'; import { CommandMenuItemResolver } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.resolver'; import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module'; import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service'; @@ -19,6 +20,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace FlatCommandMenuItemModule, FrontComponentModule, FeatureFlagModule, + I18nModule, ], providers: [ CommandMenuItemService, diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts index 25b8b0842a2..4d39e9a9bc2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.resolver.ts @@ -1,10 +1,19 @@ import { UseGuards, UseInterceptors } from '@nestjs/common'; -import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql'; +import { + Args, + Context, + Mutation, + Parent, + Query, + ResolveField, +} from '@nestjs/graphql'; import { isDefined } from 'twenty-shared/utils'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; @@ -30,6 +39,53 @@ export class CommandMenuItemResolver { private readonly frontComponentService: FrontComponentService, ) {} + @ResolveField(() => String) + async label( + @Parent() commandMenuItem: CommandMenuItemDTO, + @Context() context: { loaders: IDataloaders } & I18nContext, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return ( + (await this.commandMenuItemService.resolveNavigationField({ + commandMenuItem, + fieldName: 'label', + objectMetadataLoader: context.loaders.objectMetadataLoader, + workspaceId: workspace.id, + locale: context.req.locale, + })) ?? '' + ); + } + + @ResolveField(() => String, { nullable: true }) + async shortLabel( + @Parent() commandMenuItem: CommandMenuItemDTO, + @Context() context: { loaders: IDataloaders } & I18nContext, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return this.commandMenuItemService.resolveNavigationField({ + commandMenuItem, + fieldName: 'shortLabel', + objectMetadataLoader: context.loaders.objectMetadataLoader, + workspaceId: workspace.id, + locale: context.req.locale, + }); + } + + @ResolveField(() => String, { nullable: true }) + async icon( + @Parent() commandMenuItem: CommandMenuItemDTO, + @Context() context: { loaders: IDataloaders } & I18nContext, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return this.commandMenuItemService.resolveNavigationField({ + commandMenuItem, + fieldName: 'icon', + objectMetadataLoader: context.loaders.objectMetadataLoader, + workspaceId: workspace.id, + locale: context.req.locale, + }); + } + @ResolveField(() => FrontComponentDTO, { nullable: true }) async frontComponent( @Parent() commandMenuItem: CommandMenuItemDTO, diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.service.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.service.ts index 05eb27d8d58..93a3610ff63 100644 --- a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/command-menu-item.service.ts @@ -1,8 +1,12 @@ import { Injectable } from '@nestjs/common'; +import type DataLoader from 'dataloader'; +import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { type ObjectMetadataLoaderPayload } from 'src/engine/dataloaders/dataloader.service'; import { CommandMenuItemException, CommandMenuItemExceptionCode, @@ -10,7 +14,11 @@ import { import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input'; +import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum'; +import { isObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util'; +import { interpolateNavigationCommandMenuItemField } from 'src/engine/metadata-modules/command-menu-item/utils/interpolate-navigation-command-menu-item-field.util'; import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; +import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; import { fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util'; import { fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-delete-command-menu-item-input-to-flat-command-menu-item-or-throw.util'; import { fromFlatCommandMenuItemToCommandMenuItemDto } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util'; @@ -27,6 +35,7 @@ export class CommandMenuItemService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationService: ApplicationService, + private readonly i18nService: I18nService, ) {} async findAll(workspaceId: string): Promise { @@ -282,6 +291,62 @@ export class CommandMenuItemService { .sort((a, b) => a.position - b.position); } + async loadNavigationObjectMetadata({ + commandMenuItem, + objectMetadataLoader, + workspaceId, + }: { + commandMenuItem: CommandMenuItemDTO; + objectMetadataLoader: DataLoader< + ObjectMetadataLoaderPayload, + ObjectMetadataDTO | null + >; + workspaceId: string; + }): Promise { + if ( + commandMenuItem.engineComponentKey !== EngineComponentKey.NAVIGATION || + !isObjectMetadataCommandMenuItemPayload(commandMenuItem.payload) + ) { + return null; + } + + return objectMetadataLoader.load({ + objectMetadataId: commandMenuItem.payload.objectMetadataItemId, + workspaceId, + }); + } + + async resolveNavigationField({ + commandMenuItem, + fieldName, + objectMetadataLoader, + workspaceId, + locale, + }: { + commandMenuItem: CommandMenuItemDTO; + fieldName: 'label' | 'shortLabel' | 'icon'; + objectMetadataLoader: DataLoader< + ObjectMetadataLoaderPayload, + ObjectMetadataDTO | null + >; + workspaceId: string; + locale: keyof typeof APP_LOCALES | undefined; + }): Promise { + const objectMetadata = await this.loadNavigationObjectMetadata({ + commandMenuItem, + objectMetadataLoader, + workspaceId, + }); + + return interpolateNavigationCommandMenuItemField({ + commandMenuItem, + fieldName, + objectMetadata, + locale, + i18nInstance: this.i18nService.getI18nInstance(locale ?? SOURCE_LOCALE), + }); + } + async findByWorkflowVersionId( workflowVersionId: string, workspaceId: string, diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/__tests__/interpolate-navigation-command-menu-item-field.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/__tests__/interpolate-navigation-command-menu-item-field.util.spec.ts new file mode 100644 index 00000000000..461708a1ab0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/__tests__/interpolate-navigation-command-menu-item-field.util.spec.ts @@ -0,0 +1,197 @@ +import { type I18n } from '@lingui/core'; + +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum'; +import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum'; +import { interpolateNavigationCommandMenuItemField } from 'src/engine/metadata-modules/command-menu-item/utils/interpolate-navigation-command-menu-item-field.util'; +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 ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; + +const mockI18nInstance = { + _: (messageId: string) => messageId, +} as unknown as I18n; + +const mockObjectMetadata = { + id: 'obj-id-1', + labelPlural: 'People', + labelSingular: 'Person', + description: 'A person', + icon: 'IconUser', + isCustom: false, + standardOverrides: undefined, +} as unknown as ObjectMetadataDTO; + +const baseCommandMenuItem = { + id: 'cmd-id-1', + engineComponentKey: EngineComponentKey.NAVIGATION, + label: NAVIGATION_INTERPOLATED_LABEL, + shortLabel: NAVIGATION_INTERPOLATED_SHORT_LABEL, + icon: NAVIGATION_INTERPOLATED_ICON, + position: 1, + isPinned: false, + availabilityType: CommandMenuItemAvailabilityType.GLOBAL, + payload: { objectMetadataItemId: 'obj-id-1' }, + workspaceId: 'ws-id-1', + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('interpolateNavigationCommandMenuItemField', () => { + it('should resolve label template for NAVIGATION items', () => { + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: baseCommandMenuItem, + fieldName: 'label', + objectMetadata: mockObjectMetadata, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBe('Go to People'); + }); + + it('should resolve shortLabel template for NAVIGATION items', () => { + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: baseCommandMenuItem, + fieldName: 'shortLabel', + objectMetadata: mockObjectMetadata, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBe('People'); + }); + + it('should resolve icon template for NAVIGATION items', () => { + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: baseCommandMenuItem, + fieldName: 'icon', + objectMetadata: mockObjectMetadata, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBe('IconUser'); + }); + + it('should return raw label for non-NAVIGATION items', () => { + const nonNavigationItem = { + ...baseCommandMenuItem, + engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD, + payload: undefined, + label: 'Create New Record', + }; + + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: nonNavigationItem, + fieldName: 'label', + objectMetadata: null, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBe('Create New Record'); + }); + + it('should return undefined when object metadata is null for a NAVIGATION item', () => { + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: baseCommandMenuItem, + fieldName: 'label', + objectMetadata: null, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for undefined shortLabel', () => { + const itemWithoutShortLabel = { + ...baseCommandMenuItem, + shortLabel: undefined, + }; + + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: itemWithoutShortLabel, + fieldName: 'shortLabel', + objectMetadata: mockObjectMetadata, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBeUndefined(); + }); + + it('should resolve label for custom object metadata', () => { + const customObjectMetadata = { + ...mockObjectMetadata, + isCustom: true, + labelPlural: 'Custom Objects', + icon: 'IconCustom', + } as unknown as ObjectMetadataDTO; + + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: baseCommandMenuItem, + fieldName: 'label', + objectMetadata: customObjectMetadata, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBe('Go to Custom Objects'); + }); + + it('should resolve icon for custom object metadata', () => { + const customObjectMetadata = { + ...mockObjectMetadata, + isCustom: true, + icon: 'IconCustom', + } as unknown as ObjectMetadataDTO; + + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: baseCommandMenuItem, + fieldName: 'icon', + objectMetadata: customObjectMetadata, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBe('IconCustom'); + }); + + it('should return raw value when payload has no objectMetadataItemId', () => { + const itemWithPathPayload = { + ...baseCommandMenuItem, + payload: { path: '/settings/profile' }, + }; + + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: itemWithPathPayload, + fieldName: 'label', + objectMetadata: null, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBe(NAVIGATION_INTERPOLATED_LABEL); + }); + + it('should return literal label as-is when it has no template variables', () => { + const itemWithLiteralLabel = { + ...baseCommandMenuItem, + label: 'Go to People', + }; + + const result = interpolateNavigationCommandMenuItemField({ + commandMenuItem: itemWithLiteralLabel, + fieldName: 'label', + objectMetadata: mockObjectMetadata, + locale: undefined, + i18nInstance: mockI18nInstance, + }); + + expect(result).toBe('Go to People'); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/build-navigation-interpolation-context.util.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/build-navigation-interpolation-context.util.ts new file mode 100644 index 00000000000..0aa7e08ac28 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/build-navigation-interpolation-context.util.ts @@ -0,0 +1,54 @@ +import { type I18n } from '@lingui/core'; +import { type APP_LOCALES } from 'twenty-shared/translations'; + +import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto'; +import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util'; + +export type NavigationInterpolationObjectMetadata = { + labelPlural: string; + labelSingular: string; + description?: string | null; + icon?: string | null; + isCustom: boolean; + standardOverrides?: ObjectStandardOverridesDTO | null; +}; + +export const buildNavigationInterpolationContext = ({ + objectMetadata, + locale, + i18nInstance, +}: { + objectMetadata: NavigationInterpolationObjectMetadata; + locale: keyof typeof APP_LOCALES | undefined; + i18nInstance: I18n; +}): Record => { + const overrideInput = { + labelPlural: objectMetadata.labelPlural, + labelSingular: objectMetadata.labelSingular, + description: objectMetadata.description ?? undefined, + icon: objectMetadata.icon ?? undefined, + isCustom: objectMetadata.isCustom, + standardOverrides: objectMetadata.standardOverrides ?? undefined, + }; + + const resolvedLabelPlural = resolveObjectMetadataStandardOverride( + overrideInput, + 'labelPlural', + locale, + i18nInstance, + ); + + const resolvedIcon = resolveObjectMetadataStandardOverride( + overrideInput, + 'icon', + locale, + i18nInstance, + ); + + return { + navigateToObjectMetadataItem: { + labelPlural: resolvedLabelPlural, + icon: resolvedIcon, + }, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/interpolate-navigation-command-menu-item-field.util.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/interpolate-navigation-command-menu-item-field.util.ts new file mode 100644 index 00000000000..4eaaabcd75c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/interpolate-navigation-command-menu-item-field.util.ts @@ -0,0 +1,57 @@ +import { type I18n } from '@lingui/core'; +import { isNonEmptyString } from '@sniptt/guards'; +import { type APP_LOCALES } from 'twenty-shared/translations'; +import { + interpolateCommandMenuItemTemplate, + isDefined, +} from 'twenty-shared/utils'; + +import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; +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 { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; + +export const interpolateNavigationCommandMenuItemField = ({ + commandMenuItem, + fieldName, + objectMetadata, + locale, + i18nInstance, +}: { + commandMenuItem: CommandMenuItemDTO; + fieldName: 'label' | 'shortLabel' | 'icon'; + objectMetadata: ObjectMetadataDTO | null; + locale: keyof typeof APP_LOCALES | undefined; + i18nInstance: I18n; +}): string | undefined => { + const rawValue = commandMenuItem[fieldName]; + + if ( + commandMenuItem.engineComponentKey !== EngineComponentKey.NAVIGATION || + !isObjectMetadataCommandMenuItemPayload(commandMenuItem.payload) + ) { + return rawValue; + } + + if (!isDefined(objectMetadata)) { + return undefined; + } + + if (!isNonEmptyString(rawValue)) { + return rawValue; + } + + const context = buildNavigationInterpolationContext({ + objectMetadata, + locale, + i18nInstance, + }); + + return ( + interpolateCommandMenuItemTemplate({ + label: rawValue, + context, + }) ?? rawValue + ); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util.ts b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util.ts new file mode 100644 index 00000000000..c7c257443c7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util.ts @@ -0,0 +1,9 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type CommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item-payload.union'; +import { type ObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/types/object-metadata-command-menu-item-payload.type'; + +export const isObjectMetadataCommandMenuItemPayload = ( + payload?: CommandMenuItemPayload | null, +): payload is ObjectMetadataCommandMenuItemPayload => + isDefined(payload) && 'objectMetadataItemId' in payload; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/__tests__/build-navigation-flat-command-menu-item.util.test.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/__tests__/build-navigation-flat-command-menu-item.util.test.ts index bd549098a55..96bfdc5c752 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/__tests__/build-navigation-flat-command-menu-item.util.test.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/__tests__/build-navigation-flat-command-menu-item.util.test.ts @@ -2,7 +2,12 @@ import { v5 } from 'uuid'; import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum'; import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum'; -import { buildNavigationFlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util'; +import { + buildNavigationFlatCommandMenuItem, + 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 { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; const NAVIGATION_COMMAND_UUID_NAMESPACE = @@ -11,8 +16,6 @@ const NAVIGATION_COMMAND_UUID_NAMESPACE = const baseObjectMetadata = { id: 'obj-id-1', universalIdentifier: 'obj-universal-1', - labelPlural: 'People', - icon: 'IconUser', nameSingular: 'person', shortcut: 'P', }; @@ -38,17 +41,17 @@ describe('buildNavigationFlatCommandMenuItem', () => { expect(result.universalIdentifier).toBe(expectedUniversalIdentifier); }); - it('should set label and shortLabel from objectMetadata', () => { + it('should set label and shortLabel as interpolation templates', () => { const result = buildNavigationFlatCommandMenuItem(baseArgs); - expect(result.label).toBe('Go to People'); - expect(result.shortLabel).toBe('People'); + expect(result.label).toBe(NAVIGATION_INTERPOLATED_LABEL); + expect(result.shortLabel).toBe(NAVIGATION_INTERPOLATED_SHORT_LABEL); }); - it('should set icon from objectMetadata', () => { + it('should set icon as interpolation template', () => { const result = buildNavigationFlatCommandMenuItem(baseArgs); - expect(result.icon).toBe('IconUser'); + expect(result.icon).toBe(NAVIGATION_INTERPOLATED_ICON); }); it('should set payload with objectMetadataItemId', () => { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util.ts index 4a5731addf8..99f8c9b12a7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util.ts @@ -9,6 +9,13 @@ import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty export const NAVIGATION_COMMAND_UUID_NAMESPACE = 'b31830da-2ae0-48eb-a915-12fa4ab96dd3'; +export const NAVIGATION_INTERPOLATED_LABEL = + 'Go to ${navigateToObjectMetadataItem.labelPlural}'; +export const NAVIGATION_INTERPOLATED_SHORT_LABEL = + '${navigateToObjectMetadataItem.labelPlural}'; +export const NAVIGATION_INTERPOLATED_ICON = + '${navigateToObjectMetadataItem.icon}'; + export const buildNavigationFlatCommandMenuItem = ({ objectMetadata, commandMenuItemId, @@ -20,8 +27,6 @@ export const buildNavigationFlatCommandMenuItem = ({ objectMetadata: { id: string; universalIdentifier: string; - labelPlural: string; - icon: string | null; nameSingular: string; shortcut: string | null; }; @@ -43,9 +48,9 @@ export const buildNavigationFlatCommandMenuItem = ({ applicationUniversalIdentifier: TWENTY_STANDARD_APPLICATION.universalIdentifier, workspaceId, - label: `Go to ${objectMetadata.labelPlural}`, - shortLabel: objectMetadata.labelPlural, - icon: objectMetadata.icon, + label: NAVIGATION_INTERPOLATED_LABEL, + shortLabel: NAVIGATION_INTERPOLATED_SHORT_LABEL, + icon: NAVIGATION_INTERPOLATED_ICON, position, isPinned: false, availabilityType: CommandMenuItemAvailabilityType.GLOBAL, diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts index 6064cef9de1..3b79f0f305d 100644 --- a/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts @@ -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 { @@ -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> { + 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> { diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/__tests__/enrich-command-menu-item-event-with-resolved-navigation.util.spec.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/__tests__/enrich-command-menu-item-event-with-resolved-navigation.util.spec.ts new file mode 100644 index 00000000000..a0561807854 --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/__tests__/enrich-command-menu-item-event-with-resolved-navigation.util.spec.ts @@ -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 => + ({ + 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 => ({ + byUniversalIdentifier: { + [flatObjectMetadata.universalIdentifier]: flatObjectMetadata, + }, + universalIdentifierById: { + [flatObjectMetadata.id]: flatObjectMetadata.universalIdentifier, + }, + universalIdentifiersByApplicationId: {}, +}); + +const makeNavigationRecord = ( + overrides?: Partial, +): 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 = { + 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'); + }); +}); diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/enrich-command-menu-item-event-with-resolved-navigation.util.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/enrich-command-menu-item-event-with-resolved-navigation.util.ts new file mode 100644 index 00000000000..8ac3dd48bba --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/utils/enrich-command-menu-item-event-with-resolved-navigation.util.ts @@ -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; + 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; +}; diff --git a/packages/twenty-server/src/engine/subscriptions/subscriptions.module.ts b/packages/twenty-server/src/engine/subscriptions/subscriptions.module.ts index 7bf274bd813..6b1aebabe4a 100644 --- a/packages/twenty-server/src/engine/subscriptions/subscriptions.module.ts +++ b/packages/twenty-server/src/engine/subscriptions/subscriptions.module.ts @@ -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,