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:
@ -77,6 +77,7 @@
|
|||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cron-validate": "^1.4.5",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"danger-plugin-todos": "^1.3.1",
|
"danger-plugin-todos": "^1.3.1",
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import styled from '@emotion/styled';
|
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`
|
const StyledFormFieldInputContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -6,4 +9,35 @@ const StyledFormFieldInputContainer = styled.div`
|
|||||||
width: 100%;
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -178,7 +178,7 @@ export const FormMultiSelectFieldInput = ({
|
|||||||
const placeholderText = placeholder ?? label;
|
const placeholderText = placeholder ?? label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormFieldInputContainer data-testid={testId}>
|
<FormFieldInputContainer testId={testId}>
|
||||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||||
|
|
||||||
<FormFieldInputRowContainer>
|
<FormFieldInputRowContainer>
|
||||||
|
|||||||
@ -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 { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
|
||||||
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
|
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
|
||||||
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
|
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
|
||||||
@ -14,6 +13,8 @@ import {
|
|||||||
canBeCastAsNumberOrNull,
|
canBeCastAsNumberOrNull,
|
||||||
castAsNumberOrNull,
|
castAsNumberOrNull,
|
||||||
} from '~/utils/cast-as-number-or-null';
|
} 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)`
|
const StyledInput = styled(TextInput)`
|
||||||
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
|
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
|
||||||
@ -21,9 +22,11 @@ const StyledInput = styled(TextInput)`
|
|||||||
|
|
||||||
type FormNumberFieldInputProps = {
|
type FormNumberFieldInputProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
error?: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
defaultValue: number | string | undefined;
|
defaultValue: number | string | undefined;
|
||||||
onPersist: (value: number | null | string) => void;
|
onPersist: (value: number | null | string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
VariablePicker?: VariablePickerComponent;
|
VariablePicker?: VariablePickerComponent;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
@ -31,9 +34,11 @@ type FormNumberFieldInputProps = {
|
|||||||
|
|
||||||
export const FormNumberFieldInput = ({
|
export const FormNumberFieldInput = ({
|
||||||
label,
|
label,
|
||||||
|
error,
|
||||||
placeholder,
|
placeholder,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
onPersist,
|
onPersist,
|
||||||
|
onBlur,
|
||||||
VariablePicker,
|
VariablePicker,
|
||||||
hint,
|
hint,
|
||||||
readonly,
|
readonly,
|
||||||
@ -105,6 +110,7 @@ export const FormNumberFieldInput = ({
|
|||||||
<FormFieldInputRowContainer>
|
<FormFieldInputRowContainer>
|
||||||
<FormFieldInputInputContainer
|
<FormFieldInputInputContainer
|
||||||
hasRightElement={isDefined(VariablePicker) && !readonly}
|
hasRightElement={isDefined(VariablePicker) && !readonly}
|
||||||
|
onBlur={onBlur}
|
||||||
>
|
>
|
||||||
{draftValue.type === 'static' ? (
|
{draftValue.type === 'static' ? (
|
||||||
<StyledInput
|
<StyledInput
|
||||||
@ -132,7 +138,8 @@ export const FormNumberFieldInput = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</FormFieldInputRowContainer>
|
</FormFieldInputRowContainer>
|
||||||
|
|
||||||
{hint ? <FormFieldHint>{hint}</FormFieldHint> : null}
|
{hint ? <InputHint>{hint}</InputHint> : null}
|
||||||
|
{error && <InputErrorHelper>{error}</InputErrorHelper>}
|
||||||
</FormFieldInputContainer>
|
</FormFieldInputContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,12 +8,17 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
|
|||||||
import { parseEditorContent } from '@/workflow/workflow-variables/utils/parseEditorContent';
|
import { parseEditorContent } from '@/workflow/workflow-variables/utils/parseEditorContent';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
import { isDefined } from 'twenty-shared';
|
import { isDefined } from 'twenty-shared';
|
||||||
|
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
|
||||||
|
import { InputHint } from '@/ui/input/components/InputHint';
|
||||||
|
|
||||||
type FormTextFieldInputProps = {
|
type FormTextFieldInputProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
defaultValue: string | undefined;
|
defaultValue: string | undefined;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
onPersist: (value: string) => void;
|
onPersist: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
VariablePicker?: VariablePickerComponent;
|
VariablePicker?: VariablePickerComponent;
|
||||||
@ -21,9 +26,12 @@ type FormTextFieldInputProps = {
|
|||||||
|
|
||||||
export const FormTextFieldInput = ({
|
export const FormTextFieldInput = ({
|
||||||
label,
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
onPersist,
|
onPersist,
|
||||||
|
onBlur,
|
||||||
multiline,
|
multiline,
|
||||||
readonly,
|
readonly,
|
||||||
VariablePicker,
|
VariablePicker,
|
||||||
@ -65,6 +73,7 @@ export const FormTextFieldInput = ({
|
|||||||
<FormFieldInputInputContainer
|
<FormFieldInputInputContainer
|
||||||
hasRightElement={isDefined(VariablePicker) && !readonly}
|
hasRightElement={isDefined(VariablePicker) && !readonly}
|
||||||
multiline={multiline}
|
multiline={multiline}
|
||||||
|
onBlur={onBlur}
|
||||||
>
|
>
|
||||||
<TextVariableEditor
|
<TextVariableEditor
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@ -81,6 +90,8 @@ export const FormTextFieldInput = ({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</FormFieldInputRowContainer>
|
</FormFieldInputRowContainer>
|
||||||
|
{hint && <InputHint>{hint}</InputHint>}
|
||||||
|
{error && <InputErrorHelper>{error}</InputErrorHelper>}
|
||||||
</FormFieldInputContainer>
|
</FormFieldInputContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
export enum FormFieldInputHotKeyScope {
|
||||||
|
FormFieldInput = 'form-field-input',
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
const StyledFormFieldHint = styled.div`
|
const StyledInputHint = styled.div`
|
||||||
color: ${({ theme }) => theme.font.color.light};
|
color: ${({ theme }) => theme.font.color.light};
|
||||||
font-size: ${({ theme }) => theme.font.size.xs};
|
font-size: ${({ theme }) => theme.font.size.xs};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
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 };
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
} from 'twenty-ui';
|
} from 'twenty-ui';
|
||||||
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
|
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
|
||||||
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||||
|
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
|
||||||
|
|
||||||
const StyledContainer = styled.div<
|
const StyledContainer = styled.div<
|
||||||
Pick<TextInputV2ComponentProps, 'fullWidth'>
|
Pick<TextInputV2ComponentProps, 'fullWidth'>
|
||||||
@ -83,9 +84,7 @@ const StyledInput = styled.input<
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledErrorHelper = styled.div`
|
const StyledErrorHelper = styled(InputErrorHelper)`
|
||||||
color: ${({ theme }) => theme.color.red};
|
|
||||||
font-size: ${({ theme }) => theme.font.size.xs};
|
|
||||||
padding: ${({ theme }) => theme.spacing(1)};
|
padding: ${({ theme }) => theme.spacing(1)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@ -62,7 +62,9 @@ export const useAllActiveWorkflowVersions = ({
|
|||||||
if (!isDefined(objectMetadataItem)) {
|
if (!isDefined(objectMetadataItem)) {
|
||||||
return {
|
return {
|
||||||
records: records.filter(
|
records: records.filter(
|
||||||
(record) => !isDefined(record.trigger?.settings.objectType),
|
(record) =>
|
||||||
|
record.trigger?.type !== 'CRON' &&
|
||||||
|
!isDefined(record.trigger?.settings.objectType),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 WorkflowManualTriggerSettings = WorkflowManualTrigger['settings'];
|
||||||
|
|
||||||
export type WorkflowManualTriggerAvailability =
|
export type WorkflowManualTriggerAvailability =
|
||||||
@ -130,7 +148,8 @@ export type WorkflowManualTriggerAvailability =
|
|||||||
|
|
||||||
export type WorkflowTrigger =
|
export type WorkflowTrigger =
|
||||||
| WorkflowDatabaseEventTrigger
|
| WorkflowDatabaseEventTrigger
|
||||||
| WorkflowManualTrigger;
|
| WorkflowManualTrigger
|
||||||
|
| WorkflowCronTrigger;
|
||||||
|
|
||||||
export type WorkflowTriggerType = WorkflowTrigger['type'];
|
export type WorkflowTriggerType = WorkflowTrigger['type'];
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react';
|
||||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||||
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||||
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
|
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
|
||||||
@ -46,6 +47,16 @@ export const WorkflowDiagramStepNodeIcon = ({
|
|||||||
</StyledStepNodeLabelIconContainer>
|
</StyledStepNodeLabelIconContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'CRON': {
|
||||||
|
return (
|
||||||
|
<StyledStepNodeLabelIconContainer>
|
||||||
|
<Icon
|
||||||
|
size={theme.icon.size.md}
|
||||||
|
color={theme.font.color.tertiary}
|
||||||
|
/>
|
||||||
|
</StyledStepNodeLabelIconContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return assertUnreachable(data.triggerType);
|
return assertUnreachable(data.triggerType);
|
||||||
|
|||||||
@ -73,6 +73,14 @@ export const generateWorkflowDiagram = ({
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'CRON': {
|
||||||
|
triggerDefaultLabel = 'On a Schedule';
|
||||||
|
triggerIcon = getTriggerIcon({
|
||||||
|
type: 'CRON',
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'DATABASE_EVENT': {
|
case 'DATABASE_EVENT': {
|
||||||
const triggerEvent = splitWorkflowTriggerEventName(
|
const triggerEvent = splitWorkflowTriggerEventName(
|
||||||
trigger.settings.eventName,
|
trigger.settings.eventName,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-steps/workf
|
|||||||
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
|
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
|
||||||
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
|
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
|
||||||
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
|
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
|
||||||
|
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
|
||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import { isDefined } from 'twenty-shared';
|
import { isDefined } from 'twenty-shared';
|
||||||
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
|
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
|
||||||
@ -71,6 +72,14 @@ export const WorkflowStepDetail = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'CRON': {
|
||||||
|
return (
|
||||||
|
<WorkflowEditTriggerCronForm
|
||||||
|
trigger={stepDefinition.definition}
|
||||||
|
triggerOptions={props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return assertUnreachable(
|
return assertUnreachable(
|
||||||
|
|||||||
@ -125,7 +125,7 @@ export const WorkflowSingleRecordPicker = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormFieldInputContainer data-testid={testId}>
|
<FormFieldInputContainer testId={testId}>
|
||||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||||
<FormFieldInputRowContainer>
|
<FormFieldInputRowContainer>
|
||||||
<StyledFormSelectContainer hasRightElement={!disabled}>
|
<StyledFormSelectContainer hasRightElement={!disabled}>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorato
|
|||||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
||||||
import { WorkflowEditActionFormCreateRecord } from '../WorkflowEditActionFormCreateRecord';
|
import { WorkflowEditActionFormCreateRecord } from '../WorkflowEditActionFormCreateRecord';
|
||||||
|
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
|
||||||
|
|
||||||
const meta: Meta<typeof WorkflowEditActionFormCreateRecord> = {
|
const meta: Meta<typeof WorkflowEditActionFormCreateRecord> = {
|
||||||
title: 'Modules/Workflow/WorkflowEditActionFormCreateRecord',
|
title: 'Modules/Workflow/WorkflowEditActionFormCreateRecord',
|
||||||
@ -45,6 +46,7 @@ const meta: Meta<typeof WorkflowEditActionFormCreateRecord> = {
|
|||||||
ComponentDecorator,
|
ComponentDecorator,
|
||||||
ObjectMetadataItemsDecorator,
|
ObjectMetadataItemsDecorator,
|
||||||
SnackBarDecorator,
|
SnackBarDecorator,
|
||||||
|
WorkspaceDecorator,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
|
|||||||
import { getPeopleMock } from '~/testing/mock-data/people';
|
import { getPeopleMock } from '~/testing/mock-data/people';
|
||||||
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
||||||
import { WorkflowEditActionFormDeleteRecord } from '../WorkflowEditActionFormDeleteRecord';
|
import { WorkflowEditActionFormDeleteRecord } from '../WorkflowEditActionFormDeleteRecord';
|
||||||
|
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
|
||||||
|
|
||||||
const DEFAULT_ACTION = {
|
const DEFAULT_ACTION = {
|
||||||
id: getWorkflowNodeIdMock(),
|
id: getWorkflowNodeIdMock(),
|
||||||
@ -49,6 +50,7 @@ const meta: Meta<typeof WorkflowEditActionFormDeleteRecord> = {
|
|||||||
ObjectMetadataItemsDecorator,
|
ObjectMetadataItemsDecorator,
|
||||||
SnackBarDecorator,
|
SnackBarDecorator,
|
||||||
RouterDecorator,
|
RouterDecorator,
|
||||||
|
WorkspaceDecorator,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { graphqlMocks } from '~/testing/graphqlMocks';
|
|||||||
import { getPeopleMock } from '~/testing/mock-data/people';
|
import { getPeopleMock } from '~/testing/mock-data/people';
|
||||||
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
||||||
import { WorkflowEditActionFormUpdateRecord } from '../WorkflowEditActionFormUpdateRecord';
|
import { WorkflowEditActionFormUpdateRecord } from '../WorkflowEditActionFormUpdateRecord';
|
||||||
|
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
|
||||||
|
|
||||||
const DEFAULT_ACTION = {
|
const DEFAULT_ACTION = {
|
||||||
id: getWorkflowNodeIdMock(),
|
id: getWorkflowNodeIdMock(),
|
||||||
@ -62,6 +63,7 @@ const meta: Meta<typeof WorkflowEditActionFormUpdateRecord> = {
|
|||||||
ObjectMetadataItemsDecorator,
|
ObjectMetadataItemsDecorator,
|
||||||
SnackBarDecorator,
|
SnackBarDecorator,
|
||||||
RouterDecorator,
|
RouterDecorator,
|
||||||
|
WorkspaceDecorator,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -10,4 +10,9 @@ export const OTHER_TRIGGER_TYPES: Array<{
|
|||||||
type: 'MANUAL',
|
type: 'MANUAL',
|
||||||
icon: 'IconHandMove',
|
icon: 'IconHandMove',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
defaultLabel: 'On a Schedule',
|
||||||
|
type: 'CRON',
|
||||||
|
icon: 'IconClock',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -45,6 +45,16 @@ export const getTriggerDefaultDefinition = ({
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'CRON': {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
settings: {
|
||||||
|
type: 'HOURS',
|
||||||
|
schedule: { hour: 1, minute: 0 },
|
||||||
|
outputSchema: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
return assertUnreachable(type, `Unknown type: ${type}`);
|
return assertUnreachable(type, `Unknown type: ${type}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,9 @@ export const getTriggerIcon = (
|
|||||||
| {
|
| {
|
||||||
type: 'MANUAL';
|
type: 'MANUAL';
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'CRON';
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'DATABASE_EVENT';
|
type: 'DATABASE_EVENT';
|
||||||
eventName: string;
|
eventName: string;
|
||||||
|
|||||||
@ -6,6 +6,9 @@ export const getTriggerDefaultLabel = (
|
|||||||
| {
|
| {
|
||||||
type: 'MANUAL';
|
type: 'MANUAL';
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'CRON';
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'DATABASE_EVENT';
|
type: 'DATABASE_EVENT';
|
||||||
eventName: string;
|
eventName: string;
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export const getTriggerStepName = (trigger: WorkflowTrigger): string => {
|
|||||||
switch (trigger.type) {
|
switch (trigger.type) {
|
||||||
case 'DATABASE_EVENT':
|
case 'DATABASE_EVENT':
|
||||||
return getDatabaseEventTriggerStepName(trigger);
|
return getDatabaseEventTriggerStepName(trigger);
|
||||||
|
case 'CRON':
|
||||||
|
return 'On a Schedule';
|
||||||
case 'MANUAL':
|
case 'MANUAL':
|
||||||
if (!isDefined(trigger.settings.objectType)) {
|
if (!isDefined(trigger.settings.objectType)) {
|
||||||
return 'Manual trigger';
|
return 'Manual trigger';
|
||||||
|
|||||||
@ -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 />;
|
||||||
|
};
|
||||||
@ -59,6 +59,9 @@ export class WorkflowBuilderWorkspaceService {
|
|||||||
objectMetadataRepository: this.objectMetadataRepository,
|
objectMetadataRepository: this.objectMetadataRepository,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
case WorkflowTriggerType.CRON: {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
case WorkflowActionType.SEND_EMAIL: {
|
case WorkflowActionType.SEND_EMAIL: {
|
||||||
return this.computeSendEmailActionOutputSchema();
|
return this.computeSendEmailActionOutputSchema();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,9 +38,20 @@ export type WorkflowManualTrigger = BaseTrigger & {
|
|||||||
|
|
||||||
export type WorkflowCronTrigger = BaseTrigger & {
|
export type WorkflowCronTrigger = BaseTrigger & {
|
||||||
type: WorkflowTriggerType.CRON;
|
type: WorkflowTriggerType.CRON;
|
||||||
settings: {
|
settings: (
|
||||||
pattern: string;
|
| {
|
||||||
};
|
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 WorkflowManualTriggerSettings = WorkflowManualTrigger['settings'];
|
||||||
|
|||||||
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -70,6 +70,9 @@ function assertTriggerSettingsAreValid(
|
|||||||
break;
|
break;
|
||||||
case WorkflowTriggerType.MANUAL:
|
case WorkflowTriggerType.MANUAL:
|
||||||
break;
|
break;
|
||||||
|
case WorkflowTriggerType.CRON:
|
||||||
|
assertCronTriggerSettingsAreValid(settings);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new WorkflowTriggerException(
|
throw new WorkflowTriggerException(
|
||||||
'Invalid trigger type for enabling workflow trigger',
|
'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) {
|
function assertDatabaseEventTriggerSettingsAreValid(settings: any) {
|
||||||
if (!settings?.eventName) {
|
if (!settings?.eventName) {
|
||||||
throw new WorkflowTriggerException(
|
throw new WorkflowTriggerException(
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -36,6 +36,7 @@ import {
|
|||||||
WorkflowTriggerJob,
|
WorkflowTriggerJob,
|
||||||
WorkflowTriggerJobData,
|
WorkflowTriggerJobData,
|
||||||
} from 'src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job';
|
} 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()
|
@Injectable()
|
||||||
export class WorkflowTriggerWorkspaceService {
|
export class WorkflowTriggerWorkspaceService {
|
||||||
@ -339,7 +340,9 @@ export class WorkflowTriggerWorkspaceService {
|
|||||||
return;
|
return;
|
||||||
case WorkflowTriggerType.MANUAL:
|
case WorkflowTriggerType.MANUAL:
|
||||||
return;
|
return;
|
||||||
case WorkflowTriggerType.CRON:
|
case WorkflowTriggerType.CRON: {
|
||||||
|
const pattern = computeCronPatternFromSchedule(workflowVersion.trigger);
|
||||||
|
|
||||||
await this.messageQueueService.addCron<WorkflowTriggerJobData>({
|
await this.messageQueueService.addCron<WorkflowTriggerJobData>({
|
||||||
jobName: WorkflowTriggerJob.name,
|
jobName: WorkflowTriggerJob.name,
|
||||||
jobId: workflowVersion.workflowId,
|
jobId: workflowVersion.workflowId,
|
||||||
@ -350,12 +353,13 @@ export class WorkflowTriggerWorkspaceService {
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
repeat: {
|
repeat: {
|
||||||
pattern: workflowVersion.trigger.settings.pattern,
|
pattern,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
assertNever(workflowVersion.trigger);
|
assertNever(workflowVersion.trigger);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable no-restricted-imports */
|
/* eslint-disable no-restricted-imports */
|
||||||
export {
|
export {
|
||||||
Icon123,
|
Icon123,
|
||||||
|
Icon24Hours,
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconApi,
|
IconApi,
|
||||||
@ -48,6 +49,7 @@ export {
|
|||||||
IconCircleX,
|
IconCircleX,
|
||||||
IconClick,
|
IconClick,
|
||||||
IconClockHour8,
|
IconClockHour8,
|
||||||
|
IconClockPlay,
|
||||||
IconClockShare,
|
IconClockShare,
|
||||||
IconCode,
|
IconCode,
|
||||||
IconCodeCircle,
|
IconCodeCircle,
|
||||||
@ -247,6 +249,8 @@ export {
|
|||||||
IconTestPipe,
|
IconTestPipe,
|
||||||
IconTextSize,
|
IconTextSize,
|
||||||
IconTextWrap,
|
IconTextWrap,
|
||||||
|
IconTimeDuration30,
|
||||||
|
IconTimeDuration60,
|
||||||
IconTimelineEvent,
|
IconTimelineEvent,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
|
|||||||
43
yarn.lock
43
yarn.lock
@ -3328,6 +3328,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@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
|
version: 7.25.0
|
||||||
resolution: "@babel/template@npm:7.25.0"
|
resolution: "@babel/template@npm:7.25.0"
|
||||||
@ -17200,6 +17209,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/long@npm:^4.0.0":
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
resolution: "@types/long@npm:4.0.2"
|
resolution: "@types/long@npm:4.0.2"
|
||||||
@ -24003,6 +24019,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"cross-env@npm:^7.0.3":
|
||||||
version: 7.0.3
|
version: 7.0.3
|
||||||
resolution: "cross-env@npm:7.0.3"
|
resolution: "cross-env@npm:7.0.3"
|
||||||
@ -34147,7 +34172,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"lodash-es@npm:^4.17.21":
|
"lodash-es@npm:^4.17.15, lodash-es@npm:^4.17.21":
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
resolution: "lodash-es@npm:4.17.21"
|
resolution: "lodash-es@npm:4.17.21"
|
||||||
checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2
|
checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2
|
||||||
@ -46163,6 +46188,7 @@ __metadata:
|
|||||||
class-transformer: "npm:^0.5.1"
|
class-transformer: "npm:^0.5.1"
|
||||||
clsx: "npm:^2.1.1"
|
clsx: "npm:^2.1.1"
|
||||||
concurrently: "npm:^8.2.2"
|
concurrently: "npm:^8.2.2"
|
||||||
|
cron-validate: "npm:^1.4.5"
|
||||||
cross-env: "npm:^7.0.3"
|
cross-env: "npm:^7.0.3"
|
||||||
cross-var: "npm:^1.1.0"
|
cross-var: "npm:^1.1.0"
|
||||||
css-loader: "npm:^7.1.2"
|
css-loader: "npm:^7.1.2"
|
||||||
@ -49286,6 +49312,21 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"yup@npm:^0.32.0":
|
||||||
version: 0.32.11
|
version: 0.32.11
|
||||||
resolution: "yup@npm:0.32.11"
|
resolution: "yup@npm:0.32.11"
|
||||||
|
|||||||
Reference in New Issue
Block a user