From a8a8bbb2ed86305af2bc1fab19afef5fe0aad9a1 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Fri, 12 Jun 2026 16:11:53 +0200 Subject: [PATCH] feat(workflow): add offset to Find Records node for pagination (#21484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture d’écran 2026-06-12 à 15 38
45 ## Summary The workflow Find Records (search) node previously exposed only `objectName`, `filter`, `sort`, and `limit` (capped at `QUERY_MAX_RECORDS` = 200), with no way to page beyond the first page of results. This adds an optional **Offset** to the node so a workflow can fetch an arbitrary page (`offset = pageIndex * limit`) while keeping the same filter and sort. The underlying `FindRecordsService` already accepts `offset` (it forwards it to the query runner's `skip`, and stabilizes ordering with an `id` tiebreaker), so this change just threads `offset` through the remaining layers: - `workflowFindRecordsActionSettingsSchema` (shared zod schema) — new optional `offset` - `FindRecordsInput` type — new optional `offset?: number` - `find-records.workflow-action.ts` — forwards `offset` to `FindRecordsService.execute` - `WorkflowEditActionFindRecords.tsx` — new "Offset" number input (non-negative, defaults to 0) with form state + persistence - Default `FIND_RECORDS` step settings — `offset: 0` ### Notes / non-goals - Offset-only, single page: the node returns one page. Looping over all pages inside one run is not included (the Iterator action loops a static array and cannot re-query). The node output already returns `totalCount`, so a workflow can compute total pages as `ceil(totalCount / limit)`. - Offset on very large/changing datasets can be slow or skip/duplicate rows; cursor/keyset pagination would be a future follow-up. ## Test plan - [x] Create a Find Records node, set Limit=50, Offset=0 → returns first page - [x] Set Offset=50 with the same filter/sort → returns the second page (no overlap) - [x] Negative offset shows a validation error and is not saved - [x] Existing Find Records nodes (no offset stored) still run, defaulting to offset 0 - [x] Typecheck/lint pass in CI Review in cubic --- .../WorkflowEditActionFindRecords.tsx | 38 +++++++++++++++++++ .../types/record-crud-input.type.ts | 1 + ...rsion-step-operations.workspace-service.ts | 1 + .../find-records.workflow-action.ts | 1 + .../find-records-action-settings-schema.ts | 1 + 5 files changed, 42 insertions(+) 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 3e1a644da1..882307d490 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 @@ -66,6 +66,7 @@ type FindRecordsFormData = { filter?: FindRecordsActionFilter; orderBy?: FindRecordsActionOrderBy; limit?: number; + offset?: number; }; export type FindRecordsActionFilter = { @@ -100,11 +101,13 @@ export const WorkflowEditActionFindRecords = ({ 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)), filter: action.settings.input.filter as FindRecordsActionFilter, orderBy: action.settings.input.orderBy as FindRecordsActionOrderBy, })); const [limitError, setLimitError] = useState(undefined); + const [offsetError, setOffsetError] = useState(undefined); const isFormDisabled = actionOptions.readonly ?? false; const instanceId = `workflow-edit-action-record-find-records-${action.id}-${formData.objectNameSingular}`; @@ -140,6 +143,7 @@ export const WorkflowEditActionFindRecords = ({ const { objectNameSingular: updatedObjectName, limit: updatedLimit, + offset: updatedOffset, filter: updatedFilter, orderBy: updatedOrderBy, } = formData; @@ -151,6 +155,7 @@ export const WorkflowEditActionFindRecords = ({ input: { objectName: updatedObjectName, limit: updatedLimit ?? 1, + offset: Math.max(0, Math.floor(updatedOffset ?? 0)), filter: updatedFilter, orderBy: updatedOrderBy as Record | undefined, }, @@ -174,6 +179,7 @@ export const WorkflowEditActionFindRecords = ({ const newFormData: FindRecordsFormData = { objectNameSingular: value, limit: 1, + offset: 0, }; setFormData(newFormData); @@ -335,6 +341,38 @@ export const WorkflowEditActionFindRecords = ({ saveAction(newFormData); }} /> + + { + if (isFormDisabled === true || !isNumber(offset)) { + return; + } + + const normalizedOffset = Math.floor(offset); + + if (normalizedOffset < 0) { + setOffsetError(t`Offset cannot be negative.`); + return; + } + + setOffsetError(undefined); + + const newFormData: FindRecordsFormData = { + ...formData, + offset: normalizedOffset, + }; + + setFormData(newFormData); + + saveAction(newFormData); + }} + /> {!actionOptions.readonly && } diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/record-crud-input.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/record-crud-input.type.ts index 0705867349..91ba7af519 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/types/record-crud-input.type.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/record-crud-input.type.ts @@ -38,6 +38,7 @@ export type FindRecordsInput = { gqlOperationOrderBy?: Partial; }; limit?: number; + offset?: number; }; export type UpsertRecordInput = { diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index a862f5369d..fd1767b4cb 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -434,6 +434,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { input: { objectName: activeObjectMetadataItem?.nameSingular || '', limit: 1, + offset: 0, }, }, }, 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 fcb4d22a2e..04bf853525 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 @@ -95,6 +95,7 @@ export class FindRecordsWorkflowAction implements WorkflowAction { filter: gqlOperationFilter, orderBy: workflowActionInput.orderBy?.gqlOperationOrderBy, limit: workflowActionInput.limit, + offset: workflowActionInput.offset, authContext: executionContext.authContext, rolePermissionConfig: executionContext.rolePermissionConfig, shouldBuildEffectiveSelectFields: false, 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 30d55235bd..0dd390e5bb 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 @@ -6,6 +6,7 @@ export const workflowFindRecordsActionSettingsSchema = input: z.object({ objectName: z.string(), limit: z.number().optional(), + offset: z.number().int().nonnegative().optional(), filter: z .object({ recordFilterGroups: z.array(z.any()).optional(),