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

@ -77,6 +77,7 @@
"bytes": "^3.1.2",
"class-transformer": "^0.5.1",
"clsx": "^2.1.1",
"cron-validate": "^1.4.5",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"danger-plugin-todos": "^1.3.1",

View File

@ -1,4 +1,7 @@
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { FormFieldInputHotKeyScope } from '@/object-record/record-field/form-types/constants/FormFieldInputHotKeyScope';
const StyledFormFieldInputContainer = styled.div`
display: flex;
@ -6,4 +9,35 @@ const StyledFormFieldInputContainer = styled.div`
width: 100%;
`;
export const FormFieldInputContainer = StyledFormFieldInputContainer;
export const FormFieldInputContainer = ({
children,
testId,
}: {
children: ReactNode;
testId?: string;
}) => {
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const onFocus = () => {
setHotkeyScopeAndMemorizePreviousScope(
FormFieldInputHotKeyScope.FormFieldInput,
);
};
const onBlur = () => {
goBackToPreviousHotkeyScope();
};
return (
<StyledFormFieldInputContainer
data-testid={testId}
onFocus={onFocus}
onBlur={onBlur}
>
{children}
</StyledFormFieldInputContainer>
);
};

View File

@ -178,7 +178,7 @@ export const FormMultiSelectFieldInput = ({
const placeholderText = placeholder ?? label;
return (
<FormFieldInputContainer data-testid={testId}>
<FormFieldInputContainer testId={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>

View File

@ -1,4 +1,3 @@
import { FormFieldHint } from '@/object-record/record-field/form-types/components/FormFieldHint';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
@ -14,6 +13,8 @@ import {
canBeCastAsNumberOrNull,
castAsNumberOrNull,
} from '~/utils/cast-as-number-or-null';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
import { InputHint } from '@/ui/input/components/InputHint';
const StyledInput = styled(TextInput)`
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
@ -21,9 +22,11 @@ const StyledInput = styled(TextInput)`
type FormNumberFieldInputProps = {
label?: string;
error?: string;
placeholder: string;
defaultValue: number | string | undefined;
onPersist: (value: number | null | string) => void;
onBlur?: () => void;
VariablePicker?: VariablePickerComponent;
hint?: string;
readonly?: boolean;
@ -31,9 +34,11 @@ type FormNumberFieldInputProps = {
export const FormNumberFieldInput = ({
label,
error,
placeholder,
defaultValue,
onPersist,
onBlur,
VariablePicker,
hint,
readonly,
@ -105,6 +110,7 @@ export const FormNumberFieldInput = ({
<FormFieldInputRowContainer>
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker) && !readonly}
onBlur={onBlur}
>
{draftValue.type === 'static' ? (
<StyledInput
@ -132,7 +138,8 @@ export const FormNumberFieldInput = ({
) : null}
</FormFieldInputRowContainer>
{hint ? <FormFieldHint>{hint}</FormFieldHint> : null}
{hint ? <InputHint>{hint}</InputHint> : null}
{error && <InputErrorHelper>{error}</InputErrorHelper>}
</FormFieldInputContainer>
);
};

View File

@ -8,12 +8,17 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
import { parseEditorContent } from '@/workflow/workflow-variables/utils/parseEditorContent';
import { useId } from 'react';
import { isDefined } from 'twenty-shared';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
import { InputHint } from '@/ui/input/components/InputHint';
type FormTextFieldInputProps = {
label?: string;
error?: string;
hint?: string;
defaultValue: string | undefined;
placeholder: string;
onPersist: (value: string) => void;
onBlur?: () => void;
multiline?: boolean;
readonly?: boolean;
VariablePicker?: VariablePickerComponent;
@ -21,9 +26,12 @@ type FormTextFieldInputProps = {
export const FormTextFieldInput = ({
label,
error,
hint,
defaultValue,
placeholder,
onPersist,
onBlur,
multiline,
readonly,
VariablePicker,
@ -65,6 +73,7 @@ export const FormTextFieldInput = ({
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker) && !readonly}
multiline={multiline}
onBlur={onBlur}
>
<TextVariableEditor
editor={editor}
@ -81,6 +90,8 @@ export const FormTextFieldInput = ({
/>
) : null}
</FormFieldInputRowContainer>
{hint && <InputHint>{hint}</InputHint>}
{error && <InputErrorHelper>{error}</InputErrorHelper>}
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,3 @@
export enum FormFieldInputHotKeyScope {
FormFieldInput = 'form-field-input',
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import styled from '@emotion/styled';
const StyledInputErrorHelper = styled.div`
color: ${({ theme }) => theme.color.red};
font-size: ${({ theme }) => theme.font.size.xs};
`;
export const InputErrorHelper = ({
children,
}: {
children: React.ReactNode;
}) => (
<StyledInputErrorHelper aria-live="polite">{children}</StyledInputErrorHelper>
);

View File

@ -1,10 +1,10 @@
import styled from '@emotion/styled';
const StyledFormFieldHint = styled.div`
const StyledInputHint = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(1)};
margin-top: ${({ theme }) => theme.spacing(0.5)};
`;
export const FormFieldHint = StyledFormFieldHint;
export { StyledInputHint as InputHint };

View File

@ -19,6 +19,7 @@ import {
} from 'twenty-ui';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
const StyledContainer = styled.div<
Pick<TextInputV2ComponentProps, 'fullWidth'>
@ -83,9 +84,7 @@ const StyledInput = styled.input<
}
`;
const StyledErrorHelper = styled.div`
color: ${({ theme }) => theme.color.red};
font-size: ${({ theme }) => theme.font.size.xs};
const StyledErrorHelper = styled(InputErrorHelper)`
padding: ${({ theme }) => theme.spacing(1)};
`;

View File

@ -62,7 +62,9 @@ export const useAllActiveWorkflowVersions = ({
if (!isDefined(objectMetadataItem)) {
return {
records: records.filter(
(record) => !isDefined(record.trigger?.settings.objectType),
(record) =>
record.trigger?.type !== 'CRON' &&
!isDefined(record.trigger?.settings.objectType),
),
};
}

View File

@ -122,6 +122,24 @@ export type WorkflowManualTrigger = BaseTrigger & {
};
};
export type WorkflowCronTrigger = BaseTrigger & {
type: 'CRON';
settings: (
| {
type: 'HOURS';
schedule: { hour: number; minute: number };
}
| {
type: 'MINUTES';
schedule: { minute: number };
}
| {
type: 'CUSTOM';
pattern: string;
}
) & { outputSchema: object };
};
export type WorkflowManualTriggerSettings = WorkflowManualTrigger['settings'];
export type WorkflowManualTriggerAvailability =
@ -130,7 +148,8 @@ export type WorkflowManualTriggerAvailability =
export type WorkflowTrigger =
| WorkflowDatabaseEventTrigger
| WorkflowManualTrigger;
| WorkflowManualTrigger
| WorkflowCronTrigger;
export type WorkflowTriggerType = WorkflowTrigger['type'];

View File

@ -1,3 +1,4 @@
import React from 'react';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
@ -46,6 +47,16 @@ export const WorkflowDiagramStepNodeIcon = ({
</StyledStepNodeLabelIconContainer>
);
}
case 'CRON': {
return (
<StyledStepNodeLabelIconContainer>
<Icon
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</StyledStepNodeLabelIconContainer>
);
}
}
return assertUnreachable(data.triggerType);

View File

@ -73,6 +73,14 @@ export const generateWorkflowDiagram = ({
break;
}
case 'CRON': {
triggerDefaultLabel = 'On a Schedule';
triggerIcon = getTriggerIcon({
type: 'CRON',
});
break;
}
case 'DATABASE_EVENT': {
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,

View File

@ -11,6 +11,7 @@ import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-steps/workf
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { Suspense, lazy } from 'react';
import { isDefined } from 'twenty-shared';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
@ -71,6 +72,14 @@ export const WorkflowStepDetail = ({
/>
);
}
case 'CRON': {
return (
<WorkflowEditTriggerCronForm
trigger={stepDefinition.definition}
triggerOptions={props}
/>
);
}
}
return assertUnreachable(

View File

@ -125,7 +125,7 @@ export const WorkflowSingleRecordPicker = ({
};
return (
<FormFieldInputContainer data-testid={testId}>
<FormFieldInputContainer testId={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledFormSelectContainer hasRightElement={!disabled}>

View File

@ -9,6 +9,7 @@ import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorato
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionFormCreateRecord } from '../WorkflowEditActionFormCreateRecord';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
const meta: Meta<typeof WorkflowEditActionFormCreateRecord> = {
title: 'Modules/Workflow/WorkflowEditActionFormCreateRecord',
@ -45,6 +46,7 @@ const meta: Meta<typeof WorkflowEditActionFormCreateRecord> = {
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
WorkspaceDecorator,
],
};

View File

@ -10,6 +10,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { getPeopleMock } from '~/testing/mock-data/people';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionFormDeleteRecord } from '../WorkflowEditActionFormDeleteRecord';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
const DEFAULT_ACTION = {
id: getWorkflowNodeIdMock(),
@ -49,6 +50,7 @@ const meta: Meta<typeof WorkflowEditActionFormDeleteRecord> = {
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
WorkspaceDecorator,
],
};

View File

@ -10,6 +10,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
import { getPeopleMock } from '~/testing/mock-data/people';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionFormUpdateRecord } from '../WorkflowEditActionFormUpdateRecord';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
const DEFAULT_ACTION = {
id: getWorkflowNodeIdMock(),
@ -62,6 +63,7 @@ const meta: Meta<typeof WorkflowEditActionFormUpdateRecord> = {
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
WorkspaceDecorator,
],
};

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;

View File

@ -10,6 +10,8 @@ export const getTriggerStepName = (trigger: WorkflowTrigger): string => {
switch (trigger.type) {
case 'DATABASE_EVENT':
return getDatabaseEventTriggerStepName(trigger);
case 'CRON':
return 'On a Schedule';
case 'MANUAL':
if (!isDefined(trigger.settings.objectType)) {
return 'Manual trigger';

View File

@ -0,0 +1,14 @@
import { useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { mockCurrentWorkspace } from '~/testing/mock-data/users';
import { useEffect } from 'react';
import { Decorator } from '@storybook/react';
export const WorkspaceDecorator: Decorator = (Story) => {
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
useEffect(() => {
setCurrentWorkspace(mockCurrentWorkspace);
}, [setCurrentWorkspace]);
return <Story />;
};

View File

@ -59,6 +59,9 @@ export class WorkflowBuilderWorkspaceService {
objectMetadataRepository: this.objectMetadataRepository,
});
}
case WorkflowTriggerType.CRON: {
return {};
}
case WorkflowActionType.SEND_EMAIL: {
return this.computeSendEmailActionOutputSchema();
}

View File

@ -38,9 +38,20 @@ export type WorkflowManualTrigger = BaseTrigger & {
export type WorkflowCronTrigger = BaseTrigger & {
type: WorkflowTriggerType.CRON;
settings: {
settings: (
| {
type: 'HOURS';
schedule: { hour: number; minute: number };
}
| {
type: 'MINUTES';
schedule: { minute: number };
}
| {
type: 'CUSTOM';
pattern: string;
};
}
) & { outputSchema: object };
};
export type WorkflowManualTriggerSettings = WorkflowManualTrigger['settings'];

View File

@ -0,0 +1,88 @@
import {
WorkflowCronTrigger,
WorkflowTriggerType,
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { WorkflowTriggerException } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import { computeCronPatternFromSchedule } from 'src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule';
describe('computeCronPatternFromSchedule', () => {
it('should return the pattern for CUSTOM type', () => {
const trigger: WorkflowCronTrigger = {
name: '',
type: WorkflowTriggerType.CRON,
settings: {
type: 'CUSTOM',
pattern: '12 * * * *',
outputSchema: {},
},
};
expect(computeCronPatternFromSchedule(trigger)).toBe('12 * * * *');
});
it('should throw an exception for unsupported pattern for CUSTOM type', () => {
const trigger: WorkflowCronTrigger = {
name: '',
type: WorkflowTriggerType.CRON,
settings: {
type: 'CUSTOM',
pattern: '0 12 * * * *',
outputSchema: {},
},
};
expect(() => computeCronPatternFromSchedule(trigger)).toThrow(
WorkflowTriggerException,
);
expect(() => computeCronPatternFromSchedule(trigger)).toThrow(
"Cron pattern '0 12 * * * *' is invalid",
);
});
it('should return the correct cron pattern for HOURS type', () => {
const trigger: WorkflowCronTrigger = {
name: '',
type: WorkflowTriggerType.CRON,
settings: {
type: 'HOURS',
schedule: { hour: 10, minute: 30 },
outputSchema: {},
},
};
expect(computeCronPatternFromSchedule(trigger)).toBe('30 */10 * * *');
});
it('should return the correct cron pattern for MINUTES type', () => {
const trigger: WorkflowCronTrigger = {
name: '',
type: WorkflowTriggerType.CRON,
settings: {
type: 'MINUTES',
schedule: { minute: 15 },
outputSchema: {},
},
};
expect(computeCronPatternFromSchedule(trigger)).toBe('*/15 * * * *');
});
it('should throw an exception for unsupported schedule type', () => {
const trigger: WorkflowCronTrigger = {
name: '',
type: WorkflowTriggerType.CRON,
settings: {
type: 'INVALID_TYPE' as any,
pattern: '',
outputSchema: {},
},
};
expect(() => computeCronPatternFromSchedule(trigger)).toThrow(
WorkflowTriggerException,
);
expect(() => computeCronPatternFromSchedule(trigger)).toThrow(
'Unsupported cron schedule type',
);
});
});

View File

@ -70,6 +70,9 @@ function assertTriggerSettingsAreValid(
break;
case WorkflowTriggerType.MANUAL:
break;
case WorkflowTriggerType.CRON:
assertCronTriggerSettingsAreValid(settings);
break;
default:
throw new WorkflowTriggerException(
'Invalid trigger type for enabling workflow trigger',
@ -78,6 +81,50 @@ function assertTriggerSettingsAreValid(
}
}
function assertCronTriggerSettingsAreValid(settings: any) {
if (!settings?.type) {
throw new WorkflowTriggerException(
'No setting type provided in cron trigger',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
if (settings.type === 'CUSTOM' && !settings.pattern) {
throw new WorkflowTriggerException(
'No pattern provided in CUSTOM cron trigger',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
if (!settings.schedule) {
throw new WorkflowTriggerException(
'No schedule provided in cron trigger',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
if (settings.type === 'HOURS' && settings.schedule.hour <= 0) {
throw new WorkflowTriggerException(
'Invalid hour value. Should be integer greater than 1',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
if (
settings.type === 'HOURS' &&
(settings.schedule.minute < 0 || settings.schedule.minute > 59)
) {
throw new WorkflowTriggerException(
'Invalid minute value. Should be integer between 0 and 59',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
if (settings.type === 'MINUTES' && settings.schedule.minute <= 0) {
throw new WorkflowTriggerException(
'Invalid minute value. Should be integer greater than 1',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
}
function assertDatabaseEventTriggerSettingsAreValid(settings: any) {
if (!settings?.eventName) {
throw new WorkflowTriggerException(

View File

@ -0,0 +1,49 @@
import cron from 'cron-validate';
import { WorkflowCronTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
const validatePattern = (pattern: string) => {
const cronValidator = cron(pattern);
if (cronValidator.isError()) {
throw new WorkflowTriggerException(
`Cron pattern '${pattern}' is invalid`,
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
};
export const computeCronPatternFromSchedule = (
trigger: WorkflowCronTrigger,
) => {
switch (trigger.settings.type) {
case 'CUSTOM': {
validatePattern(trigger.settings.pattern);
return trigger.settings.pattern;
}
case 'HOURS': {
const pattern = `${trigger.settings.schedule.minute} */${trigger.settings.schedule.hour} * * *`;
validatePattern(pattern);
return pattern;
}
case 'MINUTES': {
const pattern = `*/${trigger.settings.schedule.minute} * * * *`;
validatePattern(pattern);
return pattern;
}
default:
throw new WorkflowTriggerException(
'Unsupported cron schedule type',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
);
}
};

View File

@ -36,6 +36,7 @@ import {
WorkflowTriggerJob,
WorkflowTriggerJobData,
} from 'src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job';
import { computeCronPatternFromSchedule } from 'src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule';
@Injectable()
export class WorkflowTriggerWorkspaceService {
@ -339,7 +340,9 @@ export class WorkflowTriggerWorkspaceService {
return;
case WorkflowTriggerType.MANUAL:
return;
case WorkflowTriggerType.CRON:
case WorkflowTriggerType.CRON: {
const pattern = computeCronPatternFromSchedule(workflowVersion.trigger);
await this.messageQueueService.addCron<WorkflowTriggerJobData>({
jobName: WorkflowTriggerJob.name,
jobId: workflowVersion.workflowId,
@ -350,12 +353,13 @@ export class WorkflowTriggerWorkspaceService {
},
options: {
repeat: {
pattern: workflowVersion.trigger.settings.pattern,
pattern,
},
},
});
return;
}
default: {
assertNever(workflowVersion.trigger);
}

View File

@ -1,6 +1,7 @@
/* eslint-disable no-restricted-imports */
export {
Icon123,
Icon24Hours,
IconAlertCircle,
IconAlertTriangle,
IconApi,
@ -48,6 +49,7 @@ export {
IconCircleX,
IconClick,
IconClockHour8,
IconClockPlay,
IconClockShare,
IconCode,
IconCodeCircle,
@ -247,6 +249,8 @@ export {
IconTestPipe,
IconTextSize,
IconTextWrap,
IconTimeDuration30,
IconTimeDuration60,
IconTimelineEvent,
IconTrash,
IconTrashX,

View File

@ -3328,6 +3328,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.10.5":
version: 7.26.7
resolution: "@babel/runtime@npm:7.26.7"
dependencies:
regenerator-runtime: "npm:^0.14.0"
checksum: 10c0/60199c049f90e5e41c687687430052a370aca60bac7859ff4ee761c5c1739b8ba1604d391d01588c22dc0e93828cbadb8ada742578ad1b1df240746bce98729a
languageName: node
linkType: hard
"@babel/template@npm:^7.18.10, @babel/template@npm:^7.20.7, @babel/template@npm:^7.22.15, @babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.0, @babel/template@npm:^7.3.3":
version: 7.25.0
resolution: "@babel/template@npm:7.25.0"
@ -17200,6 +17209,13 @@ __metadata:
languageName: node
linkType: hard
"@types/lodash@npm:^4.14.165":
version: 4.17.15
resolution: "@types/lodash@npm:4.17.15"
checksum: 10c0/2eb2dc6d231f5fb4603d176c08c8d7af688f574d09af47466a179cd7812d9f64144ba74bb32ca014570ffdc544eedc51b7a5657212bad083b6eecbd72223f9bb
languageName: node
linkType: hard
"@types/long@npm:^4.0.0":
version: 4.0.2
resolution: "@types/long@npm:4.0.2"
@ -24003,6 +24019,15 @@ __metadata:
languageName: node
linkType: hard
"cron-validate@npm:^1.4.5":
version: 1.4.5
resolution: "cron-validate@npm:1.4.5"
dependencies:
yup: "npm:0.32.9"
checksum: 10c0/4c210bea21832269fd8e18e4c9e9d750b314da04bc475b8b58414a6421a4c86804a6f1a8b9eda6d357af4c43f627fa3ee372f6fbb99b849d2a9b2aef2917bddf
languageName: node
linkType: hard
"cross-env@npm:^7.0.3":
version: 7.0.3
resolution: "cross-env@npm:7.0.3"
@ -34147,7 +34172,7 @@ __metadata:
languageName: node
linkType: hard
"lodash-es@npm:^4.17.21":
"lodash-es@npm:^4.17.15, lodash-es@npm:^4.17.21":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2
@ -46163,6 +46188,7 @@ __metadata:
class-transformer: "npm:^0.5.1"
clsx: "npm:^2.1.1"
concurrently: "npm:^8.2.2"
cron-validate: "npm:^1.4.5"
cross-env: "npm:^7.0.3"
cross-var: "npm:^1.1.0"
css-loader: "npm:^7.1.2"
@ -49286,6 +49312,21 @@ __metadata:
languageName: node
linkType: hard
"yup@npm:0.32.9":
version: 0.32.9
resolution: "yup@npm:0.32.9"
dependencies:
"@babel/runtime": "npm:^7.10.5"
"@types/lodash": "npm:^4.14.165"
lodash: "npm:^4.17.20"
lodash-es: "npm:^4.17.15"
nanoclone: "npm:^0.2.1"
property-expr: "npm:^2.0.4"
toposort: "npm:^2.0.2"
checksum: 10c0/b2adff31f4be85aaad338e6db12a26715b9e11270c587afe051d42c423f7f24de2d184f646047cb5c3b8c65163c37611f8309f2ef4eb6bb7a66688158a081d66
languageName: node
linkType: hard
"yup@npm:^0.32.0":
version: 0.32.11
resolution: "yup@npm:0.32.11"