-
Notifications
You must be signed in to change notification settings - Fork 4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(dashboard): workflow editor autosave #6718
Changes from all commits
e8167fa
e8e7e50
761f99d
2b1ec25
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { ReactNode } from 'react'; | ||
import { ExternalToast, toast } from 'sonner'; | ||
import { SmallToast } from './sonner'; | ||
|
||
export const smallToast = ({ children, options }: { children: ReactNode; options: ExternalToast }) => { | ||
return toast(<SmallToast>{children}</SmallToast>, { | ||
duration: 5000, | ||
unstyled: true, | ||
...options, | ||
}); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { cn } from '@/utils/ui'; | ||
import { useTheme } from 'next-themes'; | ||
import { Toaster as Sonner } from 'sonner'; | ||
|
||
type ToasterProps = React.ComponentProps<typeof Sonner>; | ||
|
||
const SmallToast = ({ children, className, ...props }: React.HTMLAttributes<HTMLDivElement>) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. small sonner implementation shown in the workflow editor I will explain to you why later in the comments |
||
return ( | ||
<div | ||
className={cn( | ||
'text-foreground-950 border-neutral-alpha-200 flex items-center gap-1 rounded-lg border px-2.5 py-2 shadow-md', | ||
className | ||
)} | ||
{...props} | ||
> | ||
{children} | ||
</div> | ||
); | ||
}; | ||
|
||
const Toaster = ({ ...props }: ToasterProps) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. autogenerated |
||
const { theme = 'system' } = useTheme(); | ||
|
||
return ( | ||
<Sonner | ||
theme={theme as ToasterProps['theme']} | ||
className="toaster group" | ||
toastOptions={{ | ||
classNames: { | ||
toast: | ||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', | ||
description: 'group-[.toast]:text-muted-foreground', | ||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', | ||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground', | ||
}, | ||
}} | ||
{...props} | ||
/> | ||
); | ||
}; | ||
|
||
export { Toaster, SmallToast }; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,17 +3,17 @@ import { StepTypeEnum } from '@/utils/enums'; | |
|
||
export const formSchema = z.object({ | ||
name: z.string(), | ||
identifier: z.string(), | ||
workflowId: z.string(), | ||
description: z.string().optional(), | ||
tags: z.array(z.string()).optional(), | ||
active: z.boolean().optional(), | ||
critical: z.boolean().optional(), | ||
steps: z.array( | ||
z.object({ | ||
name: z.string(), | ||
type: z.nativeEnum(StepTypeEnum), | ||
controls: z.object({ schema: z.object({}) }), | ||
controlValues: z.object({}), | ||
}) | ||
z | ||
.object({ | ||
name: z.string(), | ||
type: z.nativeEnum(StepTypeEnum), | ||
}) | ||
.passthrough() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh so this just doesn't strip out the unspecified fields. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes |
||
), | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,19 +1,21 @@ | ||
import { ReactNode, useMemo, useCallback } from 'react'; | ||
import { ReactNode, useMemo, useCallback, useRef, useLayoutEffect } from 'react'; | ||
import { useNavigate, useParams } from 'react-router-dom'; | ||
import { useForm, useFieldArray } from 'react-hook-form'; | ||
// eslint-disable-next-line | ||
// @ts-ignore | ||
import { zodResolver } from '@hookform/resolvers/zod'; | ||
import * as z from 'zod'; | ||
import type { StepDto } from '@novu/shared'; | ||
import { RiProgress1Line } from 'react-icons/ri'; | ||
|
||
import { WorkflowEditorContext } from './workflow-editor-context'; | ||
import { StepTypeEnum } from '@/utils/enums'; | ||
import { useFetchWorkflow } from '@/hooks/use-fetch-workflow'; | ||
import { Form } from '../primitives/form/form'; | ||
import { buildRoute, ROUTES } from '@/utils/routes'; | ||
import { useEnvironment } from '@/context/environment/hooks'; | ||
import { formSchema } from './schema'; | ||
import { useFetchWorkflow, useUpdateWorkflow, useFormAutoSave } from '@/hooks'; | ||
import { Step } from '@/utils/types'; | ||
import { smallToast } from '../primitives/sonner-helpers'; | ||
|
||
const STEP_NAME_BY_TYPE: Record<StepTypeEnum, string> = { | ||
email: 'Email Step', | ||
|
@@ -27,33 +29,75 @@ const STEP_NAME_BY_TYPE: Record<StepTypeEnum, string> = { | |
custom: 'Custom Step', | ||
}; | ||
|
||
const createStep = (type: StepTypeEnum): StepDto => ({ | ||
const createStep = (type: StepTypeEnum): Step => ({ | ||
name: STEP_NAME_BY_TYPE[type], | ||
type, | ||
controlValues: {}, | ||
controls: { | ||
schema: {}, | ||
}, | ||
}); | ||
|
||
export const WorkflowEditorProvider = ({ children }: { children: ReactNode }) => { | ||
const changesSavedToastIdRef = useRef<string | number>(); | ||
const { currentEnvironment } = useEnvironment(); | ||
const { workflowId } = useParams<{ workflowId?: string }>(); | ||
const navigate = useNavigate(); | ||
const form = useForm<z.infer<typeof formSchema>>({ mode: 'onSubmit', resolver: zodResolver(formSchema) }); | ||
const { handleSubmit, reset } = form; | ||
const { reset } = form; | ||
const steps = useFieldArray({ | ||
control: form.control, | ||
name: 'steps', | ||
}); | ||
|
||
const { workflow: _workflow } = useFetchWorkflow({ | ||
const { workflow, error } = useFetchWorkflow({ | ||
workflowId, | ||
}); | ||
|
||
useLayoutEffect(() => { | ||
if (error) { | ||
navigate(buildRoute(ROUTES.WORKFLOWS, { environmentId: currentEnvironment?._id ?? '' })); | ||
} | ||
|
||
if (!workflow) { | ||
return; | ||
} | ||
|
||
reset({ ...workflow, steps: workflow.steps.map((step) => ({ ...step })) }); | ||
}, [workflow, error, navigate, reset, currentEnvironment]); | ||
|
||
const { updateWorkflow } = useUpdateWorkflow({ | ||
onSuccess: (data) => { | ||
reset(data); | ||
reset({ ...data, steps: data.steps.map((step) => ({ ...step })) }); | ||
if (changesSavedToastIdRef.current) { | ||
return; | ||
} | ||
|
||
const id = smallToast({ | ||
children: ( | ||
<> | ||
<RiProgress1Line className="size-6" /> | ||
<span className="text-sm">Saved</span> | ||
</> | ||
), | ||
options: { | ||
position: 'bottom-left', | ||
classNames: { | ||
toast: 'ml-10', | ||
}, | ||
onAutoClose: () => { | ||
changesSavedToastIdRef.current = undefined; | ||
}, | ||
}, | ||
}); | ||
changesSavedToastIdRef.current = id; | ||
}, | ||
onError: () => { | ||
navigate(buildRoute(ROUTES.WORKFLOWS, { environmentId: currentEnvironment?._id ?? '' })); | ||
}); | ||
|
||
useFormAutoSave({ | ||
form, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. form autosave hook |
||
onSubmit: async (data: z.infer<typeof formSchema>) => { | ||
if (!workflow) { | ||
return; | ||
} | ||
|
||
updateWorkflow({ id: workflow._id, workflow: { ...workflow, ...data } as any }); | ||
}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to satisfy TS until the BE types are fixed |
||
}); | ||
|
||
|
@@ -69,10 +113,6 @@ export const WorkflowEditorProvider = ({ children }: { children: ReactNode }) => | |
[steps] | ||
); | ||
|
||
const onSubmit = async (_data: z.infer<typeof formSchema>) => { | ||
// TODO: Implement submit logic | ||
}; | ||
|
||
const value = useMemo( | ||
() => ({ | ||
addStep, | ||
|
@@ -83,9 +123,7 @@ export const WorkflowEditorProvider = ({ children }: { children: ReactNode }) => | |
return ( | ||
<WorkflowEditorContext.Provider value={value}> | ||
<Form {...form}> | ||
<form onSubmit={handleSubmit(onSubmit)} className="h-full"> | ||
{children} | ||
</form> | ||
<form className="h-full">{children}</form> | ||
</Form> | ||
</WorkflowEditorContext.Provider> | ||
); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import { useRef } from 'react'; | ||
|
||
export const useDataRef = <T>(data: T) => { | ||
const ref = useRef<T>(data); | ||
ref.current = data; | ||
|
||
return ref; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { useCallback, useEffect } from 'react'; | ||
import debounce from 'lodash.debounce'; | ||
import { useDataRef } from './use-data-ref'; | ||
|
||
export const useDebounce = <Arguments = unknown | unknown[]>(callback: (args?: Arguments) => void, ms = 0) => { | ||
const callbackRef = useDataRef(callback); | ||
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
const debouncedCallback = useCallback(debounce(callbackRef.current, ms), [callbackRef, ms]); | ||
|
||
useEffect(() => debouncedCallback.cancel, [debouncedCallback.cancel]); | ||
|
||
return debouncedCallback; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
used in the autosave hook, more details below