feat: workflow delay action (Pause - Wait/Sleep/Delay) (#14915)
## Description - This PR focuses on issue https://github.com/orgs/twentyhq/projects/1/views/33?pane=issue&itemId=93150683&issue=twentyhq%7Ccore-team-issues%7C20 - added Workflow delay as a Flow action - for V1 added Type 1: Resume at a specific date or time ## Visual Appearance <img width="1792" height="1038" alt="Screenshot 2025-10-09 at 5 46 18 PM" src="https://github.com/user-attachments/assets/e62980e9-59c7-4e5a-b8ec-1e848a462d3f" /> <img width="1792" height="1037" alt="Screenshot 2025-10-09 at 5 46 35 PM" src="https://github.com/user-attachments/assets/7c3f4e39-ab0a-40ed-97a8-4f0cdb86f295" /> --------- Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
This commit is contained in:
+12
@@ -11,6 +11,7 @@ import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workfl
|
||||
import { WorkflowEditActionEmpty } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty';
|
||||
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
|
||||
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
|
||||
import { WorkflowEditActionDelay } from '@/workflow/workflow-steps/workflow-actions/delay-actions/components/WorkflowEditActionDelay';
|
||||
import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
|
||||
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords';
|
||||
import { WorkflowEditActionFormFiller } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFiller';
|
||||
@@ -237,6 +238,17 @@ export const WorkflowRunStepNodeDetail = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'DELAY': {
|
||||
return (
|
||||
<WorkflowEditActionDelay
|
||||
key={stepId}
|
||||
action={stepDefinition.definition}
|
||||
actionOptions={{
|
||||
readonly: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -10,6 +10,7 @@ import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workfl
|
||||
import { WorkflowEditActionEmpty } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmpty';
|
||||
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
|
||||
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
|
||||
import { WorkflowEditActionDelay } from '@/workflow/workflow-steps/workflow-actions/delay-actions/components/WorkflowEditActionDelay';
|
||||
import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
|
||||
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords';
|
||||
import { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder';
|
||||
@@ -210,6 +211,15 @@ export const WorkflowStepDetail = ({
|
||||
case 'EMPTY': {
|
||||
return <WorkflowEditActionEmpty key={stepId} actionOptions={props} />;
|
||||
}
|
||||
case 'DELAY': {
|
||||
return (
|
||||
<WorkflowEditActionDelay
|
||||
key={stepId}
|
||||
action={stepDefinition.definition}
|
||||
actionOptions={props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return assertUnreachable(
|
||||
stepDefinition.definition,
|
||||
|
||||
+6
-1
@@ -2,7 +2,7 @@ import { type WorkflowActionType } from '@/workflow/types/Workflow';
|
||||
|
||||
export const FLOW_ACTIONS: Array<{
|
||||
label: string;
|
||||
type: Extract<WorkflowActionType, 'ITERATOR' | 'FILTER'>;
|
||||
type: Extract<WorkflowActionType, 'ITERATOR' | 'FILTER' | 'DELAY'>;
|
||||
icon: string;
|
||||
}> = [
|
||||
{
|
||||
@@ -15,4 +15,9 @@ export const FLOW_ACTIONS: Array<{
|
||||
type: 'FILTER',
|
||||
icon: 'IconFilter',
|
||||
},
|
||||
{
|
||||
label: 'Delay',
|
||||
type: 'DELAY',
|
||||
icon: 'IconPlayerPause',
|
||||
},
|
||||
];
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
|
||||
import { FormDateTimeFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput';
|
||||
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { type WorkflowDelayAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
HorizontalSeparator,
|
||||
IconCalendar,
|
||||
IconHourglassHigh,
|
||||
} from 'twenty-ui/display';
|
||||
import { type SelectOption } from 'twenty-ui/input';
|
||||
type WorkflowEditActionDelayProps = {
|
||||
action: WorkflowDelayAction;
|
||||
actionOptions:
|
||||
| {
|
||||
readonly: true;
|
||||
}
|
||||
| {
|
||||
readonly?: false;
|
||||
onActionUpdate: (action: WorkflowDelayAction) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export const WorkflowEditActionDelay = ({
|
||||
action,
|
||||
actionOptions,
|
||||
}: WorkflowEditActionDelayProps) => {
|
||||
const { headerTitle, headerIcon, headerIconColor, headerType, getIcon } =
|
||||
useWorkflowActionHeader({
|
||||
action,
|
||||
defaultTitle: 'Delay',
|
||||
});
|
||||
|
||||
const delayOptions: Array<SelectOption<'SCHEDULED_DATE' | 'DURATION'>> = [
|
||||
{
|
||||
label: t`At a specific date or time`,
|
||||
value: 'SCHEDULED_DATE',
|
||||
Icon: IconCalendar,
|
||||
},
|
||||
{
|
||||
label: t`After a set amount of time`,
|
||||
value: 'DURATION',
|
||||
Icon: IconHourglassHigh,
|
||||
},
|
||||
];
|
||||
|
||||
const handleDelayTypeChange = (
|
||||
newDelayType: 'SCHEDULED_DATE' | 'DURATION',
|
||||
) => {
|
||||
if (
|
||||
actionOptions.readonly === true ||
|
||||
newDelayType === action.settings.input.delayType
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newDelayType === 'SCHEDULED_DATE') {
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
delayType: 'SCHEDULED_DATE',
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
delayType: 'DURATION',
|
||||
duration: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateTimeChange = (value: string | null) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
delayType: 'SCHEDULED_DATE',
|
||||
scheduledDateTime: value ?? '',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDurationChange = (
|
||||
field: 'days' | 'hours' | 'minutes' | 'seconds',
|
||||
value: number | string | null,
|
||||
) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
delayType: 'DURATION',
|
||||
duration: {
|
||||
days:
|
||||
field === 'days'
|
||||
? (value ?? undefined)
|
||||
: action.settings.input.duration?.days,
|
||||
hours:
|
||||
field === 'hours'
|
||||
? (value ?? undefined)
|
||||
: action.settings.input.duration?.hours,
|
||||
minutes:
|
||||
field === 'minutes'
|
||||
? (value ?? undefined)
|
||||
: action.settings.input.duration?.minutes,
|
||||
seconds:
|
||||
field === 'seconds'
|
||||
? (value ?? undefined)
|
||||
: action.settings.input.duration?.seconds,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const HeaderIcon = getIcon(headerIcon ?? 'IconPlayerPause');
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidePanelHeader
|
||||
initialTitle={headerTitle}
|
||||
Icon={HeaderIcon}
|
||||
iconColor={headerIconColor}
|
||||
headerType={headerType}
|
||||
onTitleChange={(newTitle: string) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
name: newTitle,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<WorkflowStepBody>
|
||||
<Select
|
||||
dropdownId="workflow-edit-action-delay-type"
|
||||
label={t`Resume`}
|
||||
options={delayOptions}
|
||||
dropdownWidth={GenericDropdownContentWidth.Large}
|
||||
value={action.settings.input.delayType}
|
||||
onChange={handleDelayTypeChange}
|
||||
disabled={actionOptions.readonly}
|
||||
/>
|
||||
<HorizontalSeparator noMargin />
|
||||
|
||||
{action.settings.input.delayType === 'SCHEDULED_DATE' && (
|
||||
<FormDateTimeFieldInput
|
||||
label={t`Delay until date`}
|
||||
defaultValue={action.settings.input.scheduledDateTime ?? undefined}
|
||||
onChange={handleDateTimeChange}
|
||||
readonly={actionOptions.readonly}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
placeholder="Select a date"
|
||||
/>
|
||||
)}
|
||||
{action.settings.input.delayType === 'DURATION' && (
|
||||
<>
|
||||
<FormNumberFieldInput
|
||||
label={t`Days`}
|
||||
defaultValue={action.settings.input.duration?.days}
|
||||
onChange={(value) => handleDurationChange('days', value)}
|
||||
readonly={actionOptions.readonly}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
placeholder="0"
|
||||
/>
|
||||
<FormNumberFieldInput
|
||||
label={t`Hours`}
|
||||
defaultValue={action.settings.input.duration?.hours}
|
||||
onChange={(value) => handleDurationChange('hours', value)}
|
||||
readonly={actionOptions.readonly}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
placeholder="0"
|
||||
/>
|
||||
<FormNumberFieldInput
|
||||
label={t`Minutes`}
|
||||
defaultValue={action.settings.input.duration?.minutes}
|
||||
onChange={(value) => handleDurationChange('minutes', value)}
|
||||
readonly={actionOptions.readonly}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
placeholder="0"
|
||||
/>
|
||||
<FormNumberFieldInput
|
||||
label={t`Seconds`}
|
||||
defaultValue={action.settings.input.duration?.seconds}
|
||||
onChange={(value) => handleDurationChange('seconds', value)}
|
||||
readonly={actionOptions.readonly}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
placeholder="0"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
|
||||
<WorkflowActionFooter stepId={action.id} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -79,13 +79,13 @@ describe('getActionIconColorOrThrow', () => {
|
||||
});
|
||||
|
||||
describe('FILTER action type', () => {
|
||||
it('should throw an error for FILTER action type', () => {
|
||||
it('should return green color for FILTER action type', () => {
|
||||
const result = getActionIconColorOrThrow({
|
||||
theme: mockTheme,
|
||||
actionType: 'FILTER',
|
||||
});
|
||||
|
||||
expect(result).toBe(mockTheme.font.color.tertiary);
|
||||
expect(result).toBe(mockTheme.color.green60);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => {
|
||||
case 'FIND_RECORDS':
|
||||
case 'FORM':
|
||||
case 'SEND_EMAIL':
|
||||
case 'DELAY':
|
||||
return msg`Action`;
|
||||
case 'HTTP_REQUEST':
|
||||
return msg`HTTP Request`;
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
import { type WorkflowActionType } from '@/workflow/types/Workflow';
|
||||
import { AI_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/AiActions';
|
||||
import { CORE_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/CoreActions';
|
||||
import { FLOW_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/FlowActions';
|
||||
import { HUMAN_INPUT_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/HumanInputActions';
|
||||
import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions';
|
||||
|
||||
@@ -11,8 +12,6 @@ export const getActionIcon = (actionType: WorkflowActionType) => {
|
||||
case 'DELETE_RECORD':
|
||||
case 'FIND_RECORDS':
|
||||
return RECORD_ACTIONS.find((item) => item.type === actionType)?.icon;
|
||||
case 'FILTER':
|
||||
return 'IconFilter';
|
||||
case 'AI_AGENT':
|
||||
return AI_ACTIONS.find((item) => item.type === actionType)?.icon;
|
||||
case 'CODE':
|
||||
@@ -22,7 +21,9 @@ export const getActionIcon = (actionType: WorkflowActionType) => {
|
||||
case 'FORM':
|
||||
return HUMAN_INPUT_ACTIONS.find((item) => item.type === actionType)?.icon;
|
||||
case 'ITERATOR':
|
||||
return 'IconRepeat';
|
||||
case 'DELAY':
|
||||
case 'FILTER':
|
||||
return FLOW_ACTIONS.find((item) => item.type === actionType)?.icon;
|
||||
case 'EMPTY':
|
||||
return 'IconSettingsAutomation';
|
||||
default:
|
||||
|
||||
+3
-2
@@ -22,9 +22,10 @@ export const getActionIconColorOrThrow = ({
|
||||
case 'FORM':
|
||||
return theme.color.orange;
|
||||
case 'ITERATOR':
|
||||
case 'FILTER':
|
||||
case 'EMPTY':
|
||||
return theme.font.color.tertiary;
|
||||
case 'FILTER':
|
||||
case 'DELAY':
|
||||
return theme.color.green60;
|
||||
case 'AI_AGENT':
|
||||
return theme.color.pink;
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user