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:
+9
-291
@@ -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);
|
||||
};
|
||||
|
||||
+154
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user