360 workflow implement workflow cron triggers frontend 2 (#10051)

as title, closes https://github.com/twentyhq/core-team-issues/issues/360

## Cron Setting behavior

https://github.com/user-attachments/assets/0de3a8b9-d899-4455-a945-20c7541c3053

## Cron running behavior


https://github.com/user-attachments/assets/4c33f167-857c-4fcb-9dbe-0f9b661c9e61
This commit is contained in:
martmull
2025-02-07 17:15:03 +01:00
committed by GitHub
parent 988ab9697c
commit ead626c2ec
36 changed files with 826 additions and 19 deletions

View File

@ -0,0 +1,291 @@
import { WorkflowCronTrigger } from '@/workflow/types/Workflow';
import { useIcons } from 'twenty-ui';
import { useTheme } from '@emotion/react';
import { Select } from '@/ui/input/components/Select';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { isDefined } from 'twenty-shared';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { CRON_TRIGGER_INTERVAL_OPTIONS } from '@/workflow/workflow-trigger/constants/CronTriggerIntervalOptions';
import cron from 'cron-validate';
import { useState } from 'react';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { isNumber } from '@sniptt/guards';
import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings';
type WorkflowEditTriggerCronFormProps = {
trigger: WorkflowCronTrigger;
triggerOptions:
| {
readonly: true;
onTriggerUpdate?: undefined;
}
| {
readonly?: false;
onTriggerUpdate: (trigger: WorkflowCronTrigger) => void;
};
};
type FormErrorMessages = {
CUSTOM?: string | undefined;
HOURS_hour?: string | undefined;
HOURS_minute?: string | undefined;
MINUTES?: string | undefined;
};
export const WorkflowEditTriggerCronForm = ({
trigger,
triggerOptions,
}: WorkflowEditTriggerCronFormProps) => {
const theme = useTheme();
const [errorMessages, setErrorMessages] = useState<FormErrorMessages>({});
const [errorMessagesVisible, setErrorMessagesVisible] = useState(false);
const { getIcon } = useIcons();
const headerIcon = getTriggerIcon({
type: 'CRON',
});
const defaultLabel =
getTriggerDefaultLabel({
type: 'CRON',
}) ?? '';
const headerTitle = isDefined(trigger.name) ? trigger.name : defaultLabel;
const headerType = 'Trigger';
const onBlur = () => {
setErrorMessagesVisible(true);
};
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType={headerType}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-cron-trigger-interval"
label="Trigger interval"
fullWidth
disabled={triggerOptions.readonly}
value={trigger.settings.type}
options={CRON_TRIGGER_INTERVAL_OPTIONS}
onChange={(newTriggerType) => {
if (triggerOptions.readonly === true) {
return;
}
setErrorMessages({});
setErrorMessagesVisible(false);
triggerOptions.onTriggerUpdate({
...trigger,
settings: getCronTriggerDefaultSettings(newTriggerType),
});
}}
withSearchInput
/>
{trigger.settings.type === 'CUSTOM' && (
<FormTextFieldInput
label="Expression"
placeholder="0 */1 * * *"
error={errorMessagesVisible ? errorMessages.CUSTOM : undefined}
onBlur={onBlur}
hint="Format: [Second] [Minute] [Hour] [Day of Month] [Month] [Day of Week]"
readonly={triggerOptions.readonly}
defaultValue={trigger.settings.pattern}
onPersist={(newPattern: string) => {
if (triggerOptions.readonly === true) {
return;
}
const cronValidator = cron(newPattern);
if (cronValidator.isError()) {
setErrorMessages({
CUSTOM: `Invalid cron pattern, ${cronValidator
.getError()[0]
.replace(/\. \(Input cron:.*$/, '')}`,
});
return;
}
setErrorMessages((prev) => ({
...prev,
CUSTOM: undefined,
}));
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
type: 'CUSTOM',
pattern: newPattern,
},
});
}}
/>
)}
{trigger.settings.type === 'HOURS' && (
<>
<FormNumberFieldInput
label="Hours Between Triggers"
error={
errorMessagesVisible ? errorMessages.HOURS_hour : undefined
}
onBlur={onBlur}
defaultValue={trigger.settings.schedule.hour}
onPersist={(newHour) => {
if (triggerOptions.readonly === true) {
return;
}
if (!isDefined(newHour)) {
return;
}
if (!isNumber(newHour) || newHour <= 0) {
setErrorMessages((prev) => ({
...prev,
HOURS_hour: `Invalid hour value '${newHour}'. Should be integer greater than 1`,
}));
return;
}
setErrorMessages((prev) => ({
...prev,
HOURS_hour: undefined,
}));
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
type: 'HOURS',
schedule: {
hour: newHour,
minute:
trigger.settings.type === 'HOURS'
? trigger.settings.schedule.minute
: 0,
},
},
});
}}
placeholder="Enter number greater than 1"
readonly={triggerOptions.readonly}
/>
<FormNumberFieldInput
label="Trigger at Minute"
error={
errorMessagesVisible ? errorMessages.HOURS_minute : undefined
}
onBlur={onBlur}
defaultValue={trigger.settings.schedule.minute}
onPersist={(newMinute) => {
if (triggerOptions.readonly === true) {
return;
}
if (!isDefined(newMinute)) {
return;
}
if (!isNumber(newMinute) || newMinute < 0 || newMinute > 59) {
setErrorMessages((prev) => ({
...prev,
HOURS_minute: `Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
}));
return;
}
setErrorMessages((prev) => ({
...prev,
HOURS_minute: undefined,
}));
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
type: 'HOURS',
schedule: {
hour:
trigger.settings.type === 'HOURS'
? trigger.settings.schedule.hour
: 1,
minute: newMinute,
},
},
});
}}
placeholder="Enter number between 0 and 59"
readonly={triggerOptions.readonly}
/>
</>
)}
{trigger.settings.type === 'MINUTES' && (
<FormNumberFieldInput
label="Minutes Between Triggers"
error={errorMessagesVisible ? errorMessages.MINUTES : undefined}
onBlur={onBlur}
defaultValue={trigger.settings.schedule.minute}
onPersist={(newMinute) => {
if (triggerOptions.readonly === true) {
return;
}
if (!isDefined(newMinute)) {
return;
}
if (!isNumber(newMinute) || newMinute <= 0) {
setErrorMessages({
MINUTES: `Invalid minute value '${newMinute}'. Should be integer greater than 1`,
});
return;
}
setErrorMessages((prev) => ({
...prev,
MINUTES: undefined,
}));
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
type: 'MINUTES',
schedule: {
minute: newMinute,
},
},
});
}}
placeholder="Enter number greater than 1"
readonly={triggerOptions.readonly}
/>
)}
</WorkflowStepBody>
</>
);
};

View File

@ -0,0 +1,30 @@
import {
IconComponent,
Icon24Hours,
IconTimeDuration60,
IconClockPlay,
} from 'twenty-ui';
export type CronTriggerInterval = 'HOURS' | 'MINUTES' | 'CUSTOM';
export const CRON_TRIGGER_INTERVAL_OPTIONS: Array<{
label: string;
value: CronTriggerInterval;
Icon: IconComponent;
}> = [
{
label: 'Hours',
value: 'HOURS',
Icon: Icon24Hours,
},
{
label: 'Minutes',
value: 'MINUTES',
Icon: IconTimeDuration60,
},
{
label: 'Cron (Custom)',
value: 'CUSTOM',
Icon: IconClockPlay,
},
];

View File

@ -10,4 +10,9 @@ export const OTHER_TRIGGER_TYPES: Array<{
type: 'MANUAL',
icon: 'IconHandMove',
},
{
defaultLabel: 'On a Schedule',
type: 'CRON',
icon: 'IconClock',
},
];

View File

@ -0,0 +1,38 @@
import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings';
describe('getCronTriggerDefaultSettings', () => {
it('returns correct settings for HOURS interval', () => {
const result = getCronTriggerDefaultSettings('HOURS');
expect(result).toEqual({
schedule: { hour: 1, minute: 0 },
type: 'HOURS',
outputSchema: {},
});
});
it('returns correct settings for MINUTES interval', () => {
const result = getCronTriggerDefaultSettings('MINUTES');
expect(result).toEqual({
schedule: { minute: 1 },
type: 'MINUTES',
outputSchema: {},
});
});
it('returns correct settings for CUSTOM interval', () => {
const DEFAULT_CRON_PATTERN = '0 */1 * * *';
const result = getCronTriggerDefaultSettings('CUSTOM');
expect(result).toEqual({
pattern: DEFAULT_CRON_PATTERN,
type: 'CUSTOM',
outputSchema: {},
});
});
it('throws an error for an invalid interval', () => {
// @ts-expect-error Testing invalid input
expect(() => getCronTriggerDefaultSettings('INVALID')).toThrowError(
'Invalid cron trigger interval',
);
});
});

View File

@ -0,0 +1,39 @@
import { CronTriggerInterval } from '@/workflow/workflow-trigger/constants/CronTriggerIntervalOptions';
import { WorkflowCronTrigger } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
const DEFAULT_CRON_PATTERN = '0 */1 * * *'; // Every hour
export const getCronTriggerDefaultSettings = (
cronTriggerInterval: CronTriggerInterval,
): WorkflowCronTrigger['settings'] => {
switch (cronTriggerInterval) {
case 'HOURS':
return {
schedule: {
hour: 1,
minute: 0,
},
type: cronTriggerInterval,
outputSchema: {},
};
case 'MINUTES':
return {
schedule: {
minute: 1,
},
type: cronTriggerInterval,
outputSchema: {},
};
case 'CUSTOM':
return {
pattern: DEFAULT_CRON_PATTERN,
type: cronTriggerInterval,
outputSchema: {},
};
}
return assertUnreachable(
cronTriggerInterval,
'Invalid cron trigger interval',
);
};

View File

@ -45,6 +45,16 @@ export const getTriggerDefaultDefinition = ({
}),
};
}
case 'CRON': {
return {
type,
settings: {
type: 'HOURS',
schedule: { hour: 1, minute: 0 },
outputSchema: {},
},
};
}
default: {
return assertUnreachable(type, `Unknown type: ${type}`);
}

View File

@ -6,6 +6,9 @@ export const getTriggerIcon = (
| {
type: 'MANUAL';
}
| {
type: 'CRON';
}
| {
type: 'DATABASE_EVENT';
eventName: string;

View File

@ -6,6 +6,9 @@ export const getTriggerDefaultLabel = (
| {
type: 'MANUAL';
}
| {
type: 'CRON';
}
| {
type: 'DATABASE_EVENT';
eventName: string;