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): test workflow functionality #6768

Merged
merged 9 commits into from
Oct 29, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { EnvironmentWithUserObjectCommand } from '@novu/application-generic';
import { IsDefined, IsString } from 'class-validator';

export class WorkflowTestDataCommand extends EnvironmentWithUserObjectCommand {
@IsString()
@IsDefined()
identifierOrInternalId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { JSONSchema } from 'json-schema-to-ts';
import { Injectable } from '@nestjs/common';
import { NotificationTemplateEntity } from '@novu/dal';
import { UserSessionData, WorkflowTestDataResponseDto } from '@novu/shared';

import { WorkflowTestDataCommand } from './test-data.command';
import { GetWorkflowByIdsUseCase } from '../get-workflow-by-ids/get-workflow-by-ids.usecase';
import { GetWorkflowByIdsCommand } from '../get-workflow-by-ids/get-workflow-by-ids.command';

const buildToFieldSchema = ({ user }: { user: UserSessionData }) =>
({
type: 'object',
properties: {
subscriberId: { type: 'string', default: user._id },
/*
* TODO: the email and phone fields should be dynamic based on the workflow steps
* if the workflow has has an email step, then email is required etc
*/
email: { type: 'string', default: user.email ?? '', format: 'email' },
phone: { type: 'string', default: '' },
},
required: ['subscriberId', 'email', 'phone'],
additionalProperties: false,
}) as const satisfies JSONSchema;

const buildPayloadSchema = () =>
({
type: 'object',
description: 'Schema representing the workflow payload',
properties: {
/*
* TODO: the properties should be dynamic based on the workflow variables
*/
example: { type: 'string', description: 'Example field', default: 'payload.example' },
},
required: ['subscriberId', 'email', 'phone'],
additionalProperties: false,
}) as const satisfies JSONSchema;

@Injectable()
export class WorkflowTestDataUseCase {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The GET /workflow/:id/test-data endpoint returns JSONSchema for the to and payload fields that are used to populate the Test Workflow form with the data on FE.
We agreed on this with @tatarco, and the work will be continued later.

constructor(private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase) {}

async execute(command: WorkflowTestDataCommand): Promise<WorkflowTestDataResponseDto> {
const _workflowEntity: NotificationTemplateEntity | null = await this.getWorkflowByIdsUseCase.execute(
GetWorkflowByIdsCommand.create({
...command,
identifierOrInternalId: command.identifierOrInternalId,
})
);

return {
to: buildToFieldSchema({ user: command.user }),
payload: buildPayloadSchema(),
};
}
}
19 changes: 17 additions & 2 deletions apps/api/src/app/workflows-v2/workflow.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
UpdateWorkflowDto,
UserSessionData,
WorkflowResponseDto,
WorkflowTestDataResponseDto,
} from '@novu/shared';
import { UserAuthGuard, UserSession } from '@novu/application-generic';

Expand All @@ -39,8 +40,10 @@ import { DeleteWorkflowUseCase } from './usecases/delete-workflow/delete-workflo
import { DeleteWorkflowCommand } from './usecases/delete-workflow/delete-workflow.command';
import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview.usecase';
import { GeneratePreviewCommand } from './usecases/generate-preview/generate-preview-command';
import { ParseSlugIdPipe } from './pipes/parse-slug-id.pipe';
import { ParseSlugIdPipe } from './pipes/parse-slug-Id.pipe';
import { ParseSlugEnvironmentIdPipe } from './pipes/parse-slug-env-id.pipe';
import { WorkflowTestDataUseCase } from './usecases/test-data/test-data.usecase';
import { WorkflowTestDataCommand } from './usecases/test-data/test-data.command';

@ApiCommonResponses()
@Controller({ path: `/workflows`, version: '2' })
Expand All @@ -53,7 +56,8 @@ export class WorkflowController {
private getWorkflowUseCase: GetWorkflowUseCase,
private listWorkflowsUseCase: ListWorkflowsUseCase,
private deleteWorkflowUsecase: DeleteWorkflowUseCase,
private generatePreviewUseCase: GeneratePreviewUsecase
private generatePreviewUseCase: GeneratePreviewUsecase,
private workflowTestDataUseCase: WorkflowTestDataUseCase
) {}

