feat(workflow): add Pick Record action (1/3 — random selection) (#21899)
## Overview
Adds a new workflow action, **Pick Record**, that selects **one** record
from a configured candidate pool and exposes the chosen record as the
step's output. Downstream steps can then reference it through the normal
variable picker — e.g. assign an owner in an _Update Record_ step by
setting **Account Owner = `{{step.<pickRecordId>.id}}`**.
This is the foundation for building **assignment workflows**
(round-robin / load-balanced owner assignment, reviewer rotation, etc.)
in Twenty.
## This is PR 1 of a 3-PR stack
| PR | Strategy | Adds |
|----|----------|------|
| **1 (this one)** | `RANDOM` | The whole `PICK_RECORD` action,
end-to-end, stateless |
| 2 | `ROUND_ROBIN` | A persistent, atomically-incremented per-step
cursor + the strategy selector UI |
| 3 | `LOAD_BALANCED` | "fewest related records wins" via an aggregate
count |
Each PR widens the `strategy` enum (a backward-compatible change), so no
data migration is needed between them.
## How it works
- **Editor**: pick an Object, then pick the candidate records (a
multi-record selector). A random record is selected from that pool at
run time.
- **Output**: a single record of the chosen object — the same output
shape as `CREATE_RECORD`/`UPDATE_RECORD` — so it drills into
`{{step.x.id}}`, `{{step.x.name}}`, … in the variable picker.
- **Execution**: reuses `FindRecordsService` to fetch the pool (`id IN
(recordIds)`, which also transparently drops any deleted candidates),
then returns one at random.
## Design decisions & tradeoffs
1. **Standalone step that outputs a variable, not an inline "random"
mode on the relation field.** This mirrors Attio's round-robin block.
The decisive reason is composition: the chosen record is almost always
reused (assign owner **and** create a follow-up task for them **and**
email them). A variable is chosen once and reused everywhere; an inline
per-field value would re-roll independently in each place. It also keeps
the (stateful) round-robin/load-balanced logic out of the field inputs.
Tradeoff: one extra step to wire up vs. an inline control — accepted for
the composability win. An inline "Assign automatically" entry point can
still be layered on later as sugar that inserts this step.
2. **Co-located in the `record-crud` action module and reuses
`FindRecordsService`.** Avoids duplicating module wiring (auth context,
permissions, object-metadata resolution) and the data-access path.
Tradeoff: "Pick" is a selection rather than a CRUD op, so the folder
name is slightly broad; chose reuse + low risk over a separate module.
Can be extracted if the family grows.
3. **`strategy` exists in the schema (defaulted `RANDOM`) but the
selector is hidden in this PR.** A dropdown with a single option would
be UX slop, and adding the field only in PR 2 would force a data
backfill for any `PICK_RECORD` steps created in between. Keeping the
field now (hidden) avoids both. PR 2 introduces the selector once
there's a real choice.
4. **Pool is an explicit static list (`recordIds`) for v1.** Matches the
most common assignment case ("rotate among these N people") and reuses
the existing `FormMultiRecordPicker`. A filter-based pool (reusing the
Find Records filter UI) and a list-from-a-previous-step pool are natural
follow-ups, intentionally out of scope here to keep the stack focused on
the three strategies.
5. **Output schema is computed on the frontend** (like `CREATE_RECORD`),
derived from `input.objectName` — so it is **not** added to
`PERSISTED_OUTPUT_SCHEMA_TYPES` and needs no server-side schema
computation.
6. **Validation**: `PICK_RECORD` is added to object-name metadata
validation (so a deleted/invalid target object is flagged) via a
dedicated `OBJECT_TARGETING_ACTION_TYPES` set — deliberately **not** to
`VARIABLE_CONSUMING_ACTION_TYPES`, because a static pool legitimately
references no upstream variable and would otherwise raise a spurious "no
variable reference" warning.
7. **Empty pool → step error** at run time (respecting the step's
error-handling options) rather than a silent no-op, since an empty pool
is a misconfiguration or fully-deleted set.
8. **`Math.random`** is used for selection — no cryptographic guarantee
is needed for assignment fairness.
## Testing
Per our testing convention (integration test over service/`.spec`
tests): added `pick-record-workflow.integration-spec.ts`, which builds a
workflow with a manual trigger + a `PICK_RECORD` step, configures a
known two-record pool, runs it, and asserts the run completes and the
picked record is **always** within the configured pool (verifying the
pool filter) across repeated runs.
Local verification (typecheck + lint for shared/server/front) is green;
running the integration suite and attaching editor screenshots in a
follow-up comment.
## Follow-ups
- PR 2: `ROUND_ROBIN` + persistent atomic cursor (Redis `incrBy` vs. a
Postgres counter table — tradeoff to be documented on that PR) +
strategy selector.
- PR 3: `LOAD_BALANCED`.
- Later (not in this stack): filter-based / variable-list pools, an
inline "Assign automatically" entry point on relation fields, OOO-skip /
weighting.
https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8
---
_Generated by [Claude
Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21899?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:
@@ -554,6 +554,7 @@ export enum WorkflowActionType {
|
||||
IF_ELSE = 'IF_ELSE',
|
||||
ITERATOR = 'ITERATOR',
|
||||
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
|
||||
PICK_RECORD = 'PICK_RECORD',
|
||||
SEND_EMAIL = 'SEND_EMAIL',
|
||||
UPDATE_RECORD = 'UPDATE_RECORD',
|
||||
UPSERT_RECORD = 'UPSERT_RECORD'
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type workflowIteratorActionSchema,
|
||||
type workflowLogicFunctionActionSchema,
|
||||
type workflowManualTriggerSchema,
|
||||
type workflowPickRecordActionSchema,
|
||||
type workflowRunSchema,
|
||||
type workflowRunStateSchema,
|
||||
type workflowRunStatusSchema,
|
||||
@@ -56,6 +57,9 @@ export type WorkflowUpsertRecordAction = z.infer<
|
||||
export type WorkflowFindRecordsAction = z.infer<
|
||||
typeof workflowFindRecordsActionSchema
|
||||
>;
|
||||
export type WorkflowPickRecordAction = z.infer<
|
||||
typeof workflowPickRecordActionSchema
|
||||
>;
|
||||
export type WorkflowDelayAction = z.infer<typeof workflowDelayActionSchema>;
|
||||
export type WorkflowFilterAction = z.infer<typeof workflowFilterActionSchema>;
|
||||
export type WorkflowFormAction = z.infer<typeof workflowFormActionSchema>;
|
||||
@@ -79,6 +83,7 @@ export type WorkflowAction =
|
||||
| WorkflowDeleteRecordAction
|
||||
| WorkflowUpsertRecordAction
|
||||
| WorkflowFindRecordsAction
|
||||
| WorkflowPickRecordAction
|
||||
| WorkflowFilterAction
|
||||
| WorkflowIfElseAction
|
||||
| WorkflowFormAction
|
||||
|
||||
+13
@@ -19,6 +19,7 @@ import { WorkflowEditActionFormFiller } from '@/workflow/workflow-steps/workflow
|
||||
import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest';
|
||||
import { WorkflowEditActionIfElse } from '@/workflow/workflow-steps/workflow-actions/if-else-action/components/WorkflowEditActionIfElse';
|
||||
import { WorkflowEditActionIterator } from '@/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator';
|
||||
import { WorkflowEditActionPickRecord } from '@/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord';
|
||||
import { WorkflowEditActionLogicFunction } from '@/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction';
|
||||
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
|
||||
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
|
||||
@@ -199,6 +200,18 @@ export const WorkflowRunStepNodeDetail = ({
|
||||
);
|
||||
}
|
||||
|
||||
case 'PICK_RECORD': {
|
||||
return (
|
||||
<WorkflowEditActionPickRecord
|
||||
key={stepId}
|
||||
action={stepDefinition.definition}
|
||||
actionOptions={{
|
||||
readonly: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'FORM': {
|
||||
return (
|
||||
<WorkflowEditActionFormFiller
|
||||
|
||||
+11
@@ -19,6 +19,7 @@ import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflo
|
||||
import { WorkflowEditActionIfElse } from '@/workflow/workflow-steps/workflow-actions/if-else-action/components/WorkflowEditActionIfElse';
|
||||
import { WorkflowEditActionIterator } from '@/workflow/workflow-steps/workflow-actions/iterator-action/components/WorkflowEditActionIterator';
|
||||
import { WorkflowEditActionLogicFunction } from '@/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction';
|
||||
import { WorkflowEditActionPickRecord } from '@/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord';
|
||||
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
|
||||
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
|
||||
import { WorkflowEditTriggerManual } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManual';
|
||||
@@ -185,6 +186,16 @@ export const WorkflowStepDetail = ({
|
||||
);
|
||||
}
|
||||
|
||||
case 'PICK_RECORD': {
|
||||
return (
|
||||
<WorkflowEditActionPickRecord
|
||||
key={stepId}
|
||||
action={stepDefinition.definition}
|
||||
actionOptions={props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'FORM': {
|
||||
return (
|
||||
<WorkflowEditActionFormBuilder
|
||||
|
||||
+3
@@ -2,6 +2,7 @@ import { type WorkflowActionType } from '@/workflow/types/Workflow';
|
||||
import { CREATE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/CreateRecordAction';
|
||||
import { DELETE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/DeleteRecordAction';
|
||||
import { FIND_RECORDS_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/FindRecordsAction';
|
||||
import { PICK_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/PickRecordAction';
|
||||
import { UPDATE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/UpdateRecordAction';
|
||||
import { UPSERT_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/UpsertRecordAction';
|
||||
|
||||
@@ -14,6 +15,7 @@ export const RECORD_ACTIONS: Array<{
|
||||
| 'DELETE_RECORD'
|
||||
| 'UPSERT_RECORD'
|
||||
| 'FIND_RECORDS'
|
||||
| 'PICK_RECORD'
|
||||
>;
|
||||
icon: string;
|
||||
}> = [
|
||||
@@ -22,4 +24,5 @@ export const RECORD_ACTIONS: Array<{
|
||||
DELETE_RECORD_ACTION,
|
||||
FIND_RECORDS_ACTION,
|
||||
UPSERT_RECORD_ACTION,
|
||||
PICK_RECORD_ACTION,
|
||||
];
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { type WorkflowActionType } from '@/workflow/types/Workflow';
|
||||
|
||||
export const PICK_RECORD_ACTION: {
|
||||
defaultLabel: string;
|
||||
type: Extract<WorkflowActionType, 'PICK_RECORD'>;
|
||||
icon: string;
|
||||
} = {
|
||||
defaultLabel: 'Pick Record',
|
||||
type: 'PICK_RECORD',
|
||||
icon: 'IconArrowsShuffle',
|
||||
};
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { HorizontalSeparator } from 'twenty-ui/layout';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
|
||||
import { useObjectMetadataSelectHelpers } from '@/object-metadata/hooks/useObjectMetadataSelectHelpers';
|
||||
import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker';
|
||||
import { SelectControl } from '@/ui/input/components/SelectControl';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { type WorkflowPickRecordAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
|
||||
import { WorkflowObjectDropdownContent } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowObjectDropdownContent';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: block;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin-bottom: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledObjectSelectContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const defaultSelectedOptionMessage = msg`Select an option`;
|
||||
|
||||
type WorkflowEditActionPickRecordProps = {
|
||||
action: WorkflowPickRecordAction;
|
||||
actionOptions:
|
||||
| {
|
||||
readonly: true;
|
||||
}
|
||||
| {
|
||||
readonly?: false;
|
||||
onActionUpdate: (action: WorkflowPickRecordAction) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type PickRecordFormData = {
|
||||
objectNameSingular: string;
|
||||
recordIds: string[];
|
||||
};
|
||||
|
||||
export const WorkflowEditActionPickRecord = ({
|
||||
action,
|
||||
actionOptions,
|
||||
}: WorkflowEditActionPickRecordProps) => {
|
||||
const { t } = useLingui();
|
||||
const { getSelectIconPropsFromObjectMetadataItem } =
|
||||
useObjectMetadataSelectHelpers();
|
||||
|
||||
const dropdownId = `workflow-edit-action-pick-record-object-name-${action.id}`;
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const { objectMetadataItems } = useFilteredObjectMetadataItems();
|
||||
|
||||
const [formData, setFormData] = useState<PickRecordFormData>(() => ({
|
||||
objectNameSingular: action.settings.input.objectName,
|
||||
recordIds: action.settings.input.recordIds,
|
||||
}));
|
||||
|
||||
const isFormDisabled = actionOptions.readonly ?? false;
|
||||
|
||||
const selectedObjectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.nameSingular === formData.objectNameSingular,
|
||||
);
|
||||
const selectedOption = selectedObjectMetadataItem
|
||||
? {
|
||||
label: selectedObjectMetadataItem.labelPlural,
|
||||
value: selectedObjectMetadataItem.nameSingular,
|
||||
...getSelectIconPropsFromObjectMetadataItem(selectedObjectMetadataItem),
|
||||
}
|
||||
: { label: i18n._(defaultSelectedOptionMessage), value: '' };
|
||||
|
||||
const saveAction = useDebouncedCallback(
|
||||
async (updatedFormData: PickRecordFormData) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
objectName: updatedFormData.objectNameSingular,
|
||||
strategy: action.settings.input.strategy,
|
||||
recordIds: updatedFormData.recordIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
1_000,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
saveAction.flush();
|
||||
};
|
||||
}, [saveAction]);
|
||||
|
||||
const handleObjectChange = (value: string) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFormData: PickRecordFormData = {
|
||||
objectNameSingular: value,
|
||||
recordIds: [],
|
||||
};
|
||||
|
||||
setFormData(newFormData);
|
||||
saveAction(newFormData);
|
||||
closeDropdown(dropdownId);
|
||||
};
|
||||
|
||||
const handleRecordIdsChange = (recordIds: string[]) => {
|
||||
if (isFormDisabled === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFormData: PickRecordFormData = {
|
||||
...formData,
|
||||
recordIds,
|
||||
};
|
||||
|
||||
setFormData(newFormData);
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowStepBody>
|
||||
<StyledObjectSelectContainer>
|
||||
<StyledLabel>{t`Object`}</StyledLabel>
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponent={
|
||||
<SelectControl
|
||||
isDisabled={isFormDisabled}
|
||||
selectedOption={selectedOption}
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
!isFormDisabled && (
|
||||
<WorkflowObjectDropdownContent
|
||||
onOptionClick={handleObjectChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
dropdownOffset={{ y: 4 }}
|
||||
/>
|
||||
</StyledObjectSelectContainer>
|
||||
|
||||
<HorizontalSeparator noMargin />
|
||||
|
||||
{isDefined(selectedObjectMetadataItem) && (
|
||||
<FormMultiRecordPicker
|
||||
key={selectedObjectMetadataItem.nameSingular}
|
||||
label={t`Pick at random from`}
|
||||
objectNameSingular={selectedObjectMetadataItem.nameSingular}
|
||||
defaultValue={formData.recordIds}
|
||||
onChange={(value) =>
|
||||
handleRecordIdsChange(Array.isArray(value) ? value : [])
|
||||
}
|
||||
readonly={isFormDisabled}
|
||||
/>
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -12,6 +12,7 @@ export const getActionIcon = (actionType: WorkflowActionType) => {
|
||||
case 'DELETE_RECORD':
|
||||
case 'UPSERT_RECORD':
|
||||
case 'FIND_RECORDS':
|
||||
case 'PICK_RECORD':
|
||||
return RECORD_ACTIONS.find((item) => item.type === actionType)?.icon;
|
||||
case 'AI_AGENT':
|
||||
return AI_ACTIONS.find((item) => item.type === actionType)?.icon;
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ export const getActionIconColorOrThrow = (
|
||||
case 'DELETE_RECORD':
|
||||
case 'UPSERT_RECORD':
|
||||
case 'FIND_RECORDS':
|
||||
case 'PICK_RECORD':
|
||||
return themeCssVariables.font.color.tertiary;
|
||||
case 'FORM':
|
||||
return themeCssVariables.color.orange;
|
||||
|
||||
+2
-1
@@ -167,7 +167,8 @@ export const computeStepOutputSchema = ({
|
||||
case 'CREATE_RECORD':
|
||||
case 'UPDATE_RECORD':
|
||||
case 'DELETE_RECORD':
|
||||
case 'UPSERT_RECORD': {
|
||||
case 'UPSERT_RECORD':
|
||||
case 'PICK_RECORD': {
|
||||
const objectName = step.settings?.input?.objectName;
|
||||
|
||||
if (!isDefined(objectName)) {
|
||||
|
||||
+1
@@ -98,6 +98,7 @@ export class WorkflowSchemaWorkspaceService {
|
||||
case WorkflowActionType.UPDATE_RECORD:
|
||||
case WorkflowActionType.DELETE_RECORD:
|
||||
case WorkflowActionType.UPSERT_RECORD:
|
||||
case WorkflowActionType.PICK_RECORD:
|
||||
return this.computeRecordOutputSchema({
|
||||
objectType: step.settings.input.objectName,
|
||||
workspaceId,
|
||||
|
||||
+6
-1
@@ -54,6 +54,11 @@ const VARIABLE_CONSUMING_ACTION_TYPES = new Set<WorkflowActionType>([
|
||||
...RECORD_CRUD_ACTION_TYPES,
|
||||
]);
|
||||
|
||||
const OBJECT_TARGETING_ACTION_TYPES = new Set<WorkflowActionType>([
|
||||
...RECORD_CRUD_ACTION_TYPES,
|
||||
WorkflowActionType.PICK_RECORD,
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowValidationWorkspaceService {
|
||||
constructor(
|
||||
@@ -578,7 +583,7 @@ export class WorkflowValidationWorkspaceService {
|
||||
steps: WorkflowAction[];
|
||||
}): Promise<WorkflowValidationIssue[]> {
|
||||
const recordSteps = steps.filter((step) =>
|
||||
RECORD_CRUD_ACTION_TYPES.has(step.type),
|
||||
OBJECT_TARGETING_ACTION_TYPES.has(step.type),
|
||||
);
|
||||
|
||||
if (recordSteps.length === 0) {
|
||||
|
||||
+22
@@ -442,6 +442,28 @@ export class WorkflowVersionStepOperationsWorkspaceService {
|
||||
},
|
||||
};
|
||||
}
|
||||
case WorkflowActionType.PICK_RECORD: {
|
||||
const activeObjectMetadataItem =
|
||||
await this.objectMetadataRepository.findOne({
|
||||
where: { workspaceId, isActive: true, isSystem: false },
|
||||
});
|
||||
|
||||
return {
|
||||
builtStep: {
|
||||
...baseStep,
|
||||
name: 'Pick Record',
|
||||
type: WorkflowActionType.PICK_RECORD,
|
||||
settings: {
|
||||
...BASE_STEP_DEFINITION,
|
||||
input: {
|
||||
objectName: activeObjectMetadataItem?.nameSingular || '',
|
||||
strategy: 'RANDOM',
|
||||
recordIds: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case WorkflowActionType.FORM: {
|
||||
return {
|
||||
builtStep: {
|
||||
|
||||
+4
@@ -21,6 +21,7 @@ import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/
|
||||
import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action';
|
||||
import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action';
|
||||
import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action';
|
||||
import { PickRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action';
|
||||
import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action';
|
||||
import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
@@ -35,6 +36,7 @@ export class WorkflowActionFactory {
|
||||
private readonly updateRecordWorkflowAction: UpdateRecordWorkflowAction,
|
||||
private readonly deleteRecordWorkflowAction: DeleteRecordWorkflowAction,
|
||||
private readonly findRecordsWorkflowAction: FindRecordsWorkflowAction,
|
||||
private readonly pickRecordWorkflowAction: PickRecordWorkflowAction,
|
||||
private readonly formWorkflowAction: FormWorkflowAction,
|
||||
private readonly filterWorkflowAction: FilterWorkflowAction,
|
||||
private readonly ifElseWorkflowAction: IfElseWorkflowAction,
|
||||
@@ -67,6 +69,8 @@ export class WorkflowActionFactory {
|
||||
return this.deleteRecordWorkflowAction;
|
||||
case WorkflowActionType.FIND_RECORDS:
|
||||
return this.findRecordsWorkflowAction;
|
||||
case WorkflowActionType.PICK_RECORD:
|
||||
return this.pickRecordWorkflowAction;
|
||||
case WorkflowActionType.FORM:
|
||||
return this.formWorkflowAction;
|
||||
case WorkflowActionType.FILTER:
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
type WorkflowPickRecordAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
export const isWorkflowPickRecordAction = (
|
||||
action: WorkflowAction,
|
||||
): action is WorkflowPickRecordAction => {
|
||||
return action.type === WorkflowActionType.PICK_RECORD;
|
||||
};
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type ObjectRecord } from 'twenty-shared/types';
|
||||
import { resolveInput } from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import {
|
||||
WorkflowStepExecutorException,
|
||||
WorkflowStepExecutorExceptionCode,
|
||||
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service';
|
||||
import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
|
||||
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
|
||||
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
|
||||
import { isWorkflowPickRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-pick-record-action.guard';
|
||||
import { type WorkflowPickRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type';
|
||||
|
||||
@Injectable()
|
||||
export class PickRecordWorkflowAction implements WorkflowAction {
|
||||
constructor(
|
||||
private readonly findRecordsService: FindRecordsService,
|
||||
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
|
||||
) {}
|
||||
|
||||
async execute({
|
||||
currentStepId,
|
||||
steps,
|
||||
context,
|
||||
runInfo,
|
||||
}: WorkflowActionInput): Promise<WorkflowActionOutput> {
|
||||
const step = findStepOrThrow({
|
||||
steps,
|
||||
stepId: currentStepId,
|
||||
});
|
||||
|
||||
if (!isWorkflowPickRecordAction(step)) {
|
||||
throw new WorkflowStepExecutorException(
|
||||
'Step is not a pick record action',
|
||||
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
const { objectName, recordIds } = resolveInput(
|
||||
step.settings.input,
|
||||
context,
|
||||
) as WorkflowPickRecordActionInput;
|
||||
|
||||
if (!isNonEmptyString(objectName) || !Array.isArray(recordIds)) {
|
||||
return { error: 'Pick record action received invalid input' };
|
||||
}
|
||||
|
||||
if (recordIds.length === 0) {
|
||||
return { error: 'Pick record action has no candidate records' };
|
||||
}
|
||||
|
||||
const executionContext =
|
||||
await this.workflowExecutionContextService.getExecutionContext(runInfo);
|
||||
|
||||
const findRecordsOutput = await this.findRecordsService.execute({
|
||||
objectName,
|
||||
filter: { id: { in: recordIds } },
|
||||
authContext: executionContext.authContext,
|
||||
rolePermissionConfig: executionContext.rolePermissionConfig,
|
||||
shouldBuildEffectiveSelectFields: false,
|
||||
});
|
||||
|
||||
if (!findRecordsOutput.success) {
|
||||
return { error: findRecordsOutput.error || findRecordsOutput.message };
|
||||
}
|
||||
|
||||
const candidateRecords = (findRecordsOutput.result?.records ??
|
||||
[]) as ObjectRecord[];
|
||||
|
||||
if (candidateRecords.length === 0) {
|
||||
return {
|
||||
error: 'Pick record action could not find any of the candidate records',
|
||||
};
|
||||
}
|
||||
|
||||
const pickedRecord =
|
||||
candidateRecords[Math.floor(Math.random() * candidateRecords.length)];
|
||||
|
||||
return { result: pickedRecord };
|
||||
}
|
||||
}
|
||||
+3
@@ -10,6 +10,7 @@ import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-e
|
||||
import { CreateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action';
|
||||
import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/delete-record.workflow-action';
|
||||
import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action';
|
||||
import { PickRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/pick-record.workflow-action';
|
||||
import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action';
|
||||
import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action';
|
||||
import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module';
|
||||
@@ -31,6 +32,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
|
||||
UpdateRecordWorkflowAction,
|
||||
DeleteRecordWorkflowAction,
|
||||
FindRecordsWorkflowAction,
|
||||
PickRecordWorkflowAction,
|
||||
],
|
||||
exports: [
|
||||
CreateRecordWorkflowAction,
|
||||
@@ -38,6 +40,7 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
|
||||
UpdateRecordWorkflowAction,
|
||||
DeleteRecordWorkflowAction,
|
||||
FindRecordsWorkflowAction,
|
||||
PickRecordWorkflowAction,
|
||||
],
|
||||
})
|
||||
export class RecordCRUDActionModule {}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type WorkflowPickRecordStrategy = 'RANDOM';
|
||||
|
||||
export type WorkflowPickRecordActionInput = {
|
||||
objectName: string;
|
||||
strategy: WorkflowPickRecordStrategy;
|
||||
recordIds: string[];
|
||||
};
|
||||
+5
@@ -1,3 +1,4 @@
|
||||
import { type WorkflowPickRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type';
|
||||
import {
|
||||
type WorkflowUpsertRecordActionInput,
|
||||
type WorkflowCreateRecordActionInput,
|
||||
@@ -26,3 +27,7 @@ export type WorkflowFindRecordsActionSettings = BaseWorkflowActionSettings & {
|
||||
export type WorkflowUpsertRecordActionSettings = BaseWorkflowActionSettings & {
|
||||
input: WorkflowUpsertRecordActionInput;
|
||||
};
|
||||
|
||||
export type WorkflowPickRecordActionSettings = BaseWorkflowActionSettings & {
|
||||
input: WorkflowPickRecordActionInput;
|
||||
};
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ import {
|
||||
type WorkflowCreateRecordActionSettings,
|
||||
type WorkflowDeleteRecordActionSettings,
|
||||
type WorkflowFindRecordsActionSettings,
|
||||
type WorkflowPickRecordActionSettings,
|
||||
type WorkflowUpdateRecordActionSettings,
|
||||
type WorkflowUpsertRecordActionSettings,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type';
|
||||
@@ -42,6 +43,7 @@ export type WorkflowActionSettings =
|
||||
| WorkflowDeleteRecordActionSettings
|
||||
| WorkflowUpsertRecordActionSettings
|
||||
| WorkflowFindRecordsActionSettings
|
||||
| WorkflowPickRecordActionSettings
|
||||
| WorkflowFormActionSettings
|
||||
| WorkflowFilterActionSettings
|
||||
| WorkflowIfElseActionSettings
|
||||
|
||||
+7
@@ -14,6 +14,7 @@ import {
|
||||
type WorkflowCreateRecordActionSettings,
|
||||
type WorkflowDeleteRecordActionSettings,
|
||||
type WorkflowFindRecordsActionSettings,
|
||||
type WorkflowPickRecordActionSettings,
|
||||
type WorkflowUpdateRecordActionSettings,
|
||||
type WorkflowUpsertRecordActionSettings,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type';
|
||||
@@ -77,6 +78,11 @@ export type WorkflowFindRecordsAction = BaseWorkflowAction & {
|
||||
settings: WorkflowFindRecordsActionSettings;
|
||||
};
|
||||
|
||||
export type WorkflowPickRecordAction = BaseWorkflowAction & {
|
||||
type: WorkflowActionType.PICK_RECORD;
|
||||
settings: WorkflowPickRecordActionSettings;
|
||||
};
|
||||
|
||||
export type WorkflowFormAction = BaseWorkflowAction & {
|
||||
type: WorkflowActionType.FORM;
|
||||
settings: WorkflowFormActionSettings;
|
||||
@@ -126,6 +132,7 @@ export type WorkflowAction =
|
||||
| WorkflowDeleteRecordAction
|
||||
| WorkflowUpsertRecordAction
|
||||
| WorkflowFindRecordsAction
|
||||
| WorkflowPickRecordAction
|
||||
| WorkflowFormAction
|
||||
| WorkflowFilterAction
|
||||
| WorkflowIfElseAction
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import request from 'supertest';
|
||||
import {
|
||||
destroyWorkflowRun,
|
||||
runWorkflowVersion,
|
||||
waitForWorkflowCompletion,
|
||||
} from 'test/integration/graphql/suites/workflow/utils/workflow-run-test.util';
|
||||
|
||||
const client = request(`http://localhost:${APP_PORT}`);
|
||||
|
||||
describe('Pick Record Workflow (e2e)', () => {
|
||||
let createdWorkflowId: string | null = null;
|
||||
let createdWorkflowVersionId: string | null = null;
|
||||
let pickRecordStepId: string | null = null;
|
||||
let candidateRecordIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const createWorkflowResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
mutation CreateWorkflow {
|
||||
createWorkflow(data: { name: "Pick Record Test Workflow" }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(createWorkflowResponse.body.errors).toBeUndefined();
|
||||
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
|
||||
|
||||
const getWorkflowResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
query GetWorkflow($id: UUID!) {
|
||||
workflow(filter: { id: { eq: $id } }) {
|
||||
id
|
||||
versions {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id: createdWorkflowId },
|
||||
});
|
||||
|
||||
createdWorkflowVersionId =
|
||||
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
|
||||
|
||||
const updateTriggerResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
mutation UpdateWorkflowVersion($id: UUID!, $data: WorkflowVersionUpdateInput!) {
|
||||
updateWorkflowVersion(id: $id, data: $data) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id: createdWorkflowVersionId,
|
||||
data: {
|
||||
trigger: {
|
||||
name: 'Manual Trigger',
|
||||
type: 'MANUAL',
|
||||
settings: { outputSchema: {} },
|
||||
nextStepIds: [],
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateTriggerResponse.body.errors).toBeUndefined();
|
||||
|
||||
const createStepResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
|
||||
createWorkflowVersionStep(input: $input) {
|
||||
stepsDiff
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
workflowVersionId: createdWorkflowVersionId,
|
||||
stepType: 'PICK_RECORD',
|
||||
parentStepId: 'trigger',
|
||||
position: { x: 200, y: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createStepResponse.body.errors).toBeUndefined();
|
||||
|
||||
const getStepsResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
query GetWorkflowVersion($id: UUID!) {
|
||||
workflowVersion(filter: { id: { eq: $id } }) {
|
||||
id
|
||||
steps
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id: createdWorkflowVersionId },
|
||||
});
|
||||
|
||||
const steps = getStepsResponse.body.data.workflowVersion.steps;
|
||||
const pickRecordStep = steps.find(
|
||||
(step: { type: string }) => step.type === 'PICK_RECORD',
|
||||
);
|
||||
|
||||
expect(pickRecordStep).toBeDefined();
|
||||
pickRecordStepId = pickRecordStep.id;
|
||||
|
||||
const companiesResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
query Companies {
|
||||
companies(first: 2) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(companiesResponse.body.errors).toBeUndefined();
|
||||
candidateRecordIds = companiesResponse.body.data.companies.edges.map(
|
||||
(edge: { node: { id: string } }) => edge.node.id,
|
||||
);
|
||||
|
||||
expect(candidateRecordIds.length).toBe(2);
|
||||
|
||||
const updateStepResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
|
||||
updateWorkflowVersionStep(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
workflowVersionId: createdWorkflowVersionId,
|
||||
step: {
|
||||
...pickRecordStep,
|
||||
settings: {
|
||||
...pickRecordStep.settings,
|
||||
input: {
|
||||
objectName: 'company',
|
||||
strategy: 'RANDOM',
|
||||
recordIds: candidateRecordIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateStepResponse.body.errors).toBeUndefined();
|
||||
|
||||
const activateResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
|
||||
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
|
||||
}
|
||||
`,
|
||||
variables: { workflowVersionId: createdWorkflowVersionId },
|
||||
});
|
||||
|
||||
expect(activateResponse.body.errors).toBeUndefined();
|
||||
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdWorkflowId) {
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({
|
||||
query: `
|
||||
mutation DestroyWorkflow($id: ID!) {
|
||||
destroyWorkflow(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id: createdWorkflowId },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const pickRecordOnce = async (): Promise<{ id: string }> => {
|
||||
const workflowRunId = await runWorkflowVersion({
|
||||
workflowVersionId: createdWorkflowVersionId!,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const workflowRun = await waitForWorkflowCompletion(workflowRunId);
|
||||
|
||||
expect(workflowRun?.status).toBe('COMPLETED');
|
||||
expect(workflowRun?.state?.stepInfos?.[pickRecordStepId!]?.status).toBe(
|
||||
'SUCCESS',
|
||||
);
|
||||
|
||||
const result = workflowRun?.state?.stepInfos?.[pickRecordStepId!]
|
||||
?.result as { id: string } | undefined;
|
||||
|
||||
await destroyWorkflowRun(workflowRunId);
|
||||
|
||||
expect(result?.id).toBeDefined();
|
||||
|
||||
return result!;
|
||||
};
|
||||
|
||||
it('picks a record and exposes it as the step output', async () => {
|
||||
const pickedRecord = await pickRecordOnce();
|
||||
|
||||
expect(candidateRecordIds).toContain(pickedRecord.id);
|
||||
});
|
||||
|
||||
it('only ever picks records from the configured pool', async () => {
|
||||
for (let runIndex = 0; runIndex < 5; runIndex++) {
|
||||
const pickedRecord = await pickRecordOnce();
|
||||
|
||||
expect(candidateRecordIds).toContain(pickedRecord.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,11 @@ export { workflowLogicFunctionActionSchema } from './schemas/logic-function-acti
|
||||
export { workflowLogicFunctionActionSettingsSchema } from './schemas/logic-function-action-settings-schema';
|
||||
export { workflowManualTriggerSchema } from './schemas/manual-trigger-schema';
|
||||
export { objectRecordSchema } from './schemas/object-record-schema';
|
||||
export { workflowPickRecordActionSchema } from './schemas/pick-record-action-schema';
|
||||
export {
|
||||
workflowPickRecordStrategySchema,
|
||||
workflowPickRecordActionSettingsSchema,
|
||||
} from './schemas/pick-record-action-settings-schema';
|
||||
export { workflowSendEmailActionSchema } from './schemas/send-email-action-schema';
|
||||
export type { WorkflowEmailFiles } from './schemas/send-email-action-settings-schema';
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import { baseWorkflowActionSchema } from './base-workflow-action-schema';
|
||||
import { workflowPickRecordActionSettingsSchema } from './pick-record-action-settings-schema';
|
||||
|
||||
export const workflowPickRecordActionSchema = baseWorkflowActionSchema.extend({
|
||||
type: z.literal('PICK_RECORD'),
|
||||
settings: workflowPickRecordActionSettingsSchema,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
|
||||
|
||||
export const workflowPickRecordStrategySchema = z.enum(['RANDOM']);
|
||||
|
||||
export const workflowPickRecordActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
objectName: z.string(),
|
||||
strategy: workflowPickRecordStrategySchema,
|
||||
recordIds: z.array(z.string()),
|
||||
}),
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { workflowHttpRequestActionSchema } from './http-request-action-schema';
|
||||
import { workflowIfElseActionSchema } from './if-else-action-schema';
|
||||
import { workflowIteratorActionSchema } from './iterator-action-schema';
|
||||
import { workflowLogicFunctionActionSchema } from './logic-function-action-schema';
|
||||
import { workflowPickRecordActionSchema } from './pick-record-action-schema';
|
||||
import { workflowSendEmailActionSchema } from './send-email-action-schema';
|
||||
import { workflowUpdateRecordActionSchema } from './update-record-action-schema';
|
||||
import { workflowUpsertRecordActionSchema } from './upsert-record-action-schema';
|
||||
@@ -27,6 +28,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [
|
||||
workflowDeleteRecordActionSchema,
|
||||
workflowUpsertRecordActionSchema,
|
||||
workflowFindRecordsActionSchema,
|
||||
workflowPickRecordActionSchema,
|
||||
workflowFormActionSchema,
|
||||
workflowHttpRequestActionSchema,
|
||||
workflowAiAgentActionSchema,
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum WorkflowActionType {
|
||||
DELETE_RECORD = 'DELETE_RECORD',
|
||||
UPSERT_RECORD = 'UPSERT_RECORD',
|
||||
FIND_RECORDS = 'FIND_RECORDS',
|
||||
PICK_RECORD = 'PICK_RECORD',
|
||||
FORM = 'FORM',
|
||||
FILTER = 'FILTER',
|
||||
IF_ELSE = 'IF_ELSE',
|
||||
|
||||
Reference in New Issue
Block a user