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,48 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { isObjectMetadataNavigationPayload } from '@/command-menu-item/engine-command/utils/isObjectMetadataNavigationPayload';
import { isPathNavigationPayload } from '@/command-menu-item/engine-command/utils/isPathNavigationPayload';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useNavigate } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
export const NavigationEngineCommand = () => {
const { payload } = useHeadlessCommandContextApi();
const navigate = useNavigate();
const { objectMetadataItems } = useObjectMetadataItems();
const onExecute = () => {
if (!isDefined(payload)) {
return;
}
const navigationPayload = payload;
if (isObjectMetadataNavigationPayload(navigationPayload)) {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === navigationPayload.objectMetadataItemId,
);
if (!isDefined(objectMetadataItem)) {
return;
}
const path = getAppPath(AppPath.RecordIndexPage, {
objectNamePlural: objectMetadataItem.namePlural,
});
// eslint-disable-next-line twenty/no-navigate-prefer-link
navigate(path);
return;
}
if (isPathNavigationPayload(navigationPayload)) {
// eslint-disable-next-line twenty/no-navigate-prefer-link
navigate(navigationPayload.path);
}
};
return <HeadlessEngineCommandWrapperEffect execute={onExecute} />;
};
@@ -1,5 +1,13 @@
import { HeadlessFrontComponentRendererEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand';
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { HeadlessOpenSidePanelPageEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand';
import { NavigationEngineCommand } from '@/command-menu-item/engine-command/components/NavigationEngineCommand';
import { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
import { DeleteRecordsCommand } from '@/command-menu-item/engine-command/record/components/DeleteRecordsCommand';
import { DestroyRecordsCommand } from '@/command-menu-item/engine-command/record/components/DestroyRecordsCommand';
import { ExportRecordsCommand } from '@/command-menu-item/engine-command/record/components/ExportRecordsCommand';
import { RestoreRecordsCommand } from '@/command-menu-item/engine-command/record/components/RestoreRecordsCommand';
import { TriggerWorkflowVersionEngineCommand } from '@/command-menu-item/engine-command/record/components/TriggerWorkflowVersionEngineCommand';
import { MergeMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/MergeMultipleRecordsCommand';
import { UpdateMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/UpdateMultipleRecordsCommand';
import { CreateNewIndexRecordNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand';
@@ -7,10 +15,6 @@ import { CreateNewViewNoSelectionRecordCommand } from '@/command-menu-item/engin
import { HideDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand';
import { ImportRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/ImportRecordsNoSelectionRecordCommand';
import { SeeDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/SeeDeletedRecordsNoSelectionRecordCommand';
import { DeleteRecordsCommand } from '@/command-menu-item/engine-command/record/components/DeleteRecordsCommand';
import { DestroyRecordsCommand } from '@/command-menu-item/engine-command/record/components/DestroyRecordsCommand';
import { ExportRecordsCommand } from '@/command-menu-item/engine-command/record/components/ExportRecordsCommand';
import { RestoreRecordsCommand } from '@/command-menu-item/engine-command/record/components/RestoreRecordsCommand';
import { AddToFavoritesSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/AddToFavoritesSingleRecordCommand';
import { ExportNoteSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/ExportNoteSingleRecordCommand';
import { NavigateToNextRecordSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/NavigateToNextRecordSingleRecordCommand';
@@ -20,6 +24,7 @@ import { CancelDashboardSingleRecordCommand } from '@/command-menu-item/engine-c
import { DuplicateDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/DuplicateDashboardSingleRecordCommand';
import { EditDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/EditDashboardSingleRecordCommand';
import { SaveDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand';
import { ReplyToEmailThreadCommand } from '@/command-menu-item/engine-command/record/single-record/message-thread/components/ReplyToEmailThreadCommand';
import { EditRecordPageLayoutSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand';
import { SeeVersionWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand';
import { SeeWorkflowWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand';
@@ -38,10 +43,6 @@ import { SeeRunsWorkflowSingleRecordCommand } from '@/command-menu-item/engine-c
import { SeeVersionsWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/SeeVersionsWorkflowSingleRecordCommand';
import { TestWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TestWorkflowSingleRecordCommand';
import { TidyUpWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand';
import { HeadlessFrontComponentRendererEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand';
import { TriggerWorkflowVersionEngineCommand } from '@/command-menu-item/engine-command/record/components/TriggerWorkflowVersionEngineCommand';
import { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
import { ReplyToEmailThreadCommand } from '@/command-menu-item/engine-command/record/single-record/message-thread/components/ReplyToEmailThreadCommand';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { msg } from '@lingui/core/macro';
import { AppPath, SettingsPath, SidePanelPages } from 'twenty-shared/types';
@@ -122,6 +123,9 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
[EngineComponentKey.CANCEL_DASHBOARD_LAYOUT]: (
<CancelDashboardSingleRecordCommand />
),
[EngineComponentKey.NAVIGATION]: <NavigationEngineCommand />,
// TODO: Remove these keys once we have ran the migration command `upgrade:1-21:refactor-navigation-commands`
// These keys are kept for backward compatibility during migration
[EngineComponentKey.GO_TO_PEOPLE]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
@@ -49,6 +49,7 @@ const buildBaseContextApi = (
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
selectedRecords: [],
graphqlFilter: null,
payload: null,
...overrides,
});
@@ -32,6 +32,7 @@ const buildHeadlessContextApi = (
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
selectedRecords: [],
graphqlFilter: null,
payload: null,
...overrides,
});
@@ -31,6 +31,7 @@ const baseContextApi: HeadlessEngineCommandContextApi = {
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
selectedRecords: [],
graphqlFilter: null,
payload: null,
};
jest.mock(
@@ -24,6 +24,7 @@ const buildHeadlessContextApi = (
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
selectedRecords: [],
graphqlFilter: null,
payload: null,
...overrides,
});
@@ -18,6 +18,7 @@ type MountCommandParams = {
workflowVersionId?: string;
availabilityType?: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
payload?: Record<string, unknown> | null;
};
export const useMountCommand = () => {
@@ -36,11 +37,13 @@ export const useMountCommand = () => {
workflowVersionId,
availabilityType,
availabilityObjectMetadataId,
payload,
}: MountCommandParams) => {
const headlessEngineCommandContextApi = buildHeadlessCommandContextApi({
store,
contextStoreInstanceId,
engineComponentKey,
payload,
});
const commandState = isDefined(frontComponentId)
@@ -16,6 +16,7 @@ export type HeadlessEngineCommandContextApi = {
targetedRecordsRule: ContextStoreTargetedRecordsRule;
selectedRecords: ObjectRecord[];
graphqlFilter: Nullable<RecordGqlOperationFilter>;
payload: Nullable<Record<string, unknown>>;
};
export type HeadlessFrontComponentCommandContextApi =
@@ -11,6 +11,7 @@ const baseContextApi: HeadlessCommandContextApi = {
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
selectedRecords: [],
graphqlFilter: null,
payload: null,
};
describe('isHeadlessTriggerWorkflowVersionCommandContextApi', () => {
@@ -20,10 +20,12 @@ export const buildHeadlessCommandContextApi = ({
store,
contextStoreInstanceId,
engineComponentKey,
payload,
}: {
store: Store;
contextStoreInstanceId: string;
engineComponentKey: EngineComponentKey;
payload?: Record<string, unknown> | null;
}): HeadlessEngineCommandContextApi => {
const objectMetadataItemId = store.get(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
@@ -115,5 +117,6 @@ export const buildHeadlessCommandContextApi = ({
targetedRecordsRule,
selectedRecords,
graphqlFilter,
payload: payload ?? null,
};
};
@@ -0,0 +1,5 @@
export const isObjectMetadataNavigationPayload = (
payload: Record<string, unknown>,
): payload is { objectMetadataItemId: string } =>
'objectMetadataItemId' in payload &&
typeof payload.objectMetadataItemId === 'string';
@@ -0,0 +1,4 @@
export const isPathNavigationPayload = (
payload: Record<string, unknown>,
): payload is { path: string } =>
'path' in payload && typeof payload.path === 'string';