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:
+1
-2
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
+7
@@ -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);
|
||||
|
||||
+49
-13
@@ -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.`);
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+15
@@ -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;
|
||||
};
|
||||
+16
@@ -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);
|
||||
};
|
||||
+15
@@ -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}}',
|
||||
);
|
||||
Reference in New Issue
Block a user