Skip to content

Commit

Permalink
feat(dashboard): Implement email step editor
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Nov 26, 2024
1 parent f33b165 commit e54eeac
Show file tree
Hide file tree
Showing 14 changed files with 1,382 additions and 213 deletions.
1 change: 1 addition & 0 deletions apps/dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ VITE_NOVU_APP_ID=
VITE_INTERCOM_APP_ID=
VITE_GTM=
VITE_SELF_HOSTED=
VITE_EMAIL_DELAY_DIGEST_ENABLED=
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@codemirror/autocomplete": "^6.18.3",
"@hookform/resolvers": "^3.9.0",
"@lezer/highlight": "^1.2.1",
"@maily-to/core": "^0.0.16",
"@novu/framework": "workspace:*",
"@novu/js": "workspace:*",
"@novu/react": "workspace:*",
Expand Down Expand Up @@ -88,6 +89,7 @@
"@hookform/devtools": "^4.3.0",
"@playwright/test": "^1.44.0",
"@sentry/vite-plugin": "^2.22.6",
"@tiptap/core": "^2.10.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.merge": "^4.6.6",
"@types/mixpanel-browser": "^2.49.0",
Expand Down
14 changes: 13 additions & 1 deletion apps/dashboard/src/components/workflow-editor/add-step-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Badge } from '../primitives/badge';
import { cn } from '@/utils/ui';
import { StepTypeEnum } from '@/utils/enums';
import { STEP_TYPE_TO_COLOR } from '@/utils/color';
import { EMAIL_DELAY_DIGEST_ENABLED } from '@/config';

