Skip to content

Commit

Permalink
feat(dashboard): workflow editor autosave (#6718)
Browse files Browse the repository at this point in the history
  • Loading branch information
LetItRock authored Oct 21, 2024
1 parent a2beb28 commit 13aa3f8
Show file tree
Hide file tree
Showing 17 changed files with 1,041 additions and 685 deletions.
4 changes: 3 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,9 @@
"zulip",
"zwnj",
"motionone",
"xyflow"
"xyflow",
"Sonner",
"sonner",
],
"flagWords": [],
"patterns": [
Expand Down
5 changes: 5 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,23 +42,28 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.439.0",
"mixpanel-browser": "^2.52.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^1.3.0",
"react-hook-form": "7.43.9",
"react-icons": "^5.3.0",
"react-router-dom": "6.26.2",
"react-use-intercom": "^2.0.0",
"sonner": "^1.5.0",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"use-deep-compare-effect": "^1.8.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@clerk/types": "^4.6.1",
"@eslint/js": "^9.9.0",
"@playwright/test": "^1.44.0",
"@types/lodash.debounce": "^4.0.9",
"@types/mixpanel-browser": "^2.49.0",
"@types/node": "^22.7.0",
"@types/react": "^18.3.3",
Expand Down
16 changes: 14 additions & 2 deletions apps/dashboard/src/api/workflows.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CreateWorkflowDto, WorkflowResponseDto } from '@novu/shared';
import { getV2, postV2 } from './api.client';
import type { CreateWorkflowDto, UpdateWorkflowDto, WorkflowResponseDto } from '@novu/shared';
import { getV2, postV2, putV2 } from './api.client';

export const fetchWorkflow = async ({ workflowId }: { workflowId?: string }): Promise<WorkflowResponseDto> => {
const { data } = await getV2<{ data: WorkflowResponseDto }>(`/workflows/${workflowId}`);
Expand All @@ -10,3 +10,15 @@ export const fetchWorkflow = async ({ workflowId }: { workflowId?: string }): Pr
export async function createWorkflow(payload: CreateWorkflowDto) {
return postV2<{ data: WorkflowResponseDto }>(`/workflows`, payload);
}

export const updateWorkflow = async ({
id,
workflow,
}: {
id: string;
workflow: UpdateWorkflowDto;
}): Promise<WorkflowResponseDto> => {
const { data } = await putV2<{ data: WorkflowResponseDto }>(`/workflows/${id}`, workflow);

return data;
};
11 changes: 11 additions & 0 deletions apps/dashboard/src/components/primitives/sonner-helpers.tsx
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,
});
};
42 changes: 42 additions & 0 deletions apps/dashboard/src/components/primitives/sonner.tsx
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>) => {
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) => {
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 };
14 changes: 7 additions & 7 deletions apps/dashboard/src/components/workflow-editor/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
),
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
useReactFlow,
ViewportHelperFunctionOptions,
} from '@xyflow/react';
import type { StepDto } from '@novu/shared';
import '@xyflow/react/dist/style.css';
import {
AddNode,
Expand All @@ -27,6 +26,7 @@ import {
import { AddNodeEdgeType, AddNodeEdge } from './edges';
import { NODE_HEIGHT, NODE_WIDTH } from './base-node';
import { StepTypeEnum } from '@/utils/enums';
import { Step } from '@/utils/types';

const nodeTypes = {
trigger: TriggerNode,
Expand All @@ -51,7 +51,7 @@ const panOnDrag = [1, 2];
const Y_DISTANCE = NODE_HEIGHT + 50;

const mapStepToNode = (
step: StepDto,
step: Step,
previousPosition: { x: number; y: number },
addStepIndex: number
): Node<NodeData, keyof typeof nodeTypes> => {
Expand All @@ -72,7 +72,7 @@ const mapStepToNode = (
};
};

const WorkflowCanvasChild = ({ steps }: { steps: StepDto[] }) => {
const WorkflowCanvasChild = ({ steps }: { steps: Step[] }) => {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const reactFlowInstance = useReactFlow();

Expand Down Expand Up @@ -168,7 +168,7 @@ const WorkflowCanvasChild = ({ steps }: { steps: StepDto[] }) => {
);
};

export const WorkflowCanvas = ({ steps }: { steps: StepDto[] }) => {
export const WorkflowCanvas = ({ steps }: { steps: Step[] }) => {
return (
<ReactFlowProvider>
<WorkflowCanvasChild steps={steps} />
Expand Down
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',
Expand All @@ -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,
onSubmit: async (data: z.infer<typeof formSchema>) => {
if (!workflow) {
return;
}

updateWorkflow({ id: workflow._id, workflow: { ...workflow, ...data } as any });
},
});

Expand All @@ -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,
Expand All @@ -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>
);
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ export * from './use-bridge-health-check';
export * from './use-validate-bridge-url';
export * from './use-update-bridge-url';
export * from './use-telemetry';
export * from './use-form-autosave';
export * from './use-fetch-workflow';
export * from './use-update-workflow';
8 changes: 8 additions & 0 deletions apps/dashboard/src/hooks/use-data-ref.ts
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;
};
14 changes: 14 additions & 0 deletions apps/dashboard/src/hooks/use-debounce.ts
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;
};
21 changes: 2 additions & 19 deletions apps/dashboard/src/hooks/use-fetch-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,11 @@ import { QueryKeys } from '@/utils/query-keys';
import { fetchWorkflow } from '@/api/workflows';
import { useEnvironment } from '@/context/environment/hooks';

export const useFetchWorkflow = ({
workflowId,
onSuccess,
onError,
}: {
workflowId?: string;
onSuccess?: (data: WorkflowResponseDto) => void;
onError?: (error: unknown) => void;
}) => {
export const useFetchWorkflow = ({ workflowId }: { workflowId?: string }) => {
const { currentEnvironment } = useEnvironment();
const { data, isPending, error } = useQuery<WorkflowResponseDto>({
queryKey: [QueryKeys.fetchWorkflow, currentEnvironment?._id, workflowId],
queryFn: async () => {
try {
const result = await fetchWorkflow({ workflowId });
onSuccess?.(result);
return result;
} catch (error) {
onError?.(error);
throw error;
}
},
queryFn: () => fetchWorkflow({ workflowId }),
enabled: !!currentEnvironment?._id && !!workflowId,
});

Expand Down
Loading

0 comments on commit 13aa3f8

Please sign in to comment.