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:
+48
@@ -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} />;
|
||||
};
|
||||
+12
-8
@@ -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}
|
||||
|
||||
+1
@@ -49,6 +49,7 @@ const buildBaseContextApi = (
|
||||
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
|
||||
selectedRecords: [],
|
||||
graphqlFilter: null,
|
||||
payload: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ const buildHeadlessContextApi = (
|
||||
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
|
||||
selectedRecords: [],
|
||||
graphqlFilter: null,
|
||||
payload: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ const baseContextApi: HeadlessEngineCommandContextApi = {
|
||||
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
|
||||
selectedRecords: [],
|
||||
graphqlFilter: null,
|
||||
payload: null,
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ const buildHeadlessContextApi = (
|
||||
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
|
||||
selectedRecords: [],
|
||||
graphqlFilter: null,
|
||||
payload: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ export type HeadlessEngineCommandContextApi = {
|
||||
targetedRecordsRule: ContextStoreTargetedRecordsRule;
|
||||
selectedRecords: ObjectRecord[];
|
||||
graphqlFilter: Nullable<RecordGqlOperationFilter>;
|
||||
payload: Nullable<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type HeadlessFrontComponentCommandContextApi =
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ const baseContextApi: HeadlessCommandContextApi = {
|
||||
targetedRecordsRule: { mode: 'selection', selectedRecordIds: [] },
|
||||
selectedRecords: [],
|
||||
graphqlFilter: null,
|
||||
payload: null,
|
||||
};
|
||||
|
||||
describe('isHeadlessTriggerWorkflowVersionCommandContextApi', () => {
|
||||
|
||||
+3
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const isObjectMetadataNavigationPayload = (
|
||||
payload: Record<string, unknown>,
|
||||
): payload is { objectMetadataItemId: string } =>
|
||||
'objectMetadataItemId' in payload &&
|
||||
typeof payload.objectMetadataItemId === 'string';
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const isPathNavigationPayload = (
|
||||
payload: Record<string, unknown>,
|
||||
): payload is { path: string } =>
|
||||
'path' in payload && typeof payload.path === 'string';
|
||||
Reference in New Issue
Block a user