From e81fdbcc7a62b91803d3566b8324b93616dad99a Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Mon, 3 Aug 2026 14:32:06 +0200 Subject: [PATCH] feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Capture d’écran 2026-08-03 à 11 30
40 Adds workflow variable pickers to the **Search Records** action for fields that previously only accepted static values: - **Limit** and **Offset** number inputs now expose the `WorkflowVariablePicker`, so they can be bound to a variable from a previous step. The stored value can be a standalone variable string; the backend coerces the resolved value back to a number. - **Date filters** using the `Is before` (`IS_BEFORE`) and `Is after or equal` (`IS_AFTER`) operands now expose the variable picker in the advanced filter side panel (previously disabled for all date filters). The backend already resolves these inputs via `resolveInput`; the only backend change is a small numeric coercion of the resolved limit/offset. ## Changes - `WorkflowEditActionFindRecords.tsx` — pass `WorkflowVariablePicker` to the Limit/Offset inputs; make `onChange` and form state variable-aware (`number | string`). - `AdvancedFilterSidePanelValueFormInput.tsx` — enable the date `VariablePicker` only for `IS_BEFORE` / `IS_AFTER`. - `useGetRecordFilterDisplayValue.ts` — return the raw variable for a standalone `{{variable}}` value so date filters don't crash `Temporal.*.from`. - `find-records-action-settings-schema.ts` — allow a string (variable) for `limit` / `offset`. - `find-records.workflow-action.ts` — coerce resolved `limit` / `offset` to numbers before querying. ## Testing Built a workflow locally (Manual trigger → Code step returning `{ limit: 2, offset: 1, sinceDate }` → Search Records) with all three fields bound to those variables. The run completed successfully; the Search Records step returned exactly 2 records (limit applied) filtered by `createdAt >= sinceDate`, confirming the backend resolves each variable. Review in cubic --- .../AdvancedFilterSidePanelValueFormInput.tsx | 3 +- .../hooks/useGetRecordFilterDisplayValue.ts | 7 +++ .../WorkflowEditActionFindRecords.tsx | 62 +++++++++++++++---- .../find-records.workflow-action.ts | 6 +- .../utils/parse-finite-number-input.util.ts | 15 +++++ .../utils/resolve-limit-input.util.ts | 16 +++++ .../utils/resolve-offset-input.util.ts | 15 +++++ packages/twenty-shared/src/workflow/index.ts | 1 + .../find-records-action-settings-schema.ts | 10 ++- .../send-email-action-settings-schema.ts | 11 ++-- .../workflow-variable-reference-schema.ts | 8 +++ 11 files changed, 128 insertions(+), 26 deletions(-) create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/parse-finite-number-input.util.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-limit-input.util.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-offset-input.util.ts create mode 100644 packages/twenty-shared/src/workflow/schemas/workflow-variable-reference-schema.ts diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx index 20baac982c..e74f60d243 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx @@ -245,8 +245,7 @@ export const AdvancedFilterSidePanelValueFormInput = ({ defaultValue={recordFilter.value} onChange={handleChange} readonly={readonly} - // VariablePicker is not supported for date filters yet - VariablePicker={isFilterableByDateValue ? undefined : VariablePicker} + VariablePicker={VariablePicker} timeZone={timeZone} /> ); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRecordFilterDisplayValue.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRecordFilterDisplayValue.ts index c71bf010c0..1410f15ae8 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRecordFilterDisplayValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRecordFilterDisplayValue.ts @@ -10,6 +10,7 @@ import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordF import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty'; import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime'; +import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; import { type Nullable } from 'twenty-shared/types'; import { @@ -36,6 +37,12 @@ export const useGetRecordFilterDisplayValue = () => { return ''; } + const filterValue = recordFilter.value; + + if (isStandaloneVariableString(filterValue)) { + return filterValue; + } + const filterType = recordFilter.type; const operandIsEmptiness = isEmptinessOperand(recordFilter.operand); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx index c476e4d995..9f880b6b0e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords.tsx @@ -33,6 +33,8 @@ import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-a import { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect'; import { WorkflowFindRecordsSorts } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsSorts'; import { WorkflowObjectDropdownContent } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowObjectDropdownContent'; +import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; +import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledLabel = styled.span` @@ -65,8 +67,8 @@ type FindRecordsFormData = { objectNameSingular: string; filter?: FindRecordsActionFilter; orderBy?: FindRecordsActionOrderBy; - limit?: number; - offset?: number; + limit?: number | string; + offset?: number | string; }; export type FindRecordsActionFilter = { @@ -96,12 +98,16 @@ export const WorkflowEditActionFindRecords = ({ const [formData, setFormData] = useState(() => ({ objectNameSingular: action.settings.input.objectName, - limit: - isNumber(action.settings.input.limit) && - action.settings.input.limit > QUERY_MAX_RECORDS - ? QUERY_MAX_RECORDS - : (action.settings.input.limit ?? 1), - offset: Math.max(0, Math.floor(action.settings.input.offset ?? 0)), + limit: isNumber(action.settings.input.limit) + ? Math.min(action.settings.input.limit, QUERY_MAX_RECORDS) + : isStandaloneVariableString(action.settings.input.limit) + ? action.settings.input.limit + : 1, + offset: isNumber(action.settings.input.offset) + ? Math.max(0, Math.floor(action.settings.input.offset)) + : isStandaloneVariableString(action.settings.input.offset) + ? action.settings.input.offset + : 0, filter: action.settings.input.filter as FindRecordsActionFilter, orderBy: action.settings.input.orderBy as FindRecordsActionOrderBy, })); @@ -155,7 +161,9 @@ export const WorkflowEditActionFindRecords = ({ input: { objectName: updatedObjectName, limit: updatedLimit ?? 1, - offset: Math.max(0, Math.floor(updatedOffset ?? 0)), + offset: isNumber(updatedOffset) + ? Math.max(0, Math.floor(updatedOffset)) + : (updatedOffset ?? 0), filter: updatedFilter, orderBy: updatedOrderBy as Record | undefined, }, @@ -312,12 +320,26 @@ export const WorkflowEditActionFindRecords = ({ readonly={isFormDisabled} hint={t`This action can return up to ${maxRecordsFormatted} records.`} error={limitError} + VariablePicker={WorkflowVariablePicker} onChange={(limit) => { - if (isFormDisabled === true || !isNumber(limit)) { + if (isFormDisabled === true) { return; } - const normalizedLimit = Math.floor(limit); + if (isStandaloneVariableString(limit)) { + setLimitError(undefined); + + const newFormData: FindRecordsFormData = { + ...formData, + limit, + }; + + setFormData(newFormData); + saveAction(newFormData); + return; + } + + const normalizedLimit = isNumber(limit) ? Math.floor(limit) : 1; if (normalizedLimit <= 0) { setLimitError(t`Limit must be greater than 0.`); @@ -350,12 +372,26 @@ export const WorkflowEditActionFindRecords = ({ readonly={isFormDisabled} hint={t`Number of records to skip. Combine with Limit to page through results.`} error={offsetError} + VariablePicker={WorkflowVariablePicker} onChange={(offset) => { - if (isFormDisabled === true || !isNumber(offset)) { + if (isFormDisabled === true) { return; } - const normalizedOffset = Math.floor(offset); + if (isStandaloneVariableString(offset)) { + setOffsetError(undefined); + + const newFormData: FindRecordsFormData = { + ...formData, + offset, + }; + + setFormData(newFormData); + saveAction(newFormData); + return; + } + + const normalizedOffset = isNumber(offset) ? Math.floor(offset) : 0; if (normalizedOffset < 0) { setOffsetError(t`Offset cannot be negative.`); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts index 5c7580b665..ef6e1b11ec 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts @@ -23,6 +23,8 @@ import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executo import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; import { isWorkflowFindRecordsAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-find-records-action.guard'; import { type WorkflowFindRecordsActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; +import { resolveLimitInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-limit-input.util'; +import { resolveOffsetInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-offset-input.util'; @Injectable() export class FindRecordsWorkflowAction implements WorkflowAction { @@ -104,8 +106,8 @@ export class FindRecordsWorkflowAction implements WorkflowAction { objectName: workflowActionInput.objectName, filter: gqlOperationFilter, orderBy: workflowActionInput.orderBy?.gqlOperationOrderBy, - limit: workflowActionInput.limit, - offset: workflowActionInput.offset, + limit: resolveLimitInput(workflowActionInput.limit), + offset: resolveOffsetInput(workflowActionInput.offset), authContext: executionContext.authContext, rolePermissionConfig: executionContext.rolePermissionConfig, shouldBuildEffectiveSelectFields: false, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/parse-finite-number-input.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/parse-finite-number-input.util.ts new file mode 100644 index 0000000000..dcc9541726 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/parse-finite-number-input.util.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +const finiteNumberInputSchema = z.preprocess( + (value) => + typeof value === 'string' && value.trim() === '' ? undefined : value, + z.coerce.number().finite(), +); + +export const parseFiniteNumberInput = ( + value: number | string | undefined, +): number | undefined => { + const result = finiteNumberInputSchema.safeParse(value); + + return result.success ? result.data : undefined; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-limit-input.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-limit-input.util.ts new file mode 100644 index 0000000000..437bf4b577 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-limit-input.util.ts @@ -0,0 +1,16 @@ +import { QUERY_MAX_RECORDS } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; + +import { parseFiniteNumberInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/parse-finite-number-input.util'; + +export const resolveLimitInput = ( + value: number | string | undefined, +): number | undefined => { + const parsedValue = parseFiniteNumberInput(value); + + if (!isDefined(parsedValue)) { + return undefined; + } + + return Math.min(Math.max(Math.floor(parsedValue), 1), QUERY_MAX_RECORDS); +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-offset-input.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-offset-input.util.ts new file mode 100644 index 0000000000..caf5c08dd4 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/resolve-offset-input.util.ts @@ -0,0 +1,15 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { parseFiniteNumberInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/utils/parse-finite-number-input.util'; + +export const resolveOffsetInput = ( + value: number | string | undefined, +): number | undefined => { + const parsedValue = parseFiniteNumberInput(value); + + if (!isDefined(parsedValue)) { + return undefined; + } + + return Math.max(0, Math.floor(parsedValue)); +}; diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 8c3665f109..bf09afcaec 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -99,6 +99,7 @@ export { } from './schemas/workflow-run-step-log-schema'; export { workflowRunStepStatusSchema } from './schemas/workflow-run-step-status-schema'; export { workflowTriggerSchema } from './schemas/workflow-trigger-schema'; +export { workflowVariableReferenceSchema } from './schemas/workflow-variable-reference-schema'; export type { EmailRecipients } from './types/EmailRecipients'; export type { FunctionInput } from './types/FunctionInput'; export type { diff --git a/packages/twenty-shared/src/workflow/schemas/find-records-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/find-records-action-settings-schema.ts index 1145daccf4..7d880d6628 100644 --- a/packages/twenty-shared/src/workflow/schemas/find-records-action-settings-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/find-records-action-settings-schema.ts @@ -1,12 +1,18 @@ import { z } from 'zod'; import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema'; +import { workflowVariableReferenceSchema } from './workflow-variable-reference-schema'; export const workflowFindRecordsActionSettingsSchema = baseWorkflowActionSettingsSchema.extend({ input: z.object({ objectName: z.string(), - limit: z.number().optional(), - offset: z.number().int().nonnegative().optional(), + limit: z.union([z.number(), workflowVariableReferenceSchema]).optional(), + offset: z + .union([ + z.number().int().nonnegative(), + workflowVariableReferenceSchema, + ]) + .optional(), filter: z .object({ recordFilterGroups: z.array(z.any()).optional(), diff --git a/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts index 66caeb8f98..9a75a8d9ce 100644 --- a/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts @@ -1,18 +1,15 @@ import { z } from 'zod'; import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema'; import { workflowFileSchema } from './workflow-file-action-schema'; +import { workflowVariableReferenceSchema } from './workflow-variable-reference-schema'; export const workflowEmailFilesSchema = z .array( z.union([ workflowFileSchema, - z - .string() - .regex( - /^{{[^{}]+}}$/, - 'Expected a workflow variable reference like {{stepId.path}}', - ) - .describe('A workflow variable reference resolving to files'), + workflowVariableReferenceSchema.describe( + 'A workflow variable reference resolving to files', + ), ]), ) .optional() diff --git a/packages/twenty-shared/src/workflow/schemas/workflow-variable-reference-schema.ts b/packages/twenty-shared/src/workflow/schemas/workflow-variable-reference-schema.ts new file mode 100644 index 0000000000..f4ae57508b --- /dev/null +++ b/packages/twenty-shared/src/workflow/schemas/workflow-variable-reference-schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const workflowVariableReferenceSchema = z + .string() + .regex( + /^{{[^{}]+}}$/, + 'Expected a workflow variable reference like {{stepId.path}}', + );