Skip to content
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

Merged
merged 4 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Contributor Author

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

"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>) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 };
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()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

passthrough is passing all the other fields from the persisted step apart from what is defined in the schema, for example stepUuid, controls, controlValues, etc. Otherwise during the save we will be missing these and the BE will treat it as a new step.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh so this just doesn't strip out the unspecified fields.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

),
});
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,
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 });
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to satisfy TS until the BE types are fixed

});

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
Loading