diff --git a/apps/api/src/app/workflows-v2/workflow.controller.ts b/apps/api/src/app/workflows-v2/workflow.controller.ts index 4328ae85fbf..4d05d99ec2f 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.ts @@ -1,4 +1,4 @@ -import { ApiTags } from '@nestjs/swagger'; +import { ClassSerializerInterceptor, HttpStatus, Patch } from '@nestjs/common'; import { Body, Controller, @@ -12,7 +12,8 @@ import { UseGuards, UseInterceptors, } from '@nestjs/common/decorators'; -import { ClassSerializerInterceptor, HttpStatus, Patch } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { DeleteWorkflowCommand, DeleteWorkflowUseCase, UserAuthGuard, UserSession } from '@novu/application-generic'; import { CreateWorkflowDto, DirectionEnum, @@ -30,20 +31,10 @@ import { WorkflowResponseDto, WorkflowTestDataResponseDto, } from '@novu/shared'; -import { DeleteWorkflowCommand, DeleteWorkflowUseCase, UserAuthGuard, UserSession } from '@novu/application-generic'; import { ApiCommonResponses } from '../shared/framework/response.decorator'; import { UserAuthentication } from '../shared/framework/swagger/api.key.security'; -import { GetWorkflowCommand } from './usecases/get-workflow/get-workflow.command'; -import { UpsertWorkflowUseCase } from './usecases/upsert-workflow/upsert-workflow.usecase'; -import { UpsertWorkflowCommand } from './usecases/upsert-workflow/upsert-workflow.command'; -import { GetWorkflowUseCase } from './usecases/get-workflow/get-workflow.usecase'; -import { ListWorkflowsUseCase } from './usecases/list-workflows/list-workflow.usecase'; -import { ListWorkflowsCommand } from './usecases/list-workflows/list-workflows.command'; -import { SyncToEnvironmentUseCase } from './usecases/sync-to-environment/sync-to-environment.usecase'; -import { SyncToEnvironmentCommand } from './usecases/sync-to-environment/sync-to-environment.command'; -import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview.usecase'; -import { ParseSlugIdPipe } from './pipes/parse-slug-id.pipe'; import { ParseSlugEnvironmentIdPipe } from './pipes/parse-slug-env-id.pipe'; +import { ParseSlugIdPipe } from './pipes/parse-slug-id.pipe'; import { BuildStepDataCommand, BuildStepDataUsecase, @@ -51,9 +42,18 @@ import { WorkflowTestDataCommand, } from './usecases'; import { GeneratePreviewCommand } from './usecases/generate-preview/generate-preview.command'; +import { GeneratePreviewUsecase } from './usecases/generate-preview/generate-preview.usecase'; +import { GetWorkflowCommand } from './usecases/get-workflow/get-workflow.command'; +import { GetWorkflowUseCase } from './usecases/get-workflow/get-workflow.usecase'; +import { ListWorkflowsUseCase } from './usecases/list-workflows/list-workflow.usecase'; +import { ListWorkflowsCommand } from './usecases/list-workflows/list-workflows.command'; import { PatchStepCommand } from './usecases/patch-step-data'; -import { PatchWorkflowCommand, PatchWorkflowUsecase } from './usecases/patch-workflow'; import { PatchStepUsecase } from './usecases/patch-step-data/patch-step.usecase'; +import { PatchWorkflowCommand, PatchWorkflowUsecase } from './usecases/patch-workflow'; +import { SyncToEnvironmentCommand } from './usecases/sync-to-environment/sync-to-environment.command'; +import { SyncToEnvironmentUseCase } from './usecases/sync-to-environment/sync-to-environment.usecase'; +import { UpsertWorkflowCommand } from './usecases/upsert-workflow/upsert-workflow.command'; +import { UpsertWorkflowUseCase } from './usecases/upsert-workflow/upsert-workflow.usecase'; @ApiCommonResponses() @Controller({ path: `/workflows`, version: '2' }) diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index 89331019861..28a04687111 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -5,19 +5,20 @@ import { DeleteWorkflowUseCase, GetPreferences, GetWorkflowByIdsUseCase, + TierRestrictionsValidateUsecase, UpdateWorkflow, UpsertControlValuesUseCase, UpsertPreferences, - TierRestrictionsValidateUsecase, } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; -import { SharedModule } from '../shared/shared.module'; -import { MessageTemplateModule } from '../message-template/message-template.module'; -import { ChangeModule } from '../change/change.module'; import { AuthModule } from '../auth/auth.module'; +import { BridgeModule } from '../bridge'; +import { ChangeModule } from '../change/change.module'; +import { HydrateEmailSchemaUseCase } from '../environments-v1/usecases/output-renderers'; import { IntegrationModule } from '../integrations/integrations.module'; -import { WorkflowController } from './workflow.controller'; +import { MessageTemplateModule } from '../message-template/message-template.module'; +import { SharedModule } from '../shared/shared.module'; import { BuildVariableSchemaUsecase, BuildStepDataUsecase, @@ -28,12 +29,11 @@ import { SyncToEnvironmentUseCase, UpsertWorkflowUseCase, } from './usecases'; -import { BridgeModule } from '../bridge'; -import { HydrateEmailSchemaUseCase } from '../environments-v1/usecases/output-renderers'; import { PatchWorkflowUsecase } from './usecases/patch-workflow'; import { PatchStepUsecase } from './usecases/patch-step-data/patch-step.usecase'; import { BuildPayloadSchema } from './usecases/build-payload-schema/build-payload-schema.usecase'; import { BuildStepIssuesUsecase } from './usecases/build-step-issues/build-step-issues.usecase'; +import { WorkflowController } from './workflow.controller'; const DAL_REPOSITORIES = [CommunityOrganizationRepository]; diff --git a/apps/dashboard/public/images/dots.svg b/apps/dashboard/public/images/dots.svg new file mode 100644 index 00000000000..3d26324aecb --- /dev/null +++ b/apps/dashboard/public/images/dots.svg @@ -0,0 +1,386 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/src/components/create-workflow-button.tsx b/apps/dashboard/src/components/create-workflow-button.tsx index 96cf427cd45..5615896eba6 100644 --- a/apps/dashboard/src/components/create-workflow-button.tsx +++ b/apps/dashboard/src/components/create-workflow-button.tsx @@ -1,14 +1,4 @@ -import { createWorkflow } from '@/api/workflows'; import { Button } from '@/components/primitives/button'; -import { - Form, - FormControl, - FormField, - FormInput, - FormItem, - FormLabel, - FormMessage, -} from '@/components/primitives/form/form'; import { Separator } from '@/components/primitives/separator'; import { Sheet, @@ -20,61 +10,27 @@ import { SheetTitle, SheetTrigger, } from '@/components/primitives/sheet'; -import { TagInput } from '@/components/primitives/tag-input'; -import { Textarea } from '@/components/primitives/textarea'; import { ExternalLink } from '@/components/shared/external-link'; -import { useEnvironment } from '@/context/environment/hooks'; -import { useTags } from '@/hooks/use-tags'; -import { QueryKeys } from '@/utils/query-keys'; -import { buildRoute, ROUTES } from '@/utils/routes'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { type CreateWorkflowDto, slugify, WorkflowCreationSourceEnum } from '@novu/shared'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ComponentProps, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { CreateWorkflowForm } from '@/components/workflow-editor/create-workflow-form'; +import { useCreateWorkflow } from '@/hooks/use-create-workflow'; +import { ComponentProps, forwardRef, useState } from 'react'; import { RiArrowRightSLine } from 'react-icons/ri'; -import { useNavigate } from 'react-router-dom'; import { z } from 'zod'; -import { MAX_DESCRIPTION_LENGTH, MAX_TAG_ELEMENTS, workflowSchema } from './workflow-editor/schema'; - -type CreateWorkflowButtonProps = ComponentProps; +import { workflowSchema } from './workflow-editor/schema'; -export const CreateWorkflowButton = (props: CreateWorkflowButtonProps) => { - const queryClient = useQueryClient(); - const navigate = useNavigate(); - const { currentEnvironment } = useEnvironment(); +export const CreateWorkflowButton = forwardRef>((props, ref) => { const [isOpen, setIsOpen] = useState(false); - // TODO: Move to a use-create-workflow.ts hook - const { mutateAsync, isPending } = useMutation({ - mutationFn: async (workflow: CreateWorkflowDto) => createWorkflow({ environment: currentEnvironment!, workflow }), - onSuccess: async (result) => { - await queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id] }); - await queryClient.invalidateQueries({ - queryKey: [QueryKeys.fetchWorkflow, currentEnvironment?._id, result.data.workflowId], - }); - queryClient.invalidateQueries({ - queryKey: [QueryKeys.fetchTags, currentEnvironment?._id], - }); - setIsOpen(false); - form.reset(); - navigate( - buildRoute(ROUTES.EDIT_WORKFLOW, { - environmentSlug: currentEnvironment?.slug ?? '', - workflowSlug: result.data.slug ?? '', - }) - ); - }, + const { submit, isLoading: isCreating } = useCreateWorkflow({ + onSuccess: () => setIsOpen(false), }); - const { tags } = useTags(); - const form = useForm>({ - resolver: zodResolver(workflowSchema), - defaultValues: { description: '', workflowId: '', name: '', tags: [] }, - }); + const handleSubmit = (values: z.infer) => { + submit(values); + }; return ( - + e.preventDefault()}> Create workflow @@ -87,113 +43,12 @@ export const CreateWorkflowButton = (props: CreateWorkflowButtonProps) => { - - { - mutateAsync({ - name: values.name, - steps: [], - __source: WorkflowCreationSourceEnum.DASHBOARD, - workflowId: values.workflowId, - description: values.description || undefined, - tags: values.tags, - }); - })} - className="flex flex-col gap-4" - > - ( - - Name - - { - field.onChange(e); - form.setValue('workflowId', slugify(e.target.value)); - }} - /> - - - - )} - /> - - ( - - Identifier - - - - - - )} - /> - - - - ( - - - - Add tags - - - - tag.name)} - {...field} - value={field.value ?? []} - onChange={(tags) => { - field.onChange(tags); - form.setValue('tags', tags, { shouldValidate: true }); - }} - /> - - - - )} - /> - - ( - - - Description - - - - - - - )} - /> - - + { ); -}; +}); + +CreateWorkflowButton.displayName = 'CreateWorkflowButton'; diff --git a/apps/dashboard/src/components/primitives/button-group.tsx b/apps/dashboard/src/components/primitives/button-group.tsx index 65a5281c876..6c1071d2fd2 100644 --- a/apps/dashboard/src/components/primitives/button-group.tsx +++ b/apps/dashboard/src/components/primitives/button-group.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; import { PolymorphicComponentProps } from '@/utils/polymorphic'; import { recursiveCloneChildren } from '@/utils/recursive-clone-children'; @@ -141,4 +141,4 @@ function ButtonGroupIcon({ } ButtonGroupIcon.displayName = BUTTON_GROUP_ICON_NAME; -export { ButtonGroupRoot as Root, ButtonGroupItem as Item, ButtonGroupIcon as Icon }; +export { ButtonGroupIcon, ButtonGroupItem, ButtonGroupRoot }; diff --git a/apps/dashboard/src/components/primitives/hover-card.tsx b/apps/dashboard/src/components/primitives/hover-card.tsx index 2f5f7ae23ad..09f6465b7f7 100644 --- a/apps/dashboard/src/components/primitives/hover-card.tsx +++ b/apps/dashboard/src/components/primitives/hover-card.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; +import * as React from 'react'; import { cn } from '@/utils/ui'; @@ -24,4 +24,8 @@ const HoverCardContent = React.forwardRef< )); HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; -export { HoverCard, HoverCardTrigger, HoverCardContent }; +const HoverCardPortal = HoverCardPrimitive.Portal; + +const HoverCardArrow = HoverCardPrimitive.Arrow; + +export { HoverCard, HoverCardArrow, HoverCardContent, HoverCardPortal, HoverCardTrigger }; diff --git a/apps/dashboard/src/components/step-preview-hover-card.tsx b/apps/dashboard/src/components/step-preview-hover-card.tsx new file mode 100644 index 00000000000..691b30cd188 --- /dev/null +++ b/apps/dashboard/src/components/step-preview-hover-card.tsx @@ -0,0 +1,113 @@ +import { + ChannelTypeEnum, + ChatRenderOutput, + GeneratePreviewResponseDto, + InAppRenderOutput, + PushRenderOutput, + StepTypeEnum, +} from '@novu/shared'; +import { ChatPreview } from './workflow-editor/steps/chat/chat-preview'; +import { EmailPreviewHeader, EmailPreviewSubject } from './workflow-editor/steps/email/email-preview'; +import { Maily } from './workflow-editor/steps/email/maily'; +import { InboxPreview } from './workflow-editor/steps/in-app/inbox-preview'; +import { PushPreview } from './workflow-editor/steps/push/push-preview'; +import { SmsPhone } from './workflow-editor/steps/sms/sms-phone'; + +export type StepType = StepTypeEnum; + +interface StepPreviewProps { + type: StepType; + controlValues?: any; +} + +export function StepPreview({ type, controlValues }: StepPreviewProps) { + if (type === StepTypeEnum.TRIGGER || type === StepTypeEnum.DELAY || type === StepTypeEnum.DIGEST) { + return null; + } + + if (type === StepTypeEnum.IN_APP) { + const { subject, body } = controlValues; + + return ( + + ); + } + + if (type === StepTypeEnum.EMAIL) { + const { subject, body } = controlValues; + + return ( + + + + + + + + ); + } + + if (type === StepTypeEnum.SMS) { + const { body } = controlValues; + + return ( + + + + ); + } + + if (type === StepTypeEnum.CHAT) { + const { body } = controlValues; + const mockPreviewData: GeneratePreviewResponseDto = { + result: { + type: ChannelTypeEnum.CHAT as const, + preview: { + body, + content: body, + } as ChatRenderOutput, + }, + previewPayloadExample: {}, + }; + + return ( + + + + ); + } + + if (type === StepTypeEnum.PUSH) { + const { subject, body } = controlValues; + const mockPreviewData: GeneratePreviewResponseDto = { + result: { + type: ChannelTypeEnum.PUSH as const, + preview: { + subject, + body, + title: subject, + content: body, + } as PushRenderOutput, + }, + previewPayloadExample: {}, + }; + + return ( + + + + ); + } +} diff --git a/apps/dashboard/src/components/template-store/components/workflow-results.tsx b/apps/dashboard/src/components/template-store/components/workflow-results.tsx new file mode 100644 index 00000000000..95d6e615f9d --- /dev/null +++ b/apps/dashboard/src/components/template-store/components/workflow-results.tsx @@ -0,0 +1,33 @@ +import { IWorkflowSuggestion } from '../templates/types'; +import { WorkflowMode } from '../types'; +import { WorkflowCard } from '../workflow-card'; + +type WorkflowResultsProps = { + mode: WorkflowMode; + suggestions: IWorkflowSuggestion[]; + onClick: (template: IWorkflowSuggestion) => void; +}; + +export function WorkflowResults({ mode, suggestions, onClick }: WorkflowResultsProps) { + return ( + + {suggestions.map((template) => { + return ( + { + onClick(template); + }} + key={template.id} + name={template.name} + description={template.description || ''} + steps={template.workflowDefinition.steps.map((step) => step.type)} + /> + ); + })} + + ); +} diff --git a/apps/dashboard/src/components/template-store/templates/access-token.ts b/apps/dashboard/src/components/template-store/templates/access-token.ts new file mode 100644 index 00000000000..113e85653ab --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/access-token.ts @@ -0,0 +1,28 @@ +import { StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { WorkflowTemplate } from './types'; + +export const accessTokenTemplate: WorkflowTemplate = { + id: 'access-token', + name: 'Access Token', + description: 'Notify a user about a creation of a personal access token in their GitHub account', + category: 'authentication', + isPopular: false, + workflowDefinition: { + name: 'Access Token', + description: 'Notify a user about a creation of a personal access token in their GitHub account', + workflowId: 'git-hub-access-token', + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + body: '{"type":"doc","content":[{"type":"image","attrs":{"src":"https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg","alt":null,"title":null,"width":57,"height":57,"alignment":"left","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"heading","attrs":{"textAlign":"left","level":3},"content":[{"type":"variable","attrs":{"id":"payload.username","label":null,"fallback":"John Doe","required":false},"marks":[{"type":"bold"}]},{"type":"text","text":", a personal access token was created on your account."}]},{"type":"section","attrs":{"borderRadius":6,"backgroundColor":"#f7f7f7","align":"left","borderWidth":1,"borderColor":"#e2e2e2","paddingTop":12,"paddingRight":12,"paddingBottom":12,"paddingLeft":12,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Hey "},{"type":"variable","attrs":{"id":"payload.username","label":null,"fallback":"John Doe","required":false},"marks":[{"type":"bold"}]},{"type":"text","marks":[{"type":"bold"}],"text":"!"}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"A fine-grained personal access token ("},{"type":"variable","attrs":{"id":"payload.accessToken.name","label":null,"fallback":null,"required":false}},{"type":"text","text":") was recently added to your account."}]},{"type":"button","attrs":{"text":"View your token","isTextVariable":false,"url":"payload.link","isUrlVariable":true,"alignment":"center","variant":"filled","borderRadius":"smooth","buttonColor":"#428646","textColor":"#ffffff","showIfKey":null}}]},{"type":"paragraph","attrs":{"textAlign":"left"}},{"type":"footer","attrs":{"textAlign":"center","maily-component":"footer"},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://www.google.com/search?q=github+security&client=firefox-b-d&sca_esv=d12e58ed6977e94a&ei=QIR6Z6XIN6LV7M8PlMnhCA&oq=github+security&gs_lp=Egxnd3Mtd2l6LXNlcnAiD2dpdGh1YiBzZWN1cml0eSoCCAAyCxAAGIAEGJECGIoFMgsQABiABBiRAhiKBTIFEAAYgAQyCxAAGIAEGJECGIoFMgUQABiABDIFEAAYgAQyBRAAGIAEMgUQABiABDIFEAAYgAQyBRAAGIAESPYdUPsFWOMUcAB4A5ABAJgBnAmgAbQZqgEJMy0xLjQuNy0xuAEDyAEA-AEBmAIIoALJGcICBBAAGEeYAwCIBgGQBgiSBwsyLjMtMS40LjctMaAHziE&sclient=gws-wiz-serp","target":"_blank","rel":"noopener noreferrer nofollow","class":"mly-no-underline","isUrlVariable":false}},{"type":"textStyle","attrs":{"color":"#0062ff"}}],"text":"Your security audit log"},{"type":"text","text":" ・ "},{"type":"text","marks":[{"type":"link","attrs":{"href":"https://docs.github.com/en/support/contacting-github-support","target":"_blank","rel":"noopener noreferrer nofollow","class":"mly-no-underline","isUrlVariable":false}},{"type":"textStyle","attrs":{"color":"#0062ff"}}],"text":"Contact support"}]},{"type":"footer","attrs":{"textAlign":"center","maily-component":"footer"},"content":[{"type":"text","text":"GitHub, Inc. ・88 Colin P Kelly Jr Street ・San Francisco, CA 94107"}]}]}', + subject: 'Personal Access Token Was Created', + }, + }, + ], + tags: ['security'], + active: true, + __source: WorkflowCreationSourceEnum.TEMPLATE_STORE, + }, +}; diff --git a/apps/dashboard/src/components/template-store/templates/appointment-reminder.ts b/apps/dashboard/src/components/template-store/templates/appointment-reminder.ts new file mode 100644 index 00000000000..5ec78d4b761 --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/appointment-reminder.ts @@ -0,0 +1,79 @@ +import { StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { WorkflowTemplate } from './types'; + +export const appointmentReminderTemplate: WorkflowTemplate = { + id: 'clinic-appointment-reminder', + name: 'Clinic Appointment Reminder', + description: + 'The workflow reminds the patient about the upcoming appointment and also prompt for feedback after the appointment.', + category: 'operational', + isPopular: false, + workflowDefinition: { + name: 'Clinic Appointment Reminder', + description: + 'The workflow reminds the patient about the upcoming appointment and also prompt for feedback after the appointment.', + workflowId: 'clinic-appointment-reminder', + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + body: '{"type":"doc","content":[{"type":"image","attrs":{"src":"http://img.freepik.com/free-vector/health-care-logo-icon_125964-471.jpg?ga=GA1.1.747163298.1730994384&semt=ais_hybrid","alt":null,"title":null,"width":136,"height":136,"alignment":"center","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"heading","attrs":{"textAlign":"left","level":3},"content":[{"type":"text","text":"See you soon, "},{"type":"variable","attrs":{"id":"subscriber.firstName","label":null,"fallback":null,"required":false}},{"type":"text","text":" !"}]},{"type":"horizontalRule"},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"textStyle","attrs":{"color":"#000000"}}],"text":"This is a reminder about the appointment you have schedualed in our clinic."}]},{"type":"section","attrs":{"borderRadius":0,"backgroundColor":"","align":"left","borderWidth":0,"borderColor":"","paddingTop":0,"paddingRight":0,"paddingBottom":0,"paddingLeft":0,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"section","attrs":{"borderRadius":6,"backgroundColor":"#f5f5f5","align":"left","borderWidth":0,"borderColor":"#e2e2e2","paddingTop":12,"paddingRight":12,"paddingBottom":12,"paddingLeft":12,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"section","attrs":{"borderRadius":0,"backgroundColor":"","align":"left","borderWidth":0,"borderColor":"","paddingTop":0,"paddingRight":0,"paddingBottom":0,"paddingLeft":0,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"section","attrs":{"borderRadius":6,"backgroundColor":"","align":"left","borderWidth":0,"borderColor":"","paddingTop":0,"paddingRight":0,"paddingBottom":0,"paddingLeft":0,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"columns","attrs":{"showIfKey":null,"gap":8},"content":[{"type":"column","attrs":{"columnId":"b3e24c39-bc09-4e95-b267-8c73b6a3e69b","width":"auto","verticalAlign":"top"},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Appointment date"},{"type":"hardBreak"},{"type":"variable","attrs":{"id":"payload.appointment_date","label":null,"fallback":"Monday, 16 November at 11:00","required":true}},{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Appointment with"},{"type":"hardBreak"},{"type":"variable","attrs":{"id":"payload.assigned_doctor","label":null,"fallback":null,"required":false}},{"type":"text","text":" "}]}]},{"type":"column","attrs":{"columnId":"35f83ecc-9254-4a1a-a554-325b8572b78e","width":"auto","verticalAlign":"top"},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Appointment Type"},{"type":"hardBreak"},{"type":"variable","attrs":{"id":"payload.appointment_type","label":null,"fallback":null,"required":true}}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Appointment at"},{"type":"hardBreak"},{"type":"variable","attrs":{"id":"payload.clinic_address","label":null,"fallback":null,"required":false}},{"type":"text","text":" "}]}]}]}]}]}]},{"type":"footer","attrs":{"textAlign":"left","maily-component":"footer"}},{"type":"horizontalRule"},{"type":"footer","attrs":{"textAlign":"left","maily-component":"footer"},"content":[{"type":"text","text":"You\'re receiving this email, as you have an appointment services booked with us, please follow "},{"type":"text","marks":[{"type":"textStyle","attrs":{"color":"#0062ff"}},{"type":"underline"}],"text":"this"},{"type":"text","text":" link to "},{"type":"text","marks":[{"type":"underline"}],"text":"rescedule"},{"type":"text","text":" or "},{"type":"text","marks":[{"type":"underline"}],"text":"cancel"},{"type":"text","text":" the appointment. "}]}]}]}', + subject: 'Reminder: Upcoming Appointment on {{payload.appointment_date}}', + }, + }, + { + name: 'Delay Step', + type: StepTypeEnum.DELAY, + controlValues: { + amount: 5, + unit: 'days', + type: 'regular', + }, + }, + { + name: 'SMS Step', + type: StepTypeEnum.SMS, + controlValues: { + body: 'Hey {{subscriber.firstName}}, this is {{payload.assigned_doctor}}. \nJust a reminder, we meet in 48 hours for your appointment at {{payload.clinic_name}}.', + }, + }, + { + name: 'Delay Step', + type: StepTypeEnum.DELAY, + controlValues: { + amount: 24, + unit: 'hours', + type: 'regular', + }, + }, + { + name: 'SMS Step', + type: StepTypeEnum.SMS, + controlValues: { + body: 'Hi {{subscriber.firstName}}, your appointment is tomorrow at {{payload.clinic_name}}.\n\nPlease note the following parking instructions:\n\n{{payload.parking_instructions}}\n\nSee you soon!', + }, + }, + { + name: 'Delay Step', + type: StepTypeEnum.DELAY, + controlValues: { + amount: 2, + unit: 'days', + type: 'regular', + }, + }, + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + body: '{"type":"doc","content":[{"type":"image","attrs":{"src":"https://img.freepik.com/free-vector/health-care-logo-icon_125964-471.jpg?ga=GA1.1.747163298.1730994384&semt=ais_hybrid","alt":null,"title":null,"width":160,"height":160,"alignment":"center","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"horizontalRule"},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Hi "},{"type":"variable","attrs":{"id":"subscriber.firstName","label":null,"fallback":"John","required":false}},{"type":"text","text":","}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"We hope your appointment with "},{"type":"variable","attrs":{"id":"payload.assigned_doctor","label":null,"fallback":null,"required":false}},{"type":"text","text":" went well. We would love to hear your feedback to help us improve."}]},{"type":"button","attrs":{"text":"60 Seconds Survey ","isTextVariable":false,"url":"payload.feedback_link","isUrlVariable":true,"alignment":"center","variant":"filled","borderRadius":"smooth","buttonColor":"#55b2d3","textColor":"#ffffff","showIfKey":null}},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Thank you for choosing "},{"type":"variable","attrs":{"id":"payload.clinic_name","label":null,"fallback":null,"required":false}},{"type":"text","text":". We look forward to seeing you again."}]},{"type":"horizontalRule"},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Stay healthy!"}]},{"type":"paragraph","attrs":{"textAlign":"center"}},{"type":"footer","attrs":{"textAlign":"left","maily-component":"footer"},"content":[{"type":"text","marks":[{"type":"textStyle","attrs":{"color":"#b2b3b4"}}],"text":"This is a mandatory service email to keep you informed about important updates related to your account at "},{"type":"variable","attrs":{"id":"payload.clinic_name","label":null,"fallback":null,"required":false}}]}]}', + subject: 'How was your appointment at {{payload.clinic_name}} clinic?', + }, + }, + ], + tags: ['reminders'], + active: true, + __source: WorkflowCreationSourceEnum.TEMPLATE_STORE, + }, +}; diff --git a/apps/dashboard/src/components/template-store/templates/index.ts b/apps/dashboard/src/components/template-store/templates/index.ts new file mode 100644 index 00000000000..b2c112e4266 --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/index.ts @@ -0,0 +1,23 @@ +import { accessTokenTemplate } from './access-token'; +import { usageLimitTemplate } from './usage-limit'; + +import { appointmentReminderTemplate } from './appointment-reminder'; +import { otpTemplate } from './otp'; +import { paymentConfirmedTemplate } from './payment-confirmed'; +import { recentLoginTemplate } from './recent-login'; +import { renewalNoticeTemplate } from './renewal-notice'; +import { WorkflowTemplate } from './types'; + +export function getTemplates(): WorkflowTemplate[] { + return [ + accessTokenTemplate, + usageLimitTemplate, + otpTemplate, + renewalNoticeTemplate, + appointmentReminderTemplate, + recentLoginTemplate, + paymentConfirmedTemplate, + ]; +} + +export * from './types'; diff --git a/apps/dashboard/src/components/template-store/templates/otp.ts b/apps/dashboard/src/components/template-store/templates/otp.ts new file mode 100644 index 00000000000..83efe1e2f2c --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/otp.ts @@ -0,0 +1,35 @@ +import { StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { WorkflowTemplate } from './types'; + +export const otpTemplate: WorkflowTemplate = { + id: 'one-time-password', + name: 'One Time Password', + description: 'Notify users when they need to verify their identity', + category: 'authentication', + isPopular: true, + workflowDefinition: { + name: 'One Time Password', + description: '', + workflowId: 'one-time-password', + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: ' Verify Your Identity', + body: '{"type":"doc","content":[{"type":"image","attrs":{"src":"https://github.com/novuhq/blog/blob/main/media-assets/5f1528e4a6109a09086e396b5c9d43cb.png?raw=true","alt":null,"title":null,"width":270,"height":203.05785123966945,"alignment":"center","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"heading","attrs":{"textAlign":"center","level":1},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Enter the following code to finish linking Acmeapp."},{"type":"hardBreak"}]},{"type":"section","attrs":{"borderRadius":0,"backgroundColor":"#f5f5f5","align":"center","borderWidth":2,"borderColor":"#e2e2e2","paddingTop":5,"paddingRight":5,"paddingBottom":5,"paddingLeft":5,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"heading","attrs":{"textAlign":"left","level":2},"content":[{"type":"variable","attrs":{"id":"payload.otp","label":null,"fallback":"123456","required":false}}]}]},{"type":"paragraph","attrs":{"textAlign":"center"},"content":[{"type":"hardBreak"},{"type":"text","text":"Not expecting this email?"},{"type":"hardBreak"},{"type":"text","text":"Contact "},{"type":"text","marks":[{"type":"link","attrs":{"href":"mailto:login@plaid.com","target":"_blank","rel":"noopener noreferrer nofollow","class":"mly-no-underline","isUrlVariable":false}},{"type":"underline"}],"text":"login@acme.com"},{"type":"text","text":" if you did not request this code."}]}]}', + }, + }, + { + name: 'SMS Step', + type: StepTypeEnum.SMS, + controlValues: { + body: 'Your verification code is {{payload.otp}}\n\nDo not share it with anyone.\nThe code expires in 15 minutes', + }, + }, + ], + tags: ['authentication'], + active: true, + __source: WorkflowCreationSourceEnum.TEMPLATE_STORE, + }, +}; diff --git a/apps/dashboard/src/components/template-store/templates/payment-confirmed.ts b/apps/dashboard/src/components/template-store/templates/payment-confirmed.ts new file mode 100644 index 00000000000..a8b519dda86 --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/payment-confirmed.ts @@ -0,0 +1,48 @@ +import { StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { WorkflowTemplate } from './types'; + +export const paymentConfirmedTemplate: WorkflowTemplate = { + id: 'payment-confirmed', + name: 'Payment Confirmed', + description: 'Notify users when their payment is confirmed', + category: 'billing', + isPopular: false, + workflowDefinition: { + name: 'Payment Confirmed', + description: '', + workflowId: 'payment-confirmed', + steps: [ + { + name: 'In-App Step', + type: StepTypeEnum.IN_APP, + controlValues: { + body: 'Your payment of **{{payload.amount}}** for **Acme {{payload.tier}}** has been processed.', + avatar: '', + subject: 'Payment Successful!', + primaryAction: { + label: 'View Receipt', + redirect: { + target: '_self', + url: '{{payload.receipt_link}}', + }, + }, + redirect: { + url: '{{payload.receipt_link}}', + target: '_self', + }, + }, + }, + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Payment Confirmed - Thank You for Your Purchase!', + body: '{"type":"doc","content":[{"type":"image","attrs":{"src":"https://github.com/novuhq/blog/blob/main/media-assets/5f1528e4a6109a09086e396b5c9d43cb.png?raw=true","alt":null,"title":null,"width":167,"height":125,"alignment":"center","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"horizontalRule"},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Hi "},{"type":"variable","attrs":{"id":"subscriber.firstName","label":null,"fallback":null,"required":false}},{"type":"text","text":","}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"We\'re happy to let you know that your payment for Acme "},{"type":"variable","attrs":{"id":"payload.tier","label":null,"fallback":null,"required":false}},{"type":"text","text":" has been successfully processed."}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Details:"}]},{"type":"bulletList","content":[{"type":"listItem","attrs":{"color":""},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Amount: "},{"type":"variable","attrs":{"id":"payload.amount","label":null,"fallback":null,"required":false}}]}]},{"type":"listItem","attrs":{"color":""},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Payment Method: "},{"type":"variable","attrs":{"id":"payload.payment_method","label":null,"fallback":null,"required":false}}]}]},{"type":"listItem","attrs":{"color":""},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Date: "},{"type":"variable","attrs":{"id":"payload.payment_date","label":null,"fallback":null,"required":false}}]}]},{"type":"listItem","attrs":{"color":""},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Order ID: "},{"type":"variable","attrs":{"id":"payload.order_id","label":null,"fallback":null,"required":false}}]}]}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"If you have any questions or need assistance, feel free to reach out."}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Thank you for your continued trust in Acme!"}]},{"type":"horizontalRule"},{"type":"button","attrs":{"text":"View Receipt","isTextVariable":false,"url":"payload.receipt","isUrlVariable":true,"alignment":"center","variant":"filled","borderRadius":"smooth","buttonColor":"#cd5141","textColor":"#ffffff","showIfKey":null}}]}', + }, + }, + ], + tags: ['billing'], + active: true, + __source: WorkflowCreationSourceEnum.TEMPLATE_STORE, + }, +}; diff --git a/apps/dashboard/src/components/template-store/templates/recent-login.ts b/apps/dashboard/src/components/template-store/templates/recent-login.ts new file mode 100644 index 00000000000..cf705c402bc --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/recent-login.ts @@ -0,0 +1,28 @@ +import { StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { WorkflowTemplate } from './types'; + +export const recentLoginTemplate: WorkflowTemplate = { + id: 'recent-login', + name: 'Recent Login', + description: 'Notify users when a new device logs into their account', + category: 'authentication', + isPopular: true, + workflowDefinition: { + name: 'Recent Login', + description: '', + workflowId: 'recent-login', + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + body: '{"type":"doc","content":[{"type":"image","attrs":{"src":"https://github.com/novuhq/blog/blob/main/media-assets/5f1528e4a6109a09086e396b5c9d43cb.png?raw=true","alt":null,"title":null,"width":137,"height":102.75,"alignment":"left","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"image","attrs":{"src":"https://github.com/novuhq/blog/blob/main/media-assets/yelp-header.png?raw=true","alt":null,"title":null,"width":654,"height":264,"alignment":"center","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"section","attrs":{"borderRadius":0,"backgroundColor":"","align":"left","borderWidth":0,"borderColor":"","paddingTop":0,"paddingRight":0,"paddingBottom":0,"paddingLeft":0,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"heading","attrs":{"textAlign":"left","level":3},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Review a recent login from a new device"}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"There was a recent login to your "},{"type":"variable","attrs":{"id":"payload.company","label":null,"fallback":null,"required":false}},{"type":"text","text":" account. Please review the details:"},{"type":"hardBreak"}]}]},{"type":"section","attrs":{"borderRadius":6,"backgroundColor":"#f7f7f7","align":"left","borderWidth":0,"borderColor":"#e2e2e2","paddingTop":12,"paddingRight":12,"paddingBottom":12,"paddingLeft":12,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Account"},{"type":"hardBreak"},{"type":"variable","attrs":{"id":"payload.account","label":null,"fallback":null,"required":false}},{"type":"text","text":" "},{"type":"hardBreak"},{"type":"hardBreak"},{"type":"text","marks":[{"type":"bold"}],"text":"IP & Approximate location"},{"type":"hardBreak"},{"type":"variable","attrs":{"id":"payload.ip_address","label":null,"fallback":null,"required":false}},{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"bold"}],"text":"Time"},{"type":"hardBreak"},{"type":"variable","attrs":{"id":"payload.timeStamp","label":null,"fallback":null,"required":false}},{"type":"text","text":" "}]}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"hardBreak"},{"type":"text","text":"If this was not you, "},{"type":"text","marks":[{"type":"bold"}],"text":"reset your password now"},{"type":"text","text":" to protect your account and enroll in multi-factor authentication in the \\"My Account\\" tab of \\"Settings\\". If this wasn\'t you or if you have additional questions, please see our support page."}]},{"type":"button","attrs":{"text":"Go to my account","isTextVariable":false,"url":"","isUrlVariable":false,"alignment":"left","variant":"filled","borderRadius":"smooth","buttonColor":"#cd5141","textColor":"#ffffff","showIfKey":null}},{"type":"image","attrs":{"src":"https://github.com/novuhq/blog/blob/main/media-assets/yelp-footer.png?raw=true","alt":null,"title":null,"width":654,"height":65.19938650306749,"alignment":"center","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"footer","attrs":{"textAlign":"center","maily-component":"footer"},"content":[{"type":"text","text":"© 2024 | Acme Inc., 350 Mission Street, San Francisco, CA 94105, U.S.A. | "},{"type":"text","marks":[{"type":"link","attrs":{"href":"http://www.yelp.com","target":"_blank","rel":"noopener noreferrer nofollow","class":"mly-no-underline","isUrlVariable":false}}],"text":"www.acme.com"}]}]}', + subject: 'A new device logged into your account', + }, + }, + ], + tags: ['authentication'], + active: true, + __source: WorkflowCreationSourceEnum.TEMPLATE_STORE, + }, +}; diff --git a/apps/dashboard/src/components/template-store/templates/renewal-notice.ts b/apps/dashboard/src/components/template-store/templates/renewal-notice.ts new file mode 100644 index 00000000000..040f8b835e5 --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/renewal-notice.ts @@ -0,0 +1,28 @@ +import { StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { WorkflowTemplate } from './types'; + +export const renewalNoticeTemplate: WorkflowTemplate = { + id: 'subscription-renewal-approaching', + name: 'Upcoming Renewal Notice', + description: 'Notify users when their subscription is about to renew', + category: 'billing', + isPopular: false, + workflowDefinition: { + name: 'Upcoming Renewal Notice', + description: '', + workflowId: 'subscription-renewal-approaching', + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Upcoming Renewal Notice - {{payload.product}}', + body: '{"type":"doc","content":[{"type":"section","attrs":{"borderRadius":0,"backgroundColor":"#f4f4f4","align":"left","borderWidth":2,"borderColor":"#e2e2e2","paddingTop":5,"paddingRight":5,"paddingBottom":5,"paddingLeft":5,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"paragraph","attrs":{"textAlign":"left"}},{"type":"section","attrs":{"borderRadius":0,"backgroundColor":"#ffffff","align":"left","borderWidth":2,"borderColor":"#e2e2e2","paddingTop":5,"paddingRight":5,"paddingBottom":5,"paddingLeft":5,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"image","attrs":{"src":"https://github.com/novuhq/blog/blob/main/media-assets/5f1528e4a6109a09086e396b5c9d43cb.png?raw=true","alt":null,"title":null,"width":113,"height":84.84025559105432,"alignment":"left","externalLink":null,"isExternalLinkVariable":false,"isSrcVariable":false,"showIfKey":null}},{"type":"horizontalRule"},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Hey "},{"type":"variable","attrs":{"id":"subscriber.firstName","label":null,"fallback":null,"required":false}},{"type":"text","text":","}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"The support team received your request to reset your password. Click the button below to get started."}]},{"type":"button","attrs":{"text":"Reset Password","isTextVariable":false,"url":"https://{{payload.password_reset_link}}","isUrlVariable":false,"alignment":"center","variant":"filled","borderRadius":"smooth","buttonColor":"#cd5141","textColor":"#ffffff","showIfKey":null}},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"If it doesn\'t work, you can copy and paste the following link in your browser:"}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"variable","attrs":{"id":"payload.password_reset_link","label":null,"fallback":null,"required":false}}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"This link is valid for "},{"type":"variable","attrs":{"id":"payload.experation_hours","label":null,"fallback":null,"required":false}},{"type":"text","text":" or until it is used."}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Regards,"}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Acme team"}]}]},{"type":"paragraph","attrs":{"textAlign":"left"}},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Delivered by Acme 600 Harrison Street, 3rd Floor, San Francisco, CA 94107."}]}]}]}', + }, + }, + ], + tags: ['billing'], + active: true, + __source: WorkflowCreationSourceEnum.TEMPLATE_STORE, + }, +}; diff --git a/apps/dashboard/src/components/template-store/templates/types.ts b/apps/dashboard/src/components/template-store/templates/types.ts new file mode 100644 index 00000000000..ed62ca66850 --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/types.ts @@ -0,0 +1,12 @@ +import { CreateWorkflowDto } from '@novu/shared'; + +export interface WorkflowTemplate { + id: string; + name: string; + description: string; + category: 'events' | 'authentication' | 'social' | 'operational' | 'billing'; + isPopular?: boolean; + workflowDefinition: CreateWorkflowDto; +} + +export type IWorkflowSuggestion = WorkflowTemplate; diff --git a/apps/dashboard/src/components/template-store/templates/usage-limit.ts b/apps/dashboard/src/components/template-store/templates/usage-limit.ts new file mode 100644 index 00000000000..6dde6ae592d --- /dev/null +++ b/apps/dashboard/src/components/template-store/templates/usage-limit.ts @@ -0,0 +1,55 @@ +import { StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { WorkflowTemplate } from './types'; + +export const usageLimitTemplate: WorkflowTemplate = { + id: 'usage-threshold', + name: 'Usage Threshold', + description: 'Notify users when they reach a usage limit', + category: 'operational', + isPopular: true, + workflowDefinition: { + name: 'Usage Threshold', + description: '', + workflowId: 'usage-threshold', + steps: [ + { + name: 'In-App Step', + type: StepTypeEnum.IN_APP, + controlValues: { + body: "You've reached your {{payload.threshold_name}} usage limit.", + avatar: 'https://dashboard-v2.novu.co/images/warning.svg', + subject: "Heads Up! You're Approaching Your Usage Limit", + primaryAction: { + label: 'Manage Usage', + redirect: { + target: '_self', + url: '{{payload.manage_usage_link}}', + }, + }, + redirect: { + url: '', + target: '_self', + }, + }, + }, + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: "Heads Up! You're Approaching Your Usage Limit", + body: '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Hi "},{"type":"variable","attrs":{"id":"subscriber.firstName","label":null,"fallback":null,"required":false}},{"type":"text","text":" ,"}]},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"textStyle","attrs":{"color":""}}],"text":"All good things come to an end... kind of. Your team\'s Novu trial does end in a week, but if you want to keep using features from your trial? Just make sure to add your billing details."}]},{"type":"section","attrs":{"borderRadius":6,"backgroundColor":"#f7f7f7","align":"left","borderWidth":0,"borderColor":"#e2e2e2","paddingTop":12,"paddingRight":12,"paddingBottom":12,"paddingLeft":12,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"You\'re now at "},{"type":"variable","attrs":{"id":"payload.percentage_used","label":null,"fallback":null,"required":false}},{"type":"text","text":"% of your usage limit."}]}]},{"type":"paragraph","attrs":{"textAlign":"left"}},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","marks":[{"type":"textStyle","attrs":{"color":""}}],"text":"If not, your team will get downgraded to "},{"type":"variable","attrs":{"id":"payload.plan","label":null,"fallback":null,"required":false}},{"type":"text","marks":[{"type":"textStyle","attrs":{"color":""}}],"text":" plan. You\'ll be able to continue to send notifications in "},{"type":"variable","attrs":{"id":"payload.application","label":null,"fallback":null,"required":false}},{"type":"text","marks":[{"type":"textStyle","attrs":{"color":""}}],"text":", but without all the power you had during your trial."},{"type":"hardBreak","marks":[{"type":"textStyle","attrs":{"color":""}}]},{"type":"hardBreak","marks":[{"type":"textStyle","attrs":{"color":""}}]},{"type":"text","marks":[{"type":"textStyle","attrs":{"color":""}}],"text":"Got questions? We\'re here to help — "},{"type":"text","marks":[{"type":"textStyle","attrs":{"color":"#615AF1"}},{"type":"bold"}],"text":"just reach out."},{"type":"hardBreak","marks":[{"type":"textStyle","attrs":{"color":""}}]}]},{"type":"button","attrs":{"text":"Manage Usage","isTextVariable":false,"url":"payload.manage_usage_link","isUrlVariable":true,"alignment":"center","variant":"filled","borderRadius":"smooth","buttonColor":"#615AF1","textColor":"#ffffff","showIfKey":null}},{"type":"paragraph","attrs":{"textAlign":"left"},"content":[{"type":"text","text":"Best,"},{"type":"hardBreak"},{"type":"text","text":"The [Company Name] Team"}]},{"type":"horizontalRule"},{"type":"footer","attrs":{"textAlign":"center","maily-component":"footer"},"content":[{"type":"text","marks":[{"type":"textStyle","attrs":{"color":""}}],"text":"Novu, Inc., "},{"type":"hardBreak","marks":[{"type":"textStyle","attrs":{"color":""}}]},{"type":"text","marks":[{"type":"textStyle","attrs":{"color":""}}],"text":"1209 Orange Street, Wilmington, DE 19801, United States"}]}]}', + }, + }, + { + name: 'Chat Step', + type: StepTypeEnum.CHAT, + controlValues: { + body: "You've Reached Your {{payload.threshold_name}} Limit.\n\nTo ensure uninterrupted service, consider upgrading or managing your usage: {{payload.manage_usage_link}}", + }, + }, + ], + tags: ['Operational'], + active: true, + __source: WorkflowCreationSourceEnum.TEMPLATE_STORE, + }, +}; diff --git a/apps/dashboard/src/components/template-store/types.ts b/apps/dashboard/src/components/template-store/types.ts new file mode 100644 index 00000000000..4e5d347b6da --- /dev/null +++ b/apps/dashboard/src/components/template-store/types.ts @@ -0,0 +1,5 @@ +export enum WorkflowMode { + TEMPLATES = 'templates', + GENERATE = 'generate', + FROM_PROMPT = 'from_prompt', +} diff --git a/apps/dashboard/src/components/template-store/workflow-card.tsx b/apps/dashboard/src/components/template-store/workflow-card.tsx new file mode 100644 index 00000000000..4e30ba4fb43 --- /dev/null +++ b/apps/dashboard/src/components/template-store/workflow-card.tsx @@ -0,0 +1,46 @@ +import { StepTypeEnum } from '@novu/shared'; +import React from 'react'; +import { Card, CardContent } from '../primitives/card'; +import { StepType } from '../step-preview-hover-card'; +import { WorkflowStep } from '../workflow-step'; + +type WorkflowCardProps = { + name: string; + description: string; + steps?: StepType[]; + onClick?: () => void; +}; + +export function WorkflowCard({ + name, + description, + steps = [StepTypeEnum.IN_APP, StepTypeEnum.EMAIL, StepTypeEnum.SMS, StepTypeEnum.PUSH], + onClick, +}: WorkflowCardProps) { + return ( + + + + + + {steps.map((step, index) => ( + + + {index < steps.length - 1 && } + + ))} + + + + + + {name} + {description} + + + + ); +} diff --git a/apps/dashboard/src/components/template-store/workflow-sidebar.tsx b/apps/dashboard/src/components/template-store/workflow-sidebar.tsx new file mode 100644 index 00000000000..68d94bdf78a --- /dev/null +++ b/apps/dashboard/src/components/template-store/workflow-sidebar.tsx @@ -0,0 +1,193 @@ +import { CreateWorkflowButton } from '@/components/create-workflow-button'; +import { Calendar, Code2, ExternalLink, FileCode2, FileText, KeyRound, LayoutGrid, Users } from 'lucide-react'; +import { motion } from 'motion/react'; +import { ReactNode } from 'react'; +import { Badge } from '../primitives/badge'; +import { WorkflowMode } from './types'; + +interface WorkflowSidebarProps { + selectedCategory: string; + onCategorySelect: (category: string) => void; + mode: WorkflowMode; +} + +interface SidebarButtonProps { + icon: ReactNode; + label: string; + onClick?: () => void; + isActive?: boolean; + bgColor?: string; + asChild?: boolean; + hasExternalLink?: boolean; + beta?: boolean; +} + +const buttonVariants = { + initial: { scale: 1 }, + hover: { scale: 1.01 }, + tap: { scale: 0.99 }, +}; + +const iconVariants = { + initial: { rotate: 0 }, + hover: { rotate: 5 }, +}; + +function SidebarButton({ + icon, + label, + onClick, + isActive, + bgColor = 'bg-blue-50', + asChild, + beta, + hasExternalLink, +}: SidebarButtonProps) { + const ButtonWrapper = asChild ? CreateWorkflowButton : motion.button; + const content = ( + + + {icon} + + {label} + {hasExternalLink && ( + + + + )} + + ); + + return ( + + {asChild ? ( + content + ) : ( + + {content}{' '} + {beta && ( + + BETA + + )} + + )} + + ); +} + +const useCases = [ + { + id: 'popular', + icon: , + label: 'Popular', + bgColor: 'bg-blue-50', + }, + { + id: 'billing', + icon: , + label: 'Billing', + bgColor: 'bg-blue-50', + }, + { + id: 'authentication', + icon: , + label: 'Authentication', + bgColor: 'bg-green-50', + }, + { + id: 'operational', + icon: , + label: 'Operational', + bgColor: 'bg-purple-50', + }, +] as const; + +const createOptions = [ + { + icon: , + label: 'Blank workflow', + bgColor: 'bg-green-50', + asChild: true, + }, + { + icon: , + label: 'Code-based workflow', + hasExternalLink: true, + bgColor: 'bg-blue-50', + onClick: () => window.open('https://docs.novu.co/framework/overview', '_blank'), + }, +]; + +export function WorkflowSidebar({ selectedCategory, onCategorySelect, mode }: WorkflowSidebarProps) { + return ( + + + + CREATE + + + {createOptions.map((item, index) => ( + + ))} + + + + + EXPLORE + + + + {useCases.map((item) => ( + onCategorySelect(item.id)} + isActive={mode === WorkflowMode.TEMPLATES && selectedCategory === item.id} + bgColor={item.bgColor} + /> + ))} + + + + + window.open('https://docs.novu.co/workflow/overview', '_blank')} + > + + + + + Documentation + + + Find out more about how to best setup workflows + + + + ); +} diff --git a/apps/dashboard/src/components/template-store/workflow-template-modal.tsx b/apps/dashboard/src/components/template-store/workflow-template-modal.tsx new file mode 100644 index 00000000000..ca8f30b89bf --- /dev/null +++ b/apps/dashboard/src/components/template-store/workflow-template-modal.tsx @@ -0,0 +1,174 @@ +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTrigger } from '@/components/primitives/dialog'; +import { ComponentProps, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { RiArrowLeftSLine } from 'react-icons/ri'; +import { z } from 'zod'; +import { useCreateWorkflow } from '../../hooks/use-create-workflow'; +import { RouteFill } from '../icons'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '../primitives/breadcrumb'; +import { Button } from '../primitives/button'; +import { CompactButton } from '../primitives/button-compact'; +import { Form } from '../primitives/form/form'; +import TruncatedText from '../truncated-text'; +import { CreateWorkflowForm } from '../workflow-editor/create-workflow-form'; +import { workflowSchema } from '../workflow-editor/schema'; +import { WorkflowCanvas } from '../workflow-editor/workflow-canvas'; +import { WorkflowResults } from './components/workflow-results'; +import { getTemplates, IWorkflowSuggestion } from './templates'; +import { WorkflowMode } from './types'; +import { WorkflowSidebar } from './workflow-sidebar'; + +const WORKFLOW_TEMPLATES = getTemplates(); + +export type WorkflowTemplateModalProps = ComponentProps & { + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export function WorkflowTemplateModal(props: WorkflowTemplateModalProps) { + const form = useForm(); + const { submit: createFromTemplate, isLoading: isCreating } = useCreateWorkflow(); + const [selectedCategory, setSelectedCategory] = useState('popular'); + const [suggestions, setSuggestions] = useState([]); + const [mode, setMode] = useState(WorkflowMode.TEMPLATES); + const [selectedTemplate, setSelectedTemplate] = useState(null); + + const filteredTemplates = WORKFLOW_TEMPLATES.filter((template) => + selectedCategory === 'popular' ? template.isPopular : template.category === selectedCategory + ); + const templates = suggestions.length > 0 ? suggestions : filteredTemplates; + + const handleCreateWorkflow = async (values: z.infer) => { + if (!selectedTemplate) return; + + await createFromTemplate(values, selectedTemplate.workflowDefinition); + }; + + const getHeaderText = () => { + if (selectedTemplate) { + return selectedTemplate.name; + } + + if (mode === WorkflowMode.GENERATE) { + return 'AI Suggested workflows'; + } + + if (mode === WorkflowMode.FROM_PROMPT) { + return 'Scaffold your workflow'; + } + + if (mode === WorkflowMode.TEMPLATES) { + return `${selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)} workflows`; + } + + return ''; + }; + + const handleTemplateClick = (template: IWorkflowSuggestion) => { + setSelectedTemplate(template); + }; + + const handleBackClick = () => { + setSelectedTemplate(null); + setMode(WorkflowMode.TEMPLATES); + }; + + return ( + + + + + + {selectedTemplate ? ( + + ) : null} + + + {selectedTemplate && ( + <> + Templates + + > + )} + + + + + {getHeaderText()} + + + + + + + + {!selectedTemplate && ( + + { + setSelectedCategory(category); + setSuggestions([]); + setMode(WorkflowMode.TEMPLATES); + }} + mode={mode} + /> + + )} + + + {!selectedTemplate ? ( + + + + + {getHeaderText()} + + + + + + + ) : ( + + + ({ + _id: null, + slug: null, + stepId: step.name, + controls: { + values: step.controlValues ?? {}, + }, + ...step, + })) as any + } + /> + + + + + + )} + + + + {selectedTemplate && ( + + + Create workflow + + + )} + + + ); +} diff --git a/apps/dashboard/src/components/workflow-editor/base-node.tsx b/apps/dashboard/src/components/workflow-editor/base-node.tsx index 5c0c0554bcd..b4687abb158 100644 --- a/apps/dashboard/src/components/workflow-editor/base-node.tsx +++ b/apps/dashboard/src/components/workflow-editor/base-node.tsx @@ -1,10 +1,20 @@ -import { ReactNode, useState } from 'react'; -import { cva, VariantProps } from 'class-variance-authority'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { STEP_TYPE_TO_COLOR } from '@/utils/color'; import { StepTypeEnum } from '@/utils/enums'; +import { cn } from '@/utils/ui'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { cva, VariantProps } from 'class-variance-authority'; +import { ReactNode, useState } from 'react'; import { RiErrorWarningFill } from 'react-icons/ri'; +import { + HoverCard, + HoverCardArrow, + HoverCardContent, + HoverCardPortal, + HoverCardTrigger, +} from '../primitives/hover-card'; import { Popover, PopoverArrow, PopoverContent, PopoverPortal, PopoverTrigger } from '../primitives/popover'; -import { cn } from '@/utils/ui'; -import { STEP_TYPE_TO_COLOR } from '@/utils/color'; +import { StepPreview } from '../step-preview-hover-card'; const nodeBadgeVariants = cva( 'min-w-5 text-xs h-5 border rounded-full opacity-40 flex items-center justify-center p-1', @@ -60,14 +70,41 @@ export const NodeHeader = ({ children, type }: { children: ReactNode; type: Step ); }; -export const NodeBody = ({ children }: { children: ReactNode }) => { +export const NodeBody = ({ + children, + type, + controlValues, + showPreview, +}: { + children: ReactNode; + type: StepTypeEnum; + controlValues: Record; + showPreview?: boolean; +}) => { + const isPreviewEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_WORKFLOW_NODE_PREVIEW_ENABLED); + return ( - - - {children} - - - + + + + + {children} + + + + + {(isPreviewEnabled || showPreview) && ( + + {type !== StepTypeEnum.TRIGGER && ( + + + + + + )} + + )} + ); }; @@ -77,7 +114,7 @@ export const NodeError = ({ children }: { children: ReactNode }) => { setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)} > @@ -98,12 +135,12 @@ export const NODE_WIDTH = 300; export const NODE_HEIGHT = 86; const nodeVariants = cva( - `relative bg-neutral-alpha-200 transition-colors aria-selected:bg-gradient-to-tr aria-selected:to-warning/50 aria-selected:from-destructive/60 [&>span]:bg-foreground-0 flex w-[300px] flex-col p-px shadow-xs flex [&>span]:flex-1 [&>span]:rounded-[calc(var(--radius)-1px)] [&>span]:p-1 [&>span]:flex [&>span]:flex-col [&>span]:gap-1`, + `relative bg-neutral-alpha-200 transition-colors aria-selected:bg-gradient-to-bl aria-selected:from-[#FFB84D] aria-selected:to-[#E300BD] [&>span]:bg-foreground-0 flex w-[300px] flex-col p-px shadow-xs flex [&>span]:flex-1 [&>span]:rounded-[calc(var(--radius)-1px)] [&>span]:p-1 [&>span]:flex [&>span]:flex-col [&>span]:gap-1`, { variants: { variant: { - default: 'rounded-lg', - sm: 'text-neutral-400 w-min rounded-lg', + default: 'rounded-lg pointer-events-auto [&_span:not(.hover-trigger,.error-trigger)]:pointer-events-none', + sm: 'text-neutral-400 w-min rounded-lg pointer-events-auto [&_span:not(.hover-trigger,.error-trigger)]:pointer-events-none', }, }, defaultVariants: { diff --git a/apps/dashboard/src/components/workflow-editor/create-workflow-form.tsx b/apps/dashboard/src/components/workflow-editor/create-workflow-form.tsx new file mode 100644 index 00000000000..61d88aff92d --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/create-workflow-form.tsx @@ -0,0 +1,134 @@ +import { + Form, + FormControl, + FormField, + FormInput, + FormItem, + FormLabel, + FormMessage, +} from '@/components/primitives/form/form'; +import { Separator } from '@/components/primitives/separator'; +import { TagInput } from '@/components/primitives/tag-input'; +import { Textarea } from '@/components/primitives/textarea'; +import { useTags } from '@/hooks/use-tags'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { type CreateWorkflowDto, slugify } from '@novu/shared'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { MAX_DESCRIPTION_LENGTH, MAX_TAG_ELEMENTS, workflowSchema } from './schema'; + +interface CreateWorkflowFormProps { + onSubmit: (values: z.infer) => void; + template?: CreateWorkflowDto; +} + +export function CreateWorkflowForm({ onSubmit, template }: CreateWorkflowFormProps) { + const form = useForm>({ + resolver: zodResolver(workflowSchema), + defaultValues: { + description: template?.description ?? '', + workflowId: template?.workflowId ?? '', + name: template?.name ?? '', + tags: template?.tags ?? [], + }, + }); + + const { tags } = useTags(); + const tagSuggestions = tags.map((tag) => tag.name); + + return ( + + + ( + + Name + + { + field.onChange(e); + form.setValue('workflowId', slugify(e.target.value)); + }} + /> + + + + )} + /> + + ( + + Identifier + + + + + + )} + /> + + + + ( + + + + Add tags + + + + { + field.onChange(tags); + form.setValue('tags', tags, { shouldValidate: true }); + }} + /> + + + + )} + /> + + ( + + + Description + + + + + + + )} + /> + + + ); +} diff --git a/apps/dashboard/src/components/workflow-editor/nodes.tsx b/apps/dashboard/src/components/workflow-editor/nodes.tsx index c59c0f3e8f5..545af503e32 100644 --- a/apps/dashboard/src/components/workflow-editor/nodes.tsx +++ b/apps/dashboard/src/components/workflow-editor/nodes.tsx @@ -21,6 +21,10 @@ export type NodeData = { error?: string; name?: string; stepSlug?: string; + controlValues?: Record; + workflowSlug?: string; + environment?: string; + readOnly?: boolean; }; export type NodeType = FlowNode; @@ -31,13 +35,10 @@ const bottomHandleClasses = `data-[handlepos=bottom]:w-2 data-[handlepos=bottom] const handleClassName = `${topHandleClasses} ${bottomHandleClasses}`; -export const TriggerNode = ({ data }: NodeProps>) => ( - +export const TriggerNode = ({ + data, +}: NodeProps>) => { + const content = ( @@ -46,11 +47,28 @@ export const TriggerNode = ({ data }: NodeProps Workflow trigger - This step triggers this workflow + + This step triggers this workflow + - -); + ); + + if (data.readOnly) { + return content; + } + + return ( + + {content} + + ); +}; type StepNodeProps = ComponentProps & { data: NodeData }; const StepNode = (props: StepNodeProps) => { @@ -61,29 +79,54 @@ const StepNode = (props: StepNodeProps) => { const isSelected = getWorkflowIdFromSlug({ slug: stepSlug ?? '', divider: STEP_DIVIDER }) === - getWorkflowIdFromSlug({ slug: data.stepSlug ?? '', divider: STEP_DIVIDER }); + getWorkflowIdFromSlug({ slug: data.stepSlug ?? '', divider: STEP_DIVIDER }) && + !!stepSlug && + !!data.stepSlug; return ; }; +const NodeWrapper = ({ children, data }: { children: React.ReactNode; data: NodeData }) => { + if (data.readOnly) { + return children; + } + + return ( + { + // Prevent any bubbling that might interfere with the navigation + e.stopPropagation(); + }} + className="contents" + > + {children} + + ); +}; + export const EmailNode = ({ data }: NodeProps) => { const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.EMAIL]; return ( - + + {data.name || 'Email Step'} - {data.content} + + + {data.content} + {data.error && {data.error}} - + ); }; @@ -92,7 +135,7 @@ export const SmsNode = (props: NodeProps) => { const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.SMS]; return ( - + @@ -100,12 +143,14 @@ export const SmsNode = (props: NodeProps) => { {data.name || 'SMS Step'} - {data.content} + + {data.content} + {data.error && {data.error}} - + ); }; @@ -114,7 +159,7 @@ export const InAppNode = (props: NodeProps) => { const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.IN_APP]; return ( - + @@ -122,12 +167,14 @@ export const InAppNode = (props: NodeProps) => { {data.name || 'In-App Step'} - {data.content} + + {data.content} + {data.error && {data.error}} - + ); }; @@ -136,7 +183,7 @@ export const PushNode = (props: NodeProps) => { const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.PUSH]; return ( - + @@ -144,12 +191,14 @@ export const PushNode = (props: NodeProps) => { {data.name || 'Push Step'} - {data.content} + + {data.content} + {data.error && {data.error}} - + ); }; @@ -158,7 +207,7 @@ export const ChatNode = (props: NodeProps) => { const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.CHAT]; return ( - + @@ -166,12 +215,14 @@ export const ChatNode = (props: NodeProps) => { {data.name || 'Chat Step'} - {data.content} + + {data.content} + {data.error && {data.error}} - + ); }; @@ -180,7 +231,7 @@ export const DelayNode = (props: NodeProps) => { const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.DELAY]; return ( - + @@ -188,12 +239,14 @@ export const DelayNode = (props: NodeProps) => { {data.name || 'Delay Step'} - {data.content} + + {data.content} + {data.error && {data.error}} - + ); }; @@ -202,7 +255,7 @@ export const DigestNode = (props: NodeProps) => { const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.DIGEST]; return ( - + @@ -210,12 +263,14 @@ export const DigestNode = (props: NodeProps) => { {data.name || 'Digest Step'} - {data.content} + + {data.content} + {data.error && {data.error}} - + ); }; @@ -224,7 +279,7 @@ export const CustomNode = (props: NodeProps) => { const Icon = STEP_TYPE_TO_ICON[StepTypeEnum.CUSTOM]; return ( - + @@ -232,12 +287,14 @@ export const CustomNode = (props: NodeProps) => { {data.name || 'Custom Step'} - {data.content} + + {data.content} + {data.error && {data.error}} - + ); }; diff --git a/apps/dashboard/src/components/workflow-editor/schema.ts b/apps/dashboard/src/components/workflow-editor/schema.ts index 6d5c59447ef..bb686aeb905 100644 --- a/apps/dashboard/src/components/workflow-editor/schema.ts +++ b/apps/dashboard/src/components/workflow-editor/schema.ts @@ -14,10 +14,10 @@ export const workflowSchema = z.object({ tags: z .array(z.string().min(0).max(MAX_TAG_LENGTH)) .max(MAX_TAG_ELEMENTS) - .optional() .refine((tags) => tags?.every((tag) => tag.length <= MAX_TAG_LENGTH), { message: `Tags must be less than ${MAX_TAG_LENGTH} characters`, }) + .optional() .refine((tags) => new Set(tags).size === tags?.length, { message: 'Duplicate tags are not allowed', }), diff --git a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx index b3bf784f395..e802a85bf8c 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx @@ -3,8 +3,8 @@ import { UiComponentEnum } from '@novu/shared'; import { DelayAmount } from '@/components/workflow-editor/steps/delay/delay-amount'; import { DigestKey } from '@/components/workflow-editor/steps/digest/digest-key'; import { DigestWindow } from '@/components/workflow-editor/steps/digest/digest-window'; +import { EmailBodyEditor } from '@/components/workflow-editor/steps/email/email-body-editor'; import { EmailSubject } from '@/components/workflow-editor/steps/email/email-subject'; -import { Maily } from '@/components/workflow-editor/steps/email/maily'; import { InAppAction } from '@/components/workflow-editor/steps/in-app/in-app-action'; import { InAppAvatar } from '@/components/workflow-editor/steps/in-app/in-app-avatar'; import { InAppBody } from '@/components/workflow-editor/steps/in-app/in-app-body'; @@ -40,7 +40,7 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum return ; } case UiComponentEnum.BLOCK_EDITOR: { - return ; + return ; } case UiComponentEnum.TEXT_INLINE_LABEL: { return ; diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-body-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-body-editor.tsx new file mode 100644 index 00000000000..586d78a0ca1 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-body-editor.tsx @@ -0,0 +1,17 @@ +import { FormField } from '@/components/primitives/form/form'; +import { useFormContext } from 'react-hook-form'; +import { Maily } from './maily'; + +export const EmailBodyEditor = () => { + const { control } = useFormContext(); + + return ( + { + return ; + }} + /> + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/maily-config.ts b/apps/dashboard/src/components/workflow-editor/steps/email/maily-config.ts new file mode 100644 index 00000000000..dd948e71868 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/email/maily-config.ts @@ -0,0 +1,44 @@ +import { + blockquote, + bulletList, + button, + columns, + divider, + forLoop, + hardBreak, + heading1, + heading2, + heading3, + image, + orderedList, + section, + spacer, + text, +} from '@maily-to/core/blocks'; + +export const DEFAULT_EDITOR_CONFIG = { + hasMenuBar: false, + wrapClassName: 'min-h-0 max-h-full flex flex-col w-full h-full overflow-y-auto', + bodyClassName: '!bg-transparent flex flex-col basis-full !border-none !mt-0 [&>div]:basis-full [&_.tiptap]:h-full', +}; + +export const DEFAULT_EDITOR_BLOCKS = [ + text, + heading1, + heading2, + heading3, + bulletList, + orderedList, + image, + section, + columns, + divider, + spacer, + button, + hardBreak, + blockquote, +]; + +export function getEditorBlocks(isForBlockEnabled: boolean) { + return [...DEFAULT_EDITOR_BLOCKS, ...(isForBlockEnabled ? [forLoop] : [])]; +} diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx index 66febb8a373..bf64051c506 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx @@ -1,34 +1,20 @@ -import { FormControl, FormField, FormMessage } from '@/components/primitives/form/form'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { parseStepVariables } from '@/utils/parseStepVariablesToLiquidVariables'; import { cn } from '@/utils/ui'; import { Editor } from '@maily-to/core'; -import { - blockquote, - bulletList, - button, - columns, - divider, - forLoop, - hardBreak, - heading1, - heading2, - heading3, - image, - orderedList, - section, - spacer, - text, -} from '@maily-to/core/blocks'; import { FeatureFlagsKeysEnum } from '@novu/shared'; import type { Editor as TiptapEditor } from '@tiptap/core'; import { HTMLAttributes, useMemo, useState } from 'react'; -import { useFormContext } from 'react-hook-form'; +import { DEFAULT_EDITOR_CONFIG, getEditorBlocks } from './maily-config'; -type MailyProps = HTMLAttributes; -export const Maily = (props: MailyProps) => { - const { className, ...rest } = props; +type MailyProps = HTMLAttributes & { + value: string; + onChange?: (value: string) => void; + className?: string; +}; + +export const Maily = ({ value, onChange, className, ...rest }: MailyProps) => { const { step } = useWorkflow(); const mailyVariables = useMemo( () => (step ? parseStepVariables(step.variables) : { primitives: [], arrays: [], namespaces: [] }), @@ -48,121 +34,88 @@ export const Maily = (props: MailyProps) => { ); const [_, setEditor] = useState(); - const { control } = useFormContext(); const isForBlockEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_EMAIL_FOR_BLOCK_ENABLED); const isShowEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_EMAIL_SHOW_ENABLED); return ( - { - return ( - <> - {!isShowEnabled && ( - - )} - - - div]:basis-full [&_.tiptap]:h-full', - }} - blocks={[ - text, - heading1, - heading2, - heading3, - bulletList, - orderedList, - image, - section, - columns, - ...(isForBlockEnabled ? [forLoop] : []), - divider, - spacer, - button, - hardBreak, - blockquote, - ]} - variableTriggerCharacter="{{" - variables={({ query, editor, from }) => { - const queryWithoutSuffix = query.replace(/}+$/, ''); - const filteredVariables: { name: string; required: boolean }[] = []; - - function addInlineVariable() { - if (!query.endsWith('}}')) { - return; - } - if (filteredVariables.every((variable) => variable.name !== queryWithoutSuffix)) { - return; - } - const from = editor?.state.selection.from - queryWithoutSuffix.length - 4; /* for prefix */ - const to = editor?.state.selection.from; - - editor?.commands.deleteRange({ from, to }); - editor?.commands.insertContent({ - type: 'variable', - attrs: { - id: queryWithoutSuffix, - label: null, - fallback: null, - showIfKey: null, - required: false, - }, - }); - } - - if (from === 'for-variable') { - filteredVariables.push(...arrays, ...namespaces); - if (namespaces.some((namespace) => queryWithoutSuffix.includes(namespace.name))) { - filteredVariables.push({ name: queryWithoutSuffix, required: false }); - } - - addInlineVariable(); - return dedupAndSortVariables(filteredVariables, queryWithoutSuffix); - } - - const iterableName = editor?.getAttributes('for')?.each; - - const newNamespaces = [ - ...namespaces, - ...(iterableName ? [{ name: iterableName, required: false }] : []), - ]; - - filteredVariables.push(...primitives, ...newNamespaces); - if (newNamespaces.some((namespace) => queryWithoutSuffix.includes(namespace.name))) { - filteredVariables.push({ name: queryWithoutSuffix, required: false }); - } - - if (from === 'content-variable') { - addInlineVariable(); - } - return dedupAndSortVariables(filteredVariables, queryWithoutSuffix); - }} - contentJson={field.value ? JSON.parse(field.value) : undefined} - onCreate={setEditor} - onUpdate={(editor) => { - setEditor(editor); - field.onChange(JSON.stringify(editor.getJSON())); - }} - /> - - - - > - ); - }} - /> + )} + + { + const queryWithoutSuffix = query.replace(/}+$/, ''); + const filteredVariables: { name: string; required: boolean }[] = []; + + function addInlineVariable() { + if (!query.endsWith('}}')) { + return; + } + if (filteredVariables.every((variable) => variable.name !== queryWithoutSuffix)) { + return; + } + const from = editor?.state.selection.from - queryWithoutSuffix.length - 4; /* for prefix */ + const to = editor?.state.selection.from; + + editor?.commands.deleteRange({ from, to }); + editor?.commands.insertContent({ + type: 'variable', + attrs: { + id: queryWithoutSuffix, + label: null, + fallback: null, + showIfKey: null, + required: false, + }, + }); + } + + if (from === 'for-variable') { + filteredVariables.push(...arrays, ...namespaces); + if (namespaces.some((namespace) => queryWithoutSuffix.includes(namespace.name))) { + filteredVariables.push({ name: queryWithoutSuffix, required: false }); + } + + addInlineVariable(); + return dedupAndSortVariables(filteredVariables, queryWithoutSuffix); + } + + const iterableName = editor?.getAttributes('for')?.each; + + const newNamespaces = [...namespaces, ...(iterableName ? [{ name: iterableName, required: false }] : [])]; + + filteredVariables.push(...primitives, ...newNamespaces); + if (newNamespaces.some((namespace) => queryWithoutSuffix.includes(namespace.name))) { + filteredVariables.push({ name: queryWithoutSuffix, required: false }); + } + + if (from === 'content-variable') { + addInlineVariable(); + } + return dedupAndSortVariables(filteredVariables, queryWithoutSuffix); + }} + contentJson={value ? JSON.parse(value) : undefined} + onCreate={setEditor} + onUpdate={(editor) => { + setEditor(editor); + + if (onChange) { + onChange(JSON.stringify(editor.getJSON())); + } + }} + /> + + > ); }; diff --git a/apps/dashboard/src/components/workflow-editor/workflow-canvas.tsx b/apps/dashboard/src/components/workflow-editor/workflow-canvas.tsx index 92c6cd6a570..d8a975c5819 100644 --- a/apps/dashboard/src/components/workflow-editor/workflow-canvas.tsx +++ b/apps/dashboard/src/components/workflow-editor/workflow-canvas.tsx @@ -1,7 +1,9 @@ import { Background, BackgroundVariant, + BaseEdge, Controls, + EdgeProps, Node, ReactFlow, ReactFlowProvider, @@ -47,8 +49,13 @@ const nodeTypes = { add: AddNode, }; +const DefaultEdge = ({ id, sourceX, sourceY, targetX, targetY, style }: EdgeProps) => { + return ; +}; + const edgeTypes = { addNode: AddNodeEdge, + default: DefaultEdge, }; const panOnDrag = [1, 2]; @@ -87,10 +94,12 @@ const mapStepToNode = ({ addStepIndex, previousPosition, step, + readOnly, }: { addStepIndex: number; previousPosition: { x: number; y: number }; step: Step; + readOnly?: boolean; }): Node => { const content = mapStepToNodeContent(step); @@ -105,12 +114,14 @@ const mapStepToNode = ({ addStepIndex, stepSlug: step.slug, error, + controlValues: step.controls.values, + readOnly, }, type: step.type, }; }; -const WorkflowCanvasChild = ({ steps }: { steps: Step[] }) => { +const WorkflowCanvasChild = ({ steps, readOnly }: { steps: Step[]; readOnly?: boolean }) => { const reactFlowWrapper = useRef(null); const reactFlowInstance = useReactFlow(); const { currentEnvironment } = useEnvironment(); @@ -118,12 +129,13 @@ const WorkflowCanvasChild = ({ steps }: { steps: Step[] }) => { const navigate = useNavigate(); const [nodes, edges] = useMemo(() => { - const triggerNode = { + const triggerNode: Node = { id: crypto.randomUUID(), position: { x: 0, y: 0 }, data: { workflowSlug: currentWorkflow?.slug ?? '', environment: currentEnvironment?.slug ?? '', + readOnly, }, type: 'trigger', }; @@ -134,44 +146,56 @@ const WorkflowCanvasChild = ({ steps }: { steps: Step[] }) => { step, previousPosition, addStepIndex: index, + readOnly, }); previousPosition = node.position; return node; }); - const addNode: Node = { - id: crypto.randomUUID(), - position: { ...previousPosition, y: previousPosition.y + Y_DISTANCE }, - data: {}, - type: 'add', - }; + let allNodes: Node[] = [triggerNode, ...createdNodes]; - const nodes = [triggerNode, ...createdNodes, addNode]; - const edges = nodes.reduce((acc, node, index) => { + if (!readOnly) { + const addNode: Node = { + id: crypto.randomUUID(), + position: { ...previousPosition, y: previousPosition.y + Y_DISTANCE }, + data: {}, + type: 'add', + }; + allNodes = [...allNodes, addNode]; + } + + const edges = allNodes.reduce((acc, node, index) => { if (index === 0) { return acc; } - const parent = nodes[index - 1]; + const parent = allNodes[index - 1]; + acc.push({ id: `edge-${parent.id}-${node.id}`, source: parent.id, sourceHandle: 'b', targetHandle: 'a', target: node.id, - type: 'addNode', - style: { stroke: 'hsl(var(--neutral-alpha-200))', strokeWidth: 2, strokeDasharray: 5 }, - data: { - isLast: index === nodes.length - 1, - addStepIndex: index - 1, + type: readOnly ? 'default' : 'addNode', + style: { + stroke: 'hsl(var(--neutral-alpha-200))', + strokeWidth: 2, + strokeDasharray: 5, }, + data: readOnly + ? undefined + : { + isLast: index === allNodes.length - 1, + addStepIndex: index - 1, + }, }); return acc; }, []); - return [nodes, edges]; - }, [steps]); + return [allNodes, edges]; + }, [steps, readOnly, currentWorkflow?.slug, currentEnvironment?.slug]); const positionCanvas = useCallback( (options?: ViewportHelperFunctionOptions) => { @@ -198,7 +222,7 @@ const WorkflowCanvasChild = ({ steps }: { steps: Step[] }) => { }, [positionCanvas]); return ( - + { selectionOnDrag panOnDrag={panOnDrag} onPaneClick={() => { + if (readOnly) { + return; + } + // unselect node if clicked on background if (currentEnvironment?.slug && currentWorkflow?.slug) { navigate( @@ -229,10 +257,10 @@ const WorkflowCanvasChild = ({ steps }: { steps: Step[] }) => { ); }; -export const WorkflowCanvas = ({ steps }: { steps: Step[] }) => { +export const WorkflowCanvas = ({ steps, readOnly }: { steps: Step[]; readOnly?: boolean }) => { return ( - + ); }; diff --git a/apps/dashboard/src/hooks/use-create-workflow.ts b/apps/dashboard/src/hooks/use-create-workflow.ts new file mode 100644 index 00000000000..339da9b5656 --- /dev/null +++ b/apps/dashboard/src/hooks/use-create-workflow.ts @@ -0,0 +1,54 @@ +import { createWorkflow } from '@/api/workflows'; +import { useEnvironment } from '@/context/environment/hooks'; +import { QueryKeys } from '@/utils/query-keys'; +import { buildRoute, ROUTES } from '@/utils/routes'; +import { type CreateWorkflowDto, WorkflowCreationSourceEnum } from '@novu/shared'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { z } from 'zod'; +import { workflowSchema } from '../components/workflow-editor/schema'; + +interface UseCreateWorkflowOptions { + onSuccess?: () => void; +} + +export function useCreateWorkflow({ onSuccess }: UseCreateWorkflowOptions = {}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { currentEnvironment } = useEnvironment(); + + const mutation = useMutation({ + mutationFn: async (workflow: CreateWorkflowDto) => createWorkflow({ environment: currentEnvironment!, workflow }), + onSuccess: async (result) => { + await queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id] }); + queryClient.invalidateQueries({ + queryKey: [QueryKeys.fetchTags, currentEnvironment?._id], + }); + + navigate( + buildRoute(ROUTES.EDIT_WORKFLOW, { + environmentSlug: currentEnvironment?.slug ?? '', + workflowSlug: result.data.slug ?? '', + }) + ); + + onSuccess?.(); + }, + }); + + const submit = (values: z.infer, template?: CreateWorkflowDto) => { + return mutation.mutateAsync({ + name: values.name, + steps: template?.steps ?? [], + __source: template?.__source ?? WorkflowCreationSourceEnum.DASHBOARD, + workflowId: values.workflowId, + description: values.description || undefined, + tags: values.tags || [], + }); + }; + + return { + submit, + isLoading: mutation.isPending, + }; +} diff --git a/apps/dashboard/src/pages/index.ts b/apps/dashboard/src/pages/index.ts index cdf78c4a99d..d2cc1621da0 100644 --- a/apps/dashboard/src/pages/index.ts +++ b/apps/dashboard/src/pages/index.ts @@ -5,7 +5,7 @@ export * from './organization-list'; export * from './questionnaire-page'; export * from './usecase-select-page'; export * from './api-keys'; -export * from './settings'; export * from './welcome-page'; export * from './integrations-list-page'; +export * from './settings'; export * from './activity-feed'; diff --git a/apps/dashboard/src/pages/workflows.tsx b/apps/dashboard/src/pages/workflows.tsx index 3f5db5a5e5d..a830204c732 100644 --- a/apps/dashboard/src/pages/workflows.tsx +++ b/apps/dashboard/src/pages/workflows.tsx @@ -4,13 +4,25 @@ import { OptInModal } from '@/components/opt-in-modal'; import { PageMeta } from '@/components/page-meta'; import { Button } from '@/components/primitives/button'; import { WorkflowList } from '@/components/workflow-list'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; -import { useEffect } from 'react'; -import { RiRouteFill } from 'react-icons/ri'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { useEffect, useState } from 'react'; +import { RiArrowDownSLine, RiFileAddLine, RiFileMarkedLine, RiRouteFill } from 'react-icons/ri'; +import { ButtonGroupItem, ButtonGroupRoot } from '../components/primitives/button-group'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../components/primitives/dropdown-menu'; +import { WorkflowTemplateModal } from '../components/template-store/workflow-template-modal'; export const WorkflowsPage = () => { const track = useTelemetry(); + const isTemplateStoreEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_TEMPLATE_STORE_ENABLED); + const [shouldOpenTemplateModal, setShouldOpenTemplateModal] = useState(false); useEffect(() => { track(TelemetryEvent.WORKFLOWS_PAGE_VISIT); @@ -24,11 +36,61 @@ export const WorkflowsPage = () => { - - - Create workflow - - + {isTemplateStoreEnabled ? ( + + + + + Create workflow + + + + + + + + + + + + + + Blank Workflow + + + + setShouldOpenTemplateModal(true)}> + + View Template Gallery + + + + + + ) : ( + + + Create workflow + + + )} + {shouldOpenTemplateModal && ( + + <>> + + )} diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index a716abcc3f9..815c8b6efc5 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -28,6 +28,7 @@ export enum FeatureFlagsKeysEnum { IS_API_IDEMPOTENCY_ENABLED = 'IS_API_IDEMPOTENCY_ENABLED', IS_API_RATE_LIMITING_DRY_RUN_ENABLED = 'IS_API_RATE_LIMITING_DRY_RUN_ENABLED', IS_API_RATE_LIMITING_ENABLED = 'IS_API_RATE_LIMITING_ENABLED', + IS_AI_TEMPLATE_STORE_ENABLED = 'IS_AI_TEMPLATE_STORE_ENABLED', IS_CONTROLS_AUTOCOMPLETE_ENABLED = 'IS_CONTROLS_AUTOCOMPLETE_ENABLED', IS_EMAIL_INLINE_CSS_DISABLED = 'IS_EMAIL_INLINE_CSS_DISABLED', IS_EVENT_QUOTA_LIMITING_ENABLED = 'IS_EVENT_QUOTA_LIMITING_ENABLED', @@ -44,5 +45,7 @@ export enum FeatureFlagsKeysEnum { IS_USAGE_ALERTS_ENABLED = 'IS_USAGE_ALERTS_ENABLED', IS_USE_MERGED_DIGEST_ID_ENABLED = 'IS_USE_MERGED_DIGEST_ID_ENABLED', IS_V2_ENABLED = 'IS_V2_ENABLED', + IS_V2_TEMPLATE_STORE_ENABLED = 'IS_V2_TEMPLATE_STORE_ENABLED', IS_STEP_CONDITIONS_ENABLED = 'IS_STEP_CONDITIONS_ENABLED', + IS_WORKFLOW_NODE_PREVIEW_ENABLED = 'IS_WORKFLOW_NODE_PREVIEW_ENABLED', } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a40b1352ad..dfbe5c504e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19119,7 +19119,7 @@ packages: hasBin: true add-px-to-style@1.0.0: - resolution: {integrity: sha512-YMyxSlXpPjD8uWekCQGuN40lV4bnZagUwqa2m/uFv1z/tNImSk9fnXVMUI5qwME/zzI3MMQRvjZ+69zyfSSyew==} + resolution: {integrity: sha1-0ME1RB+oAUqBN5BFMQlvZ/KPJjo=} add-stream@1.0.0: resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==} @@ -19426,7 +19426,7 @@ packages: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} argv@0.0.2: - resolution: {integrity: sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw==} + resolution: {integrity: sha1-7L0W+JSbFXGDcRsb2jNPN4QBhas=} engines: {node: '>=0.6.10'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -20194,7 +20194,7 @@ packages: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -20264,7 +20264,7 @@ packages: engines: {node: '>= 0.8'} bytes@3.0.0: - resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=} engines: {node: '>= 0.8'} bytes@3.1.2: @@ -20963,7 +20963,7 @@ packages: resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==} concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -21068,7 +21068,7 @@ packages: resolution: {integrity: sha512-L2rLOcK0wzWSfSDA33YR+PUHDG10a8px7rUHKWbGLP4YfbsMed2KFUw5fczvDPbT98DDe3LEzviswl810apTEw==} cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=} cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} @@ -22256,7 +22256,7 @@ packages: hasBin: true ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} @@ -24946,7 +24946,7 @@ packages: engines: {node: '>=12'} indexof@0.0.1: - resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==} + resolution: {integrity: sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=} individual@3.0.0: resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} @@ -27133,7 +27133,7 @@ packages: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} map-stream@0.0.7: - resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} + resolution: {integrity: sha1-ih8HiW2CsQkmvTdEokIACfiJdKg=} map-visit@1.0.0: resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} @@ -27353,7 +27353,7 @@ packages: resolution: {integrity: sha512-88ZRGcNxAq4EH38cQ4D85PM57pikCwS8Z99EWHODxN7KBY+UuPiqzRTtZzS8KTXO/ywSWbdjjJST2Hly/EQxLw==} media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} engines: {node: '>= 0.6'} mediaquery-text@1.2.0: @@ -28078,6 +28078,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.8: + resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanomatch@1.2.13: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} engines: {node: '>=0.10.0'} @@ -29146,7 +29151,7 @@ packages: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} pause@0.0.1: - resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + resolution: {integrity: sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=} peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -30248,7 +30253,7 @@ packages: engines: {node: '>=10'} prefix-style@2.0.1: - resolution: {integrity: sha512-gdr1MBNVT0drzTq95CbSNdsrBDoHGlb2aDJP/FoY+1e+jSDPOb1Cv554gH2MGiSr2WTcXi/zu+NaFzfcHQkfBQ==} + resolution: {integrity: sha1-ZrupqHDP2jCKXcIOhekSCTLJWgY=} prelude-ls@1.1.2: resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} @@ -32048,7 +32053,7 @@ packages: hasBin: true run-p@0.0.0: - resolution: {integrity: sha512-ZLiUUVOXJcM/S1hMnm6Ooc1zAgAx98Mmn1qyA+y3WNeK7hOTGAusVR5r3uOQJ0NuUxZt7J9vNusYNNVgKPSbww==} + resolution: {integrity: sha1-cWpVvRICd6nZDaX4IzO3C5GAiPI=} run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -32199,7 +32204,7 @@ packages: engines: {node: '>=4'} secure-compare@3.0.1: - resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + resolution: {integrity: sha1-8aAymzCLIh+uN7mXTz1XjQypmeM=} secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} @@ -33667,7 +33672,7 @@ packages: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} to-camel-case@1.0.0: - resolution: {integrity: sha512-nD8pQi5H34kyu1QDMFjzEIYqk0xa9Alt6ZfrdEMuHCFOfTLhDG5pgTu/aAM9Wt9lXILwlXmWP43b8sav0GNE8Q==} + resolution: {integrity: sha1-GlYFSy+daWKYzmamCJcyK29CPkY=} to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} @@ -74172,6 +74177,8 @@ snapshots: nanoid@3.3.7: {} + nanoid@3.3.8: {} + nanomatch@1.2.13: dependencies: arr-diff: 4.0.0 @@ -76907,13 +76914,13 @@ snapshots: postcss@8.4.39: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.8 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.4.41: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.8 picocolors: 1.1.1 source-map-js: 1.2.1
{description}
Find out more about how to best setup workflows