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 ( + + ); +}; + +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 = ({ ( - + /> )} /> - + {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 ( + + + + + + + + + + {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 ( +
+ +
+ ); + } +); + +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 ( +
+ + +
+ ); +}; + +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) => ( + + ))} +
+
+ + +
+
+
+
+
+ ); +}; 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 ( + + ); +}; 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 ( +
+
+ Every + +
+ {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: