Refactor navigation commands to use NAVIGATION engine key with payload (#19303)

- Adds a payload JSON column to `CommandMenuItem` and introduces a
unified `NAVIGATION` engine component key that replaces all individual
GO_TO_* keys
- Navigation commands now use the payload to determine their target
(either an objectMetadataItemId or a path), making navigation commands
dynamic and eliminating the need for a hardcoded engine key per object
- Includes a 1.21 upgrade command (refactor-navigation-commands) that
migrates existing GO_TO_* items to NAVIGATION items with the appropriate
payload, and applies a CHECK constraint enforcing payload coherence



https://github.com/user-attachments/assets/4d305ba2-ae0b-4556-bb0e-e9d899777350


TODO: In a second PR, create the sync between object metadata items and
the navigation command menu items
- Object metadata item created or enabled -> Create navigation command
- Object metadata item deleted or disabled -> Delete associated
navigation command

In another PR:
- Allow `label`, `shortLabel` and `icon` to resolve the
`navigateToObjectMetadataItem` dynamically in their interpolation
instead of being hardcoded in the command menu item
- Make the icon dynamic in the command menu items as the label so that
we can resolve ${navigateToObjectMetadataItem.icon} at runTime -> This
way we won't need to keep update the command menu item icon when we
update the objectMetadataItem icon
This commit is contained in:
Raphaël Bosi
2026-04-07 17:00:04 +02:00
committed by GitHub
parent 5e9792009f
commit d341d0d624
41 changed files with 780 additions and 172 deletions
@@ -0,0 +1,136 @@
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 { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
const NAVIGATION_COMMAND_UUID_NAMESPACE =
'b31830da-2ae0-48eb-a915-12fa4ab96dd3';
const baseObjectMetadata = {
id: 'obj-id-1',
universalIdentifier: 'obj-universal-1',
labelPlural: 'People',
icon: 'IconUser',
nameSingular: 'person',
shortcut: 'P',
};
const baseArgs = {
objectMetadata: baseObjectMetadata,
commandMenuItemId: 'cmd-id-1',
applicationId: 'app-id-1',
workspaceId: 'ws-id-1',
position: 5,
now: '2026-01-01T00:00:00.000Z',
};
describe('buildNavigationFlatCommandMenuItem', () => {
it('should produce a deterministic universalIdentifier via UUID v5', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
const expectedUniversalIdentifier = v5(
baseObjectMetadata.universalIdentifier,
NAVIGATION_COMMAND_UUID_NAMESPACE,
);
expect(result.universalIdentifier).toBe(expectedUniversalIdentifier);
});
it('should set label and shortLabel from objectMetadata', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.label).toBe('Go to People');
expect(result.shortLabel).toBe('People');
});
it('should set icon from objectMetadata', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.icon).toBe('IconUser');
});
it('should set payload with objectMetadataItemId', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.payload).toEqual({ objectMetadataItemId: 'obj-id-1' });
});
it('should include shortcut in hotKeys when shortcut is defined', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.hotKeys).toEqual(['G', 'P']);
});
it('should set hotKeys to null when shortcut is null', () => {
const result = buildNavigationFlatCommandMenuItem({
...baseArgs,
objectMetadata: { ...baseObjectMetadata, shortcut: null },
});
expect(result.hotKeys).toBeNull();
});
it('should use the provided id, applicationId, workspaceId, and position', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.id).toBe('cmd-id-1');
expect(result.applicationId).toBe('app-id-1');
expect(result.workspaceId).toBe('ws-id-1');
expect(result.position).toBe(5);
});
it('should set applicationUniversalIdentifier from TWENTY_STANDARD_APPLICATION', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.applicationUniversalIdentifier).toBe(
TWENTY_STANDARD_APPLICATION.universalIdentifier,
);
});
it('should set engineComponentKey to NAVIGATION', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.engineComponentKey).toBe(EngineComponentKey.NAVIGATION);
});
it('should set availabilityType to GLOBAL', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.availabilityType).toBe(
CommandMenuItemAvailabilityType.GLOBAL,
);
});
it('should set conditionalAvailabilityExpression based on nameSingular', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.conditionalAvailabilityExpression).toBe(
'targetObjectReadPermissions.person',
);
});
it('should set isPinned to false', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.isPinned).toBe(false);
});
it('should set null fields correctly', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.frontComponentId).toBeNull();
expect(result.frontComponentUniversalIdentifier).toBeNull();
expect(result.workflowVersionId).toBeNull();
expect(result.availabilityObjectMetadataId).toBeNull();
expect(result.availabilityObjectMetadataUniversalIdentifier).toBeNull();
});
it('should set createdAt and updatedAt from the now parameter', () => {
const result = buildNavigationFlatCommandMenuItem(baseArgs);
expect(result.createdAt).toBe('2026-01-01T00:00:00.000Z');
expect(result.updatedAt).toBe('2026-01-01T00:00:00.000Z');
});
});
@@ -0,0 +1,66 @@
import { isDefined } from 'twenty-shared/utils';
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 { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
export const NAVIGATION_COMMAND_UUID_NAMESPACE =
'b31830da-2ae0-48eb-a915-12fa4ab96dd3';
export const buildNavigationFlatCommandMenuItem = ({
objectMetadata,
commandMenuItemId,
applicationId,
workspaceId,
position,
now,
}: {
objectMetadata: {
id: string;
universalIdentifier: string;
labelPlural: string;
icon: string | null;
nameSingular: string;
shortcut: string | null;
};
commandMenuItemId: string;
applicationId: string;
workspaceId: string;
position: number;
now: string;
}): FlatCommandMenuItem => {
const universalIdentifier = v5(
objectMetadata.universalIdentifier,
NAVIGATION_COMMAND_UUID_NAMESPACE,
);
return {
id: commandMenuItemId,
universalIdentifier,
applicationId,
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION.universalIdentifier,
workspaceId,
label: `Go to ${objectMetadata.labelPlural}`,
shortLabel: objectMetadata.labelPlural,
icon: objectMetadata.icon,
position,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: `targetObjectReadPermissions.${objectMetadata.nameSingular}`,
frontComponentId: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.NAVIGATION,
payload: { objectMetadataItemId: objectMetadata.id },
hotKeys: isDefined(objectMetadata.shortcut)
? ['G', objectMetadata.shortcut]
: null,
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
};
};
@@ -67,6 +67,7 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({
shortLabel: commandMenuItemEntity.shortLabel,
position: commandMenuItemEntity.position,
isPinned: commandMenuItemEntity.isPinned,
payload: commandMenuItemEntity.payload,
hotKeys: commandMenuItemEntity.hotKeys,
availabilityType: commandMenuItemEntity.availabilityType,
availabilityObjectMetadataId:
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input';
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 { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
@@ -49,6 +50,11 @@ export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({
shortLabel: createCommandMenuItemInput.shortLabel ?? null,
position: createCommandMenuItemInput.position ?? 0,
isPinned: createCommandMenuItemInput.isPinned ?? false,
payload:
createCommandMenuItemInput.engineComponentKey ===
EngineComponentKey.NAVIGATION
? (createCommandMenuItemInput.payload ?? null)
: null,
hotKeys: createCommandMenuItemInput.hotKeys ?? null,
availabilityType:
createCommandMenuItemInput.availabilityType ??
@@ -13,6 +13,7 @@ export const fromFlatCommandMenuItemToCommandMenuItemDto = (
shortLabel: flatCommandMenuItem.shortLabel ?? undefined,
position: flatCommandMenuItem.position,
isPinned: flatCommandMenuItem.isPinned,
payload: flatCommandMenuItem.payload ?? undefined,
hotKeys: flatCommandMenuItem.hotKeys ?? undefined,
availabilityType: flatCommandMenuItem.availabilityType,
conditionalAvailabilityExpression:
@@ -0,0 +1,10 @@
export const seedCompareObjectMetadataForNavigationPosition = (
a: { isSystem: boolean },
b: { isSystem: boolean },
): number => {
if (a.isSystem !== b.isSystem) {
return a.isSystem ? 1 : -1;
}
return 0;
};