@Post('')
Expand Down Expand Up @@ -136,4 +140,15 @@ export class WorkflowController {
GeneratePreviewCommand.create({ user, workflowId, stepUuid, generatePreviewRequestDto })
);
}

@Get('/:workflowId/test-data')
@UseGuards(UserAuthGuard)
async getWorkflowTestData(
@UserSession() user: UserSessionData,
@Param('workflowId', ParseSlugIdPipe) workflowId: IdentifierOrInternalId
): Promise<WorkflowTestDataResponseDto> {
return this.workflowTestDataUseCase.execute(
WorkflowTestDataCommand.create({ identifierOrInternalId: workflowId, user })
);
}
}
2 changes: 2 additions & 0 deletions apps/api/src/app/workflows-v2/workflow.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { CreateMockPayloadUseCase } from './usecases/placeholder-enrichment/payl
import { ExtractDefaultsUsecase } from './usecases/get-default-values-from-schema/extract-defaults.usecase';
import { CollectPlaceholdersFromTipTapSchemaUsecase } from './usecases/placeholder-enrichment/collect-placeholders-from-tip-tap-schema.usecase';
import { TransformPlaceholderMapUseCase } from './usecases/placeholder-enrichment/transform-placeholder.usecase';
import { WorkflowTestDataUseCase } from './usecases/test-data/test-data.usecase';

@Module({
imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, BridgeModule, IntegrationModule],
Expand All @@ -47,6 +48,7 @@ import { TransformPlaceholderMapUseCase } from './usecases/placeholder-enrichmen
ExtractDefaultsUsecase,
CollectPlaceholdersFromTipTapSchemaUsecase,
TransformPlaceholderMapUseCase,
WorkflowTestDataUseCase,
],
})
export class WorkflowModule implements NestModule {
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
"dependencies": {
"@clerk/clerk-react": "^5.2.5",
"@hookform/resolvers": "^3.9.0",
"@monaco-editor/react": "^4.6.0",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The payload JSON field is displayed in the monaco-editor. I decided to use it because we will also use it in the Step Editor.

"@novu/react": "^2.3.0",
"@novu/shared": "workspace:*",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0",
Expand All @@ -49,6 +51,7 @@
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.439.0",
"mixpanel-browser": "^2.52.0",
"monaco-editor": "^0.39.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
27 changes: 25 additions & 2 deletions apps/dashboard/src/api/workflows.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import type { CreateWorkflowDto, UpdateWorkflowDto, WorkflowResponseDto } from '@novu/shared';
import { getV2, postV2, putV2 } from './api.client';
import type {
CreateWorkflowDto,
UpdateWorkflowDto,
WorkflowResponseDto,
WorkflowTestDataResponseDto,
} from '@novu/shared';
import { getV2, post, postV2, putV2 } from './api.client';

export const fetchWorkflow = async ({ workflowId }: { workflowId?: string }): Promise<WorkflowResponseDto> => {
const { data } = await getV2<{ data: WorkflowResponseDto }>(`/workflows/${workflowId}`);

return data;
};

export const fetchWorkflowTestData = async ({
workflowId,
}: {
workflowId?: string;
}): Promise<WorkflowTestDataResponseDto> => {
const { data } = await getV2<{ data: WorkflowTestDataResponseDto }>(`/workflows/${workflowId}/test-data`);

return data;
};

export async function triggerWorkflow({ name, payload, to }: { name: string; payload: unknown; to: unknown }) {
return post<{ data: { transactionId: string } }>(`/events/trigger`, {
name,
to,
payload: { ...(payload ?? {}), __source: 'dashboard' },
});
}

export async function createWorkflow(payload: CreateWorkflowDto) {
return postV2<{ data: WorkflowResponseDto }>(`/workflows`, payload);
}
Expand Down
25 changes: 25 additions & 0 deletions apps/dashboard/src/components/icons/code-2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const Code2: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" {...props}>
<g clipPath="url(#a)">
<g clipPath="url(#b)">
<mask id="c" width="14" height="14" x="3" y="3" maskUnits="userSpaceOnUse" style={{ maskType: 'luminance' }}>
<path fill="#fff" d="M17 3H3v14h14V3Z" />
</mask>
<g fill="#7D52F4" fillRule="evenodd" clipRule="evenodd" mask="url(#c)">
<path d="M6.338 4.32c.5-.28 1.045-.37 1.412-.37a.55.55 0 1 1 0 1.1c-.216 0-.57.06-.875.23-.288.161-.515.408-.582.81-.052.31-.055.678-.059 1.116v.057c-.004.438-.01.94-.1 1.404-.09.467-.274.958-.683 1.327-.415.375-.984.556-1.7.556a.55.55 0 1 1 0-1.1c.533 0 .807-.132.962-.272.162-.146.274-.374.34-.72.068-.349.077-.753.08-1.205l.002-.088c.003-.406.006-.857.073-1.255.133-.797.613-1.3 1.13-1.59Z" />
<path d="M6.338 15.68c.5.28 1.045.37 1.412.37a.55.55 0 0 0 0-1.1c-.216 0-.57-.06-.875-.23-.288-.16-.515-.407-.582-.81-.052-.31-.055-.678-.059-1.116v-.056c-.004-.439-.01-.941-.1-1.404-.09-.467-.274-.958-.683-1.328-.415-.375-.984-.556-1.7-.556a.55.55 0 0 0 0 1.1c.533 0 .807.132.962.272.162.147.274.374.34.72.068.35.077.753.08 1.205l.002.089c.003.406.006.857.073 1.255.133.797.613 1.3 1.13 1.59ZM13.662 4.32c-.499-.28-1.045-.37-1.412-.37a.55.55 0 1 0 0 1.1c.217 0 .57.06.875.23.288.161.515.408.583.81.051.31.054.678.058 1.116v.057c.005.438.011.94.1 1.404.09.467.275.958.683 1.327.415.375.985.556 1.701.556a.55.55 0 0 0 0-1.1c-.533 0-.808-.132-.963-.272-.162-.146-.274-.374-.34-.72-.068-.349-.076-.753-.08-1.205l-.001-.088c-.003-.406-.007-.857-.073-1.255-.133-.797-.614-1.3-1.13-1.59Z" />
<path d="M13.662 15.68c-.499.28-1.045.37-1.412.37a.55.55 0 0 1 0-1.1c.217 0 .57-.06.875-.23.288-.16.515-.407.583-.81.051-.31.054-.678.058-1.116v-.056c.005-.439.011-.941.1-1.404.09-.467.275-.958.683-1.328.415-.375.985-.556 1.701-.556a.55.55 0 0 1 0 1.1c-.533 0-.808.132-.963.272-.162.147-.274.374-.34.72-.068.35-.076.753-.08 1.205l-.001.089c-.003.406-.007.857-.073 1.255-.133.797-.614 1.3-1.13 1.59ZM8.111 8.111a.55.55 0 0 1 .778 0l3 3a.55.55 0 1 1-.778.778l-3-3a.55.55 0 0 1 0-.778Z" />
<path d="M11.89 8.111a.55.55 0 0 0-.779 0l-3 3a.55.55 0 1 0 .778.778l3-3a.55.55 0 0 0 0-.778Z" />
</g>
</g>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M3 3h14v14H3z" />
</clipPath>
<clipPath id="b">
<path fill="#fff" d="M3 3h14v14H3z" />
</clipPath>
</defs>
</svg>
);
60 changes: 60 additions & 0 deletions apps/dashboard/src/components/primitives/accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as React from 'react';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, I started to create the Panel component as collapsible, but then later, we decided that there was no point in having the payload field collapsible on the Test Workflow Page. So, I left this component.

import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';

import { cn } from '@/utils/ui';

const Accordion = AccordionPrimitive.Root;

const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('bg-neutral-alpha-50 flex flex-col gap-2 rounded-lg border border-neutral-200 p-2', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';

type AccordionTriggerProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
withChevron?: boolean;
};

const AccordionTrigger = React.forwardRef<React.ElementRef<typeof AccordionPrimitive.Trigger>, AccordionTriggerProps>(
({ className, children, withChevron = true, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between text-sm font-medium transition-all [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
{withChevron && (
<ChevronDownIcon className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
)}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
);
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;

const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;

export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
30 changes: 22 additions & 8 deletions apps/dashboard/src/components/primitives/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { useState } from 'react';
import { RiFileCopyLine } from 'react-icons/ri';
import { Button } from './button';
import { RiCheckLine, RiFileCopyLine } from 'react-icons/ri';
import { Button, ButtonProps } from './button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
import { cn } from '@/utils/ui';

type CopyButtonProps = {
content: string;
value?: string;
className?: string;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
};

export const CopyButton: React.FC<CopyButtonProps> = ({ content, className }) => {
export const CopyButton: React.FC<CopyButtonProps> = ({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Improved the CopyButton so it can be better reused; fixed the issue with the tooltip disappearing

value,
content,
className,
variant = 'outline',
size = 'icon',
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isCopied, setIsCopied] = useState(false);

const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement>) => {
Expand All @@ -24,16 +35,19 @@ export const CopyButton: React.FC<CopyButtonProps> = ({ content, className }) =>

return (
<TooltipProvider>
<Tooltip>
<Tooltip open={isHovered}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={className}
variant={variant}
size={size}
className={cn('flex items-center gap-1', className)}
onClick={copyToClipboard}
aria-label="Copy to clipboard"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<RiFileCopyLine className="h-4 w-4" />
{isCopied ? <RiCheckLine className="size-4" /> : <RiFileCopyLine className="size-4" />}
{value && <span>{value}</span>}
</Button>
</TooltipTrigger>
<TooltipContent>
Expand Down
42 changes: 42 additions & 0 deletions apps/dashboard/src/components/primitives/panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import { cn } from '@/utils/ui';

const Panel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ children, className, ...restDivProps }, ref) => {
return (
<div
ref={ref}
className={cn('bg-neutral-alpha-50 flex flex-col gap-2 rounded-lg border border-neutral-200 p-2', className)}
{...restDivProps}
>
{children}
</div>
);
}
);

const PanelHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ children, ...restDivProps }, ref) => {
return (
<div ref={ref} className="flex items-center gap-1 text-sm font-medium" {...restDivProps}>
{children}
</div>
);
}
);

const PanelContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ children, className, ...restDivProps }, ref) => {
return (
<div
ref={ref}
className={cn('bg-background border-neutral-alpha-200 h-full rounded-lg border border-dashed p-3', className)}
{...restDivProps}
>
{children}
</div>
);
}
);

export { Panel, PanelHeader, PanelContent };
16 changes: 12 additions & 4 deletions apps/dashboard/src/components/primitives/sonner-helpers.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { ReactNode } from 'react';
import { ExternalToast, toast } from 'sonner';
import { SmallToast } from './sonner';
import { Toast, ToastProps } from './sonner';
import { ReactNode } from 'react';

export const smallToast = ({ children, options }: { children: ReactNode; options: ExternalToast }) => {
return toast(<SmallToast>{children}</SmallToast>, {
export const showToast = ({
options,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

improved the toast helper function so we can pass the close function to the tooltip body and use it there to close the tooltip with custom button

children,
...toastProps
}: Omit<ToastProps, 'children'> & {
options: ExternalToast;
children: (args: { close: () => void }) => ReactNode;
}) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you maybe need the instance of the toast as the first argument in the close function so that you can control the dismissal from the caller of the close function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the close in this case is the internal implementation of the toast; the problem that I faced was that I couldn't reference the id in the content component to close the toast
const id = toast(<div onClick={() => toast.dismiss(id) <--- error }></div>, ...)

return toast.custom((id) => <Toast {...toastProps}>{children({ close: () => toast.dismiss(id) })}</Toast>, {
duration: 5000,
unstyled: true,
closeButton: false,
...options,
});
};
Loading
Loading