Files
twenty/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountEngineCommand.ts
T
Raphaël Bosi abdab2fb7e [Command menu items] Create engine commands (#18681)
## Description

- Introduces a new engine command execution model that replaces the
previous approach of mapping `EngineComponentKey` to React components.
Instead, engine commands are now mounted headlessly via
`HeadlessEngineCommandMountRoot`, with their execution context populated
synchronously before mounting.
- Creates new headless command components
- Moves error handling from the SDK layer to the host app by wrapping
all mounted commands with a new `CommandMenuItemErrorBoundary`

The new flow works as follows:
- When a command menu item with an `engineComponentKey` is clicked,
`useCommandMenuItemFrontComponentCommands` calls
`useMountEngineCommand`, which synchronously reads the current context
store (object metadata, selected records, filters, view ID, etc.) and
writes a `MountedEngineCommandContext` into
`mountedEngineCommandsState`.
- The command is then mounted into `mountedEngineCommandsState`, which
triggers `HeadlessEngineCommandMountRoot` to render the corresponding
headless component from `ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP`,
wrapped in `CommandMenuItemErrorBoundary`,
`ContextStoreComponentInstanceContext.Provider`, and
`EngineCommandComponentInstanceContext.Provider`.
- Each command component reads its execution context and delegates to
one of the 4 execution patterns: `HeadlessEngineCommandWrapperEffect`
(simple actions), `HeadlessConfirmationModalEngineCommandEffect`
(destructive actions needing confirmation),
`HeadlessNavigateEngineCommand` (GO_TO_* commands), or
`HeadlessOpenSidePanelPageEngineCommand` (SEARCH_RECORDS, ASK_AI,
VIEW_PREVIOUS_AI_CHATS).
- After execution, the command self-unmounts via
`useUnmountEngineCommand`, which removes the entry from
`mountedEngineCommandsState` and stops rendering the component.
2026-03-17 17:25:18 +01:00

130 lines
4.9 KiB
TypeScript

import { useCallback } from 'react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFilterGroupsComponentState } from '@/context-store/states/contextStoreFilterGroupsComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { type EngineComponentKey } from '~/generated-metadata/graphql';
export const useMountEngineCommand = () => {
const store = useStore();
const mountEngineCommand = useCallback(
(
engineCommandId: string,
contextStoreInstanceId: string,
engineComponentKey: EngineComponentKey,
) => {
const objectMetadataItemId = store.get(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === objectMetadataItemId,
);
const currentViewId = store.get(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const targetedRecordsRule = store.get(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const selectedRecords = (
targetedRecordsRule.mode === 'selection'
? targetedRecordsRule.selectedRecordIds
: []
)
.map((id) => store.get(recordStoreFamilyState.atomFamily(id)))
.filter(isDefined);
const filters = store.get(
contextStoreFiltersComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const filterGroups = store.get(
contextStoreFilterGroupsComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const anyFieldFilterValue = store.get(
contextStoreAnyFieldFilterValueComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const currentWorkspaceMember = store.get(
currentWorkspaceMemberState.atom,
);
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const userTimezone =
currentWorkspaceMember?.timeZone !== 'system'
? (currentWorkspaceMember?.timeZone ?? systemTimeZone)
: systemTimeZone;
const graphqlFilter = isDefined(objectMetadataItem)
? computeContextStoreFilters({
contextStoreTargetedRecordsRule: targetedRecordsRule,
contextStoreFilters: filters,
contextStoreFilterGroups: filterGroups,
objectMetadataItem,
filterValueDependencies: {
currentWorkspaceMemberId: currentWorkspaceMember?.id,
timeZone: userTimezone,
},
contextStoreAnyFieldFilterValue: anyFieldFilterValue,
})
: null;
const recordIndexId =
objectMetadataItem && currentViewId
? getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
currentViewId,
)
: null;
store.set(mountedEngineCommandsState.atom, (previousMap) => {
const newMap = new Map(previousMap);
newMap.set(engineCommandId, {
engineComponentKey,
contextStoreInstanceId,
objectMetadataItem: objectMetadataItem ?? null,
currentViewId,
recordIndexId,
targetedRecordsRule,
selectedRecords,
graphqlFilter,
});
return newMap;
});
},
[store],
);
return mountEngineCommand;
};