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
@@ -2575,6 +2575,7 @@ type CommandMenuItem {
position: Float!
isPinned: Boolean!
availabilityType: CommandMenuItemAvailabilityType!
payload: JSON
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
@@ -2601,19 +2602,11 @@ enum EngineComponentKey {
SEE_DELETED_RECORDS
CREATE_NEW_VIEW
HIDE_DELETED_RECORDS
GO_TO_PEOPLE
GO_TO_COMPANIES
GO_TO_DASHBOARDS
GO_TO_OPPORTUNITIES
GO_TO_SETTINGS
GO_TO_TASKS
GO_TO_NOTES
EDIT_RECORD_PAGE_LAYOUT
EDIT_DASHBOARD_LAYOUT
SAVE_DASHBOARD_LAYOUT
CANCEL_DASHBOARD_LAYOUT
DUPLICATE_DASHBOARD
GO_TO_WORKFLOWS
ACTIVATE_WORKFLOW
DEACTIVATE_WORKFLOW
DISCARD_DRAFT_WORKFLOW
@@ -2624,7 +2617,6 @@ enum EngineComponentKey {
ADD_NODE_WORKFLOW
TIDY_UP_WORKFLOW
DUPLICATE_WORKFLOW
GO_TO_RUNS
SEE_VERSION_WORKFLOW_RUN
SEE_WORKFLOW_WORKFLOW_RUN
STOP_WORKFLOW_RUN
@@ -2636,10 +2628,20 @@ enum EngineComponentKey {
SEARCH_RECORDS_FALLBACK
ASK_AI
VIEW_PREVIOUS_AI_CHATS
NAVIGATION
TRIGGER_WORKFLOW_VERSION
FRONT_COMPONENT_RENDERER
REPLY_TO_EMAIL_THREAD
COMPOSE_EMAIL
GO_TO_PEOPLE
GO_TO_COMPANIES
GO_TO_DASHBOARDS
GO_TO_OPPORTUNITIES
GO_TO_SETTINGS
GO_TO_TASKS
GO_TO_NOTES
GO_TO_WORKFLOWS
GO_TO_RUNS
DELETE_SINGLE_RECORD
DELETE_MULTIPLE_RECORDS
RESTORE_SINGLE_RECORD
@@ -4049,6 +4051,7 @@ input CreateCommandMenuItemInput {
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
payload: JSON
}
input UpdateCommandMenuItemInput {
@@ -2286,6 +2286,7 @@ export interface CommandMenuItem {
position: Scalars['Float']
isPinned: Scalars['Boolean']
availabilityType: CommandMenuItemAvailabilityType
payload?: Scalars['JSON']
hotKeys?: Scalars['String'][]
conditionalAvailabilityExpression?: Scalars['String']
availabilityObjectMetadataId?: Scalars['UUID']
@@ -2295,7 +2296,7 @@ export interface CommandMenuItem {
__typename: 'CommandMenuItem'
}
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'GO_TO_WORKFLOWS' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'GO_TO_RUNS' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'RECORD_SELECTION' | 'FALLBACK'
@@ -5485,6 +5486,7 @@ export interface CommandMenuItemGenqlSelection{
position?: boolean | number
isPinned?: boolean | number
availabilityType?: boolean | number
payload?: boolean | number
hotKeys?: boolean | number
conditionalAvailabilityExpression?: boolean | number
availabilityObjectMetadataId?: boolean | number
@@ -6458,7 +6460,7 @@ update: UpdateLogicFunctionFromSourceInputUpdates}
export interface UpdateLogicFunctionFromSourceInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),sourceHandlerCode?: (Scalars['String'] | null),toolInputSchema?: (Scalars['JSON'] | null),handlerName?: (Scalars['String'] | null),sourceHandlerPath?: (Scalars['String'] | null),isTool?: (Scalars['Boolean'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null)}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null)}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),payload?: (Scalars['JSON'] | null)}
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null)}
@@ -9263,19 +9265,11 @@ export const enumEngineComponentKey = {
SEE_DELETED_RECORDS: 'SEE_DELETED_RECORDS' as const,
CREATE_NEW_VIEW: 'CREATE_NEW_VIEW' as const,
HIDE_DELETED_RECORDS: 'HIDE_DELETED_RECORDS' as const,
GO_TO_PEOPLE: 'GO_TO_PEOPLE' as const,
GO_TO_COMPANIES: 'GO_TO_COMPANIES' as const,
GO_TO_DASHBOARDS: 'GO_TO_DASHBOARDS' as const,
GO_TO_OPPORTUNITIES: 'GO_TO_OPPORTUNITIES' as const,
GO_TO_SETTINGS: 'GO_TO_SETTINGS' as const,
GO_TO_TASKS: 'GO_TO_TASKS' as const,
GO_TO_NOTES: 'GO_TO_NOTES' as const,
EDIT_RECORD_PAGE_LAYOUT: 'EDIT_RECORD_PAGE_LAYOUT' as const,
EDIT_DASHBOARD_LAYOUT: 'EDIT_DASHBOARD_LAYOUT' as const,
SAVE_DASHBOARD_LAYOUT: 'SAVE_DASHBOARD_LAYOUT' as const,
CANCEL_DASHBOARD_LAYOUT: 'CANCEL_DASHBOARD_LAYOUT' as const,
DUPLICATE_DASHBOARD: 'DUPLICATE_DASHBOARD' as const,
GO_TO_WORKFLOWS: 'GO_TO_WORKFLOWS' as const,
ACTIVATE_WORKFLOW: 'ACTIVATE_WORKFLOW' as const,
DEACTIVATE_WORKFLOW: 'DEACTIVATE_WORKFLOW' as const,
DISCARD_DRAFT_WORKFLOW: 'DISCARD_DRAFT_WORKFLOW' as const,
@@ -9286,7 +9280,6 @@ export const enumEngineComponentKey = {
ADD_NODE_WORKFLOW: 'ADD_NODE_WORKFLOW' as const,
TIDY_UP_WORKFLOW: 'TIDY_UP_WORKFLOW' as const,
DUPLICATE_WORKFLOW: 'DUPLICATE_WORKFLOW' as const,
GO_TO_RUNS: 'GO_TO_RUNS' as const,
SEE_VERSION_WORKFLOW_RUN: 'SEE_VERSION_WORKFLOW_RUN' as const,
SEE_WORKFLOW_WORKFLOW_RUN: 'SEE_WORKFLOW_WORKFLOW_RUN' as const,
STOP_WORKFLOW_RUN: 'STOP_WORKFLOW_RUN' as const,
@@ -9298,10 +9291,20 @@ export const enumEngineComponentKey = {
SEARCH_RECORDS_FALLBACK: 'SEARCH_RECORDS_FALLBACK' as const,
ASK_AI: 'ASK_AI' as const,
VIEW_PREVIOUS_AI_CHATS: 'VIEW_PREVIOUS_AI_CHATS' as const,
NAVIGATION: 'NAVIGATION' as const,
TRIGGER_WORKFLOW_VERSION: 'TRIGGER_WORKFLOW_VERSION' as const,
FRONT_COMPONENT_RENDERER: 'FRONT_COMPONENT_RENDERER' as const,
REPLY_TO_EMAIL_THREAD: 'REPLY_TO_EMAIL_THREAD' as const,
COMPOSE_EMAIL: 'COMPOSE_EMAIL' as const,
GO_TO_PEOPLE: 'GO_TO_PEOPLE' as const,
GO_TO_COMPANIES: 'GO_TO_COMPANIES' as const,
GO_TO_DASHBOARDS: 'GO_TO_DASHBOARDS' as const,
GO_TO_OPPORTUNITIES: 'GO_TO_OPPORTUNITIES' as const,
GO_TO_SETTINGS: 'GO_TO_SETTINGS' as const,
GO_TO_TASKS: 'GO_TO_TASKS' as const,
GO_TO_NOTES: 'GO_TO_NOTES' as const,
GO_TO_WORKFLOWS: 'GO_TO_WORKFLOWS' as const,
GO_TO_RUNS: 'GO_TO_RUNS' as const,
DELETE_SINGLE_RECORD: 'DELETE_SINGLE_RECORD' as const,
DELETE_MULTIPLE_RECORDS: 'DELETE_MULTIPLE_RECORDS' as const,
RESTORE_SINGLE_RECORD: 'RESTORE_SINGLE_RECORD' as const,
@@ -5159,6 +5159,9 @@ export default {
"availabilityType": [
297
],
"payload": [
15
],
"hotKeys": [
1
],
@@ -10247,6 +10250,9 @@ export default {
"availabilityObjectMetadataId": [
3
],
"payload": [
15
],
"__typename": [
1
]
File diff suppressed because one or more lines are too long
@@ -60,6 +60,7 @@ export const HeadlessCommandMenuItem = ({
workflowVersionId: item.workflowVersionId ?? undefined,
availabilityType: item.availabilityType,
availabilityObjectMetadataId: item.availabilityObjectMetadataId,
payload: item.payload ?? undefined,
});
};
@@ -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';
@@ -16,6 +16,7 @@ export const COMMAND_MENU_ITEM_FRAGMENT = gql`
shortLabel
position
isPinned
payload
hotKeys
conditionalAvailabilityExpression
availabilityType
@@ -270,6 +270,7 @@ export class BackfillCommandMenuItemsCommand extends ActiveOrSuspendedWorkspaceC
icon: trigger.settings.icon ?? null,
isPinned: trigger.settings.isPinned ?? false,
position: 0,
payload: null,
hotKeys: null,
availabilityType,
availabilityObjectMetadataId: availabilityObjectMetadataId ?? null,
@@ -0,0 +1,264 @@
import { InjectDataSource } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { v4, v5 } from 'uuid';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import {
type RunOnWorkspaceArgs,
type WorkspaceCommandOptions,
} from 'src/database/commands/command-runners/workspace.command-runner';
import { addPayloadCheckConstraintToCommandMenuItem } from 'src/database/typeorm/core/migrations/utils/1775129635528-add-payload-to-command-menu-item.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
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 {
buildNavigationFlatCommandMenuItem,
NAVIGATION_COMMAND_UUID_NAMESPACE,
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util';
import { seedCompareObjectMetadataForNavigationPosition } from 'src/engine/metadata-modules/flat-command-menu-item/utils/seed-compare-object-metadata-for-navigation-position.util';
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const GO_TO_ENGINE_KEYS = [
'GO_TO_PEOPLE',
'GO_TO_COMPANIES',
'GO_TO_DASHBOARDS',
'GO_TO_OPPORTUNITIES',
'GO_TO_SETTINGS',
'GO_TO_TASKS',
'GO_TO_NOTES',
'GO_TO_WORKFLOWS',
'GO_TO_RUNS',
];
@Command({
name: 'upgrade:1-21:refactor-navigation-commands',
description:
'Replace GO_TO_* command menu items with unified NAVIGATION engine key and payload',
})
export class RefactorNavigationCommandsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async run(
passedParams: string[],
options: WorkspaceCommandOptions,
): Promise<void> {
await super.run(passedParams, options);
if (options.workspaceId && options.workspaceId.size > 0) {
this.logger.log(
'Skipping CHECK constraint application: command was not launched for all workspaces',
);
return;
}
if (options.dryRun) {
this.logger.log(
'[DRY RUN] Would apply CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE',
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
try {
await addPayloadCheckConstraintToCommandMenuItem(queryRunner);
this.logger.log(
'Successfully applied CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE',
);
} finally {
await queryRunner.release();
}
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Refactoring navigation commands for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatCommandMenuItemMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
'flatObjectMetadataMaps',
]);
const allCommandMenuItems = Object.values(
flatCommandMenuItemMaps.byUniversalIdentifier,
).filter(isDefined);
const standardAppCommandMenuItems = allCommandMenuItems.filter(
(item) => item.applicationId === twentyStandardFlatApplication.id,
);
const goToItemsToDelete = standardAppCommandMenuItems.filter((item) =>
GO_TO_ENGINE_KEYS.includes(item.engineComponentKey),
);
this.logger.log(
`${isDryRun ? '[DRY RUN] Would delete' : 'Deleting'} ${goToItemsToDelete.length} old GO_TO_* command(s) for workspace ${workspaceId}`,
);
const existingNavigationUniversalIdentifiers = new Set(
allCommandMenuItems
.filter(
(item) => item.engineComponentKey === EngineComponentKey.NAVIGATION,
)
.map((item) => item.universalIdentifier),
);
const activeObjects = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((objectMetadata) => objectMetadata.isActive)
.sort(seedCompareObjectMetadataForNavigationPosition);
this.logger.log(
`Found ${activeObjects.length} active object(s) for workspace ${workspaceId}`,
);
const nonGoToItems = allCommandMenuItems.filter(
(item) => !GO_TO_ENGINE_KEYS.includes(item.engineComponentKey),
);
let nextPosition =
nonGoToItems.reduce((max, item) => Math.max(max, item.position), -1) + 1;
const now = new Date().toISOString();
const flatCommandMenuItemsToCreate: FlatCommandMenuItem[] = [];
for (const objectMetadata of activeObjects) {
const universalIdentifier = v5(
objectMetadata.universalIdentifier,
NAVIGATION_COMMAND_UUID_NAMESPACE,
);
if (existingNavigationUniversalIdentifiers.has(universalIdentifier)) {
continue;
}
flatCommandMenuItemsToCreate.push(
buildNavigationFlatCommandMenuItem({
objectMetadata,
commandMenuItemId: v4(),
applicationId: twentyStandardFlatApplication.id,
workspaceId,
position: nextPosition++,
now,
}),
);
}
const settingsUniversalIdentifier =
STANDARD_COMMAND_MENU_ITEMS.goToSettings.universalIdentifier;
if (
!existingNavigationUniversalIdentifiers.has(settingsUniversalIdentifier)
) {
flatCommandMenuItemsToCreate.push({
id: v4(),
universalIdentifier: settingsUniversalIdentifier,
applicationId: twentyStandardFlatApplication.id,
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION.universalIdentifier,
workspaceId,
label: 'Go to Settings',
shortLabel: 'Settings',
icon: 'IconSettings',
position: nextPosition++,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
frontComponentId: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.NAVIGATION,
payload: { path: '/settings/profile' },
hotKeys: ['G', 'S'],
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
});
}
if (
goToItemsToDelete.length === 0 &&
flatCommandMenuItemsToCreate.length === 0
) {
this.logger.log(
`All NAVIGATION commands already exist for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] Would create' : 'Creating'} ${flatCommandMenuItemsToCreate.length} NAVIGATION command(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: flatCommandMenuItemsToCreate,
flatEntityToDelete: goToItemsToDelete,
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to refactor navigation commands:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to refactor navigation commands for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully refactored navigation commands for workspace ${workspaceId}`,
);
}
}
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { AddComposeEmailCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-compose-email-command-menu-item.command';
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-messaging-infrastructure-to-metadata.command';
import { RefactorNavigationCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-refactor-navigation-commands.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-datasource-to-workspace.command';
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-message-thread-subject.command';
@@ -59,6 +60,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FixSelectAllCommandMenuItemsCommand,
MigrateAiAgentTextToJsonResponseFormatCommand,
UpdateEditLayoutCommandMenuItemLabelCommand,
RefactorNavigationCommandsCommand,
DropWorkspaceMessagingFksCommand,
MigrateMessageFolderParentIdToExternalIdCommand,
MigrateMessagingInfrastructureToMetadataCommand,
@@ -73,6 +75,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FixSelectAllCommandMenuItemsCommand,
MigrateAiAgentTextToJsonResponseFormatCommand,
UpdateEditLayoutCommandMenuItemLabelCommand,
RefactorNavigationCommandsCommand,
DropWorkspaceMessagingFksCommand,
MigrateMessageFolderParentIdToExternalIdCommand,
MigrateMessagingInfrastructureToMetadataCommand,
@@ -24,7 +24,9 @@ import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/co
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { UpdateStandardIndexViewNamesCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-update-standard-index-view-names.command';
import { AddComposeEmailCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-compose-email-command-menu-item.command';
import { RefactorNavigationCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-refactor-navigation-commands.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-datasource-to-workspace.command';
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-workspace-command-backfill-message-thread-subject.command';
@@ -85,6 +87,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
private readonly fixSelectAllCommandMenuItemsCommand: FixSelectAllCommandMenuItemsCommand,
private readonly migrateAiAgentTextToJsonResponseFormatCommand: MigrateAiAgentTextToJsonResponseFormatCommand,
private readonly updateEditLayoutCommandMenuItemLabelCommand: UpdateEditLayoutCommandMenuItemLabelCommand,
private readonly refactorNavigationCommandsCommand: RefactorNavigationCommandsCommand,
private readonly dropWorkspaceMessagingFksCommand: DropWorkspaceMessagingFksCommand,
private readonly migrateMessageFolderParentIdToExternalIdCommand: MigrateMessageFolderParentIdToExternalIdCommand,
) {
@@ -129,6 +132,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.fixSelectAllCommandMenuItemsCommand,
this.migrateAiAgentTextToJsonResponseFormatCommand,
this.updateEditLayoutCommandMenuItemLabelCommand,
this.refactorNavigationCommandsCommand,
this.dropWorkspaceMessagingFksCommand,
this.migrateMessageFolderParentIdToExternalIdCommand,
];
@@ -0,0 +1,56 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
import { addPayloadCheckConstraintToCommandMenuItem } from 'src/database/typeorm/core/migrations/utils/1775129635528-add-payload-to-command-menu-item.util';
export class AddPayloadToCommandMenuItem1775129635528
implements MigrationInterface
{
name = 'AddPayloadToCommandMenuItem1775129635528';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" ADD "payload" jsonb`,
);
const savepointName =
'sp_add_payload_check_constraint_to_command_menu_item';
try {
await queryRunner.query(`SAVEPOINT ${savepointName}`);
await addPayloadCheckConstraintToCommandMenuItem(queryRunner);
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
} catch (e) {
try {
await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
} catch (rollbackError) {
// oxlint-disable-next-line no-console
console.error(
'Failed to rollback to savepoint in AddPayloadToCommandMenuItem1775129635528',
rollbackError,
);
throw rollbackError;
}
// oxlint-disable-next-line no-console
console.error(
'Swallowing AddPayloadToCommandMenuItem1775129635528 error',
e,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT IF EXISTS "CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE" CHECK (("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL))`,
);
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" DROP COLUMN "payload"`,
);
}
}
@@ -0,0 +1,13 @@
import { type QueryRunner } from 'typeorm';
export const addPayloadCheckConstraintToCommandMenuItem = async (
queryRunner: QueryRunner,
): Promise<void> => {
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT IF EXISTS "CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE" CHECK (("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'NAVIGATION' AND "payload" IS NOT NULL AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL))`,
);
};
@@ -40,6 +40,7 @@ export const fromCommandMenuItemManifestToUniversalFlatCommandMenuItem = ({
availabilityObjectMetadataUniversalIdentifier:
commandMenuItemManifest.availabilityObjectUniversalIdentifier ?? null,
engineComponentKey: EngineComponentKey.FRONT_COMPONENT_RENDERER,
payload: null,
hotKeys: null,
workflowVersionId: null,
createdAt: now,
@@ -10,6 +10,7 @@ import {
IsString,
IsUUID,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
@@ -68,6 +69,10 @@ export class CommandMenuItemDTO {
@Field(() => CommandMenuItemAvailabilityType)
availabilityType: CommandMenuItemAvailabilityType;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
payload?: Record<string, unknown>;
@IsString({ each: true })
@IsOptional()
@Field(() => [String], { nullable: true })
@@ -5,10 +5,12 @@ import {
IsEnum,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
@@ -75,4 +77,9 @@ export class CreateCommandMenuItemInput {
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
availabilityObjectMetadataId?: string;
@IsObject()
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
payload?: Record<string, unknown>;
}
@@ -31,7 +31,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
])
@Check(
'CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE',
`("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL)`,
`("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'NAVIGATION' AND "payload" IS NOT NULL AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`,
)
export class CommandMenuItemEntity
extends SyncableEntity
@@ -79,6 +79,9 @@ export class CommandMenuItemEntity
})
availabilityType: CommandMenuItemAvailabilityType;
@Column({ type: 'jsonb', nullable: true })
payload: Record<string, unknown> | null;
@Column({ type: 'text', array: true, nullable: true })
hotKeys: string[] | null;
@@ -18,19 +18,11 @@ export enum EngineComponentKey {
SEE_DELETED_RECORDS = 'SEE_DELETED_RECORDS',
CREATE_NEW_VIEW = 'CREATE_NEW_VIEW',
HIDE_DELETED_RECORDS = 'HIDE_DELETED_RECORDS',
GO_TO_PEOPLE = 'GO_TO_PEOPLE',
GO_TO_COMPANIES = 'GO_TO_COMPANIES',
GO_TO_DASHBOARDS = 'GO_TO_DASHBOARDS',
GO_TO_OPPORTUNITIES = 'GO_TO_OPPORTUNITIES',
GO_TO_SETTINGS = 'GO_TO_SETTINGS',
GO_TO_TASKS = 'GO_TO_TASKS',
GO_TO_NOTES = 'GO_TO_NOTES',
EDIT_RECORD_PAGE_LAYOUT = 'EDIT_RECORD_PAGE_LAYOUT',
EDIT_DASHBOARD_LAYOUT = 'EDIT_DASHBOARD_LAYOUT',
SAVE_DASHBOARD_LAYOUT = 'SAVE_DASHBOARD_LAYOUT',
CANCEL_DASHBOARD_LAYOUT = 'CANCEL_DASHBOARD_LAYOUT',
DUPLICATE_DASHBOARD = 'DUPLICATE_DASHBOARD',
GO_TO_WORKFLOWS = 'GO_TO_WORKFLOWS',
ACTIVATE_WORKFLOW = 'ACTIVATE_WORKFLOW',
DEACTIVATE_WORKFLOW = 'DEACTIVATE_WORKFLOW',
DISCARD_DRAFT_WORKFLOW = 'DISCARD_DRAFT_WORKFLOW',
@@ -41,7 +33,6 @@ export enum EngineComponentKey {
ADD_NODE_WORKFLOW = 'ADD_NODE_WORKFLOW',
TIDY_UP_WORKFLOW = 'TIDY_UP_WORKFLOW',
DUPLICATE_WORKFLOW = 'DUPLICATE_WORKFLOW',
GO_TO_RUNS = 'GO_TO_RUNS',
SEE_VERSION_WORKFLOW_RUN = 'SEE_VERSION_WORKFLOW_RUN',
SEE_WORKFLOW_WORKFLOW_RUN = 'SEE_WORKFLOW_WORKFLOW_RUN',
STOP_WORKFLOW_RUN = 'STOP_WORKFLOW_RUN',
@@ -53,11 +44,24 @@ export enum EngineComponentKey {
SEARCH_RECORDS_FALLBACK = 'SEARCH_RECORDS_FALLBACK',
ASK_AI = 'ASK_AI',
VIEW_PREVIOUS_AI_CHATS = 'VIEW_PREVIOUS_AI_CHATS',
NAVIGATION = 'NAVIGATION',
TRIGGER_WORKFLOW_VERSION = 'TRIGGER_WORKFLOW_VERSION',
FRONT_COMPONENT_RENDERER = 'FRONT_COMPONENT_RENDERER',
REPLY_TO_EMAIL_THREAD = 'REPLY_TO_EMAIL_THREAD',
COMPOSE_EMAIL = 'COMPOSE_EMAIL',
// TODO: Remove deprecated keys once upgrade:1-21:refactor-navigation-commands has run on all workspaces
// Deprecated: replaced by NAVIGATION engine key with payload
GO_TO_PEOPLE = 'GO_TO_PEOPLE',
GO_TO_COMPANIES = 'GO_TO_COMPANIES',
GO_TO_DASHBOARDS = 'GO_TO_DASHBOARDS',
GO_TO_OPPORTUNITIES = 'GO_TO_OPPORTUNITIES',
GO_TO_SETTINGS = 'GO_TO_SETTINGS',
GO_TO_TASKS = 'GO_TO_TASKS',
GO_TO_NOTES = 'GO_TO_NOTES',
GO_TO_WORKFLOWS = 'GO_TO_WORKFLOWS',
GO_TO_RUNS = 'GO_TO_RUNS',
// Deprecated keys kept for backward compatibility until migration runs
DELETE_SINGLE_RECORD = 'DELETE_SINGLE_RECORD',
DELETE_MULTIPLE_RECORDS = 'DELETE_MULTIPLE_RECORDS',
@@ -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;
};
@@ -31,9 +31,11 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"conditionalAvailabilityExpression",
"availabilityObjectMetadataUniversalIdentifier",
"engineComponentKey",
"payload",
"hotKeys",
],
"propertiesToStringify": [
"payload",
"hotKeys",
],
},
@@ -1092,6 +1092,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
payload: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
hotKeys: {
toCompare: true,
toStringify: true,
@@ -254,105 +254,6 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.HIDE_DELETED_RECORDS,
hotKeys: null,
},
goToPeople: {
universalIdentifier: 'dfe5fef8-d42c-40f0-941f-8e3b5eb01daa',
label: 'Go to People',
icon: 'IconUser',
isPinned: false,
position: 23,
shortLabel: 'People',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.person',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_PEOPLE,
hotKeys: ['G', 'P'],
},
goToCompanies: {
universalIdentifier: '196e4eec-bfdd-48a6-bcbb-6707ef11951a',
label: 'Go to Companies',
icon: 'IconBuildingSkyscraper',
isPinned: false,
position: 24,
shortLabel: 'Companies',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.company',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_COMPANIES,
hotKeys: ['G', 'C'],
},
goToDashboards: {
universalIdentifier: '11dc07d1-21b2-4f86-af8c-6a664c02f00c',
label: 'Go to Dashboards',
icon: 'IconLayoutDashboard',
isPinned: false,
position: 25,
shortLabel: 'Dashboards',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.dashboard',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_DASHBOARDS,
hotKeys: ['G', 'D'],
},
goToOpportunities: {
universalIdentifier: 'f04f5e00-a208-422f-acf2-ff189769510d',
label: 'Go to Opportunities',
icon: 'IconTargetArrow',
isPinned: false,
position: 26,
shortLabel: 'Opportunities',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression:
'targetObjectReadPermissions.opportunity',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_OPPORTUNITIES,
hotKeys: ['G', 'O'],
},
goToSettings: {
universalIdentifier: 'ef9aba44-0068-453e-930a-f8c182af18ee',
label: 'Go to Settings',
icon: 'IconSettings',
isPinned: false,
position: 27,
shortLabel: 'Settings',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_SETTINGS,
hotKeys: ['G', 'S'],
},
goToTasks: {
universalIdentifier: 'e8e3bd0b-5ce9-4577-bb61-588c0b1ad063',
label: 'Go to Tasks',
icon: 'IconCheckbox',
isPinned: false,
position: 28,
shortLabel: 'Tasks',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.task',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_TASKS,
hotKeys: ['G', 'T'],
},
goToNotes: {
universalIdentifier: '08e0f0cc-ac2d-46cd-9bca-39a409b9addf',
label: 'Go to Notes',
icon: 'IconCheckbox',
isPinned: false,
position: 29,
shortLabel: 'Notes',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.note',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_NOTES,
hotKeys: ['G', 'N'],
},
editRecordPageLayout: {
universalIdentifier: 'd9794c67-1799-424f-8871-5ea771dd4a6d',
label: 'Edit Layout',
@@ -432,20 +333,6 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.DUPLICATE_DASHBOARD,
hotKeys: null,
},
goToWorkflows: {
universalIdentifier: '4fa778f9-7931-4d18-b895-929e1ef9c31f',
label: 'Go to Workflows',
icon: 'IconSettingsAutomation',
isPinned: false,
position: 41,
shortLabel: 'Workflows',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: 'targetObjectReadPermissions.workflow',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_WORKFLOWS,
hotKeys: ['G', 'W'],
},
activateWorkflow: {
universalIdentifier: '44f19c85-0fd0-482f-a14e-da513c60b1b3',
label: 'Activate Workflow',
@@ -606,21 +493,6 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.DUPLICATE_WORKFLOW,
hotKeys: null,
},
goToRuns: {
universalIdentifier: '1ba959da-ff49-4c1f-a517-2b78ee200508',
label: 'Go to Runs',
icon: 'IconHistoryToggle',
isPinned: false,
position: 52,
shortLabel: 'See Runs',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression:
'not hasAnySoftDeleteFilterOnView and objectMetadataItem.nameSingular != "workflowRun" and objectMetadataItem.nameSingular != "workflowVersion" and objectMetadataItem.nameSingular != "workflow"',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.GO_TO_RUNS,
hotKeys: null,
},
seeVersionWorkflowRun: {
universalIdentifier: 'cc3a065c-c89e-40ac-9449-4272c55b1bb8',
label: 'See Version',
@@ -816,4 +688,19 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
hotKeys: null,
},
goToSettings: {
universalIdentifier: 'ef9aba44-0068-453e-930a-f8c182af18ee',
label: 'Go to Settings',
icon: 'IconSettings',
isPinned: false,
position: 27,
shortLabel: 'Settings',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.NAVIGATION,
hotKeys: ['G', 'S'],
payload: { path: '/settings/profile' },
},
} as const;
@@ -1,6 +1,9 @@
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type';
import { buildNavigationFlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util';
import { seedCompareObjectMetadataForNavigationPosition } from 'src/engine/metadata-modules/flat-command-menu-item/utils/seed-compare-object-metadata-for-navigation-position.util';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -46,5 +49,39 @@ export const buildStandardFlatCommandMenuItemMaps = ({
});
}
const activeObjects = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((flatObject) => flatObject.isActive)
.sort(seedCompareObjectMetadataForNavigationPosition);
const maxStandardPosition = Object.values(
flatCommandMenuItemMaps.byUniversalIdentifier,
).reduce(
(max, item) => (isDefined(item) ? Math.max(max, item.position) : max),
-1,
);
let nextPosition = maxStandardPosition + 1;
for (const flatObject of activeObjects) {
const position = nextPosition++;
const navigationItem = buildNavigationFlatCommandMenuItem({
objectMetadata: flatObject,
commandMenuItemId: v4(),
applicationId: twentyStandardApplicationId,
workspaceId,
position,
now,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: navigationItem,
flatEntityMapsToMutate: flatCommandMenuItemMaps,
});
}
return flatCommandMenuItemMaps;
};
@@ -65,6 +65,10 @@ export const createStandardCommandMenuItemFlatMetadata = ({
frontComponentId: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: definition.engineComponentKey,
payload:
'payload' in definition && isDefined(definition.payload)
? { ...definition.payload }
: null,
hotKeys: definition.hotKeys ? [...definition.hotKeys] : null,
workflowVersionId: null,
availabilityObjectMetadataId: resolvedObjectMetadataId,
@@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-update-relation-identifiers-to-ids.util';
import {
FlatUpdateCommandMenuItemAction,
@@ -56,7 +58,7 @@ export class UpdateCommandMenuItemActionHandlerService extends WorkspaceMigratio
await commandMenuItemRepository.update(
{ id: entityId, workspaceId },
update,
update as QueryDeepPartialEntity<CommandMenuItemEntity>,
);
}