Create command menu items for workflows with manual trigger (#18746)

- Automatically create and sync command menu items for all workflows
with a manual trigger
- Refactor `useCommandMenuItemsFromBackend`
- Prefill a _Quick Lead_ workflow command menu item during workspace
setup and dev seeding
- Add a ready prop to `HeadlessEngineCommandWrapperEffect` to prevent
premature execution when async data hasn't loaded yet

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2026-03-22 23:52:43 +01:00
committed by GitHub
parent d16b94bde6
commit c107d804d2
29 changed files with 1277 additions and 860 deletions
@@ -1,217 +1,24 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { EngineCommandMenuItem } from '@/command-menu-item/display/components/EngineCommandMenuItem';
import { HeadlessFrontComponentCommandMenuItem } from '@/command-menu-item/display/components/HeadlessFrontComponentCommandMenuItem';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { useConvertBackendItemToCommandMenuItemConfig } from '@/command-menu-item/server-items/hooks/useConvertBackendItemToCommandMenuItemConfig';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import {
evaluateConditionalAvailabilityExpression,
interpolateCommandMenuItemLabel,
isDefined,
} from 'twenty-shared/utils';
import { type IconComponent, useIcons } from 'twenty-ui/display';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { useQuery } from '@apollo/client/react';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
type EngineComponentKey,
FindManyCommandMenuItemsDocument,
} from '~/generated-metadata/graphql';
type CommandMenuItemWithFrontComponent = CommandMenuItemFieldsFragment & {
frontComponentId: string;
conditionalAvailabilityExpression?: string | null;
};
type CommandMenuItemWithSource = CommandMenuItemFieldsFragment & {
conditionalAvailabilityExpression?: string | null;
};
type BuildCommandMenuItemFromFrontComponentParams = {
item: CommandMenuItemWithFrontComponent;
type?: CommandMenuItemType;
scope: CommandMenuItemScope;
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
openFrontComponentInSidePanel: (params: {
frontComponentId: string;
pageTitle: string;
pageIcon: IconComponent;
recordContext?: {
recordId: string;
objectNameSingular: string;
};
}) => void;
recordId?: string;
objectNameSingular?: string;
commandMenuContextApi: CommandMenuContextApi;
};
const buildCommandMenuItemFromFrontComponent = ({
item,
type = CommandMenuItemType.FrontComponent,
scope,
isPinned,
getIcon,
openFrontComponentInSidePanel,
recordId,
objectNameSingular,
commandMenuContextApi,
}: BuildCommandMenuItemFromFrontComponentParams) => {
const displayLabel = interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
});
const displayShortLabel = interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
});
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const isHeadless = item.frontComponent?.isHeadless === true;
const handleNonHeadlessClick = () => {
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel ?? '',
pageIcon: Icon,
recordContext:
isDefined(recordId) && isDefined(objectNameSingular)
? { recordId, objectNameSingular }
: undefined,
});
};
return {
type,
key: `command-menu-item-front-component-${item.id}`,
scope,
label: displayLabel,
shortLabel: displayShortLabel,
position: item.position,
isPinned,
Icon,
hotKeys: item.hotKeys,
shouldBeRegistered: () =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component: isHeadless ? (
<HeadlessFrontComponentCommandMenuItem
frontComponentId={item.frontComponentId}
commandMenuItemId={item.id}
recordId={recordId}
objectNameSingular={objectNameSingular}
/>
) : (
<Command onClick={handleNonHeadlessClick} />
),
};
};
type BuildCommandMenuItemFromStandardKeyParams = {
item: CommandMenuItemWithSource;
engineComponentKey: EngineComponentKey;
type?: CommandMenuItemType;
scope: CommandMenuItemScope;
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
commandMenuContextApi: CommandMenuContextApi;
};
const buildCommandItemFromEngineKey = ({
item,
engineComponentKey,
type = CommandMenuItemType.Standard,
scope,
isPinned,
getIcon,
commandMenuContextApi,
}: BuildCommandMenuItemFromStandardKeyParams) => {
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
return {
type,
key: `command-menu-item-engine-${item.id}`,
scope,
label: interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
}),
shortLabel: interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
}),
position: item.position,
isPinned,
Icon,
hotKeys: item.hotKeys,
shouldBeRegistered: () =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component: (
<EngineCommandMenuItem
commandMenuItemId={item.id}
engineComponentKey={engineComponentKey}
/>
),
};
};
export const useCommandMenuItemsFromBackend = (
commandMenuContextApi: CommandMenuContextApi,
) => {
const { getIcon } = useIcons();
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
);
const { convertBackendItemToCommandMenuItemConfig } =
useConvertBackendItemToCommandMenuItemConfig();
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const currentObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataItemId,
);
const selectedRecordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: [];
const hasRecordSelection =
selectedRecordIds.length >= 1 ||
contextStoreTargetedRecordsRule.mode === 'exclusion';
const recordId =
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
const objectNameSingular = currentObjectMetadataItem?.nameSingular;
const { data } = useQuery(FindManyCommandMenuItemsDocument);
const allItems = data?.commandMenuItems ?? [];
@@ -221,101 +28,12 @@ export const useCommandMenuItemsFromBackend = (
item.availabilityObjectMetadataId ===
contextStoreCurrentObjectMetadataItemId;
const itemsWithObjectMatches = allItems.filter(objectMatches);
const buildCommandMenuItem = ({
item,
scope,
isPinned,
typeOverride,
}: {
item: CommandMenuItemFieldsFragment;
scope: CommandMenuItemScope;
isPinned: boolean;
typeOverride?: CommandMenuItemType;
}) => {
if (isDefined(item.engineComponentKey)) {
return buildCommandItemFromEngineKey({
item,
engineComponentKey: item.engineComponentKey,
type: typeOverride,
scope,
isPinned,
getIcon,
commandMenuContextApi,
});
}
if (isDefined(item.frontComponentId)) {
return buildCommandMenuItemFromFrontComponent({
item: item as CommandMenuItemWithFrontComponent,
type: typeOverride,
scope,
isPinned,
getIcon,
openFrontComponentInSidePanel,
commandMenuContextApi,
recordId,
objectNameSingular,
});
}
return null;
};
const globalItems = itemsWithObjectMatches.filter(
(item) => item.availabilityType === CommandMenuItemAvailabilityType.GLOBAL,
);
const recordScopedItems = itemsWithObjectMatches.filter(
(item) =>
item.availabilityType ===
CommandMenuItemAvailabilityType.RECORD_SELECTION,
);
const fallbackItems = itemsWithObjectMatches.filter(
(item) =>
item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK,
);
const globalCommandMenuItems = globalItems
return allItems
.filter(objectMatches)
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.Global,
isPinned: !contextStoreIsPageInEditMode && item.isPinned,
}),
convertBackendItemToCommandMenuItemConfig(item, commandMenuContextApi),
)
.filter(isDefined);
const recordScopedCommandMenuItems = hasRecordSelection
? recordScopedItems
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.RecordSelection,
isPinned: !contextStoreIsPageInEditMode && item.isPinned,
}),
)
.filter(isDefined)
: [];
const fallbackCommandMenuItems = fallbackItems
.map((item) =>
buildCommandMenuItem({
item,
scope: CommandMenuItemScope.Global,
isPinned: false,
typeOverride: CommandMenuItemType.Fallback,
}),
)
.filter(isDefined);
return [
...globalCommandMenuItems,
...recordScopedCommandMenuItems,
...fallbackCommandMenuItems,
]
.filter(isDefined)
.filter((item) => item.shouldBeRegistered())
.sort((a, b) => a.position - b.position);
};
@@ -0,0 +1,154 @@
import { EngineCommandMenuItem } from '@/command-menu-item/display/components/EngineCommandMenuItem';
import { FrontComponentCommandMenuItem } from '@/command-menu-item/display/components/FrontComponentCommandMenuItem';
import { HeadlessFrontComponentCommandMenuItem } from '@/command-menu-item/display/components/HeadlessFrontComponentCommandMenuItem';
import { WorkflowCommandMenuItem } from '@/command-menu-item/display/components/WorkflowCommandMenuItem';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { useCallback } from 'react';
import { type CommandMenuContextApi } from 'twenty-shared/types';
import {
evaluateConditionalAvailabilityExpression,
interpolateCommandMenuItemLabel,
isDefined,
} from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import {
CommandMenuItemAvailabilityType,
type CommandMenuItemFieldsFragment,
} from '~/generated-metadata/graphql';
const resolveScope = (
availabilityType: CommandMenuItemAvailabilityType,
): CommandMenuItemScope => {
if (availabilityType === CommandMenuItemAvailabilityType.RECORD_SELECTION) {
return CommandMenuItemScope.RecordSelection;
}
return CommandMenuItemScope.Global;
};
const resolveType = (
item: CommandMenuItemFieldsFragment,
): CommandMenuItemType => {
if (item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK) {
return CommandMenuItemType.Fallback;
}
if (isDefined(item.engineComponentKey)) {
return CommandMenuItemType.Standard;
}
if (isDefined(item.frontComponentId)) {
return CommandMenuItemType.FrontComponent;
}
if (isDefined(item.workflowVersionId)) {
return CommandMenuItemType.WorkflowRun;
}
return CommandMenuItemType.Standard;
};
// TODO: Remove this hook once we finish refactoring and we use
// the new types to build command menu items.
export const useConvertBackendItemToCommandMenuItemConfig = () => {
const { getIcon } = useIcons();
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const selectedRecordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: [];
const hasRecordSelection =
selectedRecordIds.length >= 1 ||
contextStoreTargetedRecordsRule.mode === 'exclusion';
const convertBackendItemToCommandMenuItemConfig = useCallback(
(
item: CommandMenuItemFieldsFragment,
commandMenuContextApi: CommandMenuContextApi,
) => {
const scope = resolveScope(item.availabilityType);
if (
scope === CommandMenuItemScope.RecordSelection &&
!hasRecordSelection
) {
return null;
}
const isPinned =
item.availabilityType !== CommandMenuItemAvailabilityType.FALLBACK &&
!contextStoreIsPageInEditMode &&
item.isPinned;
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const label = interpolateCommandMenuItemLabel({
label: item.label,
context: commandMenuContextApi,
});
const shortLabel = interpolateCommandMenuItemLabel({
label: item.shortLabel,
context: commandMenuContextApi,
});
const component = isDefined(item.engineComponentKey) ? (
<EngineCommandMenuItem
commandMenuItemId={item.id}
engineComponentKey={item.engineComponentKey}
/>
) : isDefined(item.frontComponentId) ? (
item.frontComponent?.isHeadless === true ? (
<HeadlessFrontComponentCommandMenuItem
frontComponentId={item.frontComponentId}
commandMenuItemId={item.id}
/>
) : (
<FrontComponentCommandMenuItem
frontComponentId={item.frontComponentId}
/>
)
) : isDefined(item.workflowVersionId) ? (
<WorkflowCommandMenuItem
workflowVersionId={item.workflowVersionId}
availabilityType={item.availabilityType}
availabilityObjectMetadataId={item.availabilityObjectMetadataId}
/>
) : null;
if (!isDefined(component)) {
return;
}
return {
type: resolveType(item),
key: `command-menu-item-${item.id}`,
scope,
label,
shortLabel,
position: item.position,
isPinned,
Icon,
hotKeys: item.hotKeys,
shouldBeRegistered: () =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component,
};
},
[getIcon, contextStoreIsPageInEditMode, hasRecordSelection],
);
return { convertBackendItemToCommandMenuItemConfig };
};