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:
+136
@@ -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');
|
||||
});
|
||||
});
|
||||
+66
@@ -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,
|
||||
};
|
||||
};
|
||||
+1
@@ -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:
|
||||
|
||||
+6
@@ -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 ??
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+10
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user