diff --git a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts
index b235ca49d9f..e8fc6e508e3 100644
--- a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts
+++ b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts
@@ -12,8 +12,8 @@ import { skipControl } from './skip-control.schema';
const DigestRegularControlZodSchema = z
.object({
+ amount: z.union([z.number().min(1), z.string().min(1)]),
skip: skipControl.schema,
- amount: z.union([z.number().min(1), z.string()]),
unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS),
digestKey: z.string().optional(),
lookBackWindow: z
@@ -28,7 +28,7 @@ const DigestRegularControlZodSchema = z
const DigestTimedControlZodSchema = z
.object({
- cron: z.string(),
+ cron: z.string().min(1),
digestKey: z.string().optional(),
})
.strict();
@@ -74,7 +74,7 @@ export const digestUiSchema: UiSchema = {
},
cron: {
component: UiComponentEnum.DIGEST_CRON,
- placeholder: null,
+ placeholder: '',
},
skip: skipControl.uiSchema.properties.skip,
},
diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html
index 9692a986878..8debe43b943 100644
--- a/apps/dashboard/index.html
+++ b/apps/dashboard/index.html
@@ -16,7 +16,7 @@
rel="stylesheet"
/>
<% if (env.VITE_GTM) { %>
@@ -51,16 +51,15 @@
async="async"
type="text/javascript"
>
- <% } %>
- <% if (env.VITE_PLAIN_SUPPORT_CHAT_APP_ID) { %>
-
+ <% } %> <% if (env.VITE_PLAIN_SUPPORT_CHAT_APP_ID) { %>
+
<% } %>
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 65bdce5c519..85c8115be80 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -60,6 +60,7 @@
"@sentry/react": "^8.35.0",
"@tanstack/react-query": "^5.59.6",
"@types/js-cookie": "^3.0.6",
+ "@types/lodash.isequal": "^4.5.8",
"@uiw/codemirror-extensions-langs": "^4.23.6",
"@uiw/codemirror-theme-material": "^4.23.6",
"@uiw/codemirror-theme-white": "^4.23.6",
@@ -69,6 +70,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
+ "cron-parser": "^4.9.0",
"date-fns": "^4.1.0",
"flat": "^6.0.1",
"js-cookie": "^3.0.5",
diff --git a/apps/dashboard/src/components/amount-input.tsx b/apps/dashboard/src/components/amount-input.tsx
index f436a37f906..e4afc5e2187 100644
--- a/apps/dashboard/src/components/amount-input.tsx
+++ b/apps/dashboard/src/components/amount-input.tsx
@@ -1,9 +1,11 @@
+import { FocusEventHandler } from 'react';
+import { useFormContext } from 'react-hook-form';
+
import { cn } from '@/utils/ui';
import { FormControl, FormField, FormItem, FormMessagePure } from '@/components/primitives/form/form';
import { Input } from '@/components/primitives/input';
import { InputFieldPure } from '@/components/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select';
-import { useFormContext } from 'react-hook-form';
import { AUTOCOMPLETE_PASSWORD_MANAGERS_OFF } from '@/utils/constants';
const HEIGHT = {
@@ -22,18 +24,124 @@ type InputWithSelectProps = {
inputKey: string;
selectKey: string;
};
- options: string[];
+ options: Array<{ label: string; value: string }>;
defaultOption?: string;
className?: string;
placeholder?: string;
isReadOnly?: boolean;
- onValueChange?: (value: string) => void;
+ onValueChange?: () => void;
size?: 'sm' | 'md';
min?: number;
showError?: boolean;
+ shouldUnregister?: boolean;
+};
+
+const AmountInputContainer = ({
+ children,
+ className,
+ size = 'sm',
+ hasError,
+}: {
+ children?: React.ReactNode | React.ReactNode[];
+ className?: string;
+ size?: 'sm' | 'md';
+ hasError?: boolean;
+}) => {
+ return (
+
+ {children}
+
+ );
};
-export const AmountInput = ({
+const AmountInputField = ({
+ value,
+ min,
+ placeholder,
+ disabled,
+ onChange,
+ onBlur,
+}: {
+ value?: string | number;
+ placeholder?: string;
+ disabled?: boolean;
+ min?: number;
+ onChange: (arg: string | number) => void;
+ onBlur?: FocusEventHandler;
+}) => {
+ return (
+ {
+ if (e.key === 'e' || e.key === '-' || e.key === '+' || e.key === '.' || e.key === ',') {
+ e.preventDefault();
+ }
+ }}
+ onChange={(e) => {
+ if (e.target.value === '') {
+ onChange('');
+ return;
+ }
+
+ const numberValue = Number(e.target.value);
+ onChange(numberValue);
+ }}
+ min={min}
+ onBlur={onBlur}
+ {...AUTOCOMPLETE_PASSWORD_MANAGERS_OFF}
+ />
+ );
+};
+
+const AmountUnitSelect = ({
+ value,
+ defaultOption,
+ options,
+ size = 'sm',
+ disabled,
+ onValueChange,
+}: {
+ value?: string;
+ defaultOption?: string;
+ options: Array<{ label: string; value: string }>;
+ size?: 'sm' | 'md';
+ disabled?: boolean;
+ onValueChange?: (val: string) => void;
+}) => {
+ return (
+
+
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ {options.map(({ label, value }) => (
+
+ {label}
+
+ ))}
+
+
+ );
+};
+
+const AmountInput = ({
fields,
options,
defaultOption,
@@ -44,6 +152,7 @@ export const AmountInput = ({
size = 'sm',
min,
showError = true,
+ shouldUnregister = false,
}: InputWithSelectProps) => {
const { getFieldState, setValue, control } = useFormContext();
@@ -53,38 +162,23 @@ export const AmountInput = ({
return (
<>
-
+
(
- {
- if (e.key === 'e' || e.key === '-' || e.key === '+' || e.key === '.' || e.key === ',') {
- e.preventDefault();
- }
- }}
- onChange={(e) => {
- if (e.target.value === '') {
- field.onChange('');
- return;
- }
-
- const numberValue = Number(e.target.value);
- field.onChange(numberValue);
+ onChange={field.onChange}
+ onBlur={() => {
+ onValueChange?.();
}}
min={min}
- {...AUTOCOMPLETE_PASSWORD_MANAGERS_OFF}
/>
@@ -93,40 +187,29 @@ export const AmountInput = ({
(
- {
setValue(fields.selectKey, value, { shouldDirty: true });
- onValueChange?.(value);
+ onValueChange?.();
}}
- defaultValue={defaultOption}
- disabled={isReadOnly}
- value={field.value}
- >
-
-
-
-
- {options.map((option) => (
-
- {option}
-
- ))}
-
-
+ />
)}
/>
-
+
{showError && }
>
);
};
+
+export { AmountInput, AmountInputContainer, AmountInputField, AmountUnitSelect };
diff --git a/apps/dashboard/src/components/primitives/form/form.tsx b/apps/dashboard/src/components/primitives/form/form.tsx
index e32d3f3fafc..b8514aa24d2 100644
--- a/apps/dashboard/src/components/primitives/form/form.tsx
+++ b/apps/dashboard/src/components/primitives/form/form.tsx
@@ -61,7 +61,7 @@ const FormLabel = React.forwardRef<
>
- {tooltip}
+ {tooltip}
)}
diff --git a/apps/dashboard/src/components/primitives/input.tsx b/apps/dashboard/src/components/primitives/input.tsx
index ddde386725f..6784efcdad6 100644
--- a/apps/dashboard/src/components/primitives/input.tsx
+++ b/apps/dashboard/src/components/primitives/input.tsx
@@ -67,12 +67,13 @@ const inputFieldVariants = cva(
export type InputFieldPureProps = { children: React.ReactNode; className?: string } & VariantProps<
typeof inputFieldVariants
->;
+> &
+ Omit, 'size'>;
-const InputFieldPure = React.forwardRef(
- ({ children, className, size, state }, ref) => {
+const InputFieldPure = React.forwardRef(
+ ({ children, className, size, state, ...rest }, ref) => {
return (
-
+
{children}
);
@@ -83,7 +84,7 @@ InputFieldPure.displayName = 'InputFieldPure';
export type InputFieldProps = Omit
;
-const InputField = React.forwardRef(({ ...props }, ref) => {
+const InputField = React.forwardRef(({ ...props }, ref) => {
const { error } = useFormField();
return ;
diff --git a/apps/dashboard/src/components/primitives/multi-select.tsx b/apps/dashboard/src/components/primitives/multi-select.tsx
new file mode 100644
index 00000000000..ddce637805d
--- /dev/null
+++ b/apps/dashboard/src/components/primitives/multi-select.tsx
@@ -0,0 +1,92 @@
+import { useMemo, useState } from 'react';
+import { RiCheckLine } from 'react-icons/ri';
+import { CaretSortIcon } from '@radix-ui/react-icons';
+
+import { cn } from '@/utils/ui';
+import TruncatedText from '@/components/truncated-text';
+import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '@/components/primitives/popover';
+import { Command, CommandGroup, CommandItem, CommandList } from '@/components/primitives/command';
+import { selectTriggerVariants } from '@/components/primitives/select';
+
+export const MultiSelect = ({
+ values,
+ options,
+ isDisabled,
+ placeholder,
+ placeholderSelected,
+ placeholderAll,
+ className,
+ onValuesChange,
+}: {
+ values: T[];
+ options: Array<{ value: T; label: string }>;
+ isDisabled?: boolean;
+ placeholder?: string;
+ placeholderSelected?: string;
+ placeholderAll?: string;
+ className?: string;
+ onValuesChange: (values: T[]) => void;
+}) => {
+ const [openCombobox, setOpenCombobox] = useState(false);
+ const selectedValues = useMemo(
+ () => options.filter(({ value: optionValue }) => values.includes(optionValue)),
+ [values, options]
+ );
+
+ const onComboboxOpenChange = (value: boolean) => {
+ setOpenCombobox(value);
+ };
+
+ const onSelectValue = (value: T) => {
+ if (values.includes(value)) {
+ onValuesChange(values.filter((el) => el !== value));
+ } else {
+ onValuesChange([...values, value]);
+ }
+
+ setOpenCombobox(false);
+ };
+
+ return (
+
+
+
+
+ {selectedValues.length === 0 && (placeholder ?? 'Select options')}
+ {selectedValues.length === 1 && selectedValues[0].label}
+ {selectedValues.length === 2 && selectedValues.map(({ label }) => label).join(', ')}
+ {selectedValues.length === options.length
+ ? (placeholderAll ?? 'All selected')
+ : selectedValues.length > 2 && `${selectedValues.length} ${placeholderSelected ?? 'selected'}`}
+ {}
+
+
+
+
+
+
+
+
+
+ {options.map(({ value, label }) => {
+ const isActive = values.includes(value);
+ return (
+ onSelectValue(value)}>
+ {label}
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/dashboard/src/components/primitives/select.tsx b/apps/dashboard/src/components/primitives/select.tsx
index 9f9db2237e7..80cfb41280c 100644
--- a/apps/dashboard/src/components/primitives/select.tsx
+++ b/apps/dashboard/src/components/primitives/select.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import * as SelectPrimitive from '@radix-ui/react-select';
+import { cva, VariantProps } from 'class-variance-authority';
import { cn } from '@/utils/ui';
@@ -12,24 +13,34 @@ const SelectValue = SelectPrimitive.Value;
const SelectIcon = SelectPrimitive.Icon;
-const SelectTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
- span]:line-clamp-1',
- className
- )}
- {...props}
- >
- {children}
-
-
-
-
-));
+export const selectTriggerVariants = cva(
+ 'border-input ring-offset-background text-foreground-600 placeholder:text-foreground-400 focus:ring-ring shadow-xs flex w-full items-center justify-between whitespace-nowrap rounded-lg border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
+ {
+ variants: {
+ size: {
+ default: 'h-9',
+ sm: 'h-7',
+ },
+ },
+ defaultVariants: {
+ size: 'default',
+ },
+ }
+);
+
+type SelectTriggerProps = React.ComponentPropsWithoutRef &
+ VariantProps;
+
+const SelectTrigger = React.forwardRef, SelectTriggerProps>(
+ ({ className, children, size, ...props }, ref) => (
+
+ {children}
+
+
+
+
+ )
+);
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
diff --git a/apps/dashboard/src/components/primitives/time-picker.tsx b/apps/dashboard/src/components/primitives/time-picker.tsx
new file mode 100644
index 00000000000..d0cd0401ea0
--- /dev/null
+++ b/apps/dashboard/src/components/primitives/time-picker.tsx
@@ -0,0 +1,223 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+
+import { Input, InputFieldPure } from '@/components/primitives/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select';
+import { cn } from '@/utils/ui';
+import { display12HourValue, getArrowByType, getDateByType, setDateByType, TimePickerType } from '@/utils/time';
+
+interface TimePickerInputProps extends React.InputHTMLAttributes {
+ picker: TimePickerType;
+ date: Date;
+ setDate: (date: Date) => void;
+ period?: Period;
+ onRightFocus?: () => void;
+ onLeftFocus?: () => void;
+}
+
+const TimePickerInput = React.forwardRef(
+ (
+ {
+ className,
+ type = 'tel',
+ value,
+ id,
+ name,
+ date = new Date(new Date().setHours(0, 0, 0, 0)),
+ setDate,
+ onChange,
+ onKeyDown,
+ picker,
+ period,
+ onLeftFocus,
+ onRightFocus,
+ ...props
+ },
+ ref
+ ) => {
+ const [flag, setFlag] = useState(false);
+ const [prevIntKey, setPrevIntKey] = useState('0');
+
+ /**
+ * allow the user to enter the second digit within 2 seconds
+ * otherwise start again with entering first digit
+ */
+ useEffect(() => {
+ if (flag) {
+ const timer = setTimeout(() => {
+ setFlag(false);
+ }, 2000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [flag]);
+
+ const calculatedValue = useMemo(() => {
+ return getDateByType(date, picker);
+ }, [date, picker]);
+
+ const calculateNewValue = (key: string) => {
+ /*
+ * If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
+ * The second entered digit will break the condition and the value will be set to 10-12.
+ */
+ if (picker === '12hours') {
+ if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') return '0' + key;
+ }
+
+ return !flag ? '0' + key : calculatedValue.slice(1, 2) + key;
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Tab') return;
+ e.preventDefault();
+ if (e.key === 'ArrowRight') onRightFocus?.();
+ if (e.key === 'ArrowLeft') onLeftFocus?.();
+ if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
+ const step = e.key === 'ArrowUp' ? 1 : -1;
+ const newValue = getArrowByType(calculatedValue, step, picker);
+ if (flag) setFlag(false);
+ const tempDate = new Date(date);
+ setDate(setDateByType(tempDate, newValue, picker, period));
+ }
+ if (e.key >= '0' && e.key <= '9') {
+ if (picker === '12hours') setPrevIntKey(e.key);
+
+ const newValue = calculateNewValue(e.key);
+ if (flag) onRightFocus?.();
+ setFlag((prev) => !prev);
+ const tempDate = new Date(date);
+ setDate(setDateByType(tempDate, newValue, picker, period));
+ }
+ };
+
+ return (
+ {
+ e.preventDefault();
+ onChange?.(e);
+ }}
+ type={type}
+ inputMode="decimal"
+ onKeyDown={(e) => {
+ onKeyDown?.(e);
+ handleKeyDown(e);
+ }}
+ {...props}
+ />
+ );
+ }
+);
+
+TimePickerInput.displayName = 'TimePickerInput';
+
+type PeriodSelectorProps = {
+ period: Period;
+ setPeriod: (m: Period) => void;
+ date: Date;
+ setDate: (date: Date) => void;
+ onRightFocus?: () => void;
+ onLeftFocus?: () => void;
+};
+
+const TimePeriodSelect = React.forwardRef(
+ ({ period, setPeriod, date, setDate, onLeftFocus, onRightFocus }, ref) => {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowRight') onRightFocus?.();
+ if (e.key === 'ArrowLeft') onLeftFocus?.();
+ };
+
+ const handleValueChange = (value: Period) => {
+ setPeriod(value);
+
+ /**
+ * trigger an update whenever the user switches between AM and PM;
+ * otherwise user must manually change the hour each time
+ */
+ if (date) {
+ const tempDate = new Date(date);
+ const hours = display12HourValue(date.getHours());
+ setDate(setDateByType(tempDate, hours.toString(), '12hours', period === 'AM' ? 'PM' : 'AM'));
+ }
+ };
+
+ return (
+
+ handleValueChange(value)}>
+
+
+
+
+
+ AM
+
+
+ PM
+
+
+
+
+ );
+ }
+);
+
+TimePeriodSelect.displayName = 'TimePeriodSelect';
+
+type Period = 'AM' | 'PM';
+
+type TimePickerProps = {
+ value: Date;
+ onChange: (date: Date) => void;
+ hoursType?: 'hours' | '12hours';
+};
+
+export const TimePicker = ({ value, onChange, hoursType = '12hours' }: TimePickerProps) => {
+ const [period, setPeriod] = useState('PM');
+ const minuteRef = useRef(null);
+ const hourRef = useRef(null);
+ const periodRef = useRef(null);
+
+ return (
+
+
+ minuteRef.current?.focus()}
+ />
+ :
+
+ hourRef.current?.focus()}
+ onRightFocus={() => periodRef.current?.focus()}
+ />
+ hourRef.current?.focus()}
+ />
+
+ );
+};
diff --git a/apps/dashboard/src/components/workflow-editor/step-default-values.ts b/apps/dashboard/src/components/workflow-editor/step-default-values.ts
new file mode 100644
index 00000000000..667612f8c17
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/step-default-values.ts
@@ -0,0 +1,14 @@
+import { StepDataDto } from '@novu/shared';
+import { buildDefaultValues, buildDefaultValuesOfDataSchema } from '@/utils/schema';
+
+// Use the UI Schema to build the default values if it exists else use the data schema (code-first approach) values
+export const getStepDefaultValues = (step: StepDataDto): Record => {
+ const controlValues = step.controls.values;
+ const hasControlValues = Object.keys(controlValues).length > 0;
+
+ if (Object.keys(step.controls.uiSchema ?? {}).length !== 0) {
+ return hasControlValues ? controlValues : buildDefaultValues(step.controls.uiSchema ?? {});
+ }
+
+ return hasControlValues ? controlValues : buildDefaultValuesOfDataSchema(step.controls.dataSchema ?? {});
+};
diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx
index 1ac0f911535..68a82176824 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx
@@ -9,7 +9,6 @@ import {
WorkflowOriginEnum,
WorkflowResponseDto,
} from '@novu/shared';
-import merge from 'lodash.merge';
import { AnimatePresence, motion } from 'motion/react';
import { HTMLAttributes, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
@@ -48,8 +47,9 @@ import {
STEP_TYPE_LABELS,
TEMPLATE_CONFIGURABLE_STEP_TYPES,
} from '@/utils/constants';
+import { getStepDefaultValues } from '@/components/workflow-editor/step-default-values';
import { buildRoute, ROUTES } from '@/utils/routes';
-import { buildDefaultValues, buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema';
+import { buildDynamicZodSchema } from '@/utils/schema';
import { ConfigurePushStepPreview } from './push/configure-push-step-preview';
const STEP_TYPE_TO_INLINE_CONTROL_VALUES: Record React.JSX.Element | null> = {
@@ -76,14 +76,6 @@ const STEP_TYPE_TO_PREVIEW: Record {
- if (Object.keys(step.controls.uiSchema ?? {}).length !== 0) {
- return merge(buildDefaultValues(step.controls.uiSchema ?? {}), step.controls.values);
- }
-
- return merge(buildDefaultValuesOfDataSchema(step.controls.dataSchema ?? {}), step.controls.values);
-};
-
type ConfigureStepFormProps = {
workflow: WorkflowResponseDto;
environment: IEnvironment;
@@ -127,7 +119,7 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => {
return (step: StepDataDto) => {
if (isInlineConfigurableStep) {
return {
- controlValues: calculateDefaultControlsValues(step),
+ controlValues: getStepDefaultValues(step),
};
}
diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx
index 21ca629e138..fa01b557db6 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx
@@ -1,3 +1,6 @@
+import { useCallback, useEffect, useMemo } from 'react';
+import isEqual from 'lodash.isequal';
+import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
type StepDataDto,
@@ -6,22 +9,19 @@ import {
UpdateWorkflowDto,
type WorkflowResponseDto,
} from '@novu/shared';
-import isEqual from 'lodash.isequal';
-import merge from 'lodash.merge';
-import { useCallback, useEffect, useMemo } from 'react';
-import { useForm } from 'react-hook-form';
-import { Form } from '@/components/primitives/form/form';
import { flattenIssues, updateStepInWorkflow } from '@/components/workflow-editor/step-utils';
+import { Form } from '@/components/primitives/form/form';
import { EmailTabs } from '@/components/workflow-editor/steps/email/email-tabs';
+import { getStepDefaultValues } from '@/components/workflow-editor/step-default-values';
import { InAppTabs } from '@/components/workflow-editor/steps/in-app/in-app-tabs';
import { PushTabs } from '@/components/workflow-editor/steps/push/push-tabs';
import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context';
import { SmsTabs } from '@/components/workflow-editor/steps/sms/sms-tabs';
+import { OtherStepTabs } from '@/components/workflow-editor/steps/other-steps-tabs';
import { useFormAutosave } from '@/hooks/use-form-autosave';
-import { buildDefaultValues, buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema';
+import { buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema';
import { CommonCustomControlValues } from './common/common-custom-control-values';
-import { OtherStepTabs } from './other-steps-tabs';
const STEP_TYPE_TO_TEMPLATE_FORM: Record React.JSX.Element | null> = {
[StepTypeEnum.EMAIL]: EmailTabs,
@@ -35,15 +35,6 @@ const STEP_TYPE_TO_TEMPLATE_FORM: Record null,
};
-// Use the UI Schema to build the default values if it exists else use the data schema (code-first approach) values
-const calculateDefaultValues = (step: StepDataDto) => {
- if (Object.keys(step.controls.uiSchema ?? {}).length !== 0) {
- return merge(buildDefaultValues(step.controls.uiSchema ?? {}), step.controls.values);
- }
-
- return merge(buildDefaultValuesOfDataSchema(step.controls.dataSchema ?? {}), step.controls.values);
-};
-
export type StepEditorProps = {
workflow: WorkflowResponseDto;
step: StepDataDto;
@@ -57,9 +48,7 @@ export const ConfigureStepTemplateForm = (props: ConfigureStepTemplateFormProps)
const { workflow, step, update } = props;
const schema = useMemo(() => buildDynamicZodSchema(step.controls.dataSchema ?? {}), [step.controls.dataSchema]);
- const defaultValues = useMemo(() => {
- return calculateDefaultValues(step);
- }, [step]);
+ const defaultValues = useMemo(() => getStepDefaultValues(step), [step]);
const form = useForm({
resolver: zodResolver(schema),
diff --git a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx
index a886c6852d7..e345f4b17dd 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx
@@ -5,8 +5,7 @@ import { useMemo } from 'react';
import { TimeUnitEnum } from '@novu/shared';
import { useSaveForm } from '@/components/workflow-editor/steps/save-form-context';
import { useWorkflow } from '@/components/workflow-editor/workflow-provider';
-
-const defaultUnitValues = Object.values(TimeUnitEnum);
+import { TIME_UNIT_OPTIONS } from '@/components/workflow-editor/steps/time-units';
const amountKey = 'amount';
const unitKey = 'unit';
@@ -14,7 +13,7 @@ const unitKey = 'unit';
export const DelayAmount = () => {
const { step } = useWorkflow();
const { saveForm } = useSaveForm();
- const { dataSchema, uiSchema } = step?.controls ?? {};
+ const { dataSchema } = step?.controls ?? {};
const minAmountValue = useMemo(() => {
if (typeof dataSchema === 'object') {
@@ -28,16 +27,6 @@ export const DelayAmount = () => {
return 1;
}, [dataSchema]);
- const unitOptions = useMemo(
- () => (dataSchema?.properties?.[unitKey] as any)?.enum ?? defaultUnitValues,
- [dataSchema?.properties]
- );
-
- const defaultUnitOption = useMemo(
- () => (uiSchema?.properties?.[unitKey] as any)?.placeholder ?? TimeUnitEnum.SECONDS,
- [uiSchema?.properties]
- );
-
return (
@@ -45,8 +34,8 @@ export const DelayAmount = () => {
saveForm()}
min={minAmountValue}
/>
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/days-of-week.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/days-of-week.tsx
new file mode 100644
index 00000000000..dc3fb400c4f
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest/days-of-week.tsx
@@ -0,0 +1,90 @@
+import { ChangeEventHandler, KeyboardEventHandler, useRef } from 'react';
+
+const dayContainerClassName =
+ 'flex h-full items-center justify-center border-r border-r-neutral-200 last:border-r-0 last:rounded-r-lg first:rounded-l-lg first:border-l-0 [&_label]:first:rounded-l-lg [&_label]:last:rounded-r-lg';
+const inputClassName = 'peer hidden';
+const labelClassName =
+ 'text-foreground-600 peer-checked:bg-neutral-alpha-100 flex h-full w-full cursor-pointer select-none items-center justify-center text-xs font-normal';
+
+const Day = ({
+ id,
+ children,
+ checked,
+ onChange,
+ dataId,
+}: {
+ id?: string;
+ dataId?: number;
+ children: React.ReactNode;
+ checked?: boolean;
+ onChange?: ChangeEventHandler;
+}) => {
+ const inputRef = useRef(null);
+
+ const onKeyDown: KeyboardEventHandler = (e) => {
+ if (e.code === 'Enter' || e.code === 'Space') {
+ e.preventDefault();
+ inputRef.current?.click();
+ }
+ };
+
+ return (
+
+
+
+ {children}
+
+
+ );
+};
+
+export const DaysOfWeek = ({
+ daysOfWeek,
+ onDaysChange,
+}: {
+ daysOfWeek: number[];
+ onDaysChange: (days: number[]) => void;
+}) => {
+ const onChange = (e: React.ChangeEvent) => {
+ const dataId = parseInt(e.target.getAttribute('data-id') ?? '0');
+ if (e.target.checked) {
+ onDaysChange([...daysOfWeek, dataId]);
+ } else {
+ onDaysChange(daysOfWeek.filter((day) => day !== dataId));
+ }
+ };
+
+ return (
+
+
+ M
+
+
+ T
+
+
+ W
+
+
+ Th
+
+
+ F
+
+
+ S
+
+
+ Su
+
+
+ );
+};
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx
index c69f198e37c..3d0ed32c099 100644
--- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx
@@ -1,52 +1,36 @@
-import { useMemo } from 'react';
+import { useState } from 'react';
import { Tabs } from '@radix-ui/react-tabs';
import { RiCalendarScheduleFill } from 'react-icons/ri';
import { useFormContext } from 'react-hook-form';
-import { JSONSchemaDto, TimeUnitEnum } from '@novu/shared';
+import { TimeUnitEnum } from '@novu/shared';
-import { FormLabel, FormMessagePure } from '@/components/primitives/form/form';
-import { AmountInput } from '@/components/amount-input';
+import { FormField, FormLabel, FormMessagePure } from '@/components/primitives/form/form';
import { Separator } from '@/components/primitives/separator';
import { TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip';
-import { useWorkflow } from '@/components/workflow-editor/workflow-provider';
-import { useSaveForm } from '../save-form-context';
+import { RegularDigest } from '@/components/workflow-editor/steps/digest/regular-digest';
+import { ScheduledDigest } from '@/components/workflow-editor/steps/digest/scheduled-digest';
+import { AMOUNT_KEY, CRON_KEY, UNIT_KEY } from '@/components/workflow-editor/steps/digest/keys';
+import { useSaveForm } from '@/components/workflow-editor/steps/save-form-context';
+import { EVERY_MINUTE_CRON } from '@/components/workflow-editor/steps/digest/utils';
-const defaultUnitValues = Object.values(TimeUnitEnum);
-const amountKey = 'controlValues.amount';
-const unitKey = 'controlValues.unit';
+const REGULAR_DIGEST_TYPE = 'regular';
+const SCHEDULED_DIGEST_TYPE = 'scheduled';
+const TWO_SECONDS = 2000;
export const DigestWindow = () => {
- const { step } = useWorkflow();
+ const { control, getFieldState, setValue, setError, getValues, trigger } = useFormContext();
+ const formValues = getValues();
+ const { amount } = formValues.controlValues;
const { saveForm } = useSaveForm();
- const { getFieldState } = useFormContext();
- const { dataSchema, uiSchema } = step?.controls ?? {};
- const amountField = getFieldState(`${amountKey}`);
- const unitField = getFieldState(`${unitKey}`);
- const digestError = amountField.error || unitField.error;
-
- const minAmountValue = useMemo(() => {
- const fixedDurationSchema = dataSchema?.anyOf?.[0];
- if (typeof fixedDurationSchema === 'object') {
- const amountField = fixedDurationSchema.properties?.amount;
-
- if (typeof amountField === 'object' && amountField.type === 'number') {
- return amountField.minimum ?? 1;
- }
- }
-
- return 1;
- }, [dataSchema]);
-
- const unitOptions = useMemo(
- () => ((dataSchema?.anyOf?.[0] as JSONSchemaDto).properties?.unit as any).enum ?? defaultUnitValues,
- [dataSchema]
- );
-
- const defaultUnitOption = useMemo(
- () => (uiSchema?.properties?.unit as any).placeholder ?? TimeUnitEnum.SECONDS,
- [uiSchema?.properties]
+ const [digestType, setDigestType] = useState(
+ typeof amount !== 'undefined' ? REGULAR_DIGEST_TYPE : SCHEDULED_DIGEST_TYPE
);
+ const amountField = getFieldState(`${AMOUNT_KEY}`);
+ const unitField = getFieldState(`${UNIT_KEY}`);
+ const cronField = getFieldState(`${CRON_KEY}`);
+ const regularDigestError = amountField.error || unitField.error;
+ const scheduledDigestError = cronField.error;
return (
@@ -56,62 +40,92 @@ export const DigestWindow = () => {
Digest window
-
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onValueChange={async (value) => {
+ setDigestType(value);
+ if (value === SCHEDULED_DIGEST_TYPE) {
+ setValue(AMOUNT_KEY, undefined, { shouldDirty: true });
+ setValue(UNIT_KEY, undefined, { shouldDirty: true });
+ setValue(CRON_KEY, EVERY_MINUTE_CRON, { shouldDirty: true });
+ } else {
+ setValue(AMOUNT_KEY, '', { shouldDirty: true });
+ setValue(UNIT_KEY, TimeUnitEnum.SECONDS, { shouldDirty: true });
+ setValue(CRON_KEY, undefined, { shouldDirty: true });
+ }
+ await trigger();
+ saveForm();
+ }}
+ >
-
+
-
- Fixed duration
+
+ Regular
-
+
- Digest begins after the last sent digest, collecting events until the set time, then sends a
- summary.
+ Set the amount of time to digest events for. Once the defined time has elapsed, the digested events
+ are sent, and another digest begins immediately.
-
+
-
- Interval
+
+ Scheduled
-
- Coming soon...
+
+
+ Schedule the digest on a repeating basis (every 3 hours, every Friday at 6 p.m., etc.) to get full
+ control over when your digested events are processed and sent.
+
-
-
-
Digest events for
-
saveForm()}
- showError={false}
- min={minAmountValue}
- />
-
+
+
-
- Coming next...
+
+ (
+ {
+ field.onChange(value);
+ saveForm();
+ }}
+ onError={() => {
+ setError(CRON_KEY, { message: 'Failed to parse cron' });
+ }}
+ />
+ )}
+ />
-
+
);
};
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/keys.ts b/apps/dashboard/src/components/workflow-editor/steps/digest/keys.ts
new file mode 100644
index 00000000000..9d07bb0c128
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest/keys.ts
@@ -0,0 +1,3 @@
+export const AMOUNT_KEY = 'controlValues.amount';
+export const UNIT_KEY = 'controlValues.unit';
+export const CRON_KEY = 'controlValues.cron';
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/numbers-picker.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/numbers-picker.tsx
new file mode 100644
index 00000000000..c5ea070c4d0
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest/numbers-picker.tsx
@@ -0,0 +1,135 @@
+import { KeyboardEventHandler, useMemo, useRef, useState } from 'react';
+import { RiCornerDownLeftLine } from 'react-icons/ri';
+import type { PopoverContentProps } from '@radix-ui/react-popover';
+
+import { Button } from '@/components/primitives/button';
+import { InputFieldPure } from '@/components/primitives/input';
+import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '@/components/primitives/popover';
+import TruncatedText from '@/components/truncated-text';
+import { cn } from '@/utils/ui';
+
+const textClassName = 'text-foreground-600 text-xs font-medium px-2';
+
+export const NumbersPicker = ({
+ numbers,
+ label,
+ length,
+ placeholder = 'every',
+ zeroBased = false,
+ onNumbersChange,
+}: {
+ numbers: Array;
+ label: string;
+ placeholder?: string;
+ length: number;
+ zeroBased?: boolean;
+ onNumbersChange: (numbers: Array) => void;
+}) => {
+ const inputRef = useRef(null);
+ const [isPopoverOpened, setIsPopoverOpened] = useState(false);
+ const [internalSelectedNumbers, setInternalSelectedNumbers] = useState(numbers);
+
+ const onNumberClick = (day: T) => {
+ if (internalSelectedNumbers.includes(day)) {
+ setInternalSelectedNumbers(internalSelectedNumbers.filter((d) => d !== day));
+ } else {
+ setInternalSelectedNumbers([...internalSelectedNumbers, day]);
+ }
+ };
+
+ const onKeyDown: KeyboardEventHandler = (e) => {
+ if (e.code === 'Enter' || e.code === 'Space') {
+ e.preventDefault();
+ setIsPopoverOpened((old) => !old);
+ }
+ };
+
+ const value = useMemo(() => numbers.join(','), [numbers]);
+
+ const onClose = () => {
+ setIsPopoverOpened(false);
+ inputRef.current?.focus();
+ };
+
+ const onInteractOutside: PopoverContentProps['onInteractOutside'] = ({ target }) => {
+ if (inputRef.current?.contains(target as Node) || !isPopoverOpened) {
+ return;
+ }
+
+ onClose();
+ };
+
+ return (
+
+
+
+ {
+ setIsPopoverOpened((old) => !old);
+ }}
+ >
+
+ {value !== '' ? value : placeholder}
+
+
+ {label}
+
+
+
+
+
+
+
+
+ {Array.from({ length }, (_, i) => (zeroBased ? i : i + 1)).map((day) => (
+ onNumberClick(day as T)}
+ >
+ {day}
+
+ ))}
+
+
+ {
+ setInternalSelectedNumbers(numbers);
+ onClose();
+ }}
+ >
+ Cancel
+
+ {
+ onNumbersChange(internalSelectedNumbers);
+ onClose();
+ }}
+ >
+ Apply
+
+
+
+
+
+
+ );
+};
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/period.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/period.tsx
new file mode 100644
index 00000000000..bcf8be273a9
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest/period.tsx
@@ -0,0 +1,42 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select';
+import { cn } from '@/utils/ui';
+import { PeriodValues } from './utils';
+
+const PERIOD_OPTIONS = [
+ { value: PeriodValues.MINUTE, label: 'minute' },
+ { value: PeriodValues.HOUR, label: 'hour' },
+ { value: PeriodValues.DAY, label: 'day' },
+ { value: PeriodValues.WEEK, label: 'week' },
+ { value: PeriodValues.MONTH, label: 'month' },
+ { value: PeriodValues.YEAR, label: 'year' },
+];
+
+export const Period = ({
+ value,
+ isDisabled,
+ onPeriodChange,
+}: {
+ value: string;
+ isDisabled?: boolean;
+ onPeriodChange: (val: string) => void;
+}) => {
+ return (
+
+
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ {PERIOD_OPTIONS.map(({ label, value }) => (
+
+ {label}
+
+ ))}
+
+
+ );
+};
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/regular-digest.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/regular-digest.tsx
new file mode 100644
index 00000000000..b9e8afafa5c
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest/regular-digest.tsx
@@ -0,0 +1,42 @@
+import { useMemo } from 'react';
+import { TimeUnitEnum } from '@novu/shared';
+
+import { AmountInput } from '@/components/amount-input';
+import { TIME_UNIT_OPTIONS } from '@/components/workflow-editor/steps/time-units';
+import { AMOUNT_KEY, UNIT_KEY } from '@/components/workflow-editor/steps/digest/keys';
+import { useWorkflow } from '@/components/workflow-editor/workflow-provider';
+import { useSaveForm } from '@/components/workflow-editor/steps/save-form-context';
+
+export const RegularDigest = () => {
+ const { step } = useWorkflow();
+ const { saveForm } = useSaveForm();
+ const { dataSchema } = step?.controls ?? {};
+
+ const minAmountValue = useMemo(() => {
+ const fixedDurationSchema = dataSchema?.anyOf?.[0];
+ if (typeof fixedDurationSchema === 'object') {
+ const amountField = fixedDurationSchema.properties?.amount;
+
+ if (typeof amountField === 'object' && amountField.type === 'number') {
+ return amountField.minimum ?? 1;
+ }
+ }
+
+ return 1;
+ }, [dataSchema]);
+
+ return (
+
+
Digest events for
+
saveForm()}
+ showError={false}
+ min={minAmountValue}
+ />
+
+ );
+};
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/scheduled-digest.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/scheduled-digest.tsx
new file mode 100644
index 00000000000..97527499c62
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest/scheduled-digest.tsx
@@ -0,0 +1,168 @@
+import { useMemo } from 'react';
+import cronParser from 'cron-parser';
+
+import {
+ getCronBasedOnPeriod,
+ getPeriodFromCronParts,
+ parseCronString,
+ PeriodValues,
+ toCronFields,
+ toUiFields,
+ UiCronFields,
+} from '@/components/workflow-editor/steps/digest/utils';
+import { Period } from '@/components/workflow-editor/steps/digest/period';
+import { NumbersPicker } from '@/components/workflow-editor/steps/digest/numbers-picker';
+import { DaysOfWeek } from '@/components/workflow-editor/steps/digest/days-of-week';
+import { MultiSelect } from '@/components/primitives/multi-select';
+
+const MONTHS_OPTIONS = [
+ { value: 1, label: 'January' },
+ { value: 2, label: 'February' },
+ { value: 3, label: 'March' },
+ { value: 4, label: 'April' },
+ { value: 5, label: 'May' },
+ { value: 6, label: 'June' },
+ { value: 7, label: 'July' },
+ { value: 8, label: 'August' },
+ { value: 9, label: 'September' },
+ { value: 10, label: 'October' },
+ { value: 11, label: 'November' },
+ { value: 12, label: 'December' },
+];
+
+export const ScheduledDigest = ({
+ value,
+ isDisabled,
+ onValueChange,
+ onError,
+}: {
+ value: string;
+ isDisabled?: boolean;
+ onValueChange: (cron: string) => void;
+ onError?: (error: unknown) => void;
+}) => {
+ const period = useMemo(() => {
+ try {
+ const cronParts = parseCronString(value);
+ return getPeriodFromCronParts(cronParts);
+ } catch (e) {
+ onError?.(e);
+ return PeriodValues.MINUTE;
+ }
+ }, [value, onError]);
+
+ const { second, month, dayOfMonth, dayOfWeek, hour, minute } = useMemo(() => {
+ try {
+ const expression = cronParser.parseExpression(value);
+ return toUiFields(expression.fields);
+ } catch (e) {
+ onError?.(e);
+
+ return {
+ second: [],
+ minute: [],
+ hour: [],
+ dayOfMonth: [],
+ month: [],
+ dayOfWeek: [],
+ };
+ }
+ }, [value, onError]);
+
+ const handleValueChange = (fields: Partial) => {
+ const cronFields = toCronFields({
+ second,
+ minute,
+ hour,
+ dayOfWeek,
+ dayOfMonth,
+ month,
+ ...fields,
+ });
+
+ onValueChange(cronParser.fieldsToExpression(cronFields).stringify());
+ };
+
+ const handlePeriodChange = (period: string) => {
+ onValueChange(getCronBasedOnPeriod(period as PeriodValues, { second, minute, hour, dayOfWeek, dayOfMonth, month }));
+ };
+
+ return (
+
+
+ {period !== PeriodValues.HOUR && period !== PeriodValues.MONTH &&
}
+ {period === PeriodValues.YEAR && (
+
+ in
+ {
+ handleValueChange({ month: value });
+ }}
+ />
+
+ )}
+ {(period === PeriodValues.YEAR || period === PeriodValues.MONTH) && (
+
+ on
+ {
+ handleValueChange({ dayOfMonth: value });
+ }}
+ />
+
+ )}
+ {(period === PeriodValues.YEAR || period === PeriodValues.MONTH || period === PeriodValues.WEEK) && (
+
+ and
+ {
+ handleValueChange({ dayOfWeek: value });
+ }}
+ />
+
+ )}
+ {period !== PeriodValues.HOUR && period !== PeriodValues.MINUTE && (
+
+ at
+ {
+ handleValueChange({ hour: value });
+ }}
+ zeroBased
+ />
+
+ )}
+ {period !== PeriodValues.MINUTE && (
+
+ {period === PeriodValues.HOUR ? 'at' : ':'}
+ {
+ handleValueChange({ minute: value });
+ }}
+ zeroBased
+ />
+
+ )}
+
+ );
+};
diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/utils.ts b/apps/dashboard/src/components/workflow-editor/steps/digest/utils.ts
new file mode 100644
index 00000000000..dff3350bcc6
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/steps/digest/utils.ts
@@ -0,0 +1,403 @@
+import cronParser, {
+ CronFields,
+ DayOfTheMonthRange,
+ DayOfTheWeekRange,
+ HourRange,
+ MonthRange,
+ SixtyRange,
+} from 'cron-parser';
+import isEqual from 'lodash.isequal';
+
+import { dedup, range, sort } from '@/utils/arrays';
+
+export enum PeriodValues {
+ MINUTE = 'minute',
+ HOUR = 'hour',
+ DAY = 'day',
+ WEEK = 'week',
+ MONTH = 'month',
+ YEAR = 'year',
+}
+
+export interface Unit {
+ type: PeriodValues;
+ min: number;
+ max: number;
+ total: number;
+ alt?: string[];
+}
+
+export type UiCronFields = {
+ second: number[];
+ minute: number[];
+ hour: number[];
+ dayOfWeek: number[];
+ dayOfMonth: number[];
+ month: number[];
+};
+
+export const EVERY_SECOND = range(0, 59);
+export const EVERY_MINUTE = range(0, 59);
+export const EVERY_HOUR = range(0, 23);
+export const EVERY_DAY_OF_MONTH = range(1, 31);
+export const EVERY_MONTH = range(1, 12);
+export const EVERY_DAY_OF_WEEK = range(0, 7);
+
+export const EVERY_MINUTE_CRON = '* * * * *';
+
+const MINUTE_UNIT: Unit = {
+ type: PeriodValues.MINUTE,
+ min: 0,
+ max: 59,
+ total: 60,
+};
+
+const HOUR_UNIT: Unit = {
+ type: PeriodValues.HOUR,
+ min: 0,
+ max: 23,
+ total: 24,
+};
+
+const DAY_UNIT: Unit = {
+ type: PeriodValues.DAY,
+ min: 1,
+ max: 31,
+ total: 31,
+};
+
+const MONTH_UNIT: Unit = {
+ type: PeriodValues.MONTH,
+ min: 1,
+ max: 12,
+ total: 12,
+};
+
+const WEEK_UNIT: Unit = {
+ type: PeriodValues.WEEK,
+ min: 0,
+ max: 6,
+ total: 7,
+};
+
+export const UNITS: Unit[] = [MINUTE_UNIT, HOUR_UNIT, DAY_UNIT, MONTH_UNIT, WEEK_UNIT];
+
+function isEveryMinute(minute: number[]) {
+ return minute.length === 0 || minute.length === MINUTE_UNIT.total;
+}
+
+function isEveryHour(hour: number[]) {
+ return hour.length === 0 || hour.length === HOUR_UNIT.total;
+}
+
+function isEveryDayOfWeek(dayOfWeek: number[]) {
+ return dayOfWeek.length === 0 || dayOfWeek.length >= WEEK_UNIT.total;
+}
+
+function isEveryDayOfMonth(dayOfMonth: number[]) {
+ return dayOfMonth.length === 0 || dayOfMonth.length === DAY_UNIT.total;
+}
+
+function isEveryMonth(month: number[]) {
+ return month.length === 0 || month.length === MONTH_UNIT.total;
+}
+
+/**
+ * Convert a string to number but fail if not valid for cron
+ */
+function convertStringToNumber(str: string) {
+ const parseIntValue = parseInt(str, 10);
+ const numberValue = Number(str);
+
+ return parseIntValue === numberValue ? numberValue : NaN;
+}
+
+/**
+ * Replaces the alternative representations of numbers in a string
+ */
+function replaceAlternatives(str: string, min: number, alt?: string[]) {
+ if (alt) {
+ str = str.toUpperCase();
+
+ for (let i = 0; i < alt.length; i++) {
+ str = str.replace(alt[i], `${i + min}`);
+ }
+ }
+ return str;
+}
+
+/**
+ * Replace all 7 with 0 as Sunday can be represented by both
+ */
+function fixSunday(values: number[], unit: Unit) {
+ if (unit.type === PeriodValues.WEEK) {
+ values = values.map(function (value) {
+ if (value === 7) {
+ return 0;
+ }
+
+ return value;
+ });
+ }
+
+ return values;
+}
+
+/**
+ * Parses a range string
+ */
+function parseRange(rangeStr: string, context: string, unit: Unit) {
+ const subparts = rangeStr.split('-');
+
+ if (subparts.length === 1) {
+ const value = convertStringToNumber(subparts[0]);
+
+ if (isNaN(value)) {
+ throw new Error(`Invalid value "${context}" for ${unit.type}`);
+ }
+
+ return [value];
+ } else if (subparts.length === 2) {
+ const minValue = convertStringToNumber(subparts[0]);
+ const maxValue = convertStringToNumber(subparts[1]);
+
+ if (isNaN(minValue) || isNaN(maxValue)) {
+ throw new Error(`Invalid value "${context}" for ${unit.type}`);
+ }
+
+ // Fix to allow equal min and max range values
+ // cf: https://github.com/roccivic/cron-converter/pull/15
+ if (maxValue < minValue) {
+ throw new Error(`Max range is less than min range in "${rangeStr}" for ${unit.type}`);
+ }
+
+ return range(minValue, maxValue);
+ } else {
+ throw new Error(`Invalid value "${rangeStr}" for ${unit.type}`);
+ }
+}
+
+/**
+ * Finds an element from values that is outside of the range of unit
+ */
+function outOfRange(values: number[], unit: Unit) {
+ const first = values[0];
+ const last = values[values.length - 1];
+
+ if (first < unit.min) {
+ return first;
+ } else if (last > unit.max) {
+ return last;
+ }
+
+ return;
+}
+
+/**
+ * Parses the step from a part string
+ */
+function parseStep(step: string, unit: Unit) {
+ if (typeof step !== 'undefined') {
+ const parsedStep = convertStringToNumber(step);
+
+ if (isNaN(parsedStep) || parsedStep < 1) {
+ throw new Error(`Invalid interval step value "${step}" for ${unit.type}`);
+ }
+
+ return parsedStep;
+ }
+}
+
+/**
+ * Applies an interval step to a collection of values
+ */
+function applyInterval(values: number[], step?: number) {
+ if (step) {
+ const minVal = values[0];
+
+ values = values.filter((value) => {
+ return value % step === minVal % step || value === minVal;
+ });
+ }
+
+ return values;
+}
+
+/**
+ * Parses a string as a range of positive integers
+ */
+function parsePartString(str: string, unit: Unit) {
+ if (str === '*' || str === '*/1') {
+ return [];
+ }
+
+ const values = sort(
+ dedup(
+ fixSunday(
+ replaceAlternatives(str, unit.min, unit.alt)
+ .split(',')
+ .map((value) => {
+ const valueParts = value.split('/');
+
+ if (valueParts.length > 2) {
+ throw new Error(`Invalid value "${str} for "${unit.type}"`);
+ }
+
+ let parsedValues: number[];
+ const left = valueParts[0];
+ const right = valueParts[1];
+
+ if (left === '*') {
+ parsedValues = range(unit.min, unit.max);
+ } else {
+ parsedValues = parseRange(left, str, unit);
+ }
+
+ const step = parseStep(right, unit);
+ const intervalValues = applyInterval(parsedValues, step);
+
+ return intervalValues;
+ })
+ .flat(),
+ unit
+ )
+ )
+ );
+
+ const value = outOfRange(values, unit);
+
+ if (typeof value !== 'undefined') {
+ throw new Error(`Value "${value}" out of range for ${unit.type}`);
+ }
+
+ // Prevent to return full array
+ // If all values are selected we don't want any selection visible
+ if (values.length === unit.total) {
+ return [];
+ }
+
+ return values;
+}
+
+/**
+ * Parses a cron string to an array of parts
+ */
+export function parseCronString(str: string) {
+ if (typeof str !== 'string') {
+ throw new Error('Invalid cron string');
+ }
+
+ const parts = str.replace(/\s+/g, ' ').trim().split(' ');
+
+ if (parts.length === 5) {
+ return parts.map((partStr, idx) => {
+ return parsePartString(partStr, UNITS[idx]);
+ });
+ }
+
+ throw new Error('Invalid cron string format');
+}
+
+export function getPeriodFromCronParts(cronParts: number[][]): PeriodValues {
+ if (cronParts[3].length > 0) {
+ return PeriodValues.YEAR;
+ } else if (cronParts[2].length > 0) {
+ return PeriodValues.MONTH;
+ } else if (cronParts[4].length > 0) {
+ return PeriodValues.WEEK;
+ } else if (cronParts[1].length > 0) {
+ return PeriodValues.DAY;
+ } else if (cronParts[0].length > 0) {
+ return PeriodValues.HOUR;
+ }
+ return PeriodValues.MINUTE;
+}
+
+export function toUiFields(fields: CronFields): UiCronFields {
+ const isSecondEqual = isEqual(fields.second, EVERY_SECOND);
+ const isMinuteEqual = isEqual(fields.minute, EVERY_MINUTE);
+ const isHourEqual = isEqual(fields.hour, EVERY_HOUR);
+ const isDayOfWeekEqual = isEqual(fields.dayOfWeek, EVERY_DAY_OF_WEEK);
+ const isDayOfMonthEqual = isEqual(fields.dayOfMonth, EVERY_DAY_OF_MONTH);
+ const isMonthEqual = isEqual(fields.month, EVERY_MONTH);
+
+ return {
+ second: isSecondEqual ? [] : (fields.second as number[]),
+ minute: isMinuteEqual ? [] : (fields.minute as number[]),
+ hour: isHourEqual ? [] : (fields.hour as number[]),
+ dayOfWeek: isDayOfWeekEqual ? [] : (fields.dayOfWeek as number[]),
+ dayOfMonth: isDayOfMonthEqual ? [] : (fields.dayOfMonth as number[]),
+ month: isMonthEqual ? [] : (fields.month as number[]),
+ };
+}
+
+export function toCronFields(fields: UiCronFields): CronFields {
+ return {
+ second: (fields.second.length === 0 ? EVERY_SECOND : fields.second) as SixtyRange[],
+ minute: (fields.minute.length === 0 ? EVERY_MINUTE : fields.minute) as SixtyRange[],
+ hour: (fields.hour.length === 0 ? EVERY_HOUR : fields.hour) as HourRange[],
+ dayOfWeek: (fields.dayOfWeek.length === 0 ? EVERY_DAY_OF_WEEK : fields.dayOfWeek) as DayOfTheWeekRange[],
+ dayOfMonth: (fields.dayOfMonth.length === 0 ? EVERY_DAY_OF_MONTH : fields.dayOfMonth) as DayOfTheMonthRange[],
+ month: (fields.month.length === 0 ? EVERY_MONTH : fields.month) as MonthRange[],
+ };
+}
+
+export function getCronBasedOnPeriod(
+ period: PeriodValues,
+ { minute, hour, dayOfWeek, dayOfMonth, month }: UiCronFields
+) {
+ let cron = EVERY_MINUTE_CRON;
+ if (period === PeriodValues.HOUR) {
+ const cronFields = toCronFields({
+ second: [...EVERY_SECOND],
+ minute: isEveryMinute(minute) ? [0] : minute,
+ hour: [...EVERY_HOUR],
+ dayOfWeek: [...EVERY_DAY_OF_WEEK],
+ dayOfMonth: [...EVERY_DAY_OF_MONTH],
+ month: [...EVERY_MONTH],
+ });
+ cron = cronParser.fieldsToExpression(cronFields).stringify();
+ } else if (period === PeriodValues.DAY) {
+ const cronFields = toCronFields({
+ second: [...EVERY_SECOND],
+ minute: isEveryMinute(minute) ? [0] : minute,
+ hour: isEveryHour(hour) ? [12] : hour,
+ dayOfWeek: [...EVERY_DAY_OF_WEEK],
+ dayOfMonth: [...EVERY_DAY_OF_MONTH],
+ month: [...EVERY_MONTH],
+ });
+ cron = cronParser.fieldsToExpression(cronFields).stringify();
+ } else if (period === PeriodValues.WEEK) {
+ const cronFields = toCronFields({
+ second: [...EVERY_SECOND],
+ minute: isEveryMinute(minute) ? [0] : minute,
+ hour: isEveryHour(hour) ? [12] : hour,
+ dayOfWeek: isEveryDayOfWeek(dayOfWeek) ? [1] : dayOfWeek,
+ dayOfMonth: [...EVERY_DAY_OF_MONTH],
+ month: [...EVERY_MONTH],
+ });
+ cron = cronParser.fieldsToExpression(cronFields).stringify();
+ } else if (period === PeriodValues.MONTH) {
+ const cronFields = toCronFields({
+ second: [...EVERY_SECOND],
+ minute: isEveryMinute(minute) ? [0] : minute,
+ hour: isEveryHour(hour) ? [12] : hour,
+ dayOfWeek: isEveryDayOfWeek(dayOfWeek) ? [1] : dayOfWeek,
+ dayOfMonth: isEveryDayOfMonth(dayOfMonth) ? [1] : dayOfMonth,
+ month: [...EVERY_MONTH],
+ });
+ cron = cronParser.fieldsToExpression(cronFields).stringify();
+ } else if (period === PeriodValues.YEAR) {
+ const cronFields = toCronFields({
+ second: [...EVERY_SECOND],
+ minute: isEveryMinute(minute) ? [0] : minute,
+ hour: isEveryHour(hour) ? [12] : hour,
+ dayOfWeek: isEveryDayOfWeek(dayOfWeek) ? [1] : dayOfWeek,
+ dayOfMonth: isEveryDayOfMonth(dayOfMonth) ? [1] : dayOfMonth,
+ month: isEveryMonth(month) ? [1] : month,
+ });
+ cron = cronParser.fieldsToExpression(cronFields).stringify();
+ }
+
+ return cron;
+}
diff --git a/apps/dashboard/src/components/workflow-editor/steps/time-units.ts b/apps/dashboard/src/components/workflow-editor/steps/time-units.ts
new file mode 100644
index 00000000000..534848ba0aa
--- /dev/null
+++ b/apps/dashboard/src/components/workflow-editor/steps/time-units.ts
@@ -0,0 +1,28 @@
+import { TimeUnitEnum } from '@novu/shared';
+
+export const TIME_UNIT_OPTIONS: Array<{ label: string; value: TimeUnitEnum }> = [
+ {
+ label: 'second(s)',
+ value: TimeUnitEnum.SECONDS,
+ },
+ {
+ label: 'minute(s)',
+ value: TimeUnitEnum.MINUTES,
+ },
+ {
+ label: 'hour(s)',
+ value: TimeUnitEnum.HOURS,
+ },
+ {
+ label: 'day(s)',
+ value: TimeUnitEnum.DAYS,
+ },
+ {
+ label: 'week(s)',
+ value: TimeUnitEnum.WEEKS,
+ },
+ {
+ label: 'month(s)',
+ value: TimeUnitEnum.MONTHS,
+ },
+];
diff --git a/apps/dashboard/src/pages/edit-workflow.tsx b/apps/dashboard/src/pages/edit-workflow.tsx
index fb283b2444c..1118db2b9dd 100644
--- a/apps/dashboard/src/pages/edit-workflow.tsx
+++ b/apps/dashboard/src/pages/edit-workflow.tsx
@@ -10,7 +10,7 @@ export const EditWorkflowPage = () => {
}>
diff --git a/apps/dashboard/src/utils/arrays.ts b/apps/dashboard/src/utils/arrays.ts
new file mode 100644
index 00000000000..ad2b1628c73
--- /dev/null
+++ b/apps/dashboard/src/utils/arrays.ts
@@ -0,0 +1,29 @@
+export const sort = (array: number[]) => {
+ array.sort(function (a, b) {
+ return a - b;
+ });
+
+ return array;
+};
+
+export const range = (start: number, end: number) => {
+ const array: number[] = [];
+
+ for (let i = start; i <= end; i++) {
+ array.push(i);
+ }
+
+ return array;
+};
+
+export const dedup = (array: number[]) => {
+ const result: number[] = [];
+
+ array.forEach(function (i) {
+ if (result.indexOf(i) < 0) {
+ result.push(i);
+ }
+ });
+
+ return result;
+};
diff --git a/apps/dashboard/src/utils/schema.ts b/apps/dashboard/src/utils/schema.ts
index 67355eda29b..167989c3dff 100644
--- a/apps/dashboard/src/utils/schema.ts
+++ b/apps/dashboard/src/utils/schema.ts
@@ -39,19 +39,14 @@ const handleStringPattern = ({ value, key, pattern }: { value: z.ZodString; key:
const handleStringType = ({
key,
- format,
- pattern,
- enumValues,
- defaultValue,
requiredFields,
+ jsonSchema,
}: {
key: string;
- format?: string;
- pattern?: string;
- enumValues?: unknown;
- defaultValue?: unknown;
requiredFields: Readonly>;
+ jsonSchema: JSONSchemaDto;
}) => {
+ const { format, pattern, enum: enumValues, default: defaultValue, minLength } = jsonSchema;
const isRequired = requiredFields.includes(key);
let stringValue:
@@ -74,8 +69,8 @@ const handleStringType = ({
});
} else if (enumValues) {
stringValue = z.enum(enumValues as [string, ...string[]]);
- } else if (isRequired) {
- stringValue = stringValue.min(1);
+ } else if (isRequired || minLength) {
+ stringValue = stringValue.min(minLength ?? 1);
}
if (defaultValue) {
@@ -85,17 +80,8 @@ const handleStringType = ({
return stringValue;
};
-const handleNumberType = ({
- minimum,
- maximum,
- defaultValue,
-}: {
- key: string;
- minimum?: number;
- maximum?: number;
- defaultValue?: unknown;
- requiredFields: Readonly>;
-}) => {
+const handleNumberType = ({ jsonSchema }: { jsonSchema: JSONSchemaDto }) => {
+ const { default: defaultValue, minimum, maximum } = jsonSchema;
let numberValue: z.ZodNumber | z.ZodDefault = z.number();
if (typeof minimum === 'number') {
@@ -116,7 +102,7 @@ const getZodValueByType = (jsonSchema: JSONSchemaDefinition, key: string): ZodVa
return z.any();
}
const requiredFields = jsonSchema.required ?? [];
- const { type, format, pattern, enum: enumValues, default: defaultValue, required, minimum, maximum } = jsonSchema;
+ const { type, default: defaultValue, required } = jsonSchema;
if (type === 'object') {
let zodValue = buildDynamicZodSchema(jsonSchema, key) as z.ZodTypeAny;
@@ -130,11 +116,11 @@ const getZodValueByType = (jsonSchema: JSONSchemaDefinition, key: string): ZodVa
});
return zodValue.nullable();
} else if (type === 'string') {
- return handleStringType({ key, requiredFields, format, pattern, enumValues, defaultValue });
+ return handleStringType({ key, requiredFields, jsonSchema });
} else if (type === 'boolean') {
return z.boolean();
} else if (type === 'number') {
- return handleNumberType({ key, minimum, maximum, defaultValue, requiredFields });
+ return handleNumberType({ jsonSchema });
} else if (typeof jsonSchema === 'object' && jsonSchema.anyOf) {
const anyOf = jsonSchema.anyOf.map((oneOfObj) => buildDynamicZodSchema(oneOfObj, key));
@@ -180,7 +166,7 @@ export const buildDynamicZodSchema = (obj: JSONSchemaDefinition, key = ''): ZodV
/**
* Build default values based on the UI Schema object.
*/
-export const buildDefaultValues = (uiSchema: UiSchema): object => {
+export const buildDefaultValues = (uiSchema: UiSchema): Record => {
const properties = typeof uiSchema === 'object' ? (uiSchema.properties ?? {}) : {};
const keys: Record = Object.keys(properties).reduce((acc, key) => {
diff --git a/apps/dashboard/src/utils/time.ts b/apps/dashboard/src/utils/time.ts
new file mode 100644
index 00000000000..e8f831d81b3
--- /dev/null
+++ b/apps/dashboard/src/utils/time.ts
@@ -0,0 +1,187 @@
+/**
+ * regular expression to check for valid hour format (01-23)
+ */
+export function isValidHour(value: string) {
+ return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
+}
+
+/**
+ * regular expression to check for valid 12 hour format (01-12)
+ */
+export function isValid12Hour(value: string) {
+ return /^(0[1-9]|1[0-2])$/.test(value);
+}
+
+/**
+ * regular expression to check for valid minute format (00-59)
+ */
+export function isValidMinuteOrSecond(value: string) {
+ return /^[0-5][0-9]$/.test(value);
+}
+
+type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
+
+export function getValidNumber(value: string, { max, min = 0, loop = false }: GetValidNumberConfig) {
+ let numericValue = parseInt(value, 10);
+
+ if (!isNaN(numericValue)) {
+ if (!loop) {
+ if (numericValue > max) numericValue = max;
+ if (numericValue < min) numericValue = min;
+ } else {
+ if (numericValue > max) numericValue = min;
+ if (numericValue < min) numericValue = max;
+ }
+ return numericValue.toString().padStart(2, '0');
+ }
+
+ return '00';
+}
+
+export function getValidHour(value: string) {
+ if (isValidHour(value)) return value;
+ return getValidNumber(value, { max: 23 });
+}
+
+export function getValid12Hour(value: string) {
+ if (isValid12Hour(value)) return value;
+ return getValidNumber(value, { min: 1, max: 12 });
+}
+
+export function getValidMinuteOrSecond(value: string) {
+ if (isValidMinuteOrSecond(value)) return value;
+ return getValidNumber(value, { max: 59 });
+}
+
+type GetValidArrowNumberConfig = {
+ min: number;
+ max: number;
+ step: number;
+};
+
+export function getValidArrowNumber(value: string, { min, max, step }: GetValidArrowNumberConfig) {
+ let numericValue = parseInt(value, 10);
+ if (!isNaN(numericValue)) {
+ numericValue += step;
+ return getValidNumber(String(numericValue), { min, max, loop: true });
+ }
+ return '00';
+}
+
+export function getValidArrowHour(value: string, step: number) {
+ return getValidArrowNumber(value, { min: 0, max: 23, step });
+}
+
+export function getValidArrow12Hour(value: string, step: number) {
+ return getValidArrowNumber(value, { min: 1, max: 12, step });
+}
+
+export function getValidArrowMinuteOrSecond(value: string, step: number) {
+ return getValidArrowNumber(value, { min: 0, max: 59, step });
+}
+
+export function setMinutes(date: Date, value: string) {
+ const minutes = getValidMinuteOrSecond(value);
+ date.setMinutes(parseInt(minutes, 10));
+ return date;
+}
+
+export function setSeconds(date: Date, value: string) {
+ const seconds = getValidMinuteOrSecond(value);
+ date.setSeconds(parseInt(seconds, 10));
+ return date;
+}
+
+export function setHours(date: Date, value: string) {
+ const hours = getValidHour(value);
+ date.setHours(parseInt(hours, 10));
+ return date;
+}
+
+export function set12Hours(date: Date, value: string, period: Period) {
+ const hours = parseInt(getValid12Hour(value), 10);
+ const convertedHours = convert12HourTo24Hour(hours, period);
+ date.setHours(convertedHours);
+ return date;
+}
+
+export type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours';
+export type Period = 'AM' | 'PM';
+
+export function setDateByType(date: Date, value: string, type: TimePickerType, period?: Period) {
+ switch (type) {
+ case 'minutes':
+ return setMinutes(date, value);
+ case 'seconds':
+ return setSeconds(date, value);
+ case 'hours':
+ return setHours(date, value);
+ case '12hours': {
+ if (!period) return date;
+ return set12Hours(date, value, period);
+ }
+ default:
+ return date;
+ }
+}
+
+export function getDateByType(date: Date, type: TimePickerType) {
+ switch (type) {
+ case 'minutes':
+ return getValidMinuteOrSecond(String(date.getMinutes()));
+ case 'seconds':
+ return getValidMinuteOrSecond(String(date.getSeconds()));
+ case 'hours':
+ return getValidHour(String(date.getHours()));
+ case '12hours':
+ return getValid12Hour(String(display12HourValue(date.getHours())));
+ default:
+ return '00';
+ }
+}
+
+export function getArrowByType(value: string, step: number, type: TimePickerType) {
+ switch (type) {
+ case 'minutes':
+ return getValidArrowMinuteOrSecond(value, step);
+ case 'seconds':
+ return getValidArrowMinuteOrSecond(value, step);
+ case 'hours':
+ return getValidArrowHour(value, step);
+ case '12hours':
+ return getValidArrow12Hour(value, step);
+ default:
+ return '00';
+ }
+}
+
+/**
+ * handles value change of 12-hour input
+ * 12:00 PM is 12:00
+ * 12:00 AM is 00:00
+ */
+export function convert12HourTo24Hour(hour: number, period: Period) {
+ if (period === 'PM') {
+ if (hour <= 11) {
+ return hour + 12;
+ } else {
+ return hour;
+ }
+ } else if (period === 'AM') {
+ if (hour === 12) return 0;
+ return hour;
+ }
+ return hour;
+}
+
+/**
+ * time is stored in the 24-hour form,
+ * but needs to be displayed to the user
+ * in its 12-hour representation
+ */
+export function display12HourValue(hours: number) {
+ if (hours === 0 || hours === 12) return '12';
+ if (hours >= 22) return `${hours - 12}`;
+ if (hours % 12 > 9) return `${hours}`;
+ return `0${hours % 12}`;
+}
diff --git a/apps/dashboard/tailwind.config.js b/apps/dashboard/tailwind.config.js
index 1809d3f63c7..6301a48c135 100644
--- a/apps/dashboard/tailwind.config.js
+++ b/apps/dashboard/tailwind.config.js
@@ -116,7 +116,7 @@ export default {
},
extend: {
fontFamily: {
- code: ['Ubuntu', 'monospace'],
+ code: ['JetBrains Mono', 'monospace'],
},
opacity: {
2.5: 0.025,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9181613d4c7..96dd1739979 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -785,6 +785,9 @@ importers:
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6
+ '@types/lodash.isequal':
+ specifier: ^4.5.8
+ version: 4.5.8
'@uiw/codemirror-extensions-langs':
specifier: ^4.23.6
version: 4.23.6(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.3))(@codemirror/language-data@6.5.1(@codemirror/view@6.34.3))(@codemirror/language@6.10.3)(@codemirror/legacy-modes@6.4.1)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.19)(@lezer/lr@1.4.2)
@@ -812,6 +815,9 @@ importers:
cmdk:
specifier: 1.0.0
version: 1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ cron-parser:
+ specifier: ^4.9.0
+ version: 4.9.0
date-fns:
specifier: ^4.1.0
version: 4.1.0
@@ -909,9 +915,6 @@ importers:
'@types/lodash.debounce':
specifier: ^4.0.9
version: 4.0.9
- '@types/lodash.isequal':
- specifier: ^4.5.8
- version: 4.5.8
'@types/lodash.merge':
specifier: ^4.6.6
version: 4.6.7
@@ -61478,7 +61481,7 @@ snapshots:
bull@4.10.4:
dependencies:
- cron-parser: 4.8.1
+ cron-parser: 4.9.0
debuglog: 1.0.1
get-port: 5.1.1
ioredis: 5.3.2
@@ -62685,10 +62688,11 @@ snapshots:
cron-parser@4.8.1:
dependencies:
luxon: 3.3.0
+ optional: true
cron-parser@4.9.0:
dependencies:
- luxon: 3.3.0
+ luxon: 3.4.4
cron@3.1.7:
dependencies: