[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
@@ -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,
@@ -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<string> {
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<string | undefined> {
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<string | undefined> {
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,
@@ -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<CommandMenuItemDTO[]> {
@@ -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<ObjectMetadataDTO | null> {
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<string | undefined> {
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,
@@ -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');
});
});
@@ -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<string, unknown> => {
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,
},
};
};
@@ -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
);
};
@@ -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;