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",
|
||||
"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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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';
|
||||
|
||||
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 };
|
||||
@ -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)};
|
||||
`;
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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'];
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -125,7 +125,7 @@ export const WorkflowSingleRecordPicker = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<FormFieldInputContainer data-testid={testId}>
|
||||
<FormFieldInputContainer testId={testId}>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
<FormFieldInputRowContainer>
|
||||
<StyledFormSelectContainer hasRightElement={!disabled}>
|
||||
|
||||
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -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',
|
||||
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: {
|
||||
return assertUnreachable(type, `Unknown type: ${type}`);
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@ export const getTriggerIcon = (
|
||||
| {
|
||||
type: 'MANUAL';
|
||||
}
|
||||
| {
|
||||
type: 'CRON';
|
||||
}
|
||||
| {
|
||||
type: 'DATABASE_EVENT';
|
||||
eventName: string;
|
||||
|
||||
@ -6,6 +6,9 @@ export const getTriggerDefaultLabel = (
|
||||
| {
|
||||
type: 'MANUAL';
|
||||
}
|
||||
| {
|
||||
type: 'CRON';
|
||||
}
|
||||
| {
|
||||
type: 'DATABASE_EVENT';
|
||||
eventName: string;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
case WorkflowTriggerType.CRON: {
|
||||
return {};
|
||||
}
|
||||
case WorkflowActionType.SEND_EMAIL: {
|
||||
return this.computeSendEmailActionOutputSchema();
|
||||
}
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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;
|
||||
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(
|
||||
|
||||
@ -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,
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
43
yarn.lock
43
yarn.lock
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user