[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.
This commit is contained in:
Raphaël Bosi
2026-03-17 17:25:18 +01:00
committed by GitHub
parent bee474afdf
commit abdab2fb7e
83 changed files with 2594 additions and 132 deletions
@@ -1,5 +1,7 @@
import { Suspense, lazy } from 'react';
import { CommandMenuItemErrorBoundary } from '@/command-menu-item/display/components/CommandMenuItemErrorBoundary';
import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent';
import { mountedHeadlessFrontComponentMapsState } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -16,34 +18,37 @@ export const HeadlessFrontComponentMountRoot = () => {
const mountedHeadlessFrontComponentMaps = useAtomStateValue(
mountedHeadlessFrontComponentMapsState,
);
if (mountedHeadlessFrontComponentMaps.size === 0) {
return null;
}
const unmountHeadlessFrontComponent = useUnmountHeadlessFrontComponent();
return (
<>
{[...mountedHeadlessFrontComponentMaps.entries()].map(
([frontComponentId, mountContext]) => (
<Suspense key={frontComponentId} fallback={null}>
<LayoutRenderingProvider
value={{
targetRecordIdentifier:
isDefined(mountContext) &&
isDefined(mountContext.objectNameSingular)
? {
id: mountContext.recordId,
targetObjectNameSingular:
mountContext.objectNameSingular,
}
: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInSidePanel: false,
}}
>
<FrontComponentRenderer frontComponentId={frontComponentId} />
</LayoutRenderingProvider>
</Suspense>
<CommandMenuItemErrorBoundary
key={frontComponentId}
engineCommandId={frontComponentId}
onError={() => unmountHeadlessFrontComponent(frontComponentId)}
>
<Suspense fallback={null}>
<LayoutRenderingProvider
value={{
targetRecordIdentifier:
isDefined(mountContext) &&
isDefined(mountContext.objectNameSingular)
? {
id: mountContext.recordId,
targetObjectNameSingular:
mountContext.objectNameSingular,
}
: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInSidePanel: false,
}}
>
<FrontComponentRenderer frontComponentId={frontComponentId} />
</LayoutRenderingProvider>
</Suspense>
</CommandMenuItemErrorBoundary>
),
)}
</>