const MenuGroup = ({ children }: { children: ReactNode }) => {
return <div className="flex flex-col">{children}</div>;
Expand Down Expand Up @@ -104,7 +105,18 @@ export const AddStepMenu = ({
<MenuGroup>
<MenuTitle>Channels</MenuTitle>
<MenuItemsGroup>
<MenuItem stepType={StepTypeEnum.EMAIL}>Email</MenuItem>
<MenuItem
stepType={StepTypeEnum.EMAIL}
disabled={!EMAIL_DELAY_DIGEST_ENABLED}
onClick={() => {
if (!EMAIL_DELAY_DIGEST_ENABLED) {
return;
}
handleMenuItemClick(StepTypeEnum.EMAIL);
}}
>
Email
</MenuItem>
<MenuItem
stepType={StepTypeEnum.IN_APP}
disabled={false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { InAppSubject } from '@/components/workflow-editor/steps/in-app/in-app-s
import { InAppBody } from '@/components/workflow-editor/steps/in-app/in-app-body';
import { InAppAvatar } from '@/components/workflow-editor/steps/in-app/in-app-avatar';
import { InAppRedirect } from '@/components/workflow-editor/steps/in-app/in-app-redirect';
import { Maily } from '@/components/workflow-editor/steps/email/maily';
import { EmailSubject } from '@/components/workflow-editor/steps/email/email-subject';

export const getComponentByType = ({ component }: { component?: UiComponentEnum }) => {
switch (component) {
Expand All @@ -23,6 +25,12 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum
case UiComponentEnum.URL_TEXT_BOX: {
return <InAppRedirect />;
}
case UiComponentEnum.MAILY: {
return <Maily />;
}
case UiComponentEnum.TEXT_INLINE_LABEL: {
return <EmailSubject />;
}
default: {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SdkBanner } from '@/components/workflow-editor/steps/sdk-banner';
import { useStep } from '@/components/workflow-editor/steps/use-step';
import { EXCLUDED_EDITOR_TYPES } from '@/utils/constants';
import { ConfigureInAppStepTemplate } from '@/components/workflow-editor/steps/in-app/configure-in-app-step-template';
import { ConfigureEmailStepTemplate } from '@/components/workflow-editor/steps/email/configure-email-step-template';

export const ConfigureStepContent = () => {
const { step } = useStep();
Expand All @@ -25,6 +26,10 @@ export const ConfigureStepContent = () => {
return <ConfigureInAppStepTemplate step={step} issue={firstError} />;
}

if (step?.type === StepTypeEnum.EMAIL) {
return <ConfigureEmailStepTemplate step={step} issue={firstError} />;
}

return (
<>
<SidebarContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ import { StepSkeleton } from './step-skeleton';
import { StepEditorProvider } from './step-editor-provider';
import { useStepEditorContext } from './hooks';
import { useWorkflowEditorContext } from '../hooks';
import { StepTypeEnum } from '@novu/shared';
import { cn } from '@/utils/ui';

const transitionSetting = { ease: [0.29, 0.83, 0.57, 0.99], duration: 0.4 };

const stepTypeToClassname = {
[StepTypeEnum.IN_APP]: 'sm:max-w-[600px]',
[StepTypeEnum.EMAIL]: 'sm:max-w-[800px]',
} as Record<string, string>;

const EditStepSidebarInternal = () => {
const navigate = useNavigate();
const { workflow, isPendingWorkflow } = useWorkflowEditorContext();
Expand Down Expand Up @@ -60,9 +67,10 @@ const EditStepSidebarInternal = () => {
x: '100%',
}}
transition={transitionSetting}
className={
'bg-background fixed inset-y-0 right-0 z-50 flex h-full w-3/4 flex-col border-l shadow-lg outline-none sm:max-w-[600px]'
}
className={cn(
'bg-background fixed inset-y-0 right-0 z-50 flex h-full w-3/4 flex-col border-l shadow-lg outline-none sm:max-w-[600px]',
step?.type ? stepTypeToClassname[step.type] : undefined
)}
>
<VisuallyHidden>
<SheetTitle />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Separator } from '@/components/primitives/separator';
import { getComponentByType } from '@/components/workflow-editor/steps/component-utils';
import { type UiSchema } from '@novu/shared';

const subjectKey = 'subject';
const emailEditorKey = 'emailEditor';

type ConfigureEmailStepTemplateEditorProps = { uiSchema?: UiSchema };
export const ConfigureEmailStepTemplateEditor = (props: ConfigureEmailStepTemplateEditorProps) => {
const { uiSchema } = props;
const { [emailEditorKey]: emailEditor, [subjectKey]: subject } = uiSchema?.properties ?? {};

return (
<div className="flex flex-col gap-3">
{subject && getComponentByType({ component: subject.component })}
<Separator className="bg-neutral-100" />
{emailEditor && getComponentByType({ component: emailEditor.component })}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { type StepDataDto, type StepUpdateDto, type WorkflowResponseDto } from '@novu/shared';
import { Cross2Icon } from '@radix-ui/react-icons';
import merge from 'lodash.merge';
import { useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri';
import { useBlocker, useNavigate, useParams } from 'react-router-dom';

import { NovuApiError } from '@/api/api.client';
import { Notification5Fill } from '@/components/icons';
import { Button } from '@/components/primitives/button';
import { Form } from '@/components/primitives/form/form';
import { Separator } from '@/components/primitives/separator';
import { ToastIcon } from '@/components/primitives/sonner';
import { showToast } from '@/components/primitives/sonner-helpers';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs';
import { UnsavedChangesAlertDialog } from '@/components/unsaved-changes-alert-dialog';
import { ConfigureEmailStepTemplateEditor } from '@/components/workflow-editor/steps/email/configure-email-step-template-editor';
import { useStepEditorContext } from '@/components/workflow-editor/steps/hooks';
import useDebouncedEffect from '@/hooks/use-debounced-effect';
import { usePreviewStep } from '@/hooks/use-preview-step';
import { useUpdateWorkflow } from '@/hooks/use-update-workflow';
import { buildDefaultValues, buildDynamicZodSchema } from '@/utils/schema';
import { useWorkflowEditorContext } from '../../hooks';
import { flattenIssues } from '../../step-utils';
import { CustomStepControls } from '../controls/custom-step-controls';
import { useStep } from '../use-step';

const tabsContentClassName = 'h-full w-full py-3.5 overflow-y-auto';

type ConfigureEmailStepTemplateTabsProps = { workflow: WorkflowResponseDto; step: StepDataDto };
export const ConfigureEmailStepTemplateTabs = (props: ConfigureEmailStepTemplateTabsProps) => {
const { workflow, step } = props;
const navigate = useNavigate();
const { stepSlug = '', workflowSlug = '' } = useParams<{ workflowSlug: string; stepSlug: string }>();
const { resetWorkflowForm } = useWorkflowEditorContext();
const { refetch: refetchStep } = useStepEditorContext();
const { step: workflowStep } = useStep();

const { dataSchema, uiSchema, values } = step.controls;
const schema = useMemo(() => buildDynamicZodSchema(dataSchema ?? {}), [dataSchema]);
const newFormValues = useMemo(() => merge(buildDefaultValues(uiSchema ?? {}), values), [uiSchema, values]);

const form = useForm({
mode: 'onSubmit',
resolver: zodResolver(schema),
values: newFormValues,
shouldFocusError: true,
});
const [_, setEditorValue] = useState('{}');
const { formState, setError } = form;

const controlErrors = useMemo(() => flattenIssues(workflowStep?.issues?.controls), [workflowStep]);

useEffect(() => {
if (Object.keys(controlErrors).length) {
Object.entries(controlErrors).forEach(([key, value]) => {
setError(key as string, { message: value }, { shouldFocus: true });
});
}
}, [controlErrors, setError]);

const { previewStep } = usePreviewStep();
const { isPending, updateWorkflow } = useUpdateWorkflow({
onSuccess: (data) => {
resetWorkflowForm(data);
refetchStep();
showToast({
children: () => (
<>
<ToastIcon variant="success" />
<span className="text-sm">Saved</span>
</>
),
options: {
position: 'bottom-right',
classNames: {
toast: 'ml-10 mb-4',
},
},
});
},
onError: () => {
showToast({
children: () => (
<>
<ToastIcon variant="error" />
<span className="text-sm">Failed to save</span>
</>
),
options: {
position: 'bottom-right',
classNames: {
toast: 'ml-10 mb-4',
},
},
});
},
});

const onSubmit = async (data: any) => {
const updatedValues = Object.keys(formState.dirtyFields).reduce(
(acc, key) => {
acc[key] = data[key];
return acc;
},
{} as Record<string, unknown>
);

await updateWorkflow({
id: workflow._id,
workflow: {
...workflow,
steps: workflow.steps.map((step) =>
step.slug === stepSlug
? ({
...step,
controlValues: { ...values, ...updatedValues },
issues: undefined,
} as StepUpdateDto)
: step
),
},
});
};

const preview = async (props: {
controlValues: Record<string, unknown>;
previewPayload: Record<string, unknown>;
}) => {
try {
const res = await previewStep({
workflowSlug,
stepSlug,
data: { controlValues: props.controlValues, previewPayload: props.previewPayload },
});
setEditorValue(JSON.stringify(res.previewPayloadExample, null, 2));
} catch (err) {
if (err instanceof NovuApiError) {
showToast({
children: () => (
<>
<ToastIcon variant="error" />
<span className="text-sm">Failed to preview, Error: ${err.message}</span>
</>
),
options: {
position: 'bottom-right',
classNames: {
toast: 'ml-10 mb-4',
},
},
});
}
}
};

const formValues = useWatch(form);
useDebouncedEffect(
() => {
preview({
controlValues: form.getValues() as Record<string, unknown>,
/**
* Reset the preview payload to an empty object on form change
* to prevent showing the previous payload
*/
previewPayload: {},
});
},
2000,
[formValues]
);

const blocker = useBlocker(formState.isDirty || isPending);

return (
<>
<Form {...form}>
<form
id="save-step"
className="flex h-full flex-col"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit(onSubmit)(event);
}}
>
<Tabs defaultValue="editor" className="flex h-full flex-1 flex-col">
<header className="flex flex-row items-center gap-3 px-3 py-1.5">
<div className="mr-auto flex items-center gap-2.5 text-sm font-medium">
<RiEdit2Line className="size-4" />
<span>Configure Template</span>
</div>
<TabsList className="w-min">
<TabsTrigger value="editor" className="gap-1.5">
<RiPencilRuler2Line className="size-5 p-0.5" />
<span>Editor</span>
</TabsTrigger>
<TabsTrigger value="preview" className="gap-1.5">
<Notification5Fill className="size-5 p-0.5" />
<span>Preview</span>
</TabsTrigger>
</TabsList>

<Button
variant="ghost"
size="xs"
className="size-6"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate('../', { relative: 'path' });
}}
>
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</header>
<Separator />
<TabsContent value="editor" className={tabsContentClassName}>
<ConfigureEmailStepTemplateEditor uiSchema={uiSchema} />
<CustomStepControls dataSchema={dataSchema} origin={workflow.origin} />
</TabsContent>
<TabsContent value="preview" className={tabsContentClassName}>
{/* preview goes here */}
</TabsContent>
<Separator />
<footer className="flex justify-end px-3 py-3.5">
<Button
className="ml-auto"
variant="default"
type="submit"
form="save-step"
disabled={!formState.isDirty}
>
Save step
</Button>
</footer>
</Tabs>
</form>
</Form>

<UnsavedChangesAlertDialog
blocker={blocker}
description="This editor form has some unsaved changes. Save progress before you leave."
/>
</>
);
};
Loading

0 comments on commit e54eeac

Please sign in to comment.