Skip to content

Commit

Permalink
feat(dashboard): New in app previews
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Nov 21, 2024
1 parent cb4ca01 commit aee9e4d
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 29 deletions.
163 changes: 157 additions & 6 deletions apps/dashboard/src/components/workflow-editor/in-app-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,167 @@ import { InboxArrowDown } from '@/components/icons/inbox-arrow-down';
import { InboxBell } from '@/components/icons/inbox-bell';
import { InboxEllipsis } from '@/components/icons/inbox-ellipsis';
import { InboxSettings } from '@/components/icons/inbox-settings';
import { Button } from '@/components/primitives/button';
import { Button, ButtonProps } from '@/components/primitives/button';
import { cn } from '@/utils/ui';
import { Skeleton } from '../primitives/skeleton';

type InAppPreviewProps = HTMLAttributes<HTMLDivElement> & {
type InAppPreviewBellProps = HTMLAttributes<HTMLDivElement>;
export const InAppPreviewBell = (props: InAppPreviewBellProps) => {
const { className, ...rest } = props;
return (
<div className={cn('flex items-center justify-end p-2 text-neutral-300', className)} {...rest}>
<span className="relative rounded-lg bg-neutral-50 p-1">
<InboxBell className="relative size-5" />
<div className="bg-primary border-background absolute right-1 top-1 h-2 w-2 translate-y-[1px] rounded-full border border-solid" />
</span>
</div>
);
};

type InAppPreviewProps = HTMLAttributes<HTMLDivElement>;
export const InAppPreview = (props: InAppPreviewProps) => {
const { className, ...rest } = props;

return (
<div
className={cn(
'border-foreground-200 to-background/90 pointer-events-none relative mx-auto flex h-full w-full flex-col gap-4 rounded-xl px-2 py-3 shadow-sm',
className
)}
{...rest}
/>
);
};

type InAppPreviewHeaderProps = HTMLAttributes<HTMLDivElement>;
export const InAppPreviewHeader = (props: InAppPreviewHeaderProps) => {
const { className, ...rest } = props;

return (
<div className={cn('z-20 flex items-center justify-between px-2 text-neutral-300', className)} {...rest}>
<div className="flex items-center gap-2">
<span className="text-xl font-medium">Inbox</span>
<InboxArrowDown />
</div>
<div className="flex items-center gap-2">
<span className="p-0.5">
<InboxEllipsis />
</span>
<span className="p-0.5">
<InboxSettings />
</span>
</div>
</div>
);
};

type InAppPreviewAvatarProps = HTMLAttributes<HTMLImageElement> & {
src?: string;
isPending?: boolean;
};
export const InAppPreviewAvatar = (props: InAppPreviewAvatarProps) => {
const { className, isPending, src, ...rest } = props;

if (isPending) {
return <Skeleton className="size-8 shrink-0 rounded-full" />;
}

if (!src) {
return null;
}

return <img src={src} alt="avatar" className={cn('bg-background size-7 rounded-full')} {...rest} />;
};

type InAppPreviewNotificationProps = HTMLAttributes<HTMLDivElement>;
export const InAppPreviewNotification = (props: InAppPreviewNotificationProps) => {
const { className, ...rest } = props;

return <div className={cn('flex gap-2', className)} {...rest} />;
};

type InAppPreviewNotificationContentProps = HTMLAttributes<HTMLDivElement>;
export const InAppPreviewNotificationContent = (props: InAppPreviewNotificationContentProps) => {
const { className, ...rest } = props;

return <div className={cn('flex w-full flex-col gap-1 overflow-hidden', className)} {...rest} />;
};

type InAppPreviewSubjectProps = MarkdownProps & { isPending?: boolean };
export const InAppPreviewSubject = (props: InAppPreviewSubjectProps) => {
const { className, isPending, ...rest } = props;

if (isPending) {
return <Skeleton className="h-5 w-1/2" />;
}

return <Markdown className={cn('text-foreground-600 truncate text-xs font-medium', className)} {...rest} />;
};

type InAppPreviewBodyProps = MarkdownProps & { isPending?: boolean };
export const InAppPreviewBody = (props: InAppPreviewBodyProps) => {
const { className, isPending, ...rest } = props;

if (isPending) {
return (
<>
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</>
);
}

return <Markdown className={cn('text-foreground-400 text-xs font-normal', className)} {...rest} />;
};

type InAppPreviewActionsProps = HTMLAttributes<HTMLDivElement>;
export const InAppPreviewActions = (props: InAppPreviewActionsProps) => {
const { className, ...rest } = props;
return <div className={cn('mt-3 flex flex-wrap gap-1 overflow-hidden', className)} {...rest} />;
};

type InAppPreviewPrimaryActionProps = ButtonProps & { isPending?: boolean };
export const InAppPreviewPrimaryAction = (props: InAppPreviewPrimaryActionProps) => {
const { className, isPending, children, ...rest } = props;

if (isPending) {
return <Skeleton className="h-5 w-[12ch]" />;
}

return (
<Button
className={cn('px-3 text-xs font-medium shadow-none', className)}
type="button"
variant="primary"
size="xs"
{...rest}
>
{children}
</Button>
);
};

type InAppPreviewSecondaryActionProps = ButtonProps & { isPending?: boolean };
export const InAppPreviewSecondaryAction = (props: InAppPreviewSecondaryActionProps) => {
const { className, isPending, children, ...rest } = props;

if (isPending) {
return <Skeleton className="h-5 w-[12ch]" />;
}

return (
<Button variant="outline" className={cn('px-3 text-xs font-medium', className)} type="button" size="xs" {...rest}>
{children}
</Button>
);
};

type InAppPreview2Props = HTMLAttributes<HTMLDivElement> & {
truncateBody?: boolean;
data?: GeneratePreviewResponseDto;
isLoading?: boolean;
};
export const InAppPreview = (props: InAppPreviewProps) => {
export const InAppPreview2 = (props: InAppPreview2Props) => {
const { className, truncateBody: truncate = false, data, isLoading, ...rest } = props;

return (
Expand All @@ -29,7 +180,7 @@ export const InAppPreview = (props: InAppPreviewProps) => {

<div
className={cn(
'border-foreground-200 to-background/90 pointer-events-none relative mx-auto flex h-full min-h-64 w-full flex-col rounded-xl p-1 py-3 shadow-sm',
'border-foreground-200 to-background/90 pointer-events-none relative mx-auto flex h-full w-full flex-col rounded-xl p-1 py-3 shadow-sm',
className
)}
{...rest}
Expand Down Expand Up @@ -116,11 +267,11 @@ export const InAppPreview = (props: InAppPreviewProps) => {
);
};

type MarkdownProps = Omit<HTMLAttributes<HTMLParagraphElement>, 'children'> & { children: string };
type MarkdownProps = Omit<HTMLAttributes<HTMLParagraphElement>, 'children'> & { children?: string };
const Markdown = (props: MarkdownProps) => {
const { children, ...rest } = props;

const tokens = useMemo(() => parseMarkdownIntoTokens(children), [children]);
const tokens = useMemo(() => parseMarkdownIntoTokens(children || ''), [children]);

return (
<p {...rest}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { usePreviewStep } from '@/hooks';
import { InAppPreview } from '@/components/workflow-editor/in-app-preview';
import {
InAppPreview,
InAppPreviewAvatar,
InAppPreviewBody,
InAppPreviewHeader,
InAppPreviewNotification,
InAppPreviewNotificationContent,
InAppPreviewSubject,
} from '@/components/workflow-editor/in-app-preview';
import { useStepEditorContext } from '@/components/workflow-editor/steps/hooks';
import { InAppRenderOutput } from '@novu/shared';

export function ConfigureInAppPreview() {
const { previewStep, data, isPending: isPreviewPending } = usePreviewStep();
Expand All @@ -23,5 +32,26 @@ export function ConfigureInAppPreview() {
});
}, [workflowSlug, stepSlug, previewStep, step, isPendingStep]);

return <InAppPreview data={data} truncateBody isLoading={isPreviewPending} />;
if (!isPreviewPending && !data?.result) {
return null;
}

const preview = data?.result?.preview as InAppRenderOutput | undefined;

return (
<InAppPreview>
<InAppPreviewHeader />

<InAppPreviewNotification>
<InAppPreviewAvatar src={preview?.avatar} isPending={isPreviewPending} />

<InAppPreviewNotificationContent>
<InAppPreviewSubject isPending={isPreviewPending}>{preview?.subject}</InAppPreviewSubject>
<InAppPreviewBody isPending={isPreviewPending} className="line-clamp-2">
{preview?.body}
</InAppPreviewBody>
</InAppPreviewNotificationContent>
</InAppPreviewNotification>
</InAppPreview>
);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { CSSProperties, useEffect, useRef, useState } from 'react';
import { GeneratePreviewResponseDto } from '@novu/shared';
import { GeneratePreviewResponseDto, InAppRenderOutput } from '@novu/shared';

import { Notification5Fill } from '@/components/icons';
import { Code2 } from '@/components/icons/code-2';
import { Button } from '@/components/primitives/button';
import { Editor } from '@/components/primitives/editor';
import { InAppPreview } from '@/components/workflow-editor/in-app-preview';
import {
InAppPreview,
InAppPreviewActions,
InAppPreviewAvatar,
InAppPreviewBell,
InAppPreviewBody,
InAppPreviewHeader,
InAppPreviewNotification,
InAppPreviewNotificationContent,
InAppPreviewPrimaryAction,
InAppPreviewSecondaryAction,
InAppPreviewSubject,
} from '@/components/workflow-editor/in-app-preview';
import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion';

Expand All @@ -20,12 +32,12 @@ const getInitialAccordionValue = (value: string) => {
type InAppEditorPreviewProps = {
value: string;
onChange: (value: string) => void;
previewData?: GeneratePreviewResponseDto;
preview?: InAppRenderOutput;
applyPreview: () => void;
isPreviewLoading?: boolean;
isPreviewPending?: boolean;
};
export const InAppEditorPreview = (props: InAppEditorPreviewProps) => {
const { value, onChange, previewData, applyPreview, isPreviewLoading } = props;
const { value, onChange, preview, applyPreview, isPreviewPending } = props;
const [accordionValue, setAccordionValue] = useState<string | undefined>(getInitialAccordionValue(value));
const [payloadError, setPayloadError] = useState('');
const [height, setHeight] = useState(0);
Expand All @@ -47,13 +59,43 @@ export const InAppEditorPreview = (props: InAppEditorPreviewProps) => {
}, [value]);

return (
<div className="flex flex-col gap-3">
<div className="relative flex flex-col gap-3">
<div className="flex items-center gap-2.5 text-sm font-medium">
<Notification5Fill className="size-3" />
In-app template editor
</div>

<InAppPreview data={previewData} isLoading={isPreviewLoading} />
<div className="relative my-2">
<div className="relative mx-auto max-w-sm">
<InAppPreviewBell />

<InAppPreview className="min-h-64">
<InAppPreviewHeader />

<InAppPreviewNotification>
<InAppPreviewAvatar src={preview?.avatar} isPending={isPreviewPending} />

<InAppPreviewNotificationContent>
<InAppPreviewSubject isPending={isPreviewPending}>{preview?.subject}</InAppPreviewSubject>
<InAppPreviewBody isPending={isPreviewPending} className="line-clamp-6">
{preview?.body}
</InAppPreviewBody>

<InAppPreviewActions>
<InAppPreviewPrimaryAction isPending={isPreviewPending}>
{preview?.primaryAction?.label}
</InAppPreviewPrimaryAction>

<InAppPreviewSecondaryAction isPending={isPreviewPending}>
{preview?.secondaryAction?.label}
</InAppPreviewSecondaryAction>
</InAppPreviewActions>
</InAppPreviewNotificationContent>
</InAppPreviewNotification>
</InAppPreview>
</div>
<div className="to-background absolute -bottom-3 h-16 w-full bg-gradient-to-b from-transparent to-80%" />
</div>

<Accordion type="single" collapsible value={accordionValue} onValueChange={setAccordionValue}>
<AccordionItem value="payload">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { type StepDataDto, type StepUpdateDto, type WorkflowResponseDto } from '@novu/shared';
import { ChannelTypeEnum, type StepDataDto, type StepUpdateDto, type WorkflowResponseDto } from '@novu/shared';
import { Cross2Icon } from '@radix-ui/react-icons';
import { useEffect, useMemo, useState } from 'react';
import { FieldValues, useForm, useWatch } from 'react-hook-form';
Expand Down Expand Up @@ -222,19 +222,25 @@ export const InAppTabs = ({ workflow, step }: { workflow: WorkflowResponseDto; s
<CustomStepControls dataSchema={dataSchema} origin={workflow.origin} />
</TabsContent>
<TabsContent value="preview" className={tabsContentClassName}>
<InAppEditorPreview
value={editorValue}
onChange={setEditorValue}
previewData={previewData}
isPreviewLoading={isPreviewPending}
applyPreview={() => {
previewStep({
stepSlug,
workflowSlug,
data: { controlValues: form.getValues() as FieldValues, previewPayload: JSON.parse(editorValue) },
});
}}
/>
{previewData === undefined ||
(previewData.result?.type === ChannelTypeEnum.IN_APP && (
<InAppEditorPreview
value={editorValue}
onChange={setEditorValue}
preview={previewData?.result.preview}
isPreviewPending={isPreviewPending}
applyPreview={() => {
previewStep({
stepSlug,
workflowSlug,
data: {
controlValues: form.getValues() as FieldValues,
previewPayload: JSON.parse(editorValue),
},
});
}}
/>
))}
</TabsContent>
<Separator />
<footer className="flex justify-end px-3 py-3.5">
Expand Down

0 comments on commit aee9e4d

Please sign in to comment.