[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
@@ -6,9 +6,10 @@ import { useStore } from 'jotai';
import { useCallback, useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useRemoveRecordFilter = () => {
export const useRemoveRecordFilter = (instanceId?: string) => {
const currentRecordFilters = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
instanceId,
);
const store = useStore();
@@ -5,9 +5,10 @@ import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/h
import { useStore } from 'jotai';
import { useCallback, useContext } from 'react';
export const useUpsertRecordFilter = () => {
export const useUpsertRecordFilter = (instanceId?: string) => {
const currentRecordFilters = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
instanceId,
);
const store = useStore();
@@ -89,6 +89,7 @@ export const useRecordIndexLazyFetchRecords = ({
const findManyRecordsParams = useFindManyRecordIndexTableParams(
objectMetadataItem.nameSingular,
recordIndexId,
);
const queryFilter = computeContextStoreFilters({
@@ -102,6 +103,7 @@ export const useRecordIndexLazyFetchRecords = ({
const visibleRecordFields = useAtomComponentSelectorValue(
visibleRecordFieldsComponentSelector,
recordIndexId,
);
const finalColumns: Pick<
@@ -17,6 +17,7 @@ import {
export const useFindManyRecordIndexTableParams = (
objectNameSingular: string,
instanceId?: string,
) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@@ -31,14 +32,17 @@ export const useFindManyRecordIndexTableParams = (
const currentRecordFilterGroups = useAtomComponentStateValue(
currentRecordFilterGroupsComponentState,
instanceId,
);
const currentRecordSorts = useAtomComponentStateValue(
currentRecordSortsComponentState,
instanceId,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
instanceId,
);
const { filterValueDependencies } = useFilterValueDependencies();
@@ -52,6 +56,7 @@ export const useFindManyRecordIndexTableParams = (
const anyFieldFilterValue = useAtomComponentStateValue(
anyFieldFilterValueComponentState,
instanceId,
);
const { recordGqlOperationFilter: anyFieldFilter } =
@@ -16,11 +16,13 @@ import { ViewFilterOperand } from 'twenty-shared/types';
type UseHandleToggleTrashColumnFilterProps = {
objectNameSingular: string;
viewBarId: string;
recordFiltersInstanceId?: string;
};
export const useHandleToggleTrashColumnFilter = ({
viewBarId,
objectNameSingular,
recordFiltersInstanceId,
}: UseHandleToggleTrashColumnFilterProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@@ -35,7 +37,7 @@ export const useHandleToggleTrashColumnFilter = ({
viewBarId,
);
const { upsertRecordFilter } = useUpsertRecordFilter();
const { upsertRecordFilter } = useUpsertRecordFilter(recordFiltersInstanceId);
const handleToggleTrashColumnFilter = useCallback(() => {
const trashFieldMetadata = objectMetadataItem.fields.find(
@@ -9,11 +9,14 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
export const useBuildRecordInputFromFilters = ({
objectMetadataItem,
instanceId,
}: {
objectMetadataItem: ObjectMetadataItem;
instanceId?: string;
}) => {
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
instanceId,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
@@ -25,23 +25,28 @@ import { useNavigateApp } from '~/hooks/useNavigateApp';
type UseCreateNewIndexRecordProps = {
objectMetadataItem: ObjectMetadataItem;
instanceId?: string;
};
export const useCreateNewIndexRecord = ({
objectMetadataItem,
instanceId,
}: UseCreateNewIndexRecordProps) => {
const recordGroupDefinitions = useAtomComponentSelectorValue(
recordGroupDefinitionsComponentSelector,
instanceId,
);
const store = useStore();
const recordIndexRecordIdsByGroupCallbackState =
useAtomComponentFamilyStateCallbackState(
recordIndexRecordIdsByGroupComponentFamilyState,
instanceId,
);
const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue(
recordIndexGroupFieldMetadataItemComponentState,
instanceId,
);
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
@@ -59,6 +64,7 @@ export const useCreateNewIndexRecord = ({
const { buildRecordInputFromFilters } = useBuildRecordInputFromFilters({
objectMetadataItem,
instanceId,
});
const { buildRecordInputFromRLSPredicates } =