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
@@ -0,0 +1,69 @@
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
import { Command } from '@/command-menu-item/display/components/Command';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
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 { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const FrontComponentCommandMenuItem = ({
frontComponentId,
}: {
frontComponentId: string;
}) => {
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const commandMenuItemConfig = useContext(CommandConfigContext);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const currentObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId,
);
const selectedRecordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: [];
const recordId =
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
const objectNameSingular = currentObjectMetadataItem?.nameSingular;
const displayLabel =
typeof commandMenuItemConfig?.label === 'string'
? commandMenuItemConfig.label
: '';
const Icon = commandMenuItemConfig?.Icon;
const handleClick = () => {
if (!isDefined(Icon)) {
return;
}
openFrontComponentInSidePanel({
frontComponentId,
pageTitle: displayLabel,
pageIcon: Icon,
recordContext:
isDefined(recordId) && isDefined(objectNameSingular)
? { recordId, objectNameSingular }
: undefined,
});
};
return <Command onClick={handleClick} />;
};
@@ -1,19 +1,22 @@
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useMountHeadlessFrontComponent } from '@/front-components/hooks/useMountHeadlessFrontComponent';
import { isHeadlessFrontComponentMountedFamilySelector } from '@/front-components/selectors/isHeadlessFrontComponentMountedFamilySelector';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { HeadlessCommandMenuItem } from './HeadlessCommandMenuItem';
// TODO: Some code is duplicated in this component and FrontComponentCommandMenuItem
// This will be refactored because the logic will differ between headless and non-headless front components.
export const HeadlessFrontComponentCommandMenuItem = ({
frontComponentId,
commandMenuItemId,
recordId,
objectNameSingular,
}: {
frontComponentId: string;
commandMenuItemId: string;
recordId?: string;
objectNameSingular?: string;
}) => {
const mountHeadlessFrontComponent = useMountHeadlessFrontComponent();
@@ -22,6 +25,31 @@ export const HeadlessFrontComponentCommandMenuItem = ({
frontComponentId,
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const currentObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId,
);
const selectedRecordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: [];
const recordId =
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
const objectNameSingular = currentObjectMetadataItem?.nameSingular;
const handleClick = () => {
mountHeadlessFrontComponent(frontComponentId, {
commandMenuItemId,
@@ -0,0 +1,128 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { isBulkRecordsManualTrigger } from '@/command-menu-item/record/utils/isBulkRecordsManualTrigger';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { useStore } from 'jotai';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import {
type CommandMenuItemAvailabilityType,
CommandMenuItemAvailabilityType as CommandMenuItemAvailabilityTypeEnum,
} from '~/generated-metadata/graphql';
export const WorkflowCommandMenuItem = ({
workflowVersionId,
availabilityType,
availabilityObjectMetadataId,
}: {
workflowVersionId: string;
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
}) => {
const store = useStore();
const { runWorkflowVersion } = useRunWorkflowVersion();
const { record: workflowVersion } = useFindOneRecord<
Pick<WorkflowVersion, 'id' | 'workflowId' | 'trigger' | '__typename'>
>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
objectRecordId: workflowVersionId,
recordGqlFields: { id: true, workflowId: true, trigger: true },
});
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const selectedRecordIds =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds
: [];
const handleClick = async () => {
if (!isDefined(workflowVersion)) {
return;
}
switch (availabilityType) {
case CommandMenuItemAvailabilityTypeEnum.RECORD_SELECTION: {
if (selectedRecordIds.length === 0) {
return;
}
const limitedSelectedRecordIds = selectedRecordIds.slice(
0,
QUERY_MAX_RECORDS,
);
const objectMetadataItem = objectMetadataItems.find(
(metadata) => metadata.id === availabilityObjectMetadataId,
);
if (
isDefined(workflowVersion.trigger) &&
isBulkRecordsManualTrigger(workflowVersion.trigger)
) {
const selectedRecords = limitedSelectedRecordIds
.map((recordId) =>
store.get(recordStoreFamilyState.atomFamily(recordId)),
)
.filter(isDefined);
await runWorkflowVersion({
workflowId: workflowVersion.workflowId,
workflowVersionId: workflowVersion.id,
payload: isDefined(objectMetadataItem)
? { [objectMetadataItem.namePlural]: selectedRecords }
: undefined,
});
return;
}
for (const selectedRecordId of limitedSelectedRecordIds) {
const selectedRecord = store.get(
recordStoreFamilyState.atomFamily(selectedRecordId),
);
if (!isDefined(selectedRecord)) {
continue;
}
await runWorkflowVersion({
workflowId: workflowVersion.workflowId,
workflowVersionId: workflowVersion.id,
payload: selectedRecord,
});
}
return;
}
case CommandMenuItemAvailabilityTypeEnum.GLOBAL:
case CommandMenuItemAvailabilityTypeEnum.FALLBACK: {
await runWorkflowVersion({
workflowId: workflowVersion.workflowId,
workflowVersionId: workflowVersion.id,
});
return;
}
}
};
return (
<Command
onClick={handleClick}
closeSidePanelOnCommandMenuListExecution={false}
/>
);
};
@@ -7,10 +7,12 @@ import { useEffect } from 'react';
export type HeadlessEngineCommandWrapperEffectProps = {
execute: () => void | Promise<unknown>;
ready?: boolean;
};
export const HeadlessEngineCommandWrapperEffect = ({
execute,
ready = true,
}: HeadlessEngineCommandWrapperEffectProps) => {
const { isInitializedRef, setIsInitialized } =
useIsHeadlessEngineCommandEffectInitialized();
@@ -24,7 +26,7 @@ export const HeadlessEngineCommandWrapperEffect = ({
const { enqueueErrorSnackBar } = useSnackBar();
useEffect(() => {
if (isInitializedRef.current) {
if (isInitializedRef.current || !ready) {
return;
}
@@ -39,6 +41,7 @@ export const HeadlessEngineCommandWrapperEffect = ({
run();
}, [
execute,
ready,
isInitializedRef,
setIsInitialized,
engineCommandId,
@@ -28,5 +28,10 @@ export const ActivateWorkflowSingleRecordCommand = () => {
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
return (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={isDefined(workflowWithCurrentVersion)}
/>
);
};
@@ -27,5 +27,10 @@ export const DeactivateWorkflowSingleRecordCommand = () => {
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
return (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={isDefined(workflowWithCurrentVersion)}
/>
);
};
@@ -27,5 +27,10 @@ export const DiscardDraftWorkflowSingleRecordCommand = () => {
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
return (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={isDefined(workflowWithCurrentVersion)}
/>
);
};
@@ -28,5 +28,10 @@ export const TestWorkflowSingleRecordCommand = () => {
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
return (
<HeadlessEngineCommandWrapperEffect
execute={handleExecute}
ready={isDefined(workflowWithCurrentVersion)}
/>
);
};
@@ -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 };
};
@@ -207,6 +207,11 @@ export const graphqlMocks = {
data: { navigationMenuItems: mockedNavigationMenuItems },
});
}),
metadataGraphql.query('FindManyCommandMenuItems', () => {
return HttpResponse.json({
data: { commandMenuItems: [] },
});
}),
graphql.query('SearchPeople', () => {
return HttpResponse.json({
data: {
@@ -3099,6 +3099,10 @@ type Query {
findManyLogicFunctions: [LogicFunction!]!
getAvailablePackages(input: LogicFunctionIdInput!): JSON!
getLogicFunctionSourceCode(input: LogicFunctionIdInput!): String
commandMenuItems: [CommandMenuItem!]!
commandMenuItem(id: UUID!): CommandMenuItem
frontComponents: [FrontComponent!]!
frontComponent(id: UUID!): FrontComponent
objectRecordCounts: [ObjectRecordCount!]!
object(
"""The id of the record to find."""
@@ -3130,10 +3134,6 @@ type Query {
"""Specify to filter the records returned."""
filter: IndexFilter! = {}
): IndexConnection!
commandMenuItems: [CommandMenuItem!]!
commandMenuItem(id: UUID!): CommandMenuItem
frontComponents: [FrontComponent!]!
frontComponent(id: UUID!): FrontComponent
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
billingPortalSession(returnUrlPath: String): BillingSession!
@@ -3366,6 +3366,12 @@ type Mutation {
createOneLogicFunction(input: CreateLogicFunctionFromSourceInput!): LogicFunction!
executeOneLogicFunction(input: ExecuteOneLogicFunctionInput!): LogicFunctionExecutionResult!
updateOneLogicFunction(input: UpdateLogicFunctionFromSourceInput!): Boolean!
createCommandMenuItem(input: CreateCommandMenuItemInput!): CommandMenuItem!
updateCommandMenuItem(input: UpdateCommandMenuItemInput!): CommandMenuItem!
deleteCommandMenuItem(id: UUID!): CommandMenuItem!
createFrontComponent(input: CreateFrontComponentInput!): FrontComponent!
updateFrontComponent(input: UpdateFrontComponentInput!): FrontComponent!
deleteFrontComponent(id: UUID!): FrontComponent!
createOneObject(input: CreateOneObjectInput!): Object!
deleteOneObject(input: DeleteOneObjectInput!): Object!
updateOneObject(input: UpdateOneObjectInput!): Object!
@@ -3388,12 +3394,6 @@ type Mutation {
deleteViewFieldGroup(input: DeleteViewFieldGroupInput!): ViewFieldGroup!
destroyViewFieldGroup(input: DestroyViewFieldGroupInput!): ViewFieldGroup!
upsertFieldsWidget(input: UpsertFieldsWidgetInput!): View!
createCommandMenuItem(input: CreateCommandMenuItemInput!): CommandMenuItem!
updateCommandMenuItem(input: UpdateCommandMenuItemInput!): CommandMenuItem!
deleteCommandMenuItem(id: UUID!): CommandMenuItem!
createFrontComponent(input: CreateFrontComponentInput!): FrontComponent!
updateFrontComponent(input: UpdateFrontComponentInput!): FrontComponent!
deleteFrontComponent(id: UUID!): FrontComponent!
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
@@ -3719,6 +3719,57 @@ input UpdateLogicFunctionFromSourceInputUpdates {
httpRouteTriggerSettings: JSON
}
input CreateCommandMenuItemInput {
workflowVersionId: UUID
frontComponentId: UUID
engineComponentKey: EngineComponentKey
label: String!
icon: String
shortLabel: String
position: Float
isPinned: Boolean
availabilityType: CommandMenuItemAvailabilityType
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
}
input UpdateCommandMenuItemInput {
id: UUID!
label: String
icon: String
shortLabel: String
position: Float
isPinned: Boolean
availabilityType: CommandMenuItemAvailabilityType
availabilityObjectMetadataId: UUID
engineComponentKey: EngineComponentKey
hotKeys: [String!]
}
input CreateFrontComponentInput {
id: UUID
name: String!
description: String
sourceComponentPath: String!
builtComponentPath: String!
componentName: String!
builtComponentChecksum: String!
}
input UpdateFrontComponentInput {
"""The id of the front component to update"""
id: UUID!
"""The front component fields to update"""
update: UpdateFrontComponentInputUpdates!
}
input UpdateFrontComponentInputUpdates {
name: String
description: String
}
input CreateOneObjectInput {
"""The object to create"""
object: CreateObjectInput!
@@ -3934,57 +3985,6 @@ input UpsertFieldsWidgetFieldInput {
position: Float!
}
input CreateCommandMenuItemInput {
workflowVersionId: UUID
frontComponentId: UUID
engineComponentKey: EngineComponentKey
label: String!
icon: String
shortLabel: String
position: Float
isPinned: Boolean
availabilityType: CommandMenuItemAvailabilityType
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
}
input UpdateCommandMenuItemInput {
id: UUID!
label: String
icon: String
shortLabel: String
position: Float
isPinned: Boolean
availabilityType: CommandMenuItemAvailabilityType
availabilityObjectMetadataId: UUID
engineComponentKey: EngineComponentKey
hotKeys: [String!]
}
input CreateFrontComponentInput {
id: UUID
name: String!
description: String
sourceComponentPath: String!
builtComponentPath: String!
componentName: String!
builtComponentChecksum: String!
}
input UpdateFrontComponentInput {
"""The id of the front component to update"""
id: UUID!
"""The front component fields to update"""
update: UpdateFrontComponentInputUpdates!
}
input UpdateFrontComponentInputUpdates {
name: String
description: String
}
input CreateAgentInput {
name: String
label: String!
@@ -2692,6 +2692,10 @@ export interface Query {
findManyLogicFunctions: LogicFunction[]
getAvailablePackages: Scalars['JSON']
getLogicFunctionSourceCode?: Scalars['String']
commandMenuItems: CommandMenuItem[]
commandMenuItem?: CommandMenuItem
frontComponents: FrontComponent[]
frontComponent?: FrontComponent
objectRecordCounts: ObjectRecordCount[]
object: Object
objects: ObjectConnection
@@ -2705,10 +2709,6 @@ export interface Query {
getViewFieldGroup?: ViewFieldGroup
index: Index
indexMetadatas: IndexConnection
commandMenuItems: CommandMenuItem[]
commandMenuItem?: CommandMenuItem
frontComponents: FrontComponent[]
frontComponent?: FrontComponent
findManyAgents: Agent[]
findOneAgent: Agent
billingPortalSession: BillingSession
@@ -2831,6 +2831,12 @@ export interface Mutation {
createOneLogicFunction: LogicFunction
executeOneLogicFunction: LogicFunctionExecutionResult
updateOneLogicFunction: Scalars['Boolean']
createCommandMenuItem: CommandMenuItem
updateCommandMenuItem: CommandMenuItem
deleteCommandMenuItem: CommandMenuItem
createFrontComponent: FrontComponent
updateFrontComponent: FrontComponent
deleteFrontComponent: FrontComponent
createOneObject: Object
deleteOneObject: Object
updateOneObject: Object
@@ -2853,12 +2859,6 @@ export interface Mutation {
deleteViewFieldGroup: ViewFieldGroup
destroyViewFieldGroup: ViewFieldGroup
upsertFieldsWidget: View
createCommandMenuItem: CommandMenuItem
updateCommandMenuItem: CommandMenuItem
deleteCommandMenuItem: CommandMenuItem
createFrontComponent: FrontComponent
updateFrontComponent: FrontComponent
deleteFrontComponent: FrontComponent
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
@@ -5852,6 +5852,10 @@ export interface QueryGenqlSelection{
findManyLogicFunctions?: LogicFunctionGenqlSelection
getAvailablePackages?: { __args: {input: LogicFunctionIdInput} }
getLogicFunctionSourceCode?: { __args: {input: LogicFunctionIdInput} }
commandMenuItems?: CommandMenuItemGenqlSelection
commandMenuItem?: (CommandMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
frontComponents?: FrontComponentGenqlSelection
frontComponent?: (FrontComponentGenqlSelection & { __args: {id: Scalars['UUID']} })
objectRecordCounts?: ObjectRecordCountGenqlSelection
object?: (ObjectGenqlSelection & { __args: {
/** The id of the record to find. */
@@ -5877,10 +5881,6 @@ export interface QueryGenqlSelection{
paging: CursorPaging,
/** Specify to filter the records returned. */
filter: IndexFilter} })
commandMenuItems?: CommandMenuItemGenqlSelection
commandMenuItem?: (CommandMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
frontComponents?: FrontComponentGenqlSelection
frontComponent?: (FrontComponentGenqlSelection & { __args: {id: Scalars['UUID']} })
findManyAgents?: AgentGenqlSelection
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
billingPortalSession?: (BillingSessionGenqlSelection & { __args?: {returnUrlPath?: (Scalars['String'] | null)} })
@@ -6034,6 +6034,12 @@ export interface MutationGenqlSelection{
createOneLogicFunction?: (LogicFunctionGenqlSelection & { __args: {input: CreateLogicFunctionFromSourceInput} })
executeOneLogicFunction?: (LogicFunctionExecutionResultGenqlSelection & { __args: {input: ExecuteOneLogicFunctionInput} })
updateOneLogicFunction?: { __args: {input: UpdateLogicFunctionFromSourceInput} }
createCommandMenuItem?: (CommandMenuItemGenqlSelection & { __args: {input: CreateCommandMenuItemInput} })
updateCommandMenuItem?: (CommandMenuItemGenqlSelection & { __args: {input: UpdateCommandMenuItemInput} })
deleteCommandMenuItem?: (CommandMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
createFrontComponent?: (FrontComponentGenqlSelection & { __args: {input: CreateFrontComponentInput} })
updateFrontComponent?: (FrontComponentGenqlSelection & { __args: {input: UpdateFrontComponentInput} })
deleteFrontComponent?: (FrontComponentGenqlSelection & { __args: {id: Scalars['UUID']} })
createOneObject?: (ObjectGenqlSelection & { __args: {input: CreateOneObjectInput} })
deleteOneObject?: (ObjectGenqlSelection & { __args: {input: DeleteOneObjectInput} })
updateOneObject?: (ObjectGenqlSelection & { __args: {input: UpdateOneObjectInput} })
@@ -6056,12 +6062,6 @@ export interface MutationGenqlSelection{
deleteViewFieldGroup?: (ViewFieldGroupGenqlSelection & { __args: {input: DeleteViewFieldGroupInput} })
destroyViewFieldGroup?: (ViewFieldGroupGenqlSelection & { __args: {input: DestroyViewFieldGroupInput} })
upsertFieldsWidget?: (ViewGenqlSelection & { __args: {input: UpsertFieldsWidgetInput} })
createCommandMenuItem?: (CommandMenuItemGenqlSelection & { __args: {input: CreateCommandMenuItemInput} })
updateCommandMenuItem?: (CommandMenuItemGenqlSelection & { __args: {input: UpdateCommandMenuItemInput} })
deleteCommandMenuItem?: (CommandMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
createFrontComponent?: (FrontComponentGenqlSelection & { __args: {input: CreateFrontComponentInput} })
updateFrontComponent?: (FrontComponentGenqlSelection & { __args: {input: UpdateFrontComponentInput} })
deleteFrontComponent?: (FrontComponentGenqlSelection & { __args: {id: Scalars['UUID']} })
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
@@ -6263,6 +6263,20 @@ update: UpdateLogicFunctionFromSourceInputUpdates}
export interface UpdateLogicFunctionFromSourceInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),sourceHandlerCode?: (Scalars['String'] | null),toolInputSchema?: (Scalars['JSON'] | null),handlerName?: (Scalars['String'] | null),sourceHandlerPath?: (Scalars['String'] | null),isTool?: (Scalars['Boolean'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null)}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null)}
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null)}
export interface CreateFrontComponentInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),sourceComponentPath: Scalars['String'],builtComponentPath: Scalars['String'],componentName: Scalars['String'],builtComponentChecksum: Scalars['String']}
export interface UpdateFrontComponentInput {
/** The id of the front component to update */
id: Scalars['UUID'],
/** The front component fields to update */
update: UpdateFrontComponentInputUpdates}
export interface UpdateFrontComponentInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null)}
export interface CreateOneObjectInput {
/** The object to create */
object: CreateObjectInput}
@@ -6351,20 +6365,6 @@ export interface UpsertFieldsWidgetFieldInput {
/** The id of the view field */
viewFieldId: Scalars['UUID'],isVisible: Scalars['Boolean'],position: Scalars['Float']}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null)}
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null)}
export interface CreateFrontComponentInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),sourceComponentPath: Scalars['String'],builtComponentPath: Scalars['String'],componentName: Scalars['String'],builtComponentChecksum: Scalars['String']}
export interface UpdateFrontComponentInput {
/** The id of the front component to update */
id: Scalars['UUID'],
/** The front component fields to update */
update: UpdateFrontComponentInputUpdates}
export interface UpdateFrontComponentInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null)}
export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt: Scalars['String'],modelId: Scalars['String'],roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
@@ -6135,6 +6135,30 @@ export default {
]
}
],
"commandMenuItems": [
224
],
"commandMenuItem": [
224,
{
"id": [
3,
"UUID!"
]
}
],
"frontComponents": [
223
],
"frontComponent": [
223,
{
"id": [
3,
"UUID!"
]
}
],
"objectRecordCounts": [
167
],
@@ -6256,30 +6280,6 @@ export default {
]
}
],
"commandMenuItems": [
224
],
"commandMenuItem": [
224,
{
"id": [
3,
"UUID!"
]
}
],
"frontComponents": [
223
],
"frontComponent": [
223,
{
"id": [
3,
"UUID!"
]
}
],
"findManyAgents": [
25
],
@@ -7324,11 +7324,65 @@ export default {
]
}
],
"createCommandMenuItem": [
224,
{
"input": [
381,
"CreateCommandMenuItemInput!"
]
}
],
"updateCommandMenuItem": [
224,
{
"input": [
382,
"UpdateCommandMenuItemInput!"
]
}
],
"deleteCommandMenuItem": [
224,
{
"id": [
3,
"UUID!"
]
}
],
"createFrontComponent": [
223,
{
"input": [
383,
"CreateFrontComponentInput!"
]
}
],
"updateFrontComponent": [
223,
{
"input": [
384,
"UpdateFrontComponentInput!"
]
}
],
"deleteFrontComponent": [
223,
{
"id": [
3,
"UUID!"
]
}
],
"createOneObject": [
46,
{
"input": [
381,
386,
"CreateOneObjectInput!"
]
}
@@ -7337,7 +7391,7 @@ export default {
46,
{
"input": [
383,
388,
"DeleteOneObjectInput!"
]
}
@@ -7346,7 +7400,7 @@ export default {
46,
{
"input": [
384,
389,
"UpdateOneObjectInput!"
]
}
@@ -7355,7 +7409,7 @@ export default {
50,
{
"input": [
386,
391,
"UpdateViewFieldInput!"
]
}
@@ -7364,7 +7418,7 @@ export default {
50,
{
"input": [
388,
393,
"CreateViewFieldInput!"
]
}
@@ -7373,7 +7427,7 @@ export default {
50,
{
"inputs": [
388,
393,
"[CreateViewFieldInput!]!"
]
}
@@ -7382,7 +7436,7 @@ export default {
50,
{
"input": [
389,
394,
"DeleteViewFieldInput!"
]
}
@@ -7391,7 +7445,7 @@ export default {
50,
{
"input": [
390,
395,
"DestroyViewFieldInput!"
]
}
@@ -7400,7 +7454,7 @@ export default {
60,
{
"input": [
391,
396,
"CreateViewInput!"
]
}
@@ -7413,7 +7467,7 @@ export default {
"String!"
],
"input": [
392,
397,
"UpdateViewInput!"
]
}
@@ -7440,7 +7494,7 @@ export default {
57,
{
"input": [
393,
398,
"CreateViewSortInput!"
]
}
@@ -7449,7 +7503,7 @@ export default {
57,
{
"input": [
394,
399,
"UpdateViewSortInput!"
]
}
@@ -7458,7 +7512,7 @@ export default {
6,
{
"input": [
396,
401,
"DeleteViewSortInput!"
]
}
@@ -7467,7 +7521,7 @@ export default {
6,
{
"input": [
397,
402,
"DestroyViewSortInput!"
]
}
@@ -7476,7 +7530,7 @@ export default {
59,
{
"input": [
398,
403,
"UpdateViewFieldGroupInput!"
]
}
@@ -7485,7 +7539,7 @@ export default {
59,
{
"input": [
400,
405,
"CreateViewFieldGroupInput!"
]
}
@@ -7494,7 +7548,7 @@ export default {
59,
{
"inputs": [
400,
405,
"[CreateViewFieldGroupInput!]!"
]
}
@@ -7503,7 +7557,7 @@ export default {
59,
{
"input": [
401,
406,
"DeleteViewFieldGroupInput!"
]
}
@@ -7512,71 +7566,17 @@ export default {
59,
{
"input": [
402,
407,
"DestroyViewFieldGroupInput!"
]
}
],
"upsertFieldsWidget": [
60,
{
"input": [
403,
"UpsertFieldsWidgetInput!"
]
}
],
"createCommandMenuItem": [
224,
{
"input": [
406,
"CreateCommandMenuItemInput!"
]
}
],
"updateCommandMenuItem": [
224,
{
"input": [
407,
"UpdateCommandMenuItemInput!"
]
}
],
"deleteCommandMenuItem": [
224,
{
"id": [
3,
"UUID!"
]
}
],
"createFrontComponent": [
223,
{
"input": [
408,
"CreateFrontComponentInput!"
]
}
],
"updateFrontComponent": [
223,
{
"input": [
409,
"UpdateFrontComponentInput!"
]
}
],
"deleteFrontComponent": [
223,
{
"id": [
3,
"UUID!"
"UpsertFieldsWidgetInput!"
]
}
],
@@ -9477,9 +9477,133 @@ export default {
1
]
},
"CreateCommandMenuItemInput": {
"workflowVersionId": [
3
],
"frontComponentId": [
3
],
"engineComponentKey": [
225
],
"label": [
1
],
"icon": [
1
],
"shortLabel": [
1
],
"position": [
11
],
"isPinned": [
6
],
"availabilityType": [
226
],
"hotKeys": [
1
],
"conditionalAvailabilityExpression": [
1
],
"availabilityObjectMetadataId": [
3
],
"__typename": [
1
]
},
"UpdateCommandMenuItemInput": {
"id": [
3
],
"label": [
1
],
"icon": [
1
],
"shortLabel": [
1
],
"position": [
11
],
"isPinned": [
6
],
"availabilityType": [
226
],
"availabilityObjectMetadataId": [
3
],
"engineComponentKey": [
225
],
"hotKeys": [
1
],
"__typename": [
1
]
},
"CreateFrontComponentInput": {
"id": [
3
],
"name": [
1
],
"description": [
1
],
"sourceComponentPath": [
1
],
"builtComponentPath": [
1
],
"componentName": [
1
],
"builtComponentChecksum": [
1
],
"__typename": [
1
]
},
"UpdateFrontComponentInput": {
"id": [
3
],
"update": [
385
],
"__typename": [
1
]
},
"UpdateFrontComponentInputUpdates": {
"name": [
1
],
"description": [
1
],
"__typename": [
1
]
},
"CreateOneObjectInput": {
"object": [
382
387
],
"__typename": [
1
@@ -9539,7 +9663,7 @@ export default {
},
"UpdateOneObjectInput": {
"update": [
385
390
],
"id": [
3
@@ -9594,7 +9718,7 @@ export default {
3
],
"update": [
387
392
],
"__typename": [
1
@@ -9793,7 +9917,7 @@ export default {
3
],
"update": [
395
400
],
"__typename": [
1
@@ -9828,7 +9952,7 @@ export default {
3
],
"update": [
399
404
],
"__typename": [
1
@@ -9892,10 +10016,10 @@ export default {
3
],
"groups": [
404
409
],
"fields": [
405
410
],
"__typename": [
1
@@ -9915,7 +10039,7 @@ export default {
6
],
"fields": [
405
410
],
"__typename": [
1
@@ -9935,130 +10059,6 @@ export default {
1
]
},
"CreateCommandMenuItemInput": {
"workflowVersionId": [
3
],
"frontComponentId": [
3
],
"engineComponentKey": [
225
],
"label": [
1
],
"icon": [
1
],
"shortLabel": [
1
],
"position": [
11
],
"isPinned": [
6
],
"availabilityType": [
226
],
"hotKeys": [
1
],
"conditionalAvailabilityExpression": [
1
],
"availabilityObjectMetadataId": [
3
],
"__typename": [
1
]
},
"UpdateCommandMenuItemInput": {
"id": [
3
],
"label": [
1
],
"icon": [
1
],
"shortLabel": [
1
],
"position": [
11
],
"isPinned": [
6
],
"availabilityType": [
226
],
"availabilityObjectMetadataId": [
3
],
"engineComponentKey": [
225
],
"hotKeys": [
1
],
"__typename": [
1
]
},
"CreateFrontComponentInput": {
"id": [
3
],
"name": [
1
],
"description": [
1
],
"sourceComponentPath": [
1
],
"builtComponentPath": [
1
],
"componentName": [
1
],
"builtComponentChecksum": [
1
],
"__typename": [
1
]
},
"UpdateFrontComponentInput": {
"id": [
3
],
"update": [
410
],
"__typename": [
1
]
},
"UpdateFrontComponentInputUpdates": {
"name": [
1
],
"description": [
1
],
"__typename": [
1
]
},
"CreateAgentInput": {
"name": [
1
@@ -57,6 +57,7 @@ import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-
import { prefillDashboards } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-dashboards';
import { prefillOpportunities } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-opportunities';
import { prefillPeople } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-people';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflow-command-menu-items';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-migration/constant/default-feature-flags';
@@ -698,6 +699,8 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
flatFieldMetadataMaps,
);
await prefillWorkflowCommandMenuItems(queryRunner.manager, workspaceId);
await prefillOpportunities(queryRunner.manager, schemaName);
await prefillDashboards(
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type QueryRunner } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import {
@@ -87,6 +88,7 @@ export class CommandMenuItemService {
async create(
input: CreateCommandMenuItemInput,
workspaceId: string,
queryRunner?: QueryRunner,
): Promise<CommandMenuItemDTO> {
const { flatObjectMetadataMaps, flatFrontComponentMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
@@ -124,6 +126,7 @@ export class CommandMenuItemService {
isSystemBuild: false,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
queryRunner,
},
);
@@ -153,6 +156,7 @@ export class CommandMenuItemService {
async update(
input: UpdateCommandMenuItemInput,
workspaceId: string,
queryRunner?: QueryRunner,
): Promise<CommandMenuItemDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
@@ -191,6 +195,7 @@ export class CommandMenuItemService {
isSystemBuild: false,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
queryRunner,
},
);
@@ -217,7 +222,11 @@ export class CommandMenuItemService {
);
}
async delete(id: string, workspaceId: string): Promise<CommandMenuItemDTO> {
async delete(
id: string,
workspaceId: string,
queryRunner?: QueryRunner,
): Promise<CommandMenuItemDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -251,6 +260,7 @@ export class CommandMenuItemService {
isSystemBuild: false,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
queryRunner,
},
);
@@ -93,7 +93,7 @@ export const seedFeatureFlags = async ({
{
key: FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId: workspaceId,
value: false,
value: true,
},
{
key: FeatureFlagKey.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED,
@@ -121,6 +121,7 @@ import {
WORKSPACE_MEMBER_DATA_SEED_COLUMNS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
import { TimelineActivitySeederService } from 'src/engine/workspace-manager/dev-seeder/data/services/timeline-activity-seeder.service';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflow-command-menu-items';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
@@ -362,6 +363,8 @@ export class DevSeederDataService {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
await prefillWorkflowCommandMenuItems(entityManager, workspaceId);
},
);
}
@@ -0,0 +1,69 @@
import { type EntityManager } from 'typeorm';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { QUICK_LEAD_WORKFLOW_VERSION_ID } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
const QUICK_LEAD_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'a1b2c3d4-e5f6-7890-abcd-1234567890ab';
export const prefillWorkflowCommandMenuItems = async (
entityManager: EntityManager,
workspaceId: string,
) => {
const applicationRow = await entityManager
.createQueryBuilder()
.select('app.id')
.from('core.application', 'app')
.where('app.universalIdentifier = :universalIdentifier', {
universalIdentifier: TWENTY_STANDARD_APPLICATION.universalIdentifier,
})
.andWhere('app.workspaceId = :workspaceId', { workspaceId })
.getRawOne();
if (!applicationRow) {
return;
}
await entityManager
.createQueryBuilder()
.insert()
.into('core.commandMenuItem', [
'workspaceId',
'universalIdentifier',
'applicationId',
'workflowVersionId',
'frontComponentId',
'engineComponentKey',
'label',
'icon',
'shortLabel',
'position',
'isPinned',
'availabilityType',
'conditionalAvailabilityExpression',
'availabilityObjectMetadataId',
'hotKeys',
])
.values([
{
workspaceId,
universalIdentifier: QUICK_LEAD_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
applicationId: applicationRow.app_id,
workflowVersionId: QUICK_LEAD_WORKFLOW_VERSION_ID,
frontComponentId: null,
engineComponentKey: null,
label: 'Quick Lead',
icon: 'IconUserPlus',
shortLabel: 'Quick Lead',
position: 100,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataId: null,
hotKeys: null,
},
])
.orIgnore()
.execute();
};
@@ -9,8 +9,9 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { generateObjectRecordFields } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-object-record-fields';
const QUICK_LEAD_WORKFLOW_ID = '8b213cac-a68b-4ffe-817a-3ec994e9932d';
const QUICK_LEAD_WORKFLOW_VERSION_ID = 'ac67974f-c524-4288-9d88-af8515400b68';
export const QUICK_LEAD_WORKFLOW_ID = '8b213cac-a68b-4ffe-817a-3ec994e9932d';
export const QUICK_LEAD_WORKFLOW_VERSION_ID =
'ac67974f-c524-4288-9d88-af8515400b68';
export const prefillWorkflows = async (
entityManager: EntityManager,
@@ -5,11 +5,13 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-companies';
import { prefillPeople } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-people';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflow-command-menu-items';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workflows';
export const standardObjectsPrefillData = async (
dataSource: DataSource,
schemaName: string,
workspaceId: string,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
@@ -24,5 +26,7 @@ export const standardObjectsPrefillData = async (
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
await prefillWorkflowCommandMenuItems(entityManager, workspaceId);
});
};
@@ -5,6 +5,7 @@ import {
WorkspaceMigrationV2ExceptionCode,
} from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { type QueryRunner } from 'typeorm';
import { FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -358,12 +359,17 @@ export class WorkspaceMigrationValidateBuildAndRunService {
public async validateBuildAndRunWorkspaceMigrationFromTo(
args: WorkspaceMigrationOrchestratorBuildArgs & {
idByUniversalIdentifierByMetadataName?: IdByUniversalIdentifierByMetadataName;
queryRunner?: QueryRunner;
},
): Promise<
| WorkspaceMigrationOrchestratorFailedResult
| WorkspaceMigrationOrchestratorSuccessfulResult
> {
const { idByUniversalIdentifierByMetadataName, ...buildArgs } = args;
const {
idByUniversalIdentifierByMetadataName,
queryRunner: externalQueryRunner,
...buildArgs
} = args;
const validateAndBuildResult =
await this.workspaceMigrationBuildOrchestratorService
@@ -396,6 +402,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
{
workspaceId: args.workspaceId,
workspaceMigration,
queryRunner: externalQueryRunner,
},
);
@@ -416,7 +423,10 @@ export class WorkspaceMigrationValidateBuildAndRunService {
workspaceId,
isSystemBuild = false,
applicationUniversalIdentifier,
}: ValidateBuildAndRunWorkspaceMigrationFromMatriceArgs): Promise<
queryRunner,
}: ValidateBuildAndRunWorkspaceMigrationFromMatriceArgs & {
queryRunner?: QueryRunner;
}): Promise<
| WorkspaceMigrationOrchestratorFailedResult
| WorkspaceMigrationOrchestratorSuccessfulResult
> {
@@ -443,6 +453,7 @@ export class WorkspaceMigrationValidateBuildAndRunService {
dependencyAllFlatEntityMaps,
additionalCacheDataMaps,
idByUniversalIdentifierByMetadataName,
queryRunner,
});
}
}
@@ -3,7 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { type AllMetadataName } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { DataSource, type QueryRunner } from 'typeorm';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -154,9 +154,11 @@ export class WorkspaceMigrationRunnerService {
run = async ({
workspaceMigration: { actions, applicationUniversalIdentifier },
workspaceId,
queryRunner: externalQueryRunner,
}: {
workspaceMigration: WorkspaceMigration;
workspaceId: string;
queryRunner?: QueryRunner;
}): Promise<{
allFlatEntityMaps: AllFlatEntityMaps;
metadataEvents: MetadataEvent[];
@@ -164,7 +166,10 @@ export class WorkspaceMigrationRunnerService {
this.logger.time('Runner', 'Total execution');
this.logger.time('Runner', 'Initial cache retrieval');
const queryRunner = this.coreDataSource.createQueryRunner();
const queryRunner =
externalQueryRunner ?? this.coreDataSource.createQueryRunner();
const isTransactionAlreadyActive = queryRunner.isTransactionActive;
const actionMetadataNames = [
...new Set(actions.flatMap((action) => action.metadataName)),
];
@@ -214,10 +219,12 @@ export class WorkspaceMigrationRunnerService {
this.logger.time('Runner', 'Transaction execution');
try {
if (!isTransactionAlreadyActive) {
await queryRunner.connect();
await queryRunner.startTransaction();
}
try {
const allMetadataEvents: MetadataEvent[] = [];
for (const action of actions) {
@@ -243,7 +250,9 @@ export class WorkspaceMigrationRunnerService {
allMetadataEvents.push(...metadataEvents);
}
await queryRunner.commitTransaction();
if (!isTransactionAlreadyActive) {
await queryRunner.commitTransaction();
}
this.logger.timeEnd('Runner', 'Transaction execution');
@@ -256,10 +265,12 @@ export class WorkspaceMigrationRunnerService {
return { allFlatEntityMaps, metadataEvents: allMetadataEvents };
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction().catch((error) =>
if (!isTransactionAlreadyActive && queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction().catch((rollbackError) =>
// oxlint-disable-next-line no-console
console.trace(`Failed to rollback transaction: ${error.message}`),
console.trace(
`Failed to rollback transaction: ${rollbackError.message}`,
),
);
}
@@ -288,7 +299,9 @@ export class WorkspaceMigrationRunnerService {
code: WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR,
});
} finally {
await queryRunner.release();
if (!isTransactionAlreadyActive) {
await queryRunner.release();
}
}
};
}
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
@@ -43,6 +45,8 @@ import { WorkflowVersionValidationWorkspaceService } from 'src/modules/workflow/
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ObjectMetadataModule,
CodeStepBuildModule,
CommandMenuItemModule,
FeatureFlagModule,
],
providers: [
WorkflowCreateOnePreQueryHook,
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { WorkflowQueryHookModule } from 'src/modules/workflow/common/query-hooks/workflow-query-hook.module';
@@ -10,6 +12,8 @@ import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/work
WorkflowQueryHookModule,
LogicFunctionModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
CommandMenuItemModule,
FeatureFlagModule,
],
providers: [WorkflowCommonWorkspaceService],
exports: [WorkflowCommonWorkspaceService],
@@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -40,10 +43,14 @@ export type ObjectMetadataInfo = {
@Injectable()
export class WorkflowCommonWorkspaceService {
private readonly logger = new Logger(WorkflowCommonWorkspaceService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly logicFunctionFromSourceService: LogicFunctionFromSourceService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly commandMenuItemService: CommandMenuItemService,
private readonly featureFlagService: FeatureFlagService,
) {}
async getWorkflowVersionOrFail({
@@ -262,38 +269,100 @@ export class WorkflowCommonWorkspaceService {
{ shouldBypassPermissionChecks: true },
);
const workflow = await workflowRepository.findOne({
where: { id: workflowId },
withDeleted: true,
});
if (workflow?.statuses?.includes(WorkflowStatus.ACTIVE)) {
const newStatuses = [
...workflow.statuses.filter(
(status) => status !== WorkflowStatus.ACTIVE,
),
WorkflowStatus.DEACTIVATED,
];
await workflowRepository.update(workflowId, {
statuses: newStatuses,
});
}
const workflowVersions = await workflowVersionRepository.find({
where: {
workflowId,
},
where: { workflowId },
withDeleted: true,
});
for (const workflowVersion of workflowVersions) {
if (workflowVersion.status === WorkflowVersionStatus.ACTIVE) {
await workflowVersionRepository.update(workflowVersion.id, {
status: WorkflowVersionStatus.DEACTIVATED,
});
await this.cleanupCommandMenuItemForVersion(
workflowVersion.id,
workspaceId,
);
}
}
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
const queryRunner = workspaceDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const workflow = await workflowRepository.findOne(
{
where: { id: workflowId },
withDeleted: true,
},
queryRunner.manager,
);
if (workflow?.statuses?.includes(WorkflowStatus.ACTIVE)) {
const newStatuses = [
...workflow.statuses.filter(
(status) => status !== WorkflowStatus.ACTIVE,
),
WorkflowStatus.DEACTIVATED,
];
await workflowRepository.update(
workflowId,
{ statuses: newStatuses },
queryRunner.manager,
);
}
for (const workflowVersion of workflowVersions) {
if (workflowVersion.status === WorkflowVersionStatus.ACTIVE) {
await workflowVersionRepository.update(
workflowVersion.id,
{ status: WorkflowVersionStatus.DEACTIVATED },
queryRunner.manager,
);
}
}
await queryRunner.commitTransaction();
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
}
private async cleanupCommandMenuItemForVersion(
workflowVersionId: string,
workspaceId: string,
) {
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const existingCommandMenuItem =
await this.commandMenuItemService.findByWorkflowVersionId(
workflowVersionId,
workspaceId,
);
if (isDefined(existingCommandMenuItem)) {
await this.commandMenuItemService.delete(
existingCommandMenuItem.id,
workspaceId,
);
}
}
async handleLogicFunctionSubEntities({
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
@@ -19,21 +21,32 @@ export class AutomatedTriggerWorkspaceService {
type,
settings,
workspaceId,
entityManager,
}: {
workflowId: string;
type: AutomatedTriggerType;
settings: AutomatedTriggerSettings;
workspaceId: string;
entityManager?: WorkspaceEntityManager;
}) {
const workflowAutomatedTriggerRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowAutomatedTriggerWorkspaceEntity>(
workspaceId,
'workflowAutomatedTrigger',
);
if (isDefined(entityManager)) {
await workflowAutomatedTriggerRepository.insert(
{ type, settings, workflowId },
entityManager,
);
return;
}
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const workflowAutomatedTriggerRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowAutomatedTriggerWorkspaceEntity>(
workspaceId,
'workflowAutomatedTrigger',
);
await workflowAutomatedTriggerRepository.insert({
type,
settings,
@@ -45,19 +58,30 @@ export class AutomatedTriggerWorkspaceService {
async deleteAutomatedTrigger({
workflowId,
workspaceId,
entityManager,
}: {
workflowId: string;
workspaceId: string;
entityManager?: WorkspaceEntityManager;
}) {
const workflowAutomatedTriggerRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowAutomatedTriggerWorkspaceEntity>(
workspaceId,
'workflowAutomatedTrigger',
);
if (isDefined(entityManager)) {
await workflowAutomatedTriggerRepository.delete(
{ workflowId },
entityManager,
);
return;
}
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const workflowAutomatedTriggerRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowAutomatedTriggerWorkspaceEntity>(
workspaceId,
'workflowAutomatedTrigger',
);
await workflowAutomatedTriggerRepository.delete({ workflowId });
}, authContext);
}
@@ -40,6 +40,7 @@ export type WorkflowManualTrigger = BaseTrigger & {
settings: BaseWorkflowTriggerSettings & {
objectType?: string;
icon?: string;
isPinned?: boolean;
availability?:
| GlobalAvailability
| SingleRecordAvailability
@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { type ActorMetadata, FeatureFlagKey } from 'twenty-shared/types';
@@ -7,6 +6,7 @@ import { type ActorMetadata, FeatureFlagKey } from 'twenty-shared/types';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -39,6 +39,8 @@ import { assertNever } from 'src/utils/assert';
@Injectable()
export class WorkflowTriggerWorkspaceService {
private readonly logger = new Logger(WorkflowTriggerWorkspaceService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
@@ -183,31 +185,96 @@ export class WorkflowTriggerWorkspaceService {
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
workspaceId: string,
) {
const previousPublishedVersionId = workflow.lastPublishedVersionId;
if (
workflow.lastPublishedVersionId &&
workflowVersion.id !== workflow.lastPublishedVersionId
previousPublishedVersionId &&
workflowVersion.id !== previousPublishedVersionId
) {
await this.performDeactivationSteps(
workflow.lastPublishedVersionId,
previousPublishedVersionId,
workflowVersionRepository,
workspaceId,
);
}
await this.upgradeWorkflowVersion(
await this.createOrUpdateCommandMenuItem(
workflow,
workflowVersion.id,
workflowRepository,
workflowVersionRepository,
);
await this.setActiveVersionStatus(
workflowVersion,
workflowVersionRepository,
workspaceId,
);
await this.enableTrigger(workflow, workflowVersion, workspaceId);
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
const queryRunner = workspaceDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
if (workflow.lastPublishedVersionId !== workflowVersion.id) {
if (workflow.lastPublishedVersionId) {
await workflowVersionRepository.update(
{ id: workflow.lastPublishedVersionId },
{ status: WorkflowVersionStatus.ARCHIVED },
queryRunner.manager,
);
}
await workflowRepository.update(
{ id: workflow.id },
{ lastPublishedVersionId: workflowVersion.id },
queryRunner.manager,
);
}
const activeWorkflowVersions = await workflowVersionRepository.find(
{
where: {
workflowId: workflowVersion.workflowId,
status: WorkflowVersionStatus.ACTIVE,
},
},
queryRunner.manager,
);
if (activeWorkflowVersions.length > 0) {
throw new WorkflowTriggerException(
'Cannot have more than one active workflow version',
WorkflowTriggerExceptionCode.FORBIDDEN,
{
userFriendlyMessage: msg`Cannot have more than one active workflow version`,
},
);
}
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ status: WorkflowVersionStatus.ACTIVE },
queryRunner.manager,
);
await this.enableAutomatedTrigger(workflowVersion, workspaceId, {
entityManager: queryRunner.manager,
});
await queryRunner.commitTransaction();
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
await this.emitStatusUpdateEvents(
workflowVersion,
WorkflowVersionStatus.ACTIVE,
workspaceId,
);
}
private async performDeactivationSteps(
@@ -228,69 +295,38 @@ export class WorkflowTriggerWorkspaceService {
return;
}
await this.setDeactivatedVersionStatus(
workflowVersion,
workflowVersionRepository,
workspaceId,
);
await this.deleteCommandMenuItem(workflowVersion, workspaceId);
await this.disableTrigger(workflowVersion, workspaceId);
}
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
private async setActiveVersionStatus(
workflowVersion: WorkflowVersionWorkspaceEntity,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
workspaceId: string,
) {
const activeWorkflowVersions = await workflowVersionRepository.find({
where: {
workflowId: workflowVersion.workflowId,
status: WorkflowVersionStatus.ACTIVE,
},
});
const queryRunner = workspaceDataSource.createQueryRunner();
if (activeWorkflowVersions.length > 0) {
throw new WorkflowTriggerException(
'Cannot have more than one active workflow version',
WorkflowTriggerExceptionCode.FORBIDDEN,
{
userFriendlyMessage: msg`Cannot have more than one active workflow version`,
},
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ status: WorkflowVersionStatus.DEACTIVATED },
queryRunner.manager,
);
await this.disableAutomatedTrigger(workflowVersion, workspaceId, {
entityManager: queryRunner.manager,
});
await queryRunner.commitTransaction();
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ status: WorkflowVersionStatus.ACTIVE },
);
await this.emitStatusUpdateEvents(
workflowVersion,
WorkflowVersionStatus.ACTIVE,
workspaceId,
);
}
private async setDeactivatedVersionStatus(
workflowVersion: WorkflowVersionWorkspaceEntity,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
workspaceId: string,
) {
if (workflowVersion.status !== WorkflowVersionStatus.ACTIVE) {
throw new WorkflowTriggerException(
'Cannot disable non-active workflow version',
WorkflowTriggerExceptionCode.FORBIDDEN,
{
userFriendlyMessage: msg`Cannot disable non-active workflow version`,
},
);
}
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ status: WorkflowVersionStatus.DEACTIVATED },
);
await this.emitStatusUpdateEvents(
workflowVersion,
WorkflowVersionStatus.DEACTIVATED,
@@ -298,101 +334,161 @@ export class WorkflowTriggerWorkspaceService {
);
}
private async upgradeWorkflowVersion(
workflow: WorkflowWorkspaceEntity,
newPublishedVersionId: string,
workflowRepository: WorkspaceRepository<WorkflowWorkspaceEntity>,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
) {
if (workflow.lastPublishedVersionId === newPublishedVersionId) {
return;
private async resolveManualTriggerAvailability(
trigger: WorkflowManualTrigger,
workspaceId: string,
): Promise<{
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId: string | undefined;
}> {
const availability = trigger.settings.availability;
let availabilityType = CommandMenuItemAvailabilityType.GLOBAL;
let availabilityObjectMetadataId: string | undefined;
if (availability) {
switch (availability.type) {
case 'GLOBAL':
availabilityType = CommandMenuItemAvailabilityType.GLOBAL;
break;
case 'SINGLE_RECORD':
case 'BULK_RECORDS': {
availabilityType = CommandMenuItemAvailabilityType.RECORD_SELECTION;
const { objectIdByNameSingular } =
await this.workflowCommonWorkspaceService.getFlatEntityMaps(
workspaceId,
);
const objectId =
objectIdByNameSingular[availability.objectNameSingular];
if (!objectId) {
throw new WorkflowTriggerException(
`Object metadata not found for object: ${availability.objectNameSingular}`,
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
availabilityObjectMetadataId = objectId;
break;
}
}
}
if (workflow.lastPublishedVersionId) {
await workflowVersionRepository.update(
{ id: workflow.lastPublishedVersionId },
{ status: WorkflowVersionStatus.ARCHIVED },
);
}
await workflowRepository.update(
{ id: workflow.id },
{ lastPublishedVersionId: newPublishedVersionId },
);
return { availabilityType, availabilityObjectMetadataId };
}
private async enableTrigger(
private async createOrUpdateCommandMenuItem(
workflow: WorkflowWorkspaceEntity,
workflowVersion: WorkflowVersionWorkspaceEntity,
workspaceId: string,
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
if (workflowVersion.trigger.type !== WorkflowTriggerType.MANUAL) {
return;
}
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const trigger = workflowVersion.trigger as WorkflowManualTrigger;
const { availabilityType, availabilityObjectMetadataId } =
await this.resolveManualTriggerAvailability(trigger, workspaceId);
const label = isNonEmptyString(workflow.name)
? workflow.name
: 'Manual Trigger';
const existingCommandMenuItem =
await this.commandMenuItemService.findByWorkflowVersionId(
workflowVersion.id,
workspaceId,
);
if (existingCommandMenuItem) {
await this.commandMenuItemService.update(
{
id: existingCommandMenuItem.id,
label,
shortLabel: label,
icon: trigger.settings.icon,
isPinned: trigger.settings.isPinned,
availabilityType,
availabilityObjectMetadataId,
},
workspaceId,
);
} else {
await this.commandMenuItemService.create(
{
workflowVersionId: workflowVersion.id,
label,
shortLabel: label,
icon: trigger.settings.icon,
isPinned: trigger.settings.isPinned,
availabilityType,
availabilityObjectMetadataId,
},
workspaceId,
);
}
}
private async deleteCommandMenuItem(
workflowVersion: WorkflowVersionWorkspaceEntity,
workspaceId: string,
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
if (workflowVersion.trigger.type !== WorkflowTriggerType.MANUAL) {
return;
}
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const existingCommandMenuItem =
await this.commandMenuItemService.findByWorkflowVersionId(
workflowVersion.id,
workspaceId,
);
if (existingCommandMenuItem) {
await this.commandMenuItemService.delete(
existingCommandMenuItem.id,
workspaceId,
);
}
}
private async enableAutomatedTrigger(
workflowVersion: WorkflowVersionWorkspaceEntity,
workspaceId: string,
transactionContext?: {
entityManager: WorkspaceEntityManager;
},
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
switch (workflowVersion.trigger.type) {
case WorkflowTriggerType.MANUAL: {
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const trigger = workflowVersion.trigger as WorkflowManualTrigger;
const availability = trigger.settings.availability;
let availabilityType = CommandMenuItemAvailabilityType.GLOBAL;
let availabilityObjectMetadataId: string | undefined;
if (availability) {
switch (availability.type) {
case 'GLOBAL':
availabilityType = CommandMenuItemAvailabilityType.GLOBAL;
break;
case 'SINGLE_RECORD':
case 'BULK_RECORDS': {
availabilityType =
CommandMenuItemAvailabilityType.RECORD_SELECTION;
const { objectIdByNameSingular } =
await this.workflowCommonWorkspaceService.getFlatEntityMaps(
workspaceId,
);
const objectId =
objectIdByNameSingular[availability.objectNameSingular];
if (!objectId) {
throw new WorkflowTriggerException(
`Object metadata not found for object: ${availability.objectNameSingular}`,
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
availabilityObjectMetadataId = objectId;
break;
}
}
}
await this.commandMenuItemService.create(
{
workflowVersionId: workflowVersion.id,
label: isNonEmptyString(workflow.name)
? workflow.name
: 'Manual Trigger',
icon: trigger.settings.icon,
availabilityType,
availabilityObjectMetadataId,
},
workspaceId,
);
return;
}
case WorkflowTriggerType.MANUAL:
case WorkflowTriggerType.WEBHOOK:
return;
case WorkflowTriggerType.DATABASE_EVENT: {
@@ -404,6 +500,7 @@ export class WorkflowTriggerWorkspaceService {
type: AutomatedTriggerType.DATABASE_EVENT,
settings,
workspaceId,
entityManager: transactionContext?.entityManager,
});
return;
@@ -416,19 +513,22 @@ export class WorkflowTriggerWorkspaceService {
type: AutomatedTriggerType.CRON,
settings: { pattern },
workspaceId,
entityManager: transactionContext?.entityManager,
});
return;
}
default: {
default:
assertNever(workflowVersion.trigger);
}
}
}
private async disableTrigger(
private async disableAutomatedTrigger(
workflowVersion: WorkflowVersionWorkspaceEntity,
workspaceId: string,
transactionContext?: {
entityManager: WorkspaceEntityManager;
},
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
@@ -438,35 +538,11 @@ export class WorkflowTriggerWorkspaceService {
await this.automatedTriggerWorkspaceService.deleteAutomatedTrigger({
workflowId: workflowVersion.workflowId,
workspaceId,
entityManager: transactionContext?.entityManager,
});
return;
case WorkflowTriggerType.MANUAL: {
const isCommandMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED,
workspaceId,
);
if (!isCommandMenuItemEnabled) {
return;
}
const existingCommandMenuItem =
await this.commandMenuItemService.findByWorkflowVersionId(
workflowVersion.id,
workspaceId,
);
if (existingCommandMenuItem) {
await this.commandMenuItemService.delete(
existingCommandMenuItem.id,
workspaceId,
);
}
return;
}
case WorkflowTriggerType.MANUAL:
case WorkflowTriggerType.WEBHOOK:
return;
default: