From 7ac6cedda2b1e8060bb8268416caaacdc81c3346 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 18 Dec 2024 15:55:06 +0530 Subject: [PATCH 01/10] feat(dashboard): Nv 4884 push mini preview (#7318) --- .../public/images/phones/iphone-push.svg | 1 + .../steps/configure-step-form.tsx | 3 +- .../push/configure-push-step-preview.tsx | 37 +++++ .../steps/push/push-editor-preview.tsx | 116 ++++++++++++++++ .../steps/push/push-preview.tsx | 129 ++++++++++++++++++ .../workflow-editor/steps/push/push-tabs.tsx | 6 +- 6 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/public/images/phones/iphone-push.svg create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/configure-push-step-preview.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/push-editor-preview.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/push-preview.tsx diff --git a/apps/dashboard/public/images/phones/iphone-push.svg b/apps/dashboard/public/images/phones/iphone-push.svg new file mode 100644 index 00000000000..491443825fe --- /dev/null +++ b/apps/dashboard/public/images/phones/iphone-push.svg @@ -0,0 +1 @@ + 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 be8ee2174aa..1ac0f911535 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 @@ -50,6 +50,7 @@ import { } from '@/utils/constants'; import { buildRoute, ROUTES } from '@/utils/routes'; import { buildDefaultValues, buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema'; +import { ConfigurePushStepPreview } from './push/configure-push-step-preview'; const STEP_TYPE_TO_INLINE_CONTROL_VALUES: Record React.JSX.Element | null> = { [StepTypeEnum.DELAY]: DelayControlValues, @@ -68,7 +69,7 @@ const STEP_TYPE_TO_PREVIEW: Record { + Sentry.captureException(error); + }, + }); + + const { step, isPending } = useWorkflow(); + + const { workflowSlug, stepSlug } = useParams<{ + workflowSlug: string; + stepSlug: string; + }>(); + + useEffect(() => { + if (!workflowSlug || !stepSlug || !step || isPending) return; + + previewStep({ + workflowSlug, + stepSlug, + previewData: { controlValues: step.controls.values, previewPayload: {} }, + }); + }, [workflowSlug, stepSlug, previewStep, step, isPending]); + + return ; +} diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor-preview.tsx new file mode 100644 index 00000000000..c9031dfc436 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor-preview.tsx @@ -0,0 +1,116 @@ +import { CSSProperties, useEffect, useRef, useState } from 'react'; +import { type StepDataDto, type WorkflowResponseDto } from '@novu/shared'; +import { Code2 } from '@/components/icons/code-2'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; +import { Button } from '@/components/primitives/button'; +import { Editor } from '@/components/primitives/editor'; +import { loadLanguage } from '@uiw/codemirror-extensions-langs'; +import { useEditorPreview } from '../use-editor-preview'; +import { PushPreview } from './push-preview'; +import { RiCellphoneFill } from 'react-icons/ri'; +import { PushTabsSection } from './push-tabs-section'; + +const getInitialAccordionValue = (value: string) => { + try { + return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; + } catch (e) { + return undefined; + } +}; + +type PushEditorPreviewProps = { + workflow: WorkflowResponseDto; + step: StepDataDto; + formValues: Record; +}; + +const extensions = [loadLanguage('json')?.extension ?? []]; + +export const PushEditorPreview = ({ workflow, step, formValues }: PushEditorPreviewProps) => { + const workflowSlug = workflow.workflowId; + const stepSlug = step.stepId; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug, + stepSlug, + controlValues: formValues, + }); + const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); + const [payloadError, setPayloadError] = useState(''); + const [height, setHeight] = useState(0); + const contentRef = useRef(null); + + useEffect(() => { + setAccordionValue(getInitialAccordionValue(editorValue)); + }, [editorValue]); + + useEffect(() => { + const timeout = setTimeout(() => { + if (contentRef.current) { + const rect = contentRef.current.getBoundingClientRect(); + setHeight(rect.height); + } + }, 0); + + return () => clearTimeout(timeout); + }, [editorValue]); + + return ( + +
+
+ + Push template editor +
+
+ +
+ + + This preview shows how your message will appear on mobile. Actual rendering may vary by device. + +
+
+ + + +
+ + Configure preview +
+
+ + + {payloadError &&

{payloadError}

} + +
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-preview.tsx new file mode 100644 index 00000000000..b2a09e7cb28 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-preview.tsx @@ -0,0 +1,129 @@ +import { HTMLAttributes } from 'react'; +import { HTMLMotionProps, motion } from 'motion/react'; +import { ChannelTypeEnum, GeneratePreviewResponseDto } from '@novu/shared'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { cn } from '@/utils/ui'; + +export function PushPreview({ + isPreviewPending, + previewData, +}: { + isPreviewPending: boolean; + previewData?: GeneratePreviewResponseDto; +}) { + if (isPreviewPending) { + return ( + + + + + + + + + ); + } + + if (previewData?.result.type !== ChannelTypeEnum.PUSH) { + return ( + + + + + + + + ); + } + + return ( + + + + + + + + + + + ); +} + +type PushSubjectPreviewProps = HTMLAttributes & { + subject?: string; + isPending: boolean; +}; +export const PushSubjectPreview = ({ subject, isPending, className, ...rest }: PushSubjectPreviewProps) => { + if (isPending) { + return ; + } + + return ( +
+
+ {subject} +
+ now +
+ ); +}; + +type PushBodyPreviewProps = HTMLAttributes & { + body?: string; + isPending: boolean; +}; +export const PushBodyPreview = ({ body, isPending, className, ...rest }: PushBodyPreviewProps) => { + if (isPending) { + return ( +
+ + +
+ ); + } + + return ( +
+ {body} +
+ ); +}; + +export const PushContentContainerPreview = ({ children, className, ...rest }: HTMLMotionProps<'div'>) => { + return ( + + {children} + + ); +}; + +export const PushBackgroundWithPhone = ({ children, className, ...rest }: HTMLAttributes) => { + return ( +
+ {children} +
+ ); +}; + +export const PushNotificationContainer = ({ children, className, ...rest }: HTMLAttributes) => { + return ( +
+ {children} +
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx index 44bde28b18b..ad9fb2a1e39 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx @@ -4,12 +4,14 @@ import { StepEditorProps } from '@/components/workflow-editor/steps/configure-st import { PushEditor } from '@/components/workflow-editor/steps/push/push-editor'; import { CustomStepControls } from '../controls/custom-step-controls'; import { TemplateTabs } from '../template-tabs'; +import { PushEditorPreview } from './push-editor-preview'; +import { useFormContext } from 'react-hook-form'; export const PushTabs = (props: StepEditorProps) => { const { workflow, step } = props; const { dataSchema, uiSchema } = step.controls; const [tabsValue, setTabsValue] = useState('editor'); - + const form = useFormContext(); const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; @@ -20,7 +22,7 @@ export const PushTabs = (props: StepEditorProps) => { ); - const previewContent = <>TODO; + const previewContent = ; return ( Date: Wed, 18 Dec 2024 17:22:55 +0530 Subject: [PATCH 02/10] feat(dashboard): update node styles (#7321) --- .../src/components/workflow-editor/base-node.tsx | 8 ++++---- apps/dashboard/src/components/workflow-editor/nodes.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/base-node.tsx b/apps/dashboard/src/components/workflow-editor/base-node.tsx index 430c2263222..5c0c0554bcd 100644 --- a/apps/dashboard/src/components/workflow-editor/base-node.tsx +++ b/apps/dashboard/src/components/workflow-editor/base-node.tsx @@ -66,7 +66,7 @@ export const NodeBody = ({ children }: { children: ReactNode }) => { {children} - + ); }; @@ -98,11 +98,11 @@ export const NODE_WIDTH = 300; export const NODE_HEIGHT = 86; const nodeVariants = cva( - `relative border-neutral-alpha-200 transition-colors aria-selected:border-primary bg-foreground-0 flex w-[300px] flex-col gap-1 border p-1 shadow-xs`, + `relative bg-neutral-alpha-200 transition-colors aria-selected:bg-gradient-to-tr aria-selected:to-warning/50 aria-selected:from-destructive/60 [&>span]:bg-foreground-0 flex w-[300px] flex-col p-px shadow-xs flex [&>span]:flex-1 [&>span]:rounded-[calc(var(--radius)-1px)] [&>span]:p-1 [&>span]:flex [&>span]:flex-col [&>span]:gap-1`, { variants: { variant: { - default: 'rounded-xl', + default: 'rounded-lg', sm: 'text-neutral-400 w-min rounded-lg', }, }, @@ -118,7 +118,7 @@ export const Node = (props: BaseNodeProps) => { const { children, variant, className, ...rest } = props; return (
- {children} + {children}
); }; diff --git a/apps/dashboard/src/components/workflow-editor/nodes.tsx b/apps/dashboard/src/components/workflow-editor/nodes.tsx index 32816bcd7c6..3e54b3a2ae8 100644 --- a/apps/dashboard/src/components/workflow-editor/nodes.tsx +++ b/apps/dashboard/src/components/workflow-editor/nodes.tsx @@ -37,8 +37,8 @@ export const TriggerNode = ({ data }: NodeProps - -
+ +
TRIGGER
From 59a32273e7e9450a3d9fc2c114b394cf52744f6c Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 18 Dec 2024 18:09:57 +0530 Subject: [PATCH 03/10] feat(dashboard): Nv 5066 dashboard add inline tips primitive component (#7326) --- .../steps/controls/custom-step-controls.tsx | 11 +++++------ .../steps/push/push-editor-preview.tsx | 11 +++++------ .../workflow-editor/steps/sms/sms-editor-preview.tsx | 7 ++++++- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx index d54f54d74c9..b3430817112 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx @@ -16,6 +16,7 @@ import { buildDefaultValuesOfDataSchema } from '@/utils/schema'; import { SidebarContent } from '@/components/side-navigation/sidebar'; import { ConfirmationModal } from '@/components/confirmation-modal'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; +import { InlineToast } from '@/components/primitives/inline-toast'; type CustomStepControlsProps = { dataSchema: ControlsMetadata['dataSchema']; @@ -167,12 +168,10 @@ const OverrideMessage = ({ isOverridden }: { isOverridden: boolean }) => { return ( {isOverridden ? ( -
- - - Custom controls defined in the code have been overridden. Disable overrides to restore the original. - -
+ ) : ( { try { @@ -63,12 +64,10 @@ export const PushEditorPreview = ({ workflow, step, formValues }: PushEditorPrev
-
- - - This preview shows how your message will appear on mobile. Actual rendering may vary by device. - -
+
diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx index 94d2b72faae..5026a7e7a0d 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx @@ -10,6 +10,7 @@ import { SmsPreview } from '@/components/workflow-editor/steps/sms/sms-preview'; import { SmsTabsSection } from '@/components/workflow-editor/steps/sms/sms-tabs-section'; import { loadLanguage } from '@uiw/codemirror-extensions-langs'; import { useEditorPreview } from '../use-editor-preview'; +import { InlineToast } from '@/components/primitives/inline-toast'; const getInitialAccordionValue = (value: string) => { try { @@ -62,8 +63,12 @@ export const SmsEditorPreview = ({ workflow, step, formValues }: SmsEditorPrevie SMS template editor -
+
+
From a7b4d44fea608bc494c685b01d6a66f3ea380aa5 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Wed, 18 Dec 2024 17:02:05 +0100 Subject: [PATCH 04/10] fix(dashboard): tags client side validation (#7325) --- .../src/components/create-workflow-button.tsx | 10 +++++++++- .../workflow-editor/configure-workflow-form.tsx | 1 + apps/dashboard/src/hooks/use-form-autosave.ts | 13 +++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/components/create-workflow-button.tsx b/apps/dashboard/src/components/create-workflow-button.tsx index 757ce353f37..1adebbae0d4 100644 --- a/apps/dashboard/src/components/create-workflow-button.tsx +++ b/apps/dashboard/src/components/create-workflow-button.tsx @@ -153,7 +153,15 @@ export const CreateWorkflowButton = (props: CreateWorkflowButtonProps) => { Add tags
- tag.name)} {...field} value={field.value ?? []} /> + tag.name)} + {...field} + value={field.value ?? []} + onChange={(tags) => { + field.onChange(tags); + form.setValue('tags', tags, { shouldValidate: true }); + }} + /> diff --git a/apps/dashboard/src/components/workflow-editor/configure-workflow-form.tsx b/apps/dashboard/src/components/workflow-editor/configure-workflow-form.tsx index ffde3661e53..b5d59881eda 100644 --- a/apps/dashboard/src/components/workflow-editor/configure-workflow-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/configure-workflow-form.tsx @@ -136,6 +136,7 @@ export const ConfigureWorkflowForm = (props: ConfigureWorkflowFormProps) => { form, isReadOnly, save: update, + shouldClientValidate: true, }); const onPauseWorkflow = (active: boolean) => { diff --git a/apps/dashboard/src/hooks/use-form-autosave.ts b/apps/dashboard/src/hooks/use-form-autosave.ts index d261b5159b1..957e485cb76 100644 --- a/apps/dashboard/src/hooks/use-form-autosave.ts +++ b/apps/dashboard/src/hooks/use-form-autosave.ts @@ -1,8 +1,8 @@ // useFormAutosave.ts -import { useCallback, useEffect } from 'react'; -import { UseFormReturn, FieldValues } from 'react-hook-form'; import { useDataRef } from '@/hooks/use-data-ref'; import { useDebounce } from '@/hooks/use-debounce'; +import { useCallback, useEffect } from 'react'; +import { FieldValues, UseFormReturn } from 'react-hook-form'; const TEN_SECONDS = 10 * 1000; @@ -10,11 +10,13 @@ export function useFormAutosave, T extends Fie previousData, form: propsForm, isReadOnly, + shouldClientValidate = false, save, }: { previousData: U; form: UseFormReturn; isReadOnly?: boolean; + shouldClientValidate?: boolean; save: (data: U) => void; }) { const formRef = useDataRef(propsForm); @@ -33,7 +35,10 @@ export function useFormAutosave, T extends Fie return; } // manually trigger the validation of the form - await form.trigger(); + const isValid = await form.trigger(); + if (!isValid && shouldClientValidate) { + return; + } const values = { ...previousData, ...data }; // reset the dirty fields right away because on slow networks the patch request might take a while @@ -42,7 +47,7 @@ export function useFormAutosave, T extends Fie form.reset(values, { keepErrors: true }); save(values); }, - [formRef, previousData, isReadOnly, save] + [formRef, previousData, isReadOnly, save, shouldClientValidate] ); const debouncedOnSave = useDebounce(onSave, TEN_SECONDS); From dfe2cdce7e033b6f951f44d16239db3b318b6c50 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 18 Dec 2024 16:17:52 +0000 Subject: [PATCH 05/10] feat(dashboard): design system tokens (#7300) --- apps/dashboard/src/index.css | 289 +++++++++++++++++- apps/dashboard/tailwind.config.js | 480 +++++++++++++++++++++++++++++- 2 files changed, 739 insertions(+), 30 deletions(-) diff --git a/apps/dashboard/src/index.css b/apps/dashboard/src/index.css index c2eef11e4a2..a19dc9a225e 100644 --- a/apps/dashboard/src/index.css +++ b/apps/dashboard/src/index.css @@ -3,6 +3,23 @@ @tailwind utilities; @layer base { :root { + --gray-0: 210 10% 100%; + --gray-50: 0 0% 98%; + --gray-100: 210 30% 96%; + --gray-200: 220 18% 90%; + --gray-300: 219 15% 82%; + --gray-400: 220 11% 64%; + --gray-500: 221 8% 48%; + --gray-600: 222 11% 36%; + --gray-700: 221 16% 20%; + --gray-800: 227 17% 16%; + --gray-900: 226 21% 12%; + --gray-950: 222 32% 8%; + + --gray-alpha-24: 220 11% 64% / 24%; + --gray-alpha-16: 220 11% 64% / 16%; + --gray-alpha-10: 220 11% 64% / 10%; + --background: 0 0% 100%; --foreground-0: 210 10% 100%; @@ -18,7 +35,27 @@ --foreground-900: 226 21% 12%; --foreground-950: 222 32% 8%; - --primary: 346 73% 50%; + --novu-800: 346 72% 42%; + --novu-700: 346 70% 50%; + --novu-500: 346 73% 50%; + --novu-400: 346 73% 50% / 0.8; + --novu-300: 346 73% 50% / 0.6; + --novu-200: 346 73% 50% / 0.4; + --novu-100: 346 73% 50% / 0.2; + --novu-50: 346 73% 50% / 0.1; + + --novu-alpha-24: 346 73% 50% / 24%; + --novu-alpha-16: 346 73% 50% / 16%; + --novu-alpha-10: 346 73% 50% / 10%; + + --primary: var(--novu-500); + --primary-dark: var(--novu-800); + --primary-darker: var(--novu-700); + --primary-base: var(--novu-500); + --primary-alpha-24: var(--novu-alpha-24); + --primary-alpha-16: var(--novu-alpha-16); + --primary-alpha-10: var(--novu-alpha-10); + --primary-foreground: 0 0% 100%; --neutral-0: 210 10% 100%; @@ -37,6 +74,10 @@ --neutral-foreground: 0 0% 100%; /* Neutral scale in alpha that looks the same on white background */ + --neutral-alpha-24: var(--neutral-alpha-24); + --neutral-alpha-16: var(--neutral-alpha-16); + --neutral-alpha-10: var(--neutral-alpha-10); + --neutral-alpha-50: 0 0% 69% / 0.05; --neutral-alpha-100: 210 30% 61% / 0.1; --neutral-alpha-200: 220 18% 50% / 0.2; @@ -49,33 +90,253 @@ --neutral-alpha-900: 231 100% 3% / 0.9; --neutral-alpha-950: 219 88% 3% / 0.95; - --accent: 0 0% 96.1%; + --blue-950: 228 70% 24%; + --blue-500: 227.94 100% 60%; + --blue-400: 222.12 100% 70.39%; + --blue-300: 219.81 100% 79.61%; + --blue-200: 220 100% 87.65%; + --blue-100: 221.43 100% 91.76%; + --blue-50: 222 100% 96.08%; - --destructive: 355 96% 60%; - --destructive-foreground: 0 0% 100%; + --blue-alpha-24: 228 100% 64% / 24%; + --blue-alpha-16: 228 100% 64% / 16%; + --blue-alpha-10: 228 100% 64% / 10%; + + --orange-950: 20 70% 24%; + --orange-900: 20 71% 32%; + --orange-800: 20 70% 40%; + --orange-700: 20 69% 48%; + --orange-600: 20 80% 56%; + --orange-500: 20 100% 64%; + --orange-300: 20 100% 64% / 60%; + --orange-200: 20 100% 64% / 40%; + --orange-100: 20 100% 64% / 20%; + --orange-50: 20 100% 64% / 10%; + + --orange-alpha-24: 20 100% 64% / 24%; + --orange-alpha-16: 20 100% 64% / 16%; + --orange-alpha-10: 20 100% 64% / 10%; + + --red-950: 355 70% 24%; + --red-900: 355 71% 32%; + --red-800: 355 70% 40%; + --red-700: 355 70% 48%; + --red-600: 355 80% 56%; + --red-500: 355 96% 60%; + --red-400: 355 96% 60% / 80%; + --red-300: 355 96% 60% / 60%; + --red-200: 355 96% 60% / 40%; + --red-100: 355 96% 60% / 20%; + --red-50: 355 96% 60% / 10%; + + --red-alpha-24: 355 96% 60% / 24%; + --red-alpha-16: 355 96% 60% / 16%; + --red-alpha-10: 355 96% 60% / 10%; + + --green-950: 148 73% 16%; + --green-900: 148 64% 24%; + --green-800: 148 64% 28%; + --green-700: 148 72% 32%; + --green-600: 148 72% 40%; + --green-500: 148 72% 44%; + --green-400: 148 72% 44% / 80%; + --green-300: 148 72% 44% / 60%; + --green-200: 148 72% 44% / 40%; + --green-100: 148 72% 44% / 20%; + --green-50: 148 72% 44% / 10%; + + --green-alpha-24: 148 72% 44% / 24%; + --green-alpha-16: 148 72% 44% / 16%; + --green-alpha-10: 148 72% 44% / 10%; + + --yellow-950: 42 61% 24%; + --yellow-900: 42 64% 32%; + --yellow-800: 42 64% 40%; + --yellow-700: 42 64% 48%; + --yellow-600: 42 80% 50%; + --yellow-500: 42 92% 54%; + --yellow-400: 42 92% 54% / 80%; + --yellow-300: 42 92% 54% / 60%; + --yellow-200: 42 92% 54% / 40%; + --yellow-100: 42 92% 54% / 20%; + --yellow-50: 42 92% 54% / 10%; + + --yellow-alpha-24: 42 92% 64% / 24%; + --yellow-alpha-16: 42 92% 64% / 16%; + --yellow-alpha-10: 42 92% 64% / 10%; + + --purple-950: 258 64% 28%; + --purple-900: 258 64% 32%; + --purple-800: 258 64% 40%; + --purple-700: 258 64% 48%; + --purple-600: 256 72% 56%; + --purple-500: 256 88% 64%; + --purple-400: 256 88% 64% / 80%; + --purple-300: 256 88% 64% / 60%; + --purple-200: 256 88% 64% / 40%; + --purple-100: 256 88% 64% / 20%; + --purple-50: 256 88% 64% / 10%; + + --purple-alpha-24: 256 84% 62% / 24%; + --purple-alpha-16: 256 84% 62% / 16%; + --purple-alpha-10: 256 84% 62% / 10%; + + --sky-950: 200 70% 24%; + --sky-900: 200 71% 32%; + --sky-800: 200 70% 40%; + --sky-700: 200 70% 48%; + --sky-600: 200 80% 56%; + --sky-500: 200 100% 64%; + --sky-400: 200 100% 64% / 80%; + --sky-300: 200 100% 64% / 60%; + --sky-200: 200 100% 64% / 40%; + --sky-100: 200 100% 64% / 20%; + --sky-50: 200 100% 64% / 10%; + + --sky-alpha-24: 200 100% 64% / 24%; + --sky-alpha-16: 200 100% 64% / 16%; + --sky-alpha-10: 200 100% 64% / 10%; - --success: 148 72% 44%; + --pink-950: 330 70% 24%; + --pink-900: 330 71% 32%; + --pink-800: 330 70% 40%; + --pink-700: 330 70% 48%; + --pink-600: 330 80% 56%; + --pink-500: 330 96% 64%; + --pink-400: 330 96% 64% / 80%; + --pink-300: 330 96% 64% / 60%; + --pink-200: 330 96% 64% / 40%; + --pink-100: 330 96% 64% / 20%; + --pink-50: 330 96% 64% / 10%; - --warning: 20 100% 64%; + --pink-alpha-24: 330 96% 64% / 24%; + --pink-alpha-16: 330 96% 64% / 16%; + --pink-alpha-10: 330 96% 64% / 10%; - --feature: 256 88% 64%; + --teal-950: 172 73% 16%; + --teal-900: 172 64% 24%; + --teal-800: 172 64% 28%; + --teal-700: 172 72% 32%; + --teal-600: 172 72% 40%; + --teal-500: 172 72% 48%; + --teal-400: 172 72% 48% / 80%; + --teal-300: 172 72% 48% / 60%; + --teal-200: 172 72% 48% / 40%; + --teal-100: 172 72% 48% / 20%; + --teal-50: 172 72% 48% / 10%; - --information: 228 100% 60%; + --teal-alpha-24: 172 72% 48% / 24%; + --teal-alpha-16: 172 72% 48% / 16%; + --teal-alpha-10: 172 72% 48% / 10%; - --highlighted: 330 96% 64%; + --white-alpha-24: 0 0% 100% / 24%; + --white-alpha-16: 0 0% 100% / 16%; + --white-alpha-10: 0 0% 100% / 10%; - --stable: 172 72% 48%; + --black-alpha-24: 222 32% 8% / 24%; + --black-alpha-16: 222 32% 8% / 16%; + --black-alpha-10: 222 32% 8% / 10%; - --alert: 42 92% 54%; + /* Misc */ - --verified: 200 100% 64%; + --static-black: var(--neutral-950); + --static-white: var(--neutral-0); - --input: 220 18% 90%; - --ring: 346 73% 50%; + --bg-strong: var(--neutral-950); + --bg-surface: var(--neutral-800); + --bg-sub: var(--neutral-300); + --bg-soft: var(--neutral-200); + --bg-weak: var(--neutral-50); + --bg-white: var(--neutral-0); + + --text-strong: var(--neutral-950); + --text-sub: var(--neutral-600); + --text-soft: var(--neutral-400); + --text-disabled: var(--neutral-300); + --text-white: var(--neutral-0); + + --icon-strong: var(--neutral-950); + --icon-sub: var(--neutral-600); + --icon-soft: var(--neutral-400); + --icon-disabled: var(--neutral-300); + --icon-white: var(--neutral-0); + + --stroke-strong: var(--neutral-950); + --stroke-sub: var(--neutral-300); + --stroke-soft: var(--neutral-200); + --stroke-white: var(--neutral-0); + + --faded-dark: var(--neutral-800); + --faded-base: var(--neutral-500); + --faded-light: var(--neutral-200); + --faded-lighter: var(--neutral-100); + + --information: var(--blue-500); + --information-dark: var(--blue-950); + --information-base: var(--blue-500); + --information-light: var(--blue-200); + --information-lighter: var(--blue-50); + + --warning: var(--orange-500); + --warning-dark: var(--orange-950); + --warning-base: var(--orange-500); + --warning-light: var(--orange-200); + --warning-lighter: var(--orange-50); + + --error-dark: var(--red-950); + --error-base: var(--red-500); + --error-light: var(--red-200); + --error-lighter: var(--red-50); + + --success: var(--green-500); + --success-dark: var(--green-950); + --success-base: var(--green-500); + --success-light: var(--green-200); + --success-lighter: var(--green-50); + + --away-dark: var(--yellow-950); + --away-base: var(--yellow-500); + --away-light: var(--yellow-200); + --away-lighter: var(--yellow-50); + + --feature: var(--purple-500); + --feature-dark: var(--purple-950); + --feature-base: var(--purple-500); + --feature-light: var(--purple-200); + --feature-lighter: var(--purple-50); + + --verified: var(--sky-500); + --verified-dark: var(--sky-950); + --verified-base: var(--sky-500); + --verified-light: var(--sky-200); + --verified-lighter: var(--sky-50); + + --highlighted: var(--pink-500); + --highlighted-dark: var(--pink-950); + --highlighted-base: var(--pink-500); + --highlighted-light: var(--pink-200); + --highlighted-lighter: var(--pink-50); + + --stable: var(--teal-500); + --stable-dark: var(--teal-950); + --stable-base: var(--teal-500); + --stable-light: var(--teal-200); + --stable-lighter: var(--teal-50); + + --accent: 0 0% 96.1%; + + --destructive: var(--red-500); + --destructive-foreground: 0 0% 100%; + + --alert: var(--yellow-500); + + --input: var(--neutral-200); + --ring: var(--novu-500); --radius: 0.5rem; } } + @layer base { * { @apply border-neutral-alpha-200 focus-visible:outline-primary; diff --git a/apps/dashboard/tailwind.config.js b/apps/dashboard/tailwind.config.js index 1809d3f63c7..94ef7ef32e1 100644 --- a/apps/dashboard/tailwind.config.js +++ b/apps/dashboard/tailwind.config.js @@ -1,20 +1,375 @@ /** @type {import('tailwindcss').Config} */ + +export const texts = { + 'title-h1': [ + '3.5rem', + { + lineHeight: '4rem', + letterSpacing: '-0.035em', + fontWeight: '500', + }, + ], + 'title-h2': [ + '3rem', + { + lineHeight: '3.5rem', + letterSpacing: '-0.035em', + fontWeight: '500', + }, + ], + 'title-h3': [ + '2.5rem', + { + lineHeight: '3rem', + letterSpacing: '-0.035em', + fontWeight: '500', + }, + ], + 'title-h4': [ + '2rem', + { + lineHeight: '2.5rem', + letterSpacing: '-0.01em', + fontWeight: '500', + }, + ], + 'title-h5': [ + '1.5rem', + { + lineHeight: '2rem', + letterSpacing: '0em', + fontWeight: '500', + }, + ], + 'title-h6': [ + '1.25rem', + { + lineHeight: '1.75rem', + letterSpacing: '0em', + fontWeight: '500', + }, + ], + 'label-xl': [ + '1.5rem', + { + lineHeight: '2rem', + letterSpacing: '-0.0225em', + fontWeight: '500', + }, + ], + 'label-lg': [ + '1.125rem', + { + lineHeight: '1.5rem', + letterSpacing: '-0.0225em', + fontWeight: '500', + }, + ], + 'label-md': [ + '1rem', + { + lineHeight: '1.5rem', + letterSpacing: '-0.011em', + fontWeight: '500', + }, + ], + 'label-sm': [ + '.875rem', + { + lineHeight: '1.25rem', + letterSpacing: '-0.00525em', + fontWeight: '500', + }, + ], + 'label-xs': [ + '.75rem', + { + lineHeight: '1rem', + letterSpacing: '0em', + fontWeight: '500', + }, + ], + 'paragraph-xl': [ + '1.5rem', + { + lineHeight: '2rem', + letterSpacing: '-0.0225em', + fontWeight: '400', + }, + ], + 'paragraph-lg': [ + '1.125rem', + { + lineHeight: '1.5rem', + letterSpacing: '-0.016875em', + fontWeight: '400', + }, + ], + 'paragraph-md': [ + '1rem', + { + lineHeight: '1.5rem', + letterSpacing: '-0.011em', + fontWeight: '400', + }, + ], + 'paragraph-sm': [ + '.875rem', + { + lineHeight: '1.25rem', + letterSpacing: '-0.00525em', + fontWeight: '400', + }, + ], + 'paragraph-xs': [ + '.75rem', + { + lineHeight: '1rem', + letterSpacing: '0em', + fontWeight: '400', + }, + ], + 'paragraph-2xs': [ + '0.625rem', + { + lineHeight: '0.875rem', + letterSpacing: '0em', + fontWeight: '400', + }, + ], + 'subheading-md': [ + '1rem', + { + lineHeight: '1.5rem', + letterSpacing: '0.06em', + fontWeight: '500', + }, + ], + 'subheading-sm': [ + '.875rem', + { + lineHeight: '1.25rem', + letterSpacing: '0.05em', + fontWeight: '500', + }, + ], + 'subheading-xs': [ + '.75rem', + { + lineHeight: '1rem', + letterSpacing: '0.03em', + fontWeight: '500', + }, + ], + 'subheading-2xs': [ + '.6875rem', + { + lineHeight: '.75rem', + letterSpacing: '0.01375em', + fontWeight: '500', + }, + ], + 'code-sm': [ + '0.75rem', + { + lineHeight: '1rem', + letterSpacing: '-0.015em', + fontWeight: '500', + }, + ], +}; + +export const shadows = { + xs: '0px 1px 2px 0px rgba(10, 13, 20, 0.03)', + sm: '0px 1px 2px 0px #1018280F,0px 1px 3px 0px #1018281A', + md: '0px 16px 32px -12px rgba(14, 18, 27, 0.10)', + DEFAULT: '0px 16px 32px -12px #0E121B1A', +}; + export default { darkMode: ['class'], content: ['./index.html', './src/**/*.{ts,tsx}'], theme: { boxShadow: { - xs: '0px 1px 2px 0px rgba(10, 13, 20, 0.03)', - sm: '0px 1px 2px 0px #1018280F,0px 1px 3px 0px #1018281A', - md: '0px 16px 32px -12px rgba(14, 18, 27, 0.10)', - DEFAULT: '0px 16px 32px -12px #0E121B1A', + ...shadows, }, colors: { - black: 'black', - white: 'white', + white: { + DEFAULT: '#fff', + 'alpha-24': 'hsl(var(--white-alpha-24))', + 'alpha-16': 'hsl(var(--white-alpha-16))', + 'alpha-10': 'hsl(var(--white-alpha-10))', + }, + black: { + DEFAULT: '#000', + 'alpha-24': 'hsl(var(--black-alpha-24))', + 'alpha-16': 'hsl(var(--black-alpha-16))', + 'alpha-10': 'hsl(var(--black-alpha-10))', + }, transparent: 'transparent', background: 'hsl(var(--background))', + gray: { + 0: 'hsl(var(--gray-0))', + 50: 'hsl(var(--gray-50))', + 100: 'hsl(var(--gray-100))', + 200: 'hsl(var(--gray-200))', + 300: 'hsl(var(--gray-300))', + 400: 'hsl(var(--gray-400))', + 500: 'hsl(var(--gray-500))', + 600: 'hsl(var(--gray-600))', + 700: 'hsl(var(--gray-700))', + 800: 'hsl(var(--gray-800))', + 900: 'hsl(var(--gray-900))', + 950: 'hsl(var(--gray-950))', + 'alpha-24': 'hsl(var(--gray-alpha-24))', + 'alpha-16': 'hsl(var(--gray-alpha-16))', + 'alpha-10': 'hsl(var(--gray-alpha-10))', + }, + blue: { + 50: 'hsl(var(--blue-50))', + 100: 'hsl(var(--blue-100))', + 200: 'hsl(var(--blue-200))', + 300: 'hsl(var(--blue-300))', + 400: 'hsl(var(--blue-400))', + 500: 'hsl(var(--blue-500))', + 600: 'hsl(var(--blue-600))', + 700: 'hsl(var(--blue-700))', + 800: 'hsl(var(--blue-800))', + 900: 'hsl(var(--blue-900))', + 950: 'hsl(var(--blue-950))', + 'alpha-24': 'hsl(var(--blue-alpha-24))', + 'alpha-16': 'hsl(var(--blue-alpha-16))', + 'alpha-10': 'hsl(var(--blue-alpha-10))', + }, + orange: { + 50: 'hsl(var(--orange-50))', + 100: 'hsl(var(--orange-100))', + 200: 'hsl(var(--orange-200))', + 300: 'hsl(var(--orange-300))', + 400: 'hsl(var(--orange-400))', + 500: 'hsl(var(--orange-500))', + 600: 'hsl(var(--orange-600))', + 700: 'hsl(var(--orange-700))', + 800: 'hsl(var(--orange-800))', + 900: 'hsl(var(--orange-900))', + 950: 'hsl(var(--orange-950))', + 'alpha-24': 'hsl(var(--orange-alpha-24))', + 'alpha-16': 'hsl(var(--orange-alpha-16))', + 'alpha-10': 'hsl(var(--orange-alpha-10))', + }, + red: { + 50: 'hsl(var(--red-50))', + 100: 'hsl(var(--red-100))', + 200: 'hsl(var(--red-200))', + 300: 'hsl(var(--red-300))', + 400: 'hsl(var(--red-400))', + 500: 'hsl(var(--red-500))', + 600: 'hsl(var(--red-600))', + 700: 'hsl(var(--red-700))', + 800: 'hsl(var(--red-800))', + 900: 'hsl(var(--red-900))', + 950: 'hsl(var(--red-950))', + 'alpha-24': 'hsl(var(--red-alpha-24))', + 'alpha-16': 'hsl(var(--red-alpha-16))', + 'alpha-10': 'hsl(var(--red-alpha-10))', + }, + green: { + 50: 'hsl(var(--green-50))', + 100: 'hsl(var(--green-100))', + 200: 'hsl(var(--green-200))', + 300: 'hsl(var(--green-300))', + 400: 'hsl(var(--green-400))', + 500: 'hsl(var(--green-500))', + 600: 'hsl(var(--green-600))', + 700: 'hsl(var(--green-700))', + 800: 'hsl(var(--green-800))', + 900: 'hsl(var(--green-900))', + 950: 'hsl(var(--green-950))', + 'alpha-24': 'hsl(var(--green-alpha-24))', + 'alpha-16': 'hsl(var(--green-alpha-16))', + 'alpha-10': 'hsl(var(--green-alpha-10))', + }, + yellow: { + 50: 'hsl(var(--yellow-50))', + 100: 'hsl(var(--yellow-100))', + 200: 'hsl(var(--yellow-200))', + 300: 'hsl(var(--yellow-300))', + 400: 'hsl(var(--yellow-400))', + 500: 'hsl(var(--yellow-500))', + 600: 'hsl(var(--yellow-600))', + 700: 'hsl(var(--yellow-700))', + 800: 'hsl(var(--yellow-800))', + 900: 'hsl(var(--yellow-900))', + 950: 'hsl(var(--yellow-950))', + 'alpha-24': 'hsl(var(--yellow-alpha-24))', + 'alpha-16': 'hsl(var(--yellow-alpha-16))', + 'alpha-10': 'hsl(var(--yellow-alpha-10))', + }, + purple: { + 50: 'hsl(var(--purple-50))', + 100: 'hsl(var(--purple-100))', + 200: 'hsl(var(--purple-200))', + 300: 'hsl(var(--purple-300))', + 400: 'hsl(var(--purple-400))', + 500: 'hsl(var(--purple-500))', + 600: 'hsl(var(--purple-600))', + 700: 'hsl(var(--purple-700))', + 800: 'hsl(var(--purple-800))', + 900: 'hsl(var(--purple-900))', + 950: 'hsl(var(--purple-950))', + 'alpha-24': 'hsl(var(--purple-alpha-24))', + 'alpha-16': 'hsl(var(--purple-alpha-16))', + 'alpha-10': 'hsl(var(--purple-alpha-10))', + }, + sky: { + 50: 'hsl(var(--sky-50))', + 100: 'hsl(var(--sky-100))', + 200: 'hsl(var(--sky-200))', + 300: 'hsl(var(--sky-300))', + 400: 'hsl(var(--sky-400))', + 500: 'hsl(var(--sky-500))', + 600: 'hsl(var(--sky-600))', + 700: 'hsl(var(--sky-700))', + 800: 'hsl(var(--sky-800))', + 900: 'hsl(var(--sky-900))', + 950: 'hsl(var(--sky-950))', + 'alpha-24': 'hsl(var(--sky-alpha-24))', + 'alpha-16': 'hsl(var(--sky-alpha-16))', + 'alpha-10': 'hsl(var(--sky-alpha-10))', + }, + pink: { + 50: 'hsl(var(--pink-50))', + 100: 'hsl(var(--pink-100))', + 200: 'hsl(var(--pink-200))', + 300: 'hsl(var(--pink-300))', + 400: 'hsl(var(--pink-400))', + 500: 'hsl(var(--pink-500))', + 600: 'hsl(var(--pink-600))', + 700: 'hsl(var(--pink-700))', + 800: 'hsl(var(--pink-800))', + 900: 'hsl(var(--pink-900))', + 950: 'hsl(var(--pink-950))', + 'alpha-24': 'hsl(var(--pink-alpha-24))', + 'alpha-16': 'hsl(var(--pink-alpha-16))', + 'alpha-10': 'hsl(var(--pink-alpha-10))', + }, + teal: { + 50: 'hsl(var(--teal-50))', + 100: 'hsl(var(--teal-100))', + 200: 'hsl(var(--teal-200))', + 300: 'hsl(var(--teal-300))', + 400: 'hsl(var(--teal-400))', + 500: 'hsl(var(--teal-500))', + 600: 'hsl(var(--teal-600))', + 700: 'hsl(var(--teal-700))', + 800: 'hsl(var(--teal-800))', + 900: 'hsl(var(--teal-900))', + 950: 'hsl(var(--teal-950))', + 'alpha-24': 'hsl(var(--teal-alpha-24))', + 'alpha-16': 'hsl(var(--teal-alpha-16))', + 'alpha-10': 'hsl(var(--teal-alpha-10))', + }, foreground: { 0: 'hsl(var(--foreground-0))', 50: 'hsl(var(--foreground-50))', @@ -44,6 +399,9 @@ export default { 900: 'hsl(var(--neutral-900))', 950: 'hsl(var(--neutral-950))', 1000: 'hsl(var(--neutral-1000))', + 'alpha-24': 'hsl(var(--neutral-alpha-24))', + 'alpha-16': 'hsl(var(--neutral-alpha-16))', + 'alpha-10': 'hsl(var(--neutral-alpha-10))', foreground: 'hsl(var(--neutral-foreground))', }, 'neutral-alpha': { @@ -62,43 +420,120 @@ export default { }, primary: { DEFAULT: 'hsl(var(--primary))', + dark: 'hsl(var(--primary-dark))', + darker: 'hsl(var(--primary-darker))', + base: 'hsl(var(--primary-base))', + 'alpha-24': 'hsl(var(--primary-alpha-24))', + 'alpha-16': 'hsl(var(--primary-alpha-16))', + 'alpha-10': 'hsl(var(--primary-alpha-10))', foreground: 'hsl(var(--primary-foreground))', }, + bg: { + strong: 'hsl(var(--bg-strong))', + surface: 'hsl(var(--bg-surface))', + sub: 'hsl(var(--bg-sub))', + soft: 'hsl(var(--bg-soft))', + weak: 'hsl(var(--bg-weak))', + white: 'hsl(var(--bg-white))', + }, + stroke: { + strong: 'hsl(var(--stroke-strong))', + sub: 'hsl(var(--stroke-sub))', + soft: 'hsl(var(--stroke-soft))', + white: 'hsl(var(--stroke-white))', + }, + faded: { + dark: 'hsl(var(--faded-dark))', + base: 'hsl(var(--faded-base))', + light: 'hsl(var(--faded-light))', + lighter: 'hsl(var(--faded-lighter))', + }, + information: { + DEFAULT: 'hsl(var(--information))', + dark: 'hsl(var(--information-dark))', + base: 'hsl(var(--information-base))', + light: 'hsl(var(--information-light))', + lighter: 'hsl(var(--information-lighter))', + }, + static: { + black: 'hsl(var(--static-black))', + white: 'hsl(var(--static-white))', + }, accent: { - DEFAULT: 'hsl(var(--accent))', + DEFAULT: 'hsl(var(--accent))', // DEPRECATED }, destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', + DEFAULT: 'hsl(var(--destructive))', // DEPRECATED + foreground: 'hsl(var(--destructive-foreground))', // DEPRECATED }, success: { DEFAULT: 'hsl(var(--success))', + dark: 'hsl(var(--success-dark))', + base: 'hsl(var(--success-base))', + light: 'hsl(var(--success-light))', + lighter: 'hsl(var(--success-lighter))', }, warning: { DEFAULT: 'hsl(var(--warning))', + dark: 'hsl(var(--warning-dark))', + base: 'hsl(var(--warning-base))', + light: 'hsl(var(--warning-light))', + lighter: 'hsl(var(--warning-lighter))', + }, + away: { + dark: 'hsl(var(--away-dark))', + base: 'hsl(var(--away-base))', + light: 'hsl(var(--away-light))', + lighter: 'hsl(var(--away-lighter))', + }, + error: { + DEFAULT: 'hsl(var(--error))', + dark: 'hsl(var(--error-dark))', + base: 'hsl(var(--error-base))', + light: 'hsl(var(--error-light))', + lighter: 'hsl(var(--error-lighter))', }, feature: { DEFAULT: 'hsl(var(--feature))', + dark: 'hsl(var(--feature-dark))', + base: 'hsl(var(--feature-base))', + light: 'hsl(var(--feature-light))', + lighter: 'hsl(var(--feature-lighter))', }, - information: { - DEFAULT: 'hsl(var(--information))', - }, + highlighted: { DEFAULT: 'hsl(var(--highlighted))', + dark: 'hsl(var(--highlighted-dark))', + base: 'hsl(var(--highlighted-base))', + light: 'hsl(var(--highlighted-light))', + lighter: 'hsl(var(--highlighted-lighter))', }, stable: { DEFAULT: 'hsl(var(--stable))', + dark: 'hsl(var(--stable-dark))', + base: 'hsl(var(--stable-base))', + light: 'hsl(var(--stable-light))', + lighter: 'hsl(var(--stable-lighter))', }, verified: { DEFAULT: 'hsl(var(--verified))', + dark: 'hsl(var(--verified-dark))', + base: 'hsl(var(--verified-base))', + light: 'hsl(var(--verified-light))', + lighter: 'hsl(var(--verified-lighter))', }, alert: { - DEFAULT: 'hsl(var(--alert))', + DEFAULT: 'hsl(var(--alert))', // DEPRECATED + }, + overlay: { + DEFAULT: 'hsl(var(--overlay))', }, input: 'hsl(var(--input))', ring: 'hsl(var(--ring))', + current: 'currentColor', }, fontSize: { + // DEPRECATED '2xs': ['0.625rem', '0.875rem'], // 10px font size, 14px line height xs: ['0.75rem', '1rem'], // 12px font size, 16px line height sm: ['0.875rem', '1.25rem'], // 14px font size, 20px line height @@ -113,6 +548,10 @@ export default { '7xl': ['4.5rem', '1'], // 72px font size, 1 line height '8xl': ['6rem', '1'], // 96px font size, 1 line height '9xl': ['8rem', '1'], // 128px font size, 1 line height + // END DEPRECATED + + inherit: 'inherit', + ...texts, }, extend: { fontFamily: { @@ -122,9 +561,18 @@ export default { 2.5: 0.025, }, borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', + 4: '.25rem', + 6: '.375rem', + 8: '.5rem', + 10: '.625rem', + 12: '.75rem', + 16: '1rem', + 20: '1.25rem', + 24: '1.5rem', + full: '999px', + lg: 'var(--radius)', // DEPRECATED + md: 'calc(var(--radius) - 2px)', // DEPRECATED + sm: 'calc(var(--radius) - 4px)', // DEPRECATED }, keyframes: { 'pulse-shadow': { From 72c992ce87bb275d8777db6e5cf554ccc8fdc95a Mon Sep 17 00:00:00 2001 From: George Desipris <73396808+desiprisg@users.noreply.github.com> Date: Wed, 18 Dec 2024 19:07:48 +0200 Subject: [PATCH 06/10] fix(api,dashboard): Correct variable generation and parsing (#7324) --- .../build-payload-schema.usecase.ts | 12 ++++++++++-- .../build-available-variable-schema.usecase.ts | 14 +++++++++++++- .../utils/parseStepVariablesToLiquidVariables.ts | 12 +++++++----- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts index 1c4bcbf717e..a17ea777f8f 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-payload-schema/build-payload-schema.usecase.ts @@ -17,12 +17,20 @@ export class BuildPayloadSchema { const controlValues = await this.buildControlValues(command); if (!controlValues.length) { - return {}; + return { + type: 'object', + properties: {}, + additionalProperties: true, + }; } const templateVars = this.extractTemplateVariables(controlValues); if (templateVars.length === 0) { - return {}; + return { + type: 'object', + properties: {}, + additionalProperties: true, + }; } const variablesExample = pathsToObject(templateVars, { diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts index 5f08ff76558..aeaef56a4c5 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts @@ -39,6 +39,12 @@ export class BuildAvailableVariableSchemaUsecase { format: 'date-time', description: 'The last time the subscriber was online (optional)', }, + data: { + type: 'object', + properties: {}, + description: 'Additional data about the subscriber', + additionalProperties: true, + }, }, required: ['firstName', 'lastName', 'email', 'subscriberId'], additionalProperties: false, @@ -56,7 +62,13 @@ export class BuildAvailableVariableSchemaUsecase { command: BuildAvailableVariableSchemaCommand ): Promise { if (workflow.payloadSchema) { - return parsePayloadSchema(workflow.payloadSchema, { safe: true }) || {}; + return ( + parsePayloadSchema(workflow.payloadSchema, { safe: true }) || { + type: 'object', + properties: {}, + additionalProperties: true, + } + ); } return this.buildPayloadSchema.execute( diff --git a/apps/dashboard/src/utils/parseStepVariablesToLiquidVariables.ts b/apps/dashboard/src/utils/parseStepVariablesToLiquidVariables.ts index 24c3ab6be14..a8d02515f52 100644 --- a/apps/dashboard/src/utils/parseStepVariablesToLiquidVariables.ts +++ b/apps/dashboard/src/utils/parseStepVariablesToLiquidVariables.ts @@ -20,11 +20,13 @@ export function parseStepVariablesToLiquidVariables(schema: JSONSchemaDefinition for (const [key, value] of Object.entries(obj.properties)) { const fullPath = path ? `${path}.${key}` : key; - // Add each property as a variable for autocompletion - variables.push({ - type: 'variable', - label: `${fullPath}`, - }); + // Only push variables that are not of type "object" or have additionalProperties set to true + if (typeof value === 'object' && (value.type !== 'object' || value.additionalProperties === true)) { + variables.push({ + type: 'variable', + label: `${fullPath}`, + }); + } // Recursively process nested objects if (typeof value === 'object' && (value.type === 'object' || value.type === 'array')) { From 015af51597b7b820de33f41338b5cc0de91ad598 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 19 Dec 2024 07:31:22 +0000 Subject: [PATCH 07/10] fix(dashboard): show more click triggers another trigger (#7327) --- .../src/components/activity/activity-job-item.tsx | 1 + .../test-workflow/test-workflow-logs-sidebar.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/activity/activity-job-item.tsx b/apps/dashboard/src/components/activity/activity-job-item.tsx index 4502e480fda..a8ba7c7dfe5 100644 --- a/apps/dashboard/src/components/activity/activity-job-item.tsx +++ b/apps/dashboard/src/components/activity/activity-job-item.tsx @@ -54,6 +54,7 @@ export function ActivityJobItem({ job, isFirst, isLast }: ActivityJobItemProps) + + + + + + + {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 94ef7ef32e1..779e823cb1b 100644 --- a/apps/dashboard/tailwind.config.js +++ b/apps/dashboard/tailwind.config.js @@ -555,7 +555,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 4a3c377ac4a..26e997efe03 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 @@ -61344,7 +61347,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 @@ -62545,10 +62548,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: