Skip to content

Commit

Permalink
feat(dashboard): test workflow functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
LetItRock committed Oct 24, 2024
1 parent 74a2e9a commit c5d5c72
Show file tree
Hide file tree
Showing 37 changed files with 2,362 additions and 2,350 deletions.
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 {
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(),
};
}
}
17 changes: 16 additions & 1 deletion 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 @@ -40,6 +41,8 @@ import { DeleteWorkflowCommand } from './usecases/delete-workflow/delete-workflo
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 { 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 @@ -52,7 +55,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 @@ -135,4 +139,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",
"@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 @@ -48,6 +50,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
23 changes: 21 additions & 2 deletions apps/dashboard/src/api/workflows.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
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 });
}

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';
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> = ({
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,
children,
...toastProps
}: Omit<ToastProps, 'children'> & {
options: ExternalToast;
children: (args: { close: () => void }) => ReactNode;
}) => {
return toast.custom((id) => <Toast {...toastProps}>{children({ close: () => toast.dismiss(id) })}</Toast>, {
duration: 5000,
unstyled: true,
closeButton: false,
...options,
});
};
Loading

0 comments on commit c5d5c72

Please sign in to comment.