Support Select All for workflow manual triggers (#19734)

## Summary
- Move record fetching and payload building from the enrichment hook
into `TriggerWorkflowVersionEngineCommand`, following the same
component-based pattern as `DeleteRecordsCommand` and
`RestoreRecordsCommand`
- The enrichment hook now only stores workflow metadata (`trigger`,
`availabilityType`, `availabilityObjectMetadataId`); the component uses
`useLazyFetchAllRecords` for exclusion mode (Select All) with full
pagination
- `buildTriggerWorkflowVersionPayloads` is now a pure function accepting
`selectedRecords: ObjectRecord[]` instead of reading from the Jotai
store

Fixes the issue introduced by #19718 which blocked Select All with a
warning toast instead of implementing it.

## Test plan
- [ ] Select individual records → run workflow trigger from command menu
→ works as before
- [ ] Click Select All → run workflow trigger from command menu →
fetches all matching records and runs the workflow
- [ ] Select All with some records deselected → correctly excludes those
records
- [ ] Global workflows (no object context) → run without payload as
before
- [ ] Bulk record triggers → payload wraps records in `{namePlural:
[records]}`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-16 13:33:03 +02:00
committed by GitHub
parent d54092b0e2
commit 9beb1ca326
7 changed files with 79 additions and 230 deletions
@@ -9,8 +9,6 @@ import {
} from '~/generated-metadata/graphql';
const mockFindOneWorkflowVersion = jest.fn();
const mockEnqueueWarningSnackBar = jest.fn();
const mockBuildTriggerWorkflowVersionPayloads = jest.fn();
jest.mock('@/object-record/hooks/useLazyFindOneRecord', () => ({
useLazyFindOneRecord: () => ({
@@ -18,20 +16,6 @@ jest.mock('@/object-record/hooks/useLazyFindOneRecord', () => ({
}),
}));
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
useSnackBar: () => ({
enqueueWarningSnackBar: mockEnqueueWarningSnackBar,
}),
}));
jest.mock(
'@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads',
() => ({
buildTriggerWorkflowVersionPayloads: (...args: unknown[]) =>
mockBuildTriggerWorkflowVersionPayloads(...args),
}),
);
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
@@ -58,7 +42,7 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
jest.clearAllMocks();
});
it('should return enriched context API with workflow info and payloads', async () => {
it('should return enriched context with workflow metadata', async () => {
const store = createStore();
const wrapper = getWrapper(store);
@@ -75,9 +59,6 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
},
);
const expectedPayloads = [{ recordId: 'rec-1' }];
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue(expectedPayloads);
const { result } = renderHook(
() =>
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
@@ -94,7 +75,8 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
{
headlessEngineCommandContextApi,
workflowVersionId: 'wf-version-1',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: 'obj-1',
},
);
});
@@ -103,7 +85,9 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
...headlessEngineCommandContextApi,
workflowId: 'workflow-1',
workflowVersionId: 'wf-version-1',
payloads: expectedPayloads,
trigger: { type: 'MANUAL' },
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: 'obj-1',
});
});
@@ -134,99 +118,4 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
expect(enrichedResult).toBeUndefined();
});
it('should return undefined for RECORD_SELECTION type when payloads are empty', async () => {
const store = createStore();
const wrapper = getWrapper(store);
const workflowVersionRecord = {
id: 'wf-version-1',
workflowId: 'workflow-1',
trigger: { type: 'MANUAL' },
__typename: 'WorkflowVersion' as const,
};
mockFindOneWorkflowVersion.mockImplementation(
async ({ onCompleted }: { onCompleted: (data: unknown) => void }) => {
onCompleted(workflowVersionRecord);
},
);
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue([]);
const { result } = renderHook(
() =>
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
{ wrapper },
);
let enrichedResult: unknown;
await act(async () => {
enrichedResult =
await result.current.enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(
{
headlessEngineCommandContextApi: buildBaseContextApi(),
workflowVersionId: 'wf-version-1',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
},
);
});
expect(enrichedResult).toBeUndefined();
});
it('should show warning snackbar when selected records exceed QUERY_MAX_RECORDS', async () => {
const store = createStore();
const wrapper = getWrapper(store);
const workflowVersionRecord = {
id: 'wf-version-1',
workflowId: 'workflow-1',
trigger: { type: 'MANUAL' },
__typename: 'WorkflowVersion' as const,
};
mockFindOneWorkflowVersion.mockImplementation(
async ({ onCompleted }: { onCompleted: (data: unknown) => void }) => {
onCompleted(workflowVersionRecord);
},
);
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue([
{ recordId: 'rec-1' },
]);
const selectedRecordIds = Array.from({ length: 201 }, (_, index) =>
String(index),
);
const headlessEngineCommandContextApi = buildBaseContextApi({
targetedRecordsRule: { mode: 'selection', selectedRecordIds },
});
const { result } = renderHook(
() =>
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
{ wrapper },
);
await act(async () => {
await result.current.enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(
{
headlessEngineCommandContextApi,
workflowVersionId: 'wf-version-1',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
},
);
});
expect(mockEnqueueWarningSnackBar).toHaveBeenCalledWith(
expect.objectContaining({
options: {
dedupeKey: 'workflow-manual-trigger-selection-limit',
},
}),
);
});
});
@@ -84,7 +84,8 @@ describe('useMountCommand', () => {
...baseContextApi,
workflowId: 'workflow-1',
workflowVersionId: 'wf-version-1',
payloads: [{ recordId: 'rec-1' }],
trigger: { type: 'MANUAL' },
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
};
mockEnrichFn.mockResolvedValue(enrichedState);
@@ -4,27 +4,18 @@ import {
type HeadlessCommandContextApi,
type HeadlessEngineCommandContextApi,
} from '@/command-menu-item/engine-command/types/HeadlessCommandContextApi';
import { buildTriggerWorkflowVersionPayloads } from '@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useLazyFindOneRecord } from '@/object-record/hooks/useLazyFindOneRecord';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
CommandMenuItemAvailabilityType as CommandMenuItemAvailabilityTypeEnum,
type CommandMenuItemAvailabilityType,
} from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
type WorkflowVersionRecord = Pick<
WorkflowVersion,
'id' | 'workflowId' | 'trigger' | '__typename'
>;
type BuildTriggerWorkflowVersionCommandStateParams = {
type EnrichParams = {
headlessEngineCommandContextApi: HeadlessEngineCommandContextApi;
workflowVersionId: string;
availabilityType: CommandMenuItemAvailabilityType;
@@ -33,9 +24,6 @@ type BuildTriggerWorkflowVersionCommandStateParams = {
export const useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation =
() => {
const store = useStore();
const { enqueueWarningSnackBar } = useSnackBar();
const { findOneRecord: findOneWorkflowVersion } =
useLazyFindOneRecord<WorkflowVersionRecord>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
@@ -65,76 +53,23 @@ export const useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInforma
workflowVersionId,
availabilityType,
availabilityObjectMetadataId,
}: BuildTriggerWorkflowVersionCommandStateParams): Promise<
HeadlessCommandContextApi | undefined
> => {
}: EnrichParams): Promise<HeadlessCommandContextApi | undefined> => {
const workflowVersion = await fetchWorkflowVersion(workflowVersionId);
if (!isDefined(workflowVersion)) {
return undefined;
}
if (
headlessEngineCommandContextApi.targetedRecordsRule.mode ===
'exclusion'
) {
enqueueWarningSnackBar({
message: t`Running workflows on all records is not yet supported. Please select records manually.`,
options: {
dedupeKey: 'workflow-manual-trigger-select-all-not-supported',
},
});
return undefined;
}
const selectedRecordIds =
headlessEngineCommandContextApi.targetedRecordsRule
.selectedRecordIds;
if (selectedRecordIds.length > QUERY_MAX_RECORDS) {
const selectedCountFormatted =
selectedRecordIds.length.toLocaleString();
const limitFormatted = QUERY_MAX_RECORDS.toLocaleString();
enqueueWarningSnackBar({
message: t`You selected ${selectedCountFormatted} records but manual triggers can run on at most ${limitFormatted} records at once. Only the first ${limitFormatted} records will be processed.`,
options: {
dedupeKey: 'workflow-manual-trigger-selection-limit',
},
});
}
const objectMetadataItems = store.get(
objectMetadataItemsSelector.atom,
);
const payloads = buildTriggerWorkflowVersionPayloads({
store,
trigger: workflowVersion.trigger,
availabilityType,
availabilityObjectMetadataId,
objectMetadataItems,
selectedRecordIds,
});
if (
availabilityType ===
CommandMenuItemAvailabilityTypeEnum.RECORD_SELECTION &&
!isNonEmptyArray(payloads)
) {
return undefined;
}
return {
...headlessEngineCommandContextApi,
workflowId: workflowVersion.workflowId,
workflowVersionId: workflowVersion.id,
payloads,
trigger: workflowVersion.trigger,
availabilityType,
availabilityObjectMetadataId,
};
},
[store, fetchWorkflowVersion, enqueueWarningSnackBar],
[fetchWorkflowVersion],
);
return {
@@ -1,23 +1,63 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { buildTriggerWorkflowVersionPayloads } from '@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads';
import { isHeadlessTriggerWorkflowVersionCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessTriggerWorkflowVersionCommandContextApi';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import {
CoreObjectNameSingular,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
export const TriggerWorkflowVersionEngineCommand = () => {
const mountedCommandState = useHeadlessCommandContextApi();
const store = useStore();
if (!isHeadlessTriggerWorkflowVersionCommandContextApi(mountedCommandState)) {
throw new Error(
'TriggerWorkflowVersionEngineCommand requires a workflow trigger context',
);
}
const noMatchFilter: RecordGqlOperationFilter = { id: { in: [] } };
const { fetchAllRecords } = useLazyFetchAllRecords({
objectNameSingular:
mountedCommandState.objectMetadataItem?.nameSingular ??
CoreObjectNameSingular.Person,
filter: mountedCommandState.graphqlFilter ?? noMatchFilter,
limit: DEFAULT_QUERY_PAGE_SIZE,
});
const { runWorkflowVersion } = useRunWorkflowVersion();
const execute = useCallback(async () => {
if (
!isHeadlessTriggerWorkflowVersionCommandContextApi(mountedCommandState)
) {
return;
let selectedRecords: ObjectRecord[];
if (mountedCommandState.targetedRecordsRule.mode === 'selection') {
selectedRecords = mountedCommandState.selectedRecords;
} else {
selectedRecords = await fetchAllRecords();
}
if (!isNonEmptyArray(mountedCommandState.payloads)) {
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const payloads = buildTriggerWorkflowVersionPayloads({
trigger: mountedCommandState.trigger,
availabilityType: mountedCommandState.availabilityType,
availabilityObjectMetadataId:
mountedCommandState.availabilityObjectMetadataId,
objectMetadataItems,
selectedRecords,
});
if (!isNonEmptyArray(payloads)) {
await runWorkflowVersion({
workflowId: mountedCommandState.workflowId,
workflowVersionId: mountedCommandState.workflowVersionId,
@@ -26,14 +66,14 @@ export const TriggerWorkflowVersionEngineCommand = () => {
return;
}
for (const payload of mountedCommandState.payloads) {
for (const payload of payloads) {
await runWorkflowVersion({
workflowId: mountedCommandState.workflowId,
workflowVersionId: mountedCommandState.workflowVersionId,
payload,
});
}
}, [runWorkflowVersion, mountedCommandState]);
}, [mountedCommandState, fetchAllRecords, runWorkflowVersion, store]);
return <HeadlessEngineCommandWrapperEffect execute={execute} />;
};
@@ -1,11 +1,13 @@
import { type ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
import {
type Nullable,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import {
type CommandMenuItemAvailabilityType,
type EngineComponentKey,
type CommandMenuItemPayload,
} from '~/generated-metadata/graphql';
@@ -31,7 +33,9 @@ export type HeadlessTriggerWorkflowVersionCommandContextApi =
HeadlessEngineCommandContextApi & {
workflowId: string;
workflowVersionId: string;
payloads: Record<string, any>[];
trigger: WorkflowTrigger | null;
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
};
export type HeadlessCommandContextApi =
@@ -1,6 +1,9 @@
import { type HeadlessCommandContextApi } from '@/command-menu-item/engine-command/types/HeadlessCommandContextApi';
import { isHeadlessTriggerWorkflowVersionCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessTriggerWorkflowVersionCommandContextApi';
import { EngineComponentKey } from '~/generated-metadata/graphql';
import {
CommandMenuItemAvailabilityType,
EngineComponentKey,
} from '~/generated-metadata/graphql';
const baseContextApi: HeadlessCommandContextApi = {
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
@@ -20,7 +23,8 @@ describe('isHeadlessTriggerWorkflowVersionCommandContextApi', () => {
...baseContextApi,
workflowId: 'wf-1',
workflowVersionId: 'wfv-1',
payloads: [],
trigger: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
};
expect(
@@ -1,10 +1,7 @@
import type { Store } from 'jotai/vanilla/store';
import { isBulkRecordsManualTrigger } from '@/command-menu-item/record/utils/isBulkRecordsManualTrigger';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import {
type CommandMenuItemAvailabilityType,
@@ -12,44 +9,31 @@ import {
} from '~/generated-metadata/graphql';
export const buildTriggerWorkflowVersionPayloads = ({
store,
trigger,
availabilityType,
availabilityObjectMetadataId,
objectMetadataItems,
selectedRecordIds,
selectedRecords,
}: {
store: Store;
trigger: WorkflowTrigger | null;
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
objectMetadataItems: EnrichedObjectMetadataItem[];
selectedRecordIds: string[];
selectedRecords: ObjectRecord[];
}): Record<string, any>[] => {
const payloads: Record<string, any>[] = [];
switch (availabilityType) {
case CommandMenuItemAvailabilityTypeEnum.RECORD_SELECTION: {
if (selectedRecordIds.length === 0) {
if (selectedRecords.length === 0) {
return payloads;
}
const limitedSelectedRecordIds = selectedRecordIds.slice(
0,
QUERY_MAX_RECORDS,
);
const objectMetadataItem = objectMetadataItems.find(
(metadata) => metadata.id === availabilityObjectMetadataId,
);
if (isDefined(trigger) && isBulkRecordsManualTrigger(trigger)) {
const selectedRecords = limitedSelectedRecordIds
.map((recordId) =>
store.get(recordStoreFamilyState.atomFamily(recordId)),
)
.filter(isDefined);
if (isDefined(objectMetadataItem)) {
payloads.push({
[objectMetadataItem.namePlural]: selectedRecords,
@@ -59,15 +43,7 @@ export const buildTriggerWorkflowVersionPayloads = ({
return payloads;
}
for (const selectedRecordId of limitedSelectedRecordIds) {
const selectedRecord = store.get(
recordStoreFamilyState.atomFamily(selectedRecordId),
);
if (!isDefined(selectedRecord)) {
continue;
}
for (const selectedRecord of selectedRecords) {
payloads.push(selectedRecord);
}