feat(workflow): variable pickers for Search Records limit, offset and date filters (#23696)

## Summary

<img width="491" height="390" alt="Capture d’écran 2026-08-03 à 11 30
40"
src="https://github.com/user-attachments/assets/6db59b8a-3e8e-41b8-80b3-c736e56b7f7f"
/>

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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23696?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Thomas Trompette
2026-08-03 14:32:06 +02:00
committed by GitHub
parent 9e25121616
commit e81fdbcc7a
11 changed files with 128 additions and 26 deletions
@@ -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}
/>
);
@@ -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);
@@ -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<FindRecordsFormData>(() => ({
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<string, any[]> | 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.`);
@@ -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,
@@ -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;
};
@@ -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);
};
@@ -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));
};
@@ -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 {
@@ -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(),
@@ -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()
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const workflowVariableReferenceSchema = z
.string()
.regex(
/^{{[^{}]+}}$/,
'Expected a workflow variable reference like {{stepId.path}}',
);