From c5d5c72d0eab9b6bec77e8874ad23c79a4a79fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82?= Date: Thu, 24 Oct 2024 23:45:26 +0200 Subject: [PATCH 1/5] feat(dashboard): test workflow functionality --- .../usecases/test-data/test-data.command.ts | 8 + .../usecases/test-data/test-data.usecase.ts | 57 + .../app/workflows-v2/workflow.controller.ts | 17 +- .../src/app/workflows-v2/workflow.module.ts | 2 + apps/dashboard/package.json | 3 + apps/dashboard/src/api/workflows.ts | 23 +- .../dashboard/src/components/icons/code-2.tsx | 25 + .../src/components/primitives/accordion.tsx | 60 + .../src/components/primitives/copy-button.tsx | 30 +- .../src/components/primitives/panel.tsx | 42 + .../components/primitives/sonner-helpers.tsx | 16 +- .../src/components/primitives/sonner.tsx | 86 +- .../src/components/primitives/tabs.tsx | 2 +- .../src/components/primitives/variants.ts | 2 +- .../workflow-editor/configure-workflow.tsx | 4 +- .../workflow-editor/editor-breadcrumbs.tsx | 63 + .../src/components/workflow-editor/schema.ts | 69 +- .../test-workflow/test-workflow-form.tsx | 205 + .../test-workflow-logs-sidebar.tsx | 3 + .../test-workflow/test-workflow-tabs.tsx | 120 + .../workflow-editor-provider.tsx | 16 +- .../workflow-editor/workflow-editor.tsx | 46 +- .../src/hooks/use-fetch-workflow-test-data.ts | 20 + .../src/hooks/use-trigger-workflow.ts | 16 + apps/dashboard/src/main.tsx | 5 + apps/dashboard/src/pages/edit-workflow.tsx | 75 +- apps/dashboard/src/pages/test-workflow.tsx | 18 + apps/dashboard/src/utils/code-snippets.ts | 193 + apps/dashboard/src/utils/query-keys.ts | 1 + apps/dashboard/src/utils/routes.ts | 1 + apps/dashboard/src/utils/string.ts | 3 + apps/dashboard/tailwind.config.js | 20 + apps/dashboard/vite.config.ts | 6 + .../src/pages/activities/ActivitiesPage.tsx | 19 +- packages/shared/src/dto/workflows/index.ts | 1 + .../workflow-test-data-response-dto.ts | 12 + pnpm-lock.yaml | 3423 ++++++----------- 37 files changed, 2362 insertions(+), 2350 deletions(-) create mode 100644 apps/api/src/app/workflows-v2/usecases/test-data/test-data.command.ts create mode 100644 apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts create mode 100644 apps/dashboard/src/components/icons/code-2.tsx create mode 100644 apps/dashboard/src/components/primitives/accordion.tsx create mode 100644 apps/dashboard/src/components/primitives/panel.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/editor-breadcrumbs.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-form.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-tabs.tsx create mode 100644 apps/dashboard/src/hooks/use-fetch-workflow-test-data.ts create mode 100644 apps/dashboard/src/hooks/use-trigger-workflow.ts create mode 100644 apps/dashboard/src/pages/test-workflow.tsx create mode 100644 apps/dashboard/src/utils/code-snippets.ts create mode 100644 apps/dashboard/src/utils/string.ts create mode 100644 packages/shared/src/dto/workflows/workflow-test-data-response-dto.ts diff --git a/apps/api/src/app/workflows-v2/usecases/test-data/test-data.command.ts b/apps/api/src/app/workflows-v2/usecases/test-data/test-data.command.ts new file mode 100644 index 00000000000..34c92963f32 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/test-data/test-data.command.ts @@ -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; +} diff --git a/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts b/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts new file mode 100644 index 00000000000..bf1e5d8f583 --- /dev/null +++ b/apps/api/src/app/workflows-v2/usecases/test-data/test-data.usecase.ts @@ -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 { + const _workflowEntity: NotificationTemplateEntity | null = await this.getWorkflowByIdsUseCase.execute( + GetWorkflowByIdsCommand.create({ + ...command, + identifierOrInternalId: command.identifierOrInternalId, + }) + ); + + return { + to: buildToFieldSchema({ user: command.user }), + payload: buildPayloadSchema(), + }; + } +} diff --git a/apps/api/src/app/workflows-v2/workflow.controller.ts b/apps/api/src/app/workflows-v2/workflow.controller.ts index a0b0ffdcd73..3c417f06c6f 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.ts @@ -24,6 +24,7 @@ import { UpdateWorkflowDto, UserSessionData, WorkflowResponseDto, + WorkflowTestDataResponseDto, } from '@novu/shared'; import { UserAuthGuard, UserSession } from '@novu/application-generic'; @@ -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' }) @@ -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('') @@ -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 { + return this.workflowTestDataUseCase.execute( + WorkflowTestDataCommand.create({ identifierOrInternalId: workflowId, user }) + ); + } } diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index d011c0480da..c7646ebc238 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -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], @@ -47,6 +48,7 @@ import { TransformPlaceholderMapUseCase } from './usecases/placeholder-enrichmen ExtractDefaultsUsecase, CollectPlaceholdersFromTipTapSchemaUsecase, TransformPlaceholderMapUseCase, + WorkflowTestDataUseCase, ], }) export class WorkflowModule implements NestModule { diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index acb98bafd3f..34903e49a73 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -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", @@ -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", diff --git a/apps/dashboard/src/api/workflows.ts b/apps/dashboard/src/api/workflows.ts index b485e6bf7b3..190c2c13dad 100644 --- a/apps/dashboard/src/api/workflows.ts +++ b/apps/dashboard/src/api/workflows.ts @@ -1,5 +1,10 @@ -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 => { const { data } = await getV2<{ data: WorkflowResponseDto }>(`/workflows/${workflowId}`); @@ -7,6 +12,20 @@ export const fetchWorkflow = async ({ workflowId }: { workflowId?: string }): Pr return data; }; +export const fetchWorkflowTestData = async ({ + workflowId, +}: { + workflowId?: string; +}): Promise => { + 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); } diff --git a/apps/dashboard/src/components/icons/code-2.tsx b/apps/dashboard/src/components/icons/code-2.tsx new file mode 100644 index 00000000000..18b34549b6e --- /dev/null +++ b/apps/dashboard/src/components/icons/code-2.tsx @@ -0,0 +1,25 @@ +export const Code2: React.FC> = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/apps/dashboard/src/components/primitives/accordion.tsx b/apps/dashboard/src/components/primitives/accordion.tsx new file mode 100644 index 00000000000..82d81032368 --- /dev/null +++ b/apps/dashboard/src/components/primitives/accordion.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +type AccordionTriggerProps = React.ComponentPropsWithoutRef & { + withChevron?: boolean; +}; + +const AccordionTrigger = React.forwardRef, AccordionTriggerProps>( + ({ className, children, withChevron = true, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + {withChevron && ( + + )} + + + ) +); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/dashboard/src/components/primitives/copy-button.tsx b/apps/dashboard/src/components/primitives/copy-button.tsx index 6a288a341c5..1fd0e56e6de 100644 --- a/apps/dashboard/src/components/primitives/copy-button.tsx +++ b/apps/dashboard/src/components/primitives/copy-button.tsx @@ -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 = ({ content, className }) => { +export const CopyButton: React.FC = ({ + value, + content, + className, + variant = 'outline', + size = 'icon', +}) => { + const [isHovered, setIsHovered] = useState(false); const [isCopied, setIsCopied] = useState(false); const copyToClipboard = async (e: React.MouseEvent) => { @@ -24,16 +35,19 @@ export const CopyButton: React.FC = ({ content, className }) => return ( - + diff --git a/apps/dashboard/src/components/primitives/panel.tsx b/apps/dashboard/src/components/primitives/panel.tsx new file mode 100644 index 00000000000..5ec6472ffd4 --- /dev/null +++ b/apps/dashboard/src/components/primitives/panel.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { cn } from '@/utils/ui'; + +const Panel = React.forwardRef>( + ({ children, className, ...restDivProps }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +const PanelHeader = React.forwardRef>( + ({ children, ...restDivProps }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +const PanelContent = React.forwardRef>( + ({ children, className, ...restDivProps }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +export { Panel, PanelHeader, PanelContent }; diff --git a/apps/dashboard/src/components/primitives/sonner-helpers.tsx b/apps/dashboard/src/components/primitives/sonner-helpers.tsx index c8b330c515c..20d3b52674e 100644 --- a/apps/dashboard/src/components/primitives/sonner-helpers.tsx +++ b/apps/dashboard/src/components/primitives/sonner-helpers.tsx @@ -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({children}, { +export const showToast = ({ + options, + children, + ...toastProps +}: Omit & { + options: ExternalToast; + children: (args: { close: () => void }) => ReactNode; +}) => { + return toast.custom((id) => {children({ close: () => toast.dismiss(id) })}, { duration: 5000, unstyled: true, + closeButton: false, ...options, }); }; diff --git a/apps/dashboard/src/components/primitives/sonner.tsx b/apps/dashboard/src/components/primitives/sonner.tsx index c1794f2565f..886adfc9fc4 100644 --- a/apps/dashboard/src/components/primitives/sonner.tsx +++ b/apps/dashboard/src/components/primitives/sonner.tsx @@ -1,21 +1,84 @@ -import { cn } from '@/utils/ui'; +import { cva, VariantProps } from 'class-variance-authority'; import { useTheme } from 'next-themes'; +import React from 'react'; +import { IconBaseProps } from 'react-icons/lib'; +import { + RiAlertFill, + RiCheckboxCircleFill, + RiCloseLine, + RiErrorWarningFill, + RiInformationFill, + RiProgress1Line, +} from 'react-icons/ri'; import { Toaster as Sonner } from 'sonner'; +import { Button } from './button'; +import { cn } from '@/utils/ui'; type ToasterProps = React.ComponentProps; -const SmallToast = ({ children, className, ...props }: React.HTMLAttributes) => { +const toastVariants = cva( + 'text-foreground-950 text-sm border-neutral-alpha-200 flex items-start gap-1 border shadow-md bg-background', + { + variants: { + variant: { + default: 'rounded-lg p-2', + md: 'rounded-lg px-2.5 py-2', + lg: 'rounded-xl p-3.5', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export type ToastProps = React.HTMLAttributes & VariantProps; + +const Toast = React.forwardRef(({ children, className, variant, ...props }, ref) => { return ( -
+
{children}
); +}); + +const toastIconVariants = cva('min-w-5 size-5 p-[2px]', { + variants: { + variant: { + default: 'fill-foreground-950', + success: 'fill-success', + error: 'fill-destructive', + warning: 'fill-warning', + info: 'fill-information', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +type ToastIconProps = IconBaseProps & VariantProps; + +const VARIANT_ICONS = { + success: RiCheckboxCircleFill, + info: RiInformationFill, + warning: RiAlertFill, + error: RiErrorWarningFill, + default: RiProgress1Line, +}; + +const ToastIcon = ({ className, variant = 'default', ...props }: ToastIconProps) => { + const Icon = VARIANT_ICONS[variant as keyof typeof VARIANT_ICONS]; + + return ; +}; + +const ToastClose = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ); }; const Toaster = ({ ...props }: ToasterProps) => { @@ -33,10 +96,13 @@ const Toaster = ({ ...props }: ToasterProps) => { actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground', }, + style: { + height: 'initial', + }, }} {...props} /> ); }; -export { Toaster, SmallToast }; +export { Toaster, Toast, ToastIcon, ToastClose }; diff --git a/apps/dashboard/src/components/primitives/tabs.tsx b/apps/dashboard/src/components/primitives/tabs.tsx index f464612ad85..f194a5d7a61 100644 --- a/apps/dashboard/src/components/primitives/tabs.tsx +++ b/apps/dashboard/src/components/primitives/tabs.tsx @@ -25,7 +25,7 @@ const TabsTrigger = React.forwardRef< >(); + const { control } = useFormContext>(); return (