From 82b7f7215dc79581b21971b09825ef2f93b77568 Mon Sep 17 00:00:00 2001 From: Pawan Jain Date: Tue, 17 Dec 2024 07:01:02 +0530 Subject: [PATCH 01/12] fix(web): change new dashboard widget ff condition (#7309) --- apps/web/src/components/nav/RootNavMenu.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/nav/RootNavMenu.tsx b/apps/web/src/components/nav/RootNavMenu.tsx index b87b8fb3463..e775d660b4c 100644 --- a/apps/web/src/components/nav/RootNavMenu.tsx +++ b/apps/web/src/components/nav/RootNavMenu.tsx @@ -48,6 +48,7 @@ export const RootNavMenu: React.FC = () => { const { updateOnboardingStatus, showOnboarding, isLoading: isLoadingOnboardingStatus } = useUserOnboardingStatus(); const { readonly: isEnvReadonly, environment } = useEnvironment(); const isV2Enabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_ENABLED); + const isNewDashboardEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_NEW_DASHBOARD_ENABLED); const [isLocalStudioModalOpen, toggleLocalStudioModalOpen] = useToggle(); const { navigateToLocalStudio } = useNavigateToLocalStudio({ fallbackFn: toggleLocalStudioModalOpen }); @@ -159,7 +160,7 @@ export const RootNavMenu: React.FC = () => { > - {isV2Enabled ? ( + {isNewDashboardEnabled ? ( <> {!IS_SELF_HOSTED && IS_EE_AUTH_ENABLED && } From cea01e073541108b662788f65af6946ff669221e Mon Sep 17 00:00:00 2001 From: Himanshu Garg Date: Tue, 17 Dec 2024 14:47:29 +0530 Subject: [PATCH 02/12] fix(dal): adds & removes indicies across repository to make it consistent across environments (#7296) --- .../src/repositories/change/change.schema.ts | 12 +++++++++++ .../environment/environment.schema.ts | 16 +++++++++++++++ .../execution-details.schema.ts | 4 ---- libs/dal/src/repositories/feed/feed.schema.ts | 12 +++++++++++ .../integration/integration.schema.ts | 4 ++++ libs/dal/src/repositories/job/job.schema.ts | 4 ---- .../src/repositories/layout/layout.schema.ts | 4 ++++ .../src/repositories/member/member.schema.ts | 16 +++++++++++++++ .../notification-group.schema.ts | 8 ++++++++ .../notification-template.schema.ts | 5 +++++ .../subscriber/subscriber.schema.ts | 12 ++++++++++- .../src/repositories/tenant/tenant.schema.ts | 18 ----------------- .../topic/topic-subscribers.schema.ts | 20 +++++++++++++++++++ .../src/repositories/topic/topic.schema.ts | 8 ++++++++ 14 files changed, 116 insertions(+), 27 deletions(-) diff --git a/libs/dal/src/repositories/change/change.schema.ts b/libs/dal/src/repositories/change/change.schema.ts index 915aaf96f1d..dd399cee97a 100644 --- a/libs/dal/src/repositories/change/change.schema.ts +++ b/libs/dal/src/repositories/change/change.schema.ts @@ -43,5 +43,17 @@ changeSchema.virtual('user', { justOne: true, }); +changeSchema.index({ + _environmentId: 1, +}); + +changeSchema.index({ + _creatorId: 1, +}); + +changeSchema.index({ + _entityId: 1, +}); + export const Change = (mongoose.models.Change as mongoose.Model) || mongoose.model('Change', changeSchema); diff --git a/libs/dal/src/repositories/environment/environment.schema.ts b/libs/dal/src/repositories/environment/environment.schema.ts index e9388aafa6b..8d12cd9103b 100644 --- a/libs/dal/src/repositories/environment/environment.schema.ts +++ b/libs/dal/src/repositories/environment/environment.schema.ts @@ -81,6 +81,22 @@ environmentSchema.index({ 'apiKeys.hash': 1, }); +environmentSchema.index( + { + identifier: 1, + }, + { unique: true } +); + +environmentSchema.index( + { + 'apiKeys.key': 1, + }, + { + unique: true, + } +); + export const Environment = (mongoose.models.Environment as mongoose.Model) || mongoose.model('Environment', environmentSchema); diff --git a/libs/dal/src/repositories/execution-details/execution-details.schema.ts b/libs/dal/src/repositories/execution-details/execution-details.schema.ts index b6c76e58de1..b01c5fc19e4 100644 --- a/libs/dal/src/repositories/execution-details/execution-details.schema.ts +++ b/libs/dal/src/repositories/execution-details/execution-details.schema.ts @@ -106,10 +106,6 @@ executionDetailsSchema.index({ _notificationId: 1, }); -executionDetailsSchema.index({ - _environmentId: 1, -}); - /* * This index was created to push entries to Online Archive */ diff --git a/libs/dal/src/repositories/feed/feed.schema.ts b/libs/dal/src/repositories/feed/feed.schema.ts index 7a50016b7c0..d8c93c62a24 100644 --- a/libs/dal/src/repositories/feed/feed.schema.ts +++ b/libs/dal/src/repositories/feed/feed.schema.ts @@ -26,6 +26,18 @@ const feedSchema = new Schema( schemaOptions ); +feedSchema.index({ + _organizationId: 1, +}); + +feedSchema.index({ + _environmentId: 1, +}); + +feedSchema.index({ + identifier: 1, +}); + feedSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const Feed = diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 851d823653a..2ab1d60e9bb 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -104,6 +104,10 @@ integrationSchema.index({ active: 1, }); +integrationSchema.index({ + _environmentId: 1, +}); + integrationSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const Integration = diff --git a/libs/dal/src/repositories/job/job.schema.ts b/libs/dal/src/repositories/job/job.schema.ts index b05c2ae996c..0bf13fbb44a 100644 --- a/libs/dal/src/repositories/job/job.schema.ts +++ b/libs/dal/src/repositories/job/job.schema.ts @@ -383,10 +383,6 @@ jobSchema.index({ _notificationId: 1, }); -jobSchema.index({ - _environmentId: 1, -}); - jobSchema.index( { _mergedDigestId: 1, diff --git a/libs/dal/src/repositories/layout/layout.schema.ts b/libs/dal/src/repositories/layout/layout.schema.ts index e49f9df7222..fe25645b11a 100644 --- a/libs/dal/src/repositories/layout/layout.schema.ts +++ b/libs/dal/src/repositories/layout/layout.schema.ts @@ -55,5 +55,9 @@ const layoutSchema = new Schema( layoutSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); +layoutSchema.index({ + _environmentId: 1, +}); + export const Layout = (mongoose.models.Layout as mongoose.Model) || mongoose.model('Layout', layoutSchema); diff --git a/libs/dal/src/repositories/member/member.schema.ts b/libs/dal/src/repositories/member/member.schema.ts index 3363d653fe1..9f7508661db 100644 --- a/libs/dal/src/repositories/member/member.schema.ts +++ b/libs/dal/src/repositories/member/member.schema.ts @@ -34,5 +34,21 @@ const memberSchema = new Schema( schemaOptions ); +memberSchema.index({ + _userId: 1, +}); + +memberSchema.index({ + 'invite.token': 1, +}); + +memberSchema.index({ + _organizationId: 1, +}); + +memberSchema.index({ + 'organizationId._userId._id': 1, +}); + export const Member = (mongoose.models.Member as mongoose.Model) || mongoose.model('Member', memberSchema); diff --git a/libs/dal/src/repositories/notification-group/notification-group.schema.ts b/libs/dal/src/repositories/notification-group/notification-group.schema.ts index 72460503896..915ab7fae86 100644 --- a/libs/dal/src/repositories/notification-group/notification-group.schema.ts +++ b/libs/dal/src/repositories/notification-group/notification-group.schema.ts @@ -24,6 +24,14 @@ const NotificationGroupSchema = new Schema( schemaOptions ); +NotificationGroupSchema.index({ + _organizationId: 1, +}); + +NotificationGroupSchema.index({ + _environmentId: 1, +}); + export const NotificationGroup = (mongoose.models.NotificationGroup as mongoose.Model) || mongoose.model('NotificationGroup', NotificationGroupSchema); diff --git a/libs/dal/src/repositories/notification-template/notification-template.schema.ts b/libs/dal/src/repositories/notification-template/notification-template.schema.ts index 2c9a7454f3a..91bc9a74bf6 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.schema.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.schema.ts @@ -271,6 +271,11 @@ notificationTemplateSchema.index({ name: 1, }); +notificationTemplateSchema.index({ + _environmentId: 1, + 'triggers.identifier': 1, +}); + notificationTemplateSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const NotificationTemplate = diff --git a/libs/dal/src/repositories/subscriber/subscriber.schema.ts b/libs/dal/src/repositories/subscriber/subscriber.schema.ts index 8ad9bfc7387..68f0a4c87a8 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.schema.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.schema.ts @@ -175,7 +175,7 @@ subscriberSchema.index({ * * We can not add `deleted` field to the index the client wont be able to delete twice subscriber with the same subscriberId. */ -index( +subscriberSchema.index( { subscriberId: 1, _environmentId: 1, @@ -183,6 +183,16 @@ index( { unique: true } ); +subscriberSchema.index({ + _organizationId: 1, +}); + +subscriberSchema.index({ + _environmentId: 1, + _organizationId: 1, + deleted: 1, +}); + subscriberSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const Subscriber = diff --git a/libs/dal/src/repositories/tenant/tenant.schema.ts b/libs/dal/src/repositories/tenant/tenant.schema.ts index 56496ae18b9..952dcaeb4a4 100644 --- a/libs/dal/src/repositories/tenant/tenant.schema.ts +++ b/libs/dal/src/repositories/tenant/tenant.schema.ts @@ -43,23 +43,5 @@ tenantSchema.index({ createdAt: -1, }); -/* - * This index was initially created to optimize: - * - * Path: apps/api/src/app/tenant/usecases/create-tenant/create-tenant.usecase.ts - * Context: execute() - * Query: findOne({ - * _environmentId: command.environmentId, - * identifier: command.identifier, - * }); - */ -tenantSchema.index( - { - _environmentId: 1, - identifier: 1, - }, - { unique: true } -); - export const Tenant = (mongoose.models.Tenant as mongoose.Model) || mongoose.model('Tenant', tenantSchema); diff --git a/libs/dal/src/repositories/topic/topic-subscribers.schema.ts b/libs/dal/src/repositories/topic/topic-subscribers.schema.ts index 8d20dce1557..46786f65c7c 100644 --- a/libs/dal/src/repositories/topic/topic-subscribers.schema.ts +++ b/libs/dal/src/repositories/topic/topic-subscribers.schema.ts @@ -40,6 +40,26 @@ const topicSubscribersSchema = new Schema( schemaOptions ); +topicSubscribersSchema.index({ + _environmentId: 1, +}); + +topicSubscribersSchema.index({ + _organizationId: 1, +}); + +topicSubscribersSchema.index({ + _subscriberId: 1, +}); + +topicSubscribersSchema.index({ + _topicId: 1, +}); + +topicSubscribersSchema.index({ + topicKey: 1, +}); + export const TopicSubscribers = (mongoose.models.TopicSubscribers as mongoose.Model) || mongoose.model('TopicSubscribers', topicSubscribersSchema); diff --git a/libs/dal/src/repositories/topic/topic.schema.ts b/libs/dal/src/repositories/topic/topic.schema.ts index 72b41337fd2..6f00121cd6f 100644 --- a/libs/dal/src/repositories/topic/topic.schema.ts +++ b/libs/dal/src/repositories/topic/topic.schema.ts @@ -29,5 +29,13 @@ const topicSchema = new Schema( schemaOptions ); +topicSchema.index({ + _environmentId: 1, +}); + +topicSchema.index({ + _organizationId: 1, +}); + export const Topic = (mongoose.models.Topic as mongoose.Model) || mongoose.model('Topic', topicSchema); From 5e814e95a1657623cf7d8128ccc5dc452b92f873 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 17 Dec 2024 16:29:21 +0530 Subject: [PATCH 03/12] feat(dashboard): Nv 4866 Enable override custom controls and implement custom controls for Delay & Digest (#7288) --- apps/dashboard/package.json | 2 + .../src/components/primitives/input.tsx | 7 +- .../common/common-custom-control-values.tsx | 39 +++ .../steps/configure-step-form.tsx | 236 +++++++++--------- .../steps/configure-step-template-form.tsx | 11 +- .../steps/controls/custom-step-controls.tsx | 199 ++++++++++++--- .../steps/controls/json-form.tsx | 2 +- .../steps/delay/delay-amount.tsx | 2 +- .../steps/delay/delay-control-values.tsx | 4 +- .../steps/digest/digest-control-values.tsx | 1 - .../steps/digest/digest-window.tsx | 2 +- .../steps/email/email-tabs.tsx | 7 +- .../steps/in-app/in-app-tabs.tsx | 9 +- .../steps/save-form-context.ts | 2 +- apps/dashboard/src/hooks/use-form-autosave.ts | 11 +- pnpm-lock.yaml | 13 + 16 files changed, 368 insertions(+), 179 deletions(-) create mode 100644 apps/dashboard/src/components/workflow-editor/steps/common/common-custom-control-values.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 8ae5550652f..65bdce5c519 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -74,6 +74,7 @@ "js-cookie": "^3.0.5", "launchdarkly-react-client-sdk": "^3.3.2", "lodash.debounce": "^4.0.8", + "lodash.isequal": "^4.5.0", "lodash.merge": "^4.6.2", "lucide-react": "^0.439.0", "mixpanel-browser": "^2.52.0", @@ -102,6 +103,7 @@ "@sentry/vite-plugin": "^2.22.6", "@tiptap/core": "^2.10.3", "@types/lodash.debounce": "^4.0.9", + "@types/lodash.isequal": "^4.5.8", "@types/lodash.merge": "^4.6.6", "@types/mixpanel-browser": "^2.49.0", "@types/node": "^22.7.0", diff --git a/apps/dashboard/src/components/primitives/input.tsx b/apps/dashboard/src/components/primitives/input.tsx index dcad8fad7b8..ddde386725f 100644 --- a/apps/dashboard/src/components/primitives/input.tsx +++ b/apps/dashboard/src/components/primitives/input.tsx @@ -39,7 +39,12 @@ const inputFieldVariants = cva( 'has-[input:read-only]:text-foreground-700', 'has-[input:read-only]:bg-neutral-alpha-100', 'has-[input:read-only]:opacity-70', - 'has-[input:read-only]:border-neutral-alpha-200' + 'has-[input:read-only]:border-neutral-alpha-200', + 'has-[.cm-content[aria-readonly=true]]:cursor-not-allowed', + 'has-[.cm-content[aria-readonly=true]]:text-foreground-700', + 'has-[.cm-content[aria-readonly=true]]:bg-neutral-alpha-100', + 'has-[.cm-content[aria-readonly=true]]:opacity-70', + 'has-[.cm-content[aria-readonly=true]]:border-neutral-alpha-200' ), { variants: { diff --git a/apps/dashboard/src/components/workflow-editor/steps/common/common-custom-control-values.tsx b/apps/dashboard/src/components/workflow-editor/steps/common/common-custom-control-values.tsx new file mode 100644 index 00000000000..8e736b8d304 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/common/common-custom-control-values.tsx @@ -0,0 +1,39 @@ +import { Button } from '@/components/primitives/button'; +import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; +import { RiCloseLine, RiEdit2Line } from 'react-icons/ri'; +import { useNavigate } from 'react-router-dom'; +import { CustomStepControls } from '../controls/custom-step-controls'; + +export const CommonCustomControlValues = () => { + const { step, workflow } = useWorkflow(); + const { dataSchema } = step?.controls ?? {}; + const navigate = useNavigate(); + + if (!dataSchema || !workflow) { + return null; + } + + return ( + <> +
+
+ + Configure Template +
+ +
+ + + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx index 812794ffbb7..c66bfa63ab9 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx @@ -9,7 +9,7 @@ import { WorkflowOriginEnum, WorkflowResponseDto, } from '@novu/shared'; -import { motion } from 'motion/react'; +import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useCallback, useMemo, useState, HTMLAttributes, ReactNode } from 'react'; import { useForm } from 'react-hook-form'; import { RiArrowLeftSLine, RiArrowRightSLine, RiCloseFill, RiDeleteBin2Line, RiPencilRuler2Fill } from 'react-icons/ri'; @@ -114,6 +114,8 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { const isTemplateConfigurableStep = isSupportedStep && TEMPLATE_CONFIGURABLE_STEP_TYPES.includes(step.type); const isInlineConfigurableStep = isSupportedStep && INLINE_CONFIGURABLE_STEP_TYPES.includes(step.type); + const hasCustomControls = Object.keys(step.controls.dataSchema ?? {}).length > 0 && !step.controls.uiSchema; + const isInlineConfigurableStepWithCustomControls = isInlineConfigurableStep && hasCustomControls; const onDeleteStep = () => { update({ ...workflow, steps: workflow.steps.filter((s) => s._id !== step._id) }); @@ -191,134 +193,132 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { return ( <> - - - - - - Configure Step - - - - + + + + + + + Configure Step + + + + + + - +
+ + + + ( + + Name + + + + + + + + )} + /> + ( + + Identifier + + + + + + + + + )} + /> + + + + {isInlineConfigurableStep && !hasCustomControls && } + +
+ -
- - + {(isTemplateConfigurableStep || isInlineConfigurableStepWithCustomControls) && ( + <> - ( - - Name - - - - - - - - )} - /> - ( - - Identifier - - - - - - - - - )} - /> + + + - {isInlineConfigurableStep && } - -
- - - {isTemplateConfigurableStep && ( - <> - - - - - - - - {firstError ? ( - <> - - - - ) : ( - Preview && ( + {firstError ? ( <> - - - + - ) - )} - - )} + ) : ( + Preview && ( + <> + + + + + + ) + )} + + )} - {!isSupportedStep && ( - <> + {!isSupportedStep && ( - - )} + )} - {!isReadOnly && ( - <> + {!isReadOnly && ( { Delete step - - )} -
+ )} +
+ ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx index 157b572a024..818a01e79d0 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import merge from 'lodash.merge'; +import isEqual from 'lodash.isequal'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { @@ -18,6 +19,7 @@ import { Form } from '@/components/primitives/form/form'; import { useFormAutosave } from '@/hooks/use-form-autosave'; import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context'; import { EmailTabs } from '@/components/workflow-editor/steps/email/email-tabs'; +import { CommonCustomControlValues } from './common/common-custom-control-values'; const STEP_TYPE_TO_TEMPLATE_FORM: Record React.JSX.Element | null> = { [StepTypeEnum.EMAIL]: EmailTabs, @@ -25,8 +27,8 @@ const STEP_TYPE_TO_TEMPLATE_FORM: Record null, - [StepTypeEnum.DELAY]: () => null, + [StepTypeEnum.DIGEST]: CommonCustomControlValues, + [StepTypeEnum.DELAY]: CommonCustomControlValues, [StepTypeEnum.TRIGGER]: () => null, [StepTypeEnum.CUSTOM]: () => null, }; @@ -67,9 +69,12 @@ export const ConfigureStepTemplateForm = (props: ConfigureStepTemplateFormProps) previousData: defaultValues, form, save: (data) => { + const defaultValues = buildDefaultValuesOfDataSchema(step.controls.dataSchema ?? {}); + const isDefaultValues = isEqual(data, defaultValues); + const updateData = isDefaultValues ? {} : data; // transform form fields to step update dto const updateStepData: Partial = { - controlValues: data, + controlValues: updateData, }; update(updateStepInWorkflow(workflow, step.stepId, updateStepData)); }, diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx index 1b09af1751e..d54f54d74c9 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx @@ -1,52 +1,187 @@ -import { ComponentProps, useState } from 'react'; +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { RiBookMarkedLine, RiInputField, RiQuestionLine } from 'react-icons/ri'; +import { motion } from 'motion/react'; +import { Link } from 'react-router-dom'; import { RJSFSchema } from '@rjsf/utils'; -import { RiArrowDownSLine, RiArrowUpSLine, RiInputField } from 'react-icons/ri'; import { type ControlsMetadata } from '@novu/shared'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/primitives/collapsible'; -import { JsonForm } from './json-form'; +import { Separator } from '@/components/primitives/separator'; +import { Switch } from '@/components/primitives/switch'; import { WorkflowOriginEnum } from '@/utils/enums'; import { cn } from '@/utils/ui'; +import { JsonForm } from './json-form'; +import { useSaveForm } from '@/components/workflow-editor/steps/save-form-context'; +import { useWorkflow } from '../../workflow-provider'; +import { buildDefaultValuesOfDataSchema } from '@/utils/schema'; +import { SidebarContent } from '@/components/side-navigation/sidebar'; +import { ConfirmationModal } from '@/components/confirmation-modal'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; -type CustomStepControlsProps = ComponentProps & { +type CustomStepControlsProps = { dataSchema: ControlsMetadata['dataSchema']; origin: WorkflowOriginEnum; + className?: string; }; + +const CONTROLS_DOCS_LINK = 'https://docs.novu.co/concepts/controls'; + export const CustomStepControls = (props: CustomStepControlsProps) => { - const { className, dataSchema, origin, ...rest } = props; - const [isEditorOpen, setIsEditorOpen] = useState(true); + const { className, dataSchema, origin } = props; + const [isRestoreDefaultModalOpen, setIsRestoreDefaultModalOpen] = useState(false); + const { step } = useWorkflow(); + const [isOverridden, setIsOverridden] = useState(() => Object.keys(step?.controls.values ?? {}).length > 0); + const { reset } = useFormContext(); + const { saveForm } = useSaveForm(); - if (!dataSchema?.properties || origin !== WorkflowOriginEnum.EXTERNAL) { - return null; + if (origin !== WorkflowOriginEnum.EXTERNAL || Object.keys(dataSchema?.properties ?? {}).length === 0) { + return ( + + + + +
+ + Code-defined step controls +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+

No controls defined yet

+ + Define step controls to render fields here. This lets your team collaborate and ensure changes + are validated in code. + +
+
+
+ + + View docs + +
+
+
+ + + + + ); } return ( - - -
- - Custom step controls + + { + const defaultValues = buildDefaultValuesOfDataSchema(step?.controls.dataSchema ?? {}); + reset(defaultValues); + saveForm(true); + setIsRestoreDefaultModalOpen(false); + setIsOverridden(false); + }} + title="Proceeding will restore controls to defaults." + description="All edits will be discarded, and defaults will be restored from the code." + confirmButtonText="Proceed anyway" + /> +
+
+ Override code defined defaults + + Code-defined defaults are read-only by default, you can override them using this toggle. +
+ { + if (!checked) { + setIsRestoreDefaultModalOpen(true); + return; + } + setIsOverridden(checked); + }} + /> +
+ - {isEditorOpen ? ( - - ) : ( - + + type="single" + defaultValue="controls" + collapsible + > + + +
+ + Code-defined step controls +
+
- -
- + +
+ +
+
+ + + + + ); +}; + +const OverrideMessage = ({ isOverridden }: { isOverridden: boolean }) => { + const fadeAnimation = { + initial: { opacity: 0, scale: 0.95 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.95 }, + transition: { duration: 0.1 }, + }; + + return ( + + {isOverridden ? ( +
+ + + Custom controls defined in the code have been overridden. Disable overrides to restore the original. +
- - + ) : ( + + Learn more about code-defined controls. + + )} +
); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/json-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/json-form.tsx index dac959e0f83..3743adadeee 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/json-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/json-form.tsx @@ -9,7 +9,7 @@ import { JSON_SCHEMA_FORM_ID_DELIMITER, UI_SCHEMA, WIDGETS } from './template-ut type JsonFormProps = Pick< FormProps, - 'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName' | 'onError' + 'onChange' | 'onSubmit' | 'onBlur' | 'schema' | 'formData' | 'tagName' | 'onError' | 'disabled' > & { variables?: string[]; }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx index 0dbc3f6b407..a886c6852d7 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-amount.tsx @@ -47,7 +47,7 @@ export const DelayAmount = () => { fields={{ inputKey: `controlValues.${amountKey}`, selectKey: `controlValues.${unitKey}` }} options={unitOptions} defaultOption={defaultUnitOption} - onValueChange={saveForm} + onValueChange={() => saveForm()} min={minAmountValue} />
diff --git a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-control-values.tsx b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-control-values.tsx index e47801bcb56..94d29ff4e5e 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/delay/delay-control-values.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/delay/delay-control-values.tsx @@ -2,7 +2,6 @@ import { UiSchemaGroupEnum } from '@novu/shared'; import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { SidebarContent } from '@/components/side-navigation/sidebar'; import { Separator } from '@/components/primitives/separator'; -import { CustomStepControls } from '@/components/workflow-editor/steps/controls/custom-step-controls'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; const amountKey = 'amount'; @@ -11,7 +10,7 @@ const typeKey = 'type'; export const DelayControlValues = () => { const { workflow, step } = useWorkflow(); - const { uiSchema, dataSchema } = step?.controls ?? {}; + const { uiSchema } = step?.controls ?? {}; if (!uiSchema || !workflow || uiSchema?.group !== UiSchemaGroupEnum.DELAY) { return null; @@ -27,7 +26,6 @@ export const DelayControlValues = () => { )} - ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx index 064c1863ac7..edfe866de89 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-control-values.tsx @@ -1,5 +1,4 @@ import { UiSchemaGroupEnum } from '@novu/shared'; - import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { Separator } from '@/components/primitives/separator'; diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx index b1e79681515..c69f198e37c 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx @@ -99,7 +99,7 @@ export const DigestWindow = () => { options={unitOptions} defaultOption={defaultUnitOption} className="w-min [&_input]:!w-[3ch] [&_input]:!min-w-[3ch]" - onValueChange={saveForm} + onValueChange={() => saveForm()} showError={false} min={minAmountValue} /> diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx index 92f6408fc92..006e661c5dc 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx @@ -3,7 +3,6 @@ import { WorkflowOriginEnum } from '@novu/shared'; import { EmailEditor } from '@/components/workflow-editor/steps/email/email-editor'; import { EmailEditorPreview } from '@/components/workflow-editor/steps/email/email-editor-preview'; import { CustomStepControls } from '../controls/custom-step-controls'; -import { EmailTabsSection } from '@/components/workflow-editor/steps/email/email-tabs-section'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { useState } from 'react'; @@ -20,11 +19,7 @@ export const EmailTabs = (props: StepEditorProps) => { const editorContent = ( <> {isNovuCloud && } - {isExternal && ( - - - - )} + {isExternal && } ); diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx index a2a4480c42d..2ee9cf46e3f 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx @@ -1,12 +1,11 @@ +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { InAppEditor } from '@/components/workflow-editor/steps/in-app/in-app-editor'; import { InAppEditorPreview } from '@/components/workflow-editor/steps/in-app/in-app-editor-preview'; import { CustomStepControls } from '../controls/custom-step-controls'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; -import { InAppTabsSection } from '@/components/workflow-editor/steps/in-app/in-app-tabs-section'; import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { WorkflowOriginEnum } from '@/utils/enums'; -import { useState } from 'react'; export const InAppTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -20,11 +19,7 @@ export const InAppTabs = (props: StepEditorProps) => { const editorContent = ( <> {isNovuCloud && } - {isExternal && ( - - - - )} + {isExternal && } ); diff --git a/apps/dashboard/src/components/workflow-editor/steps/save-form-context.ts b/apps/dashboard/src/components/workflow-editor/steps/save-form-context.ts index 909068ef67d..df9025bd11a 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/save-form-context.ts +++ b/apps/dashboard/src/components/workflow-editor/steps/save-form-context.ts @@ -1,7 +1,7 @@ import React from 'react'; type SaveFormContextValue = { - saveForm: () => Promise; + saveForm: (forceSubmit?: boolean) => Promise; }; export const SaveFormContext = React.createContext({} as SaveFormContextValue); diff --git a/apps/dashboard/src/hooks/use-form-autosave.ts b/apps/dashboard/src/hooks/use-form-autosave.ts index f8d892fc2e6..d261b5159b1 100644 --- a/apps/dashboard/src/hooks/use-form-autosave.ts +++ b/apps/dashboard/src/hooks/use-form-autosave.ts @@ -20,13 +20,16 @@ export function useFormAutosave, T extends Fie const formRef = useDataRef(propsForm); const onSave = useCallback( - async (data: T) => { + async (data: T, options?: { forceSubmit?: boolean }) => { + if (isReadOnly) { + return; + } // use the form reference instead of destructuring the props to avoid stale closures const form = formRef.current; const dirtyFields = form.formState.dirtyFields; // somehow the form isDirty flag is lost on first blur that why we fallback to dirtyFields const isDirty = form.formState.isDirty || Object.keys(dirtyFields).length > 0; - if (!isDirty || isReadOnly) { + if (!isDirty && !options?.forceSubmit) { return; } // manually trigger the validation of the form @@ -60,14 +63,14 @@ export function useFormAutosave, T extends Fie ); // flush the form updates right away - const saveForm = (): Promise => { + const saveForm = (forceSubmit: boolean = false): Promise => { return new Promise((resolve) => { // await for the state to be updated setTimeout(async () => { // use the form reference instead of destructuring the props to avoid stale closures const form = formRef.current; const values = form.getValues(); - await onSave(values); + await onSave(values, { forceSubmit }); resolve(); }, 0); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bd339fcb8f..2427c182cbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -827,6 +827,9 @@ importers: lodash.debounce: specifier: ^4.0.8 version: 4.0.8 + lodash.isequal: + specifier: ^4.5.0 + version: 4.5.0 lodash.merge: specifier: ^4.6.2 version: 4.6.2 @@ -906,6 +909,9 @@ importers: '@types/lodash.debounce': specifier: ^4.0.9 version: 4.0.9 + '@types/lodash.isequal': + specifier: ^4.5.8 + version: 4.5.8 '@types/lodash.merge': specifier: ^4.6.6 version: 4.6.7 @@ -17320,6 +17326,9 @@ packages: '@types/lodash.get@4.4.7': resolution: {integrity: sha512-af34Mj+KdDeuzsJBxc/XeTtOx0SZHZNLd+hdrn+PcKGQs0EG2TJTzQAOTCZTgDJCArahlCzLWSy8c2w59JRz7Q==} + '@types/lodash.isequal@4.5.8': + resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} + '@types/lodash.merge@4.6.7': resolution: {integrity: sha512-OwxUJ9E50gw3LnAefSHJPHaBLGEKmQBQ7CZe/xflHkyy/wH2zVyEIAKReHvVrrn7zKdF58p16We9kMfh7v0RRQ==} @@ -57464,6 +57473,10 @@ snapshots: dependencies: '@types/lodash': 4.14.192 + '@types/lodash.isequal@4.5.8': + dependencies: + '@types/lodash': 4.14.192 + '@types/lodash.merge@4.6.7': dependencies: '@types/lodash': 4.14.192 From 3678a0c337d34f0f5711d7daf2faac9df6279dca Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 17 Dec 2024 17:16:34 +0530 Subject: [PATCH 04/12] feat(api): Nv 5045 update the api to have same behavior as preference (#7302) --- .../patch-step-data/patch-step.command.ts | 2 +- .../patch-step-data/patch-step.usecase.ts | 22 ++++++++++++++++--- .../upsert-step-data.command.ts | 2 +- .../upsert-workflow.usecase.ts | 4 ++-- .../src/app/workflows-v2/workflow.module.ts | 2 ++ .../steps/configure-step-template-form.tsx | 2 +- .../steps/email/email-tabs.tsx | 2 +- .../delete-control-values.command.ts | 12 ++++++++++ .../delete-control-values.usecase.ts | 21 ++++++++++++++++++ .../usecases/delete-control-values/index.ts | 2 ++ .../application-generic/src/usecases/index.ts | 1 + packages/shared/src/dto/workflows/step.dto.ts | 4 ++-- 12 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 libs/application-generic/src/usecases/delete-control-values/delete-control-values.command.ts create mode 100644 libs/application-generic/src/usecases/delete-control-values/delete-control-values.usecase.ts create mode 100644 libs/application-generic/src/usecases/delete-control-values/index.ts diff --git a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.command.ts b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.command.ts index e754b027026..591045b9007 100644 --- a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.command.ts @@ -17,5 +17,5 @@ export class PatchStepCommand extends EnvironmentWithUserObjectCommand { @IsOptional() @IsObject() - controlValues?: Record; + controlValues?: Record | null; } diff --git a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts index 26b2126c0d5..02d8924a1a1 100644 --- a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts @@ -1,8 +1,10 @@ /* eslint-disable no-param-reassign */ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; import { StepDataDto, UserSessionData } from '@novu/shared'; import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { + DeleteControlValuesCommand, + DeleteControlValuesUseCase, GetWorkflowByIdsUseCase, UpsertControlValuesCommand, UpsertControlValuesUseCase, @@ -22,7 +24,9 @@ export class PatchStepUsecase { private buildStepDataUsecase: BuildStepDataUsecase, private notificationTemplateRepository: NotificationTemplateRepository, private upsertControlValuesUseCase: UpsertControlValuesUseCase, - private postProcessWorkflowUpdate: PostProcessWorkflowUpdate + private postProcessWorkflowUpdate: PostProcessWorkflowUpdate, + @Inject(forwardRef(() => DeleteControlValuesUseCase)) + private deleteControlValuesUseCase: DeleteControlValuesUseCase ) {} async execute(command: PatchStepCommand): Promise { @@ -43,7 +47,19 @@ export class PatchStepUsecase { } if (command.controlValues !== undefined) { - await this.updateControlValues(persistedItems, command); + if (command.controlValues === null) { + await this.deleteControlValuesUseCase.execute( + DeleteControlValuesCommand.create({ + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + stepId: persistedItems.currentStep._id as string, + workflowId: persistedItems.workflow._id, + userId: command.user._id, + }) + ); + } else { + await this.updateControlValues(persistedItems, command); + } } } diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts index 94ce1125c5f..8b55e29e18b 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts @@ -12,7 +12,7 @@ export class UpsertStepDataCommand { type: StepTypeEnum; @IsOptional() - controlValues?: Record; + controlValues?: Record | null; @IsOptional() @IsString() diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts index a34b5f91014..adf24b7c0a7 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts @@ -299,7 +299,7 @@ export class UpsertWorkflowUseCase { ): Promise { for (const step of workflow.steps) { const controlValues = this.findControlValueInRequest(step, command.workflowDto.steps); - if (!controlValues) { + if (controlValues === undefined) { continue; } await this.patchStepDataUsecase.execute({ @@ -316,7 +316,7 @@ export class UpsertWorkflowUseCase { private findControlValueInRequest( step: NotificationStepEntity, steps: (StepCreateDto | StepUpdateDto)[] | StepCreateDto[] - ): Record | undefined { + ): Record | undefined | null { return steps.find((stepRequest) => { if (this.isStepUpdateDto(stepRequest)) { return stepRequest._id === step._templateId; diff --git a/apps/api/src/app/workflows-v2/workflow.module.ts b/apps/api/src/app/workflows-v2/workflow.module.ts index 4bd4396ea5f..9b9b95530a0 100644 --- a/apps/api/src/app/workflows-v2/workflow.module.ts +++ b/apps/api/src/app/workflows-v2/workflow.module.ts @@ -9,6 +9,7 @@ import { UpsertControlValuesUseCase, UpsertPreferences, TierRestrictionsValidateUsecase, + DeleteControlValuesUseCase, } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; @@ -77,6 +78,7 @@ const DAL_REPOSITORIES = [CommunityOrganizationRepository]; PatchWorkflowUsecase, TierRestrictionsValidateUsecase, BuildPayloadSchema, + DeleteControlValuesUseCase, ], }) export class WorkflowModule implements NestModule { diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx index 818a01e79d0..a43bb1aea16 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx @@ -71,7 +71,7 @@ export const ConfigureStepTemplateForm = (props: ConfigureStepTemplateFormProps) save: (data) => { const defaultValues = buildDefaultValuesOfDataSchema(step.controls.dataSchema ?? {}); const isDefaultValues = isEqual(data, defaultValues); - const updateData = isDefaultValues ? {} : data; + const updateData = isDefaultValues ? null : data; // transform form fields to step update dto const updateStepData: Partial = { controlValues: updateData, diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx index 006e661c5dc..e549af18818 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { WorkflowOriginEnum } from '@novu/shared'; import { EmailEditor } from '@/components/workflow-editor/steps/email/email-editor'; @@ -5,7 +6,6 @@ import { EmailEditorPreview } from '@/components/workflow-editor/steps/email/ema import { CustomStepControls } from '../controls/custom-step-controls'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; -import { useState } from 'react'; export const EmailTabs = (props: StepEditorProps) => { const { workflow, step } = props; diff --git a/libs/application-generic/src/usecases/delete-control-values/delete-control-values.command.ts b/libs/application-generic/src/usecases/delete-control-values/delete-control-values.command.ts new file mode 100644 index 00000000000..3ba499810f0 --- /dev/null +++ b/libs/application-generic/src/usecases/delete-control-values/delete-control-values.command.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { EnvironmentWithUserCommand } from '../../commands'; + +export class DeleteControlValuesCommand extends EnvironmentWithUserCommand { + @IsString() + @IsNotEmpty() + readonly workflowId: string; + + @IsString() + @IsNotEmpty() + readonly stepId: string; +} diff --git a/libs/application-generic/src/usecases/delete-control-values/delete-control-values.usecase.ts b/libs/application-generic/src/usecases/delete-control-values/delete-control-values.usecase.ts new file mode 100644 index 00000000000..2e73b7bd3e9 --- /dev/null +++ b/libs/application-generic/src/usecases/delete-control-values/delete-control-values.usecase.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { ControlValuesRepository } from '@novu/dal'; +import { ControlValuesLevelEnum } from '@novu/shared'; +import { DeleteControlValuesCommand } from './delete-control-values.command'; +import { InstrumentUsecase } from '../../instrumentation'; + +@Injectable() +export class DeleteControlValuesUseCase { + constructor(private controlValuesRepository: ControlValuesRepository) {} + + @InstrumentUsecase() + public async execute(command: DeleteControlValuesCommand): Promise { + await this.controlValuesRepository.delete({ + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _workflowId: command.workflowId, + _stepId: command.stepId, + level: ControlValuesLevelEnum.STEP_CONTROLS, + }); + } +} diff --git a/libs/application-generic/src/usecases/delete-control-values/index.ts b/libs/application-generic/src/usecases/delete-control-values/index.ts new file mode 100644 index 00000000000..57965d1a57c --- /dev/null +++ b/libs/application-generic/src/usecases/delete-control-values/index.ts @@ -0,0 +1,2 @@ +export * from './delete-control-values.usecase'; +export * from './delete-control-values.command'; diff --git a/libs/application-generic/src/usecases/index.ts b/libs/application-generic/src/usecases/index.ts index 93003165c0a..dcc91021c17 100644 --- a/libs/application-generic/src/usecases/index.ts +++ b/libs/application-generic/src/usecases/index.ts @@ -48,3 +48,4 @@ export * from './get-preferences'; export * from './delete-preferences'; export * from './get-decrypted-secret-key'; export * from './tier-restrictions-validate'; +export * from './delete-control-values'; diff --git a/packages/shared/src/dto/workflows/step.dto.ts b/packages/shared/src/dto/workflows/step.dto.ts index f2f99bd9706..f3f7e2cdc00 100644 --- a/packages/shared/src/dto/workflows/step.dto.ts +++ b/packages/shared/src/dto/workflows/step.dto.ts @@ -21,12 +21,12 @@ export type StepUpdateDto = StepCreateDto & { }; export type StepCreateDto = StepDto & { - controlValues?: Record; + controlValues?: Record | null; }; export type PatchStepDataDto = { name?: string; - controlValues?: Record; + controlValues?: Record | null; }; export type StepDto = { From c27185efd7248725ee24ffe4716bb087dc58dbba Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 17 Dec 2024 17:31:02 +0530 Subject: [PATCH 05/12] fix(dashboard): implement redirect to legacy studio auth page (#7313) --- apps/dashboard/src/main.tsx | 10 +++------- .../src/pages/redirect-to-legacy-studio-auth.tsx | 12 ++++++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 apps/dashboard/src/pages/redirect-to-legacy-studio-auth.tsx diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 7998f6fae0b..941734e8455 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -17,7 +17,7 @@ import { ActivityFeed, } from '@/pages'; import './index.css'; -import { LEGACY_ROUTES, ROUTES } from './utils/routes'; +import { ROUTES } from './utils/routes'; import { EditWorkflowPage } from './pages/edit-workflow'; import { TestWorkflowPage } from './pages/test-workflow'; import { initializeSentry } from './utils/sentry'; @@ -30,6 +30,7 @@ import { ChannelPreferences } from './components/workflow-editor/channel-prefere import { FeatureFlagsProvider } from './context/feature-flags-provider'; import { ConfigureStep } from '@/components/workflow-editor/steps/configure-step'; import { ConfigureStepTemplate } from '@/components/workflow-editor/steps/configure-step-template'; +import { RedirectToLegacyStudioAuth } from './pages/redirect-to-legacy-studio-auth'; initializeSentry(); overrideZodErrorMap(); @@ -159,12 +160,7 @@ const router = createBrowserRouter([ }, { path: ROUTES.LOCAL_STUDIO_AUTH, - element: (() => { - const currentSearch = window.location.search; - window.location.href = `${LEGACY_ROUTES.LOCAL_STUDIO_AUTH}${currentSearch}&studio_path_hint=/legacy/studio`; - - return null; - })(), + element: , }, { path: '*', diff --git a/apps/dashboard/src/pages/redirect-to-legacy-studio-auth.tsx b/apps/dashboard/src/pages/redirect-to-legacy-studio-auth.tsx new file mode 100644 index 00000000000..5974562af7b --- /dev/null +++ b/apps/dashboard/src/pages/redirect-to-legacy-studio-auth.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; +import { LEGACY_ROUTES } from '@/utils/routes'; + +export const RedirectToLegacyStudioAuth = () => { + useEffect(() => { + const currentSearch = window.location.search; + const redirectUrl = `${LEGACY_ROUTES.LOCAL_STUDIO_AUTH}${currentSearch}&studio_path_hint=/legacy/studio`; + window.location.href = redirectUrl; + }, []); + + return null; +}; From 75af9d3f6e37a21d855b9a033934341b4e113cca Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Tue, 17 Dec 2024 18:24:44 +0530 Subject: [PATCH 06/12] feat(dashboard): Nv 4885 push step editor (#7306) --- .../workflow-editor/add-step-menu.tsx | 9 ++- .../workflow-editor/steps/base/base-body.tsx | 55 +++++++++++++++++++ .../steps/base/base-subject.tsx | 48 ++++++++++++++++ .../workflow-editor/steps/component-utils.tsx | 9 +++ .../steps/configure-step-template-form.tsx | 3 +- .../steps/push/push-editor.tsx | 28 ++++++++++ .../steps/push/push-tabs-section.tsx | 8 +++ .../workflow-editor/steps/push/push-tabs.tsx | 33 +++++++++++ packages/shared/src/types/feature-flags.ts | 1 + 9 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/push-tabs-section.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx diff --git a/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx b/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx index 20d164029c5..1474e8b9d00 100644 --- a/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx +++ b/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx @@ -78,6 +78,7 @@ export const AddStepMenu = ({ }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const areNewStepsEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_DELAY_DIGEST_EMAIL_ENABLED); + const arePushChatSMSEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_SMS_CHAT_PUSH_ENABLED); const handleMenuItemClick = (stepType: StepTypeEnum) => { onMenuItemClick(stepType); @@ -123,7 +124,13 @@ export const AddStepMenu = ({ > In-App - Push + handleMenuItemClick(StepTypeEnum.PUSH)} + > + Push + Chat SMS diff --git a/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx b/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx new file mode 100644 index 00000000000..4d22ff77e47 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/base/base-body.tsx @@ -0,0 +1,55 @@ +import { EditorView } from '@uiw/react-codemirror'; +import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Editor } from '@/components/primitives/editor'; +import { FormControl, FormField, FormItem, FormMessage } from '@/components/primitives/form/form'; +import { InputField } from '@/components/primitives/input'; +import { completions } from '@/utils/liquid-autocomplete'; +import { parseStepVariablesToLiquidVariables } from '@/utils/parseStepVariablesToLiquidVariables'; +import { capitalize } from '@/utils/string'; +import { autocompletion } from '@codemirror/autocomplete'; +import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; + +const bodyKey = 'body'; + +const basicSetup = { + defaultKeymap: true, +}; + +export const BaseBody = () => { + const { control } = useFormContext(); + const { step } = useWorkflow(); + const variables = useMemo(() => (step ? parseStepVariablesToLiquidVariables(step.variables) : []), [step]); + const extensions = useMemo( + () => [autocompletion({ override: [completions(variables)] }), EditorView.lineWrapping], + [variables] + ); + + return ( + ( + + + + + + + {`This supports markdown and variables, type { for more.`} + + )} + /> + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx b/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx new file mode 100644 index 00000000000..3f72d19a9ff --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/base/base-subject.tsx @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { EditorView } from '@uiw/react-codemirror'; +import { useFormContext } from 'react-hook-form'; + +import { Editor } from '@/components/primitives/editor'; +import { FormControl, FormField, FormItem, FormMessage } from '@/components/primitives/form/form'; +import { InputField } from '@/components/primitives/input'; +import { completions } from '@/utils/liquid-autocomplete'; +import { parseStepVariablesToLiquidVariables } from '@/utils/parseStepVariablesToLiquidVariables'; +import { capitalize } from '@/utils/string'; +import { autocompletion } from '@codemirror/autocomplete'; +import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; + +const subjectKey = 'subject'; + +export const BaseSubject = () => { + const { control } = useFormContext(); + const { step } = useWorkflow(); + const variables = useMemo(() => (step ? parseStepVariablesToLiquidVariables(step.variables) : []), [step]); + const extensions = useMemo( + () => [autocompletion({ override: [completions(variables)] }), EditorView.lineWrapping], + [variables] + ); + + return ( + ( + + + + + + + + + )} + /> + ); +}; 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 91f42bbbbba..27e630e55f2 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx @@ -10,6 +10,8 @@ import { Maily } from '@/components/workflow-editor/steps/email/maily'; import { EmailSubject } from '@/components/workflow-editor/steps/email/email-subject'; import { DigestKey } from '@/components/workflow-editor/steps/digest/digest-key'; import { DigestWindow } from '@/components/workflow-editor/steps/digest/digest-window'; +import { BaseBody } from './base/base-body'; +import { BaseSubject } from './base/base-subject'; export const getComponentByType = ({ component }: { component?: UiComponentEnum }) => { switch (component) { @@ -47,6 +49,13 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum case UiComponentEnum.DIGEST_CRON: { return ; } + case UiComponentEnum.PUSH_BODY: { + return ; + } + case UiComponentEnum.PUSH_SUBJECT: { + return ; + } + default: { return null; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx index a43bb1aea16..bcdc813ae36 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx @@ -20,13 +20,14 @@ import { useFormAutosave } from '@/hooks/use-form-autosave'; import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context'; import { EmailTabs } from '@/components/workflow-editor/steps/email/email-tabs'; import { CommonCustomControlValues } from './common/common-custom-control-values'; +import { PushTabs } from '@/components/workflow-editor/steps/push/push-tabs'; const STEP_TYPE_TO_TEMPLATE_FORM: Record React.JSX.Element | null> = { [StepTypeEnum.EMAIL]: EmailTabs, [StepTypeEnum.CHAT]: OtherStepTabs, [StepTypeEnum.IN_APP]: InAppTabs, [StepTypeEnum.SMS]: OtherStepTabs, - [StepTypeEnum.PUSH]: OtherStepTabs, + [StepTypeEnum.PUSH]: PushTabs, [StepTypeEnum.DIGEST]: CommonCustomControlValues, [StepTypeEnum.DELAY]: CommonCustomControlValues, [StepTypeEnum.TRIGGER]: () => null, diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx new file mode 100644 index 00000000000..5604eb2a62b --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor.tsx @@ -0,0 +1,28 @@ +import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; +import { PushTabsSection } from '@/components/workflow-editor/steps/push/push-tabs-section'; +import { type UiSchema } from '@novu/shared'; +import { RiCellphoneFill } from 'react-icons/ri'; + +const subjectKey = 'subject'; +const bodyKey = 'body'; + +type PushEditorProps = { uiSchema: UiSchema }; +export const PushEditor = (props: PushEditorProps) => { + const { uiSchema } = props; + const { [bodyKey]: body, [subjectKey]: subject } = uiSchema?.properties ?? {}; + + return ( +
+ +
+ + Push template editor +
+
+ {getComponentByType({ component: subject.component })} + {getComponentByType({ component: body.component })} +
+
+
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs-section.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs-section.tsx new file mode 100644 index 00000000000..06a0f74837a --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs-section.tsx @@ -0,0 +1,8 @@ +import { cn } from '@/utils/ui'; +import { HTMLAttributes } from 'react'; + +type PushTabsSectionProps = HTMLAttributes; +export const PushTabsSection = (props: PushTabsSectionProps) => { + const { className, ...rest } = props; + return
; +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx new file mode 100644 index 00000000000..44bde28b18b --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx @@ -0,0 +1,33 @@ +import { useState } from 'react'; +import { WorkflowOriginEnum } from '@novu/shared'; +import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; +import { PushEditor } from '@/components/workflow-editor/steps/push/push-editor'; +import { CustomStepControls } from '../controls/custom-step-controls'; +import { TemplateTabs } from '../template-tabs'; + +export const PushTabs = (props: StepEditorProps) => { + const { workflow, step } = props; + const { dataSchema, uiSchema } = step.controls; + const [tabsValue, setTabsValue] = useState('editor'); + + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; + const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + + const editorContent = ( + <> + {isNovuCloud && } + {isExternal && } + + ); + + const previewContent = <>TODO; + + return ( + + ); +}; diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index 3cd3afcb8d7..437c7eed1f7 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -47,4 +47,5 @@ export enum FeatureFlagsKeysEnum { IS_NEW_DASHBOARD_GETTING_STARTED_ENABLED = 'IS_NEW_DASHBOARD_GETTING_STARTED_ENABLED', IS_ND_DELAY_DIGEST_EMAIL_ENABLED = 'IS_ND_DELAY_DIGEST_EMAIL_ENABLED', IS_NEW_DASHBOARD_ACTIVITY_FEED_ENABLED = 'IS_NEW_DASHBOARD_ACTIVITY_FEED_ENABLED', + IS_ND_SMS_CHAT_PUSH_ENABLED = 'IS_ND_SMS_CHAT_PUSH_ENABLED', } From 32ef340f35702eb544c7499f8299184bc6016385 Mon Sep 17 00:00:00 2001 From: George Djabarov <39195835+djabarovgeorge@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:37:42 +0200 Subject: [PATCH 07/12] refactor(api): refactor none-email control value preview usecase (#7303) --- .../render-email-output.usecase.ts | 4 +- .../workflows-v2/e2e/generate-preview.e2e.ts | 4 +- .../app/workflows-v2/generate-preview.e2e.ts | 27 +- .../shared/schemas/email-control.schema.ts | 18 +- .../shared/step-type-to-control.mapper.ts | 8 +- .../generate-preview.usecase.ts | 448 +++++++++++++++--- .../template-parser/liquid-parser.spec.ts | 61 ++- .../util/template-parser/liquid-parser.ts | 90 +++- libs/application-generic/src/utils/index.ts | 1 + .../utils/sanitize-preview-control-values.ts | 222 +++++++++ pnpm-lock.yaml | 68 ++- 11 files changed, 773 insertions(+), 178 deletions(-) create mode 100644 libs/application-generic/src/utils/sanitize-preview-control-values.ts diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts index 36309a69827..81ec6db1480 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts @@ -5,7 +5,7 @@ import { Instrument, InstrumentUsecase } from '@novu/application-generic'; import isEmpty from 'lodash/isEmpty'; import { FullPayloadForRender, RenderCommand } from './render-command'; import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase'; -import { EmailStepControlZodSchema } from '../../../workflows-v2/shared'; +import { emailStepControlZodSchema } from '../../../workflows-v2/shared'; export class RenderEmailOutputCommand extends RenderCommand {} @@ -15,7 +15,7 @@ export class RenderEmailOutputUsecase { @InstrumentUsecase() async execute(renderCommand: RenderEmailOutputCommand): Promise { - const { body, subject } = EmailStepControlZodSchema.parse(renderCommand.controlValues); + const { body, subject } = emailStepControlZodSchema.parse(renderCommand.controlValues); if (isEmpty(body)) { return { subject, body: '' }; diff --git a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts index 236c38d7ddb..0ecf30de17c 100644 --- a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts @@ -59,7 +59,7 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview', () => preview: { subject: 'Welcome {{subscriber.firstName}}', // cspell:disable-next-line - body: 'Hello {{subscriber.firstName}} {{subscriber.lastName}}, Welcome to {{PAYLOAD.ORGANIZATIONNAME | UPCASE}}!', + body: 'Hello {{subscriber.firstName}} {{subscriber.lastName}}, Welcome to {{PAYLOAD.ORGANIZATIONNAME}}!', }, type: 'in_app', }, @@ -69,7 +69,7 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview', () => lastName: '{{subscriber.lastName}}', }, payload: { - organizationName: '{{payload.organizationName | upcase}}', + organizationName: '{{payload.organizationName}}', }, }, }, diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts index 88f9d3626ba..fd480ac9855 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -328,11 +328,9 @@ describe('Generate Preview', () => { if (previewResponseDto.result!.type !== 'sms') { throw new Error('Expected sms'); } - expect(previewResponseDto.result!.preview.body).to.contain('{{PAYLOAD.VARIABLENAME | UPCASE}}'); + expect(previewResponseDto.result!.preview.body).to.contain('{{PAYLOAD.VARIABLENAME}}'); expect(previewResponseDto.previewPayloadExample).to.exist; - expect(previewResponseDto?.previewPayloadExample?.payload?.variableName).to.equal( - '{{payload.variableName | upcase}}' - ); + expect(previewResponseDto?.previewPayloadExample?.payload?.variableName).to.equal('{{payload.variableName}}'); }); it('Should not fail if inApp is providing partial URL in redirect', async () => { @@ -413,26 +411,7 @@ describe('Generate Preview', () => { ); if (generatePreviewResponseDto.result?.type === ChannelTypeEnum.IN_APP) { - expect(generatePreviewResponseDto.result.preview.body).to.equal( - { - subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, - body: `Hello, World! {{payload.placeholder.body}}`, - avatar: 'https://www.example.com/avatar.png', - primaryAction: { - label: '{{payload.secondaryUrl}}', - redirect: { - target: RedirectTargetEnum.BLANK, - }, - }, - secondaryAction: null, - redirect: { - target: RedirectTargetEnum.BLANK, - url: ' ', - }, - }.body - ); - expect(generatePreviewResponseDto.result.preview.primaryAction?.redirect?.url).to.be.ok; - expect(generatePreviewResponseDto.result.preview.primaryAction?.redirect?.url).to.contain('https'); + expect(generatePreviewResponseDto.result.preview.body).to.equal('Hello, World! {{payload.placeholder.body}}'); } }); }); diff --git a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts index 41d3065de7d..897c4f88429 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts @@ -1,20 +1,23 @@ import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; - import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { skipControl } from './skip-control.schema'; +import { TipTapSchema } from '../../../environments-v1/usecases/output-renderers'; -export const EmailStepControlZodSchema = z +export const emailStepControlZodSchema = z .object({ skip: skipControl.schema, + /* + * todo: we need to validate the email editor (body) by type and not string, + * updating it to TipTapSchema will break the existing upsert issues generation + */ body: z.string().optional().default(''), subject: z.string().optional().default(''), }) .strict(); -export const emailStepControlSchema = zodToJsonSchema(EmailStepControlZodSchema) as JSONSchemaDto; - -export type EmailStepControlType = z.infer; +export const emailStepControlSchema = zodToJsonSchema(emailStepControlZodSchema) as JSONSchemaDto; +export type EmailStepControlType = z.infer; export const emailStepUiSchema: UiSchema = { group: UiSchemaGroupEnum.EMAIL, @@ -28,3 +31,8 @@ export const emailStepUiSchema: UiSchema = { skip: skipControl.uiSchema.properties.skip, }, }; + +export const emailStepControl = { + uiSchema: emailStepUiSchema, + schema: emailStepControlSchema, +}; diff --git a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts index 0d737a2c2f0..663d6b10642 100644 --- a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts +++ b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts @@ -1,6 +1,6 @@ -import { ActionStepEnum, ChannelStepEnum, channelStepSchemas } from '@novu/framework/internal'; +import { ActionStepEnum, ChannelStepEnum } from '@novu/framework/internal'; import { ControlSchemas, JSONSchemaDto } from '@novu/shared'; -import { emailStepControlSchema, emailStepUiSchema, inAppControlSchema, inAppUiSchema } from './schemas'; +import { emailStepControl, inAppControlSchema, inAppUiSchema } from './schemas'; import { DelayTimeControlSchema, delayUiSchema } from './schemas/delay-control.schema'; import { DigestOutputJsonSchema, digestUiSchema } from './schemas/digest-control.schema'; import { smsStepControl } from './schemas/sms-control.schema'; @@ -20,8 +20,8 @@ export const stepTypeToControlSchema: Record; +}; + +type ProcessedControlResult = { + controlValues: Record; + variablesExample: Record | null; +}; + @Injectable() export class GeneratePreviewUsecase { constructor( - private legacyPreviewStepUseCase: PreviewStep, + private previewStepUsecase: PreviewStep, private buildStepDataUsecase: BuildStepDataUsecase, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private readonly logger: PinoLogger, - private prepareAndValidateContentUsecase: PrepareAndValidateContentUsecase, - private buildPayloadSchema: BuildPayloadSchema + private buildPayloadSchema: BuildPayloadSchema, + private prepareAndValidateContentUsecase: PrepareAndValidateContentUsecase ) {} @InstrumentUsecase() async execute(command: GeneratePreviewCommand): Promise { try { - const { previewPayload: commandVariablesExample, controlValues: commandControlValues } = - command.generatePreviewRequestDto; - const stepData = await this.getStepData(command); - const controlValues = commandControlValues || stepData.controls.values || {}; - const workflow = await this.findWorkflow(command); - const payloadSchema = await this.buildPayloadSchema.execute( - BuildPayloadSchemaCommand.create({ - environmentId: command.user.environmentId, - organizationId: command.user.organizationId, - userId: command.user._id, - workflowId: command.workflowIdOrInternalId, - controlValues, - }) - ); + const { + stepData, + controlValues: initialControlValues, + variableSchema, + workflow, + } = await this.initializePreviewContext(command); - const variableSchema = this.buildVariablesSchema(stepData.variables, payloadSchema); - const preparedAndValidatedContent = await this.prepareAndValidateContentUsecase.execute({ - user: command.user, - previewPayloadFromDto: commandVariablesExample, - controlValues, - controlDataSchema: stepData.controls.dataSchema || {}, + const sanitizedValidatedControls = sanitizePreviewControlValues(initialControlValues, stepData.type); + + if (!sanitizedValidatedControls) { + throw new Error( + // eslint-disable-next-line max-len + 'Control values normalization failed: The normalizeControlValues function requires maintenance to sanitize the provided type or data structure correctly' + ); + } + + const destructuredControlValues = this.destructureControlValues(sanitizedValidatedControls); + + const { variablesExample: tiptapVariablesExample, controlValues: tiptapControlValues } = + await this.handleTipTapControl( + destructuredControlValues.tiptapControlValues, + command, + stepData, + variableSchema + ); + const { variablesExample: simpleVariablesExample, controlValues: simpleControlValues } = this.handleSimpleControl( + destructuredControlValues.simpleControlValues, variableSchema, - }); - const variablesExample = this.buildVariablesExample( workflow, - preparedAndValidatedContent.finalPayload, - commandVariablesExample + command.generatePreviewRequestDto.previewPayload ); + const previewData = { + variablesExample: _.merge({}, tiptapVariablesExample || {}, simpleVariablesExample || {}), + controlValues: { ...tiptapControlValues, ...simpleControlValues }, + }; const executeOutput = await this.executePreviewUsecase( command, stepData, - variablesExample, - preparedAndValidatedContent.finalControlValues + previewData.variablesExample, + previewData.controlValues ); return { @@ -84,7 +111,7 @@ export class GeneratePreviewUsecase { preview: executeOutput.outputs as any, type: stepData.type as unknown as ChannelTypeEnum, }, - previewPayloadExample: variablesExample, + previewPayloadExample: previewData.variablesExample, }; } catch (error) { this.logger.error( @@ -96,7 +123,6 @@ export class GeneratePreviewUsecase { `Unexpected error while generating preview`, LOG_CONTEXT ); - if (process.env.SENTRY_DSN) { captureException(error); } @@ -111,39 +137,149 @@ export class GeneratePreviewUsecase { } } - /** - * Merges the payload schema into the variables schema to enable proper validation - * and sanitization of control values in the prepareAndValidateContentUsecase. - */ - @Instrument() - private buildVariablesSchema(variables: Record, payloadSchema: JSONSchemaDto) { - if (Object.keys(payloadSchema).length === 0) { - return variables; + private async safeAttemptToParseEmailSchema( + tiptapControl: string, + command: GeneratePreviewCommand, + controlValues: Record, + controlSchema: Record, + variableSchema: Record + ): Promise | null> { + if (typeof tiptapControl !== 'string') { + return null; } - return _.merge(variables, { properties: { payload: payloadSchema } }); + try { + const preparedAndValidatedContent = await this.prepareAndValidateContentUsecase.execute({ + user: command.user, + previewPayloadFromDto: command.generatePreviewRequestDto.previewPayload, + controlValues, + controlDataSchema: controlSchema || {}, + variableSchema, + }); + + return preparedAndValidatedContent.finalPayload as Record; + } catch (e) { + return null; + } } - @Instrument() - private buildVariablesExample( + private async handleTipTapControl( + tiptapControlValue: { + emailEditor?: string | null; + body?: string | null; + } | null, + command: GeneratePreviewCommand, + stepData: StepDataDto, + variableSchema: Record + ): Promise { + if (!tiptapControlValue || (!tiptapControlValue?.emailEditor && !tiptapControlValue?.body)) { + return { + variablesExample: null, + controlValues: tiptapControlValue as Record, + }; + } + + const emailVariables = await this.safeAttemptToParseEmailSchema( + tiptapControlValue?.emailEditor || tiptapControlValue?.body || '', + command, + tiptapControlValue, + stepData.controls.dataSchema || {}, + variableSchema + ); + + return { + variablesExample: emailVariables, + controlValues: tiptapControlValue, + }; + } + + private handleSimpleControl( + controlValues: Record, + variableSchema: Record, workflow: WorkflowInternalResponseDto, - finalPayload?: PreviewPayload, - commandVariablesExample?: PreviewPayload | undefined - ) { - if (workflow.origin !== WorkflowOriginEnum.EXTERNAL) { - return finalPayload; + commandVariablesExample: PreviewPayload | undefined + ): ProcessedControlResult { + const variables = this.processControlValueVariables(controlValues, variableSchema); + const processedControlValues = this.fixControlValueInvalidVariables(controlValues, variables.invalid); + const extractedTemplateVariables = variables.valid.map((variable) => variable.name); + const payloadVariableExample = + workflow.origin === WorkflowOriginEnum.EXTERNAL + ? createMockObjectFromSchema({ + type: 'object', + properties: { payload: workflow.payloadSchema }, + }) + : {}; + + if (extractedTemplateVariables.length === 0) { + return { + variablesExample: payloadVariableExample, + controlValues: processedControlValues, + }; } - const examplePayloadSchema = createMockObjectFromSchema({ - type: 'object', - properties: { payload: workflow.payloadSchema }, + const variablesExample: Record = pathsToObject(extractedTemplateVariables, { + valuePrefix: '{{', + valueSuffix: '}}', }); - if (!examplePayloadSchema || Object.keys(examplePayloadSchema).length === 0) { - return finalPayload; + const variablesExampleForPreview = _.merge(variablesExample, payloadVariableExample, commandVariablesExample || {}); + + return { + variablesExample: variablesExampleForPreview, + controlValues: processedControlValues, + }; + } + + private async initializePreviewContext(command: GeneratePreviewCommand) { + const stepData = await this.getStepData(command); + const controlValues = command.generatePreviewRequestDto.controlValues || stepData.controls.values || {}; + const workflow = await this.findWorkflow(command); + const variableSchema = await this.buildVariablesSchema(stepData.variables, command, controlValues); + + return { stepData, controlValues, variableSchema, workflow }; + } + + private processControlValueVariables( + controlValues: Record, + variableSchema: Record + ): { + valid: Variable[]; + invalid: Variable[]; + } { + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(controlValues)); + + const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables( + variableSchema, + validVariables + ); + + return { + valid: validSchemaVariables, + invalid: [...invalidVariables, ...invalidSchemaVariables], + }; + } + + @Instrument() + private async buildVariablesSchema( + variables: Record, + command: GeneratePreviewCommand, + controlValues: Record + ) { + const payloadSchema = await this.buildPayloadSchema.execute( + BuildPayloadSchemaCommand.create({ + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + userId: command.user._id, + workflowId: command.workflowIdOrInternalId, + controlValues, + }) + ); + + if (Object.keys(payloadSchema).length === 0) { + return variables; } - return _.merge(finalPayload as Record, examplePayloadSchema, commandVariablesExample || {}); + return _.merge(variables, { properties: { payload: payloadSchema } }); } @Instrument() @@ -180,7 +316,7 @@ export class GeneratePreviewUsecase { ) { const state = buildState(hydratedPayload.steps); try { - return await this.legacyPreviewStepUseCase.execute( + return await this.previewStepUsecase.execute( PreviewStepCommand.create({ payload: hydratedPayload.payload || {}, subscriber: hydratedPayload.subscriber, @@ -202,6 +338,55 @@ export class GeneratePreviewUsecase { } } } + + private destructureControlValues(controlValues: Record): DestructuredControlValues { + try { + const localControlValue = _.cloneDeep(controlValues); + let tiptapControlString: string | null = null; + + if (isTipTapNode(localControlValue.emailEditor)) { + tiptapControlString = localControlValue.emailEditor; + delete localControlValue.emailEditor; + + return { tiptapControlValues: { emailEditor: tiptapControlString }, simpleControlValues: localControlValue }; + } + + if (isTipTapNode(localControlValue.body)) { + tiptapControlString = localControlValue.body; + delete localControlValue.body; + + return { tiptapControlValues: { body: tiptapControlString }, simpleControlValues: localControlValue }; + } + + return { tiptapControlValues: null, simpleControlValues: localControlValue }; + } catch (error) { + this.logger.error({ error }, 'Failed to extract TipTap control', LOG_CONTEXT); + + return { tiptapControlValues: null, simpleControlValues: controlValues }; + } + } + + private fixControlValueInvalidVariables( + controlValues: Record, + invalidVariables: Variable[] + ): Record { + try { + let controlValuesString = JSON.stringify(controlValues); + + for (const invalidVariable of invalidVariables) { + if (!controlValuesString.includes(invalidVariable.template)) { + continue; + } + + const EMPTY_STRING = ''; + controlValuesString = replaceAll(controlValuesString, invalidVariable.template, EMPTY_STRING); + } + + return JSON.parse(controlValuesString) as Record; + } catch (error) { + return controlValues; + } + } } function buildState(steps: Record | undefined): FrameworkPreviousStepsOutputState[] { @@ -241,3 +426,154 @@ class FrameworkError { message: string; name: string; } + +/** + * Validates liquid template variables against a schema, the result is an object with valid and invalid variables + * @example + * const variables = [ + * { name: 'subscriber.firstName' }, + * { name: 'subscriber.orderId' } + * ]; + * const schema = { + * properties: { + * subscriber: { + * properties: { + * firstName: { type: 'string' } + * } + * } + * } + * }; + * const invalid = [{ name: 'unknown.variable' }]; + * + * validateVariablesAgainstSchema(variables, schema, invalid); + * // Returns: + * // { + * // validVariables: [{ name: 'subscriber.firstName' }], + * // invalidVariables: [{ name: 'unknown.variable' }, { name: 'subscriber.orderId' }] + * // } + */ +function identifyUnknownVariables( + variableSchema: Record, + validVariables: Variable[] +): TemplateParseResult { + const validVariablesCopy: Variable[] = _.cloneDeep(validVariables); + + const result = validVariablesCopy.reduce( + (acc, variable: Variable) => { + const parts = variable.name.split('.'); + let isValid = true; + let currentPath = 'properties'; + + for (const part of parts) { + currentPath += `.${part}`; + const valueSearch = _.get(variableSchema, currentPath); + + currentPath += '.properties'; + const propertiesSearch = _.get(variableSchema, currentPath); + + if (valueSearch === undefined && propertiesSearch === undefined) { + isValid = false; + break; + } + } + + if (isValid) { + acc.validVariables.push(variable); + } else { + acc.invalidVariables.push({ + name: variable.template, + context: variable.context, + message: 'Variable is not supported', + template: variable.template, + }); + } + + return acc; + }, + { + validVariables: [] as Variable[], + invalidVariables: [] as Variable[], + } as TemplateParseResult + ); + + return result; +} + +/** + * Fixes invalid Liquid template variables for preview by replacing them with error messages. + * + * @example + * // Input controlValues: + * { "message": "Hello {{invalid.var}}" } + * + * // Output: + * { "message": "Hello [[Invalid Variable: invalid.var]]" } + */ +function replaceAll(text: string, searchValue: string, replaceValue: string): string { + return _.replace(text, new RegExp(_.escapeRegExp(searchValue), 'g'), replaceValue); +} + +/** + * + * @param value minimal tiptap object from the client is + * { + * "type": "doc", + * "content": [ + * { + * "type": "paragraph", + * "attrs": { + * "textAlign": "left" + * }, + * "content": [ + * { + * "type": "text", + * "text": " " + * } + * ] + * } + *] + *} + */ +export function isTipTapNode(value: unknown): value is string { + let localValue = value; + if (typeof localValue === 'string') { + try { + localValue = JSON.parse(localValue); + } catch { + return false; + } + } + + if (!localValue || typeof localValue !== 'object') return false; + + const doc = localValue as TipTapNode; + + // TODO check if validate type === doc is enough + if (doc.type !== 'doc' || !Array.isArray(doc.content)) return false; + + return true; + + /* + * TODO check we need to validate the content + * return doc.content.every((node) => isValidTipTapContent(node)); + */ +} + +function isValidTipTapContent(node: unknown): boolean { + if (!node || typeof node !== 'object') return false; + const content = node as TipTapNode; + if (typeof content.type !== 'string') return false; + if (content.attrs !== undefined && (typeof content.attrs !== 'object' || content.attrs === null)) { + return false; + } + if (content.text !== undefined && typeof content.text !== 'string') { + return false; + } + if (content.content !== undefined) { + if (!Array.isArray(content.content)) return false; + + return content.content.every((child) => isValidTipTapContent(child)); + } + + return true; +} diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts index c794cca8190..ce717cd3481 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.spec.ts @@ -2,12 +2,15 @@ import { expect } from 'chai'; import { extractLiquidTemplateVariables } from './liquid-parser'; describe('parseLiquidVariables', () => { - it('should extract simple variable names', () => { + it('should not extract variable without namespace', () => { const template = '{{name}} {{age}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['name', 'age']); + expect(validVariablesNames).to.have.members([]); + expect(invalidVariables).to.have.lengthOf(2); + expect(invalidVariables[0].name).to.equal('{{name}}'); + expect(invalidVariables[1].name).to.equal('{{age}}'); }); it('should extract nested object paths', () => { @@ -19,73 +22,87 @@ describe('parseLiquidVariables', () => { }); it('should handle multiple occurrences of the same variable', () => { - const template = '{{user.name}} {{user.name}} {{user.name}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const template = '{{user.name}} {{user.name}} {{user.name}} {{invalid..foo}} {{invalid..foo}}'; + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); expect(validVariablesNames).to.have.members(['user.name']); + expect(invalidVariables).to.have.lengthOf(1); + expect(invalidVariables[0].name).to.equal('{{invalid..foo}}'); }); it('should handle mixed content with HTML and variables', () => { const template = '
Hello {{user.name}}
{{status}}'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['user.name', 'status']); + expect(validVariablesNames).to.have.members(['user.name']); + expect(invalidVariables).to.have.lengthOf(1); + expect(invalidVariables[0].name).to.equal('{{status}}'); }); it('should handle whitespace in template syntax', () => { - const template = '{{ user.name }} {{ status }}'; + const template = '{{ user.name }}'; const { validVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['user.name', 'status']); + expect(validVariablesNames).to.have.members(['user.name']); }); it('should handle empty template string', () => { const template = ''; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(0); }); it('should handle template with no variables', () => { const template = 'Hello World!'; - const { validVariables } = extractLiquidTemplateVariables(template); + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(template); expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(0); }); it('should handle special characters in variable names', () => { - const template = '{{special_var_1}} {{data-point}}'; + const template = '{{subscriber.special_var_1}} {{subscriber.data-point}}'; const { validVariables } = extractLiquidTemplateVariables(template); const validVariablesNames = validVariables.map((variable) => variable.name); - expect(validVariablesNames).to.have.members(['special_var_1', 'data-point']); + expect(validVariablesNames).to.have.members(['subscriber.special_var_1', 'subscriber.data-point']); + }); + + it('should handle whitespace in between template syntax', () => { + const template = '{{ user. name }}'; + const { validVariables } = extractLiquidTemplateVariables(template); + + expect(validVariables).to.have.lengthOf(1); + expect(validVariables[0].name).to.equal('user.name'); }); describe('Error handling', () => { it('should handle invalid liquid syntax gracefully', () => { - const { validVariables: variables, invalidVariables: errors } = extractLiquidTemplateVariables( + const { validVariables, invalidVariables } = extractLiquidTemplateVariables( '{{invalid..syntax}} {{invalid2..syntax}}' ); - expect(variables).to.have.lengthOf(0); - expect(errors).to.have.lengthOf(2); - expect(errors[0].message).to.contain('expected "|" before filter'); - expect(errors[0].name).to.equal('{{invalid..syntax}}'); - expect(errors[1].name).to.equal('{{invalid2..syntax}}'); + expect(validVariables).to.have.lengthOf(0); + expect(invalidVariables).to.have.lengthOf(2); + expect(invalidVariables[0].message).to.contain('expected "|" before filter'); + expect(invalidVariables[0].name).to.equal('{{invalid..syntax}}'); + expect(invalidVariables[1].name).to.equal('{{invalid2..syntax}}'); }); it('should handle invalid liquid syntax gracefully, return valid variables', () => { - const { validVariables, invalidVariables: errors } = extractLiquidTemplateVariables( + const { validVariables, invalidVariables } = extractLiquidTemplateVariables( '{{subscriber.name}} {{invalid..syntax}}' ); const validVariablesNames = validVariables.map((variable) => variable.name); expect(validVariablesNames).to.have.members(['subscriber.name']); - expect(errors[0].message).to.contain('expected "|" before filter'); - expect(errors[0].name).to.equal('{{invalid..syntax}}'); + expect(invalidVariables[0].message).to.contain('expected "|" before filter'); + expect(invalidVariables[0].name).to.equal('{{invalid..syntax}}'); }); it('should handle undefined input gracefully', () => { diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts index cec68e1c16b..8cba93edd84 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts @@ -12,6 +12,7 @@ export type Variable = { context?: string; message?: string; name: string; + template: string; }; export type TemplateParseResult = { @@ -75,62 +76,103 @@ export function extractLiquidTemplateVariables(template: string): TemplateParseR } function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { - const validVariables = new Set(); + const validVariables: Variable[] = []; const invalidVariables: Variable[] = []; + const processedVariables = new Set(); + + function addVariable(variable: Variable, isValid: boolean) { + if (!processedVariables.has(variable.name)) { + processedVariables.add(variable.name); + (isValid ? validVariables : invalidVariables).push(variable); + } + } for (const rawOutput of rawOutputs) { try { - const parsedVars = parseByLiquid(rawOutput); - parsedVars.forEach((variable) => validVariables.add(variable)); + const result = parseByLiquid(rawOutput); + result.validVariables.forEach((variable) => addVariable(variable, true)); + result.invalidVariables.forEach((variable) => addVariable(variable, false)); } catch (error: unknown) { if (isLiquidErrors(error)) { - invalidVariables.push( - ...error.errors.map((e: RenderError) => ({ - context: e.context, - message: e.message, - name: rawOutput, - })) - ); + error.errors.forEach((e: RenderError) => { + addVariable( + { + name: rawOutput, + message: e.message, + context: e.context, + template: rawOutput, + }, + false + ); + }); } } } - return { - validVariables: [...validVariables].map((name) => ({ name })), - invalidVariables, - }; + return { validVariables, invalidVariables }; } -function parseByLiquid(expression: string): Set { - const variables = new Set(); +function parseByLiquid(rawOutput: string): TemplateParseResult { + const validVariables: Variable[] = []; + const invalidVariables: Variable[] = []; const engine = new Liquid(LIQUID_CONFIG); - const parsed = engine.parse(expression) as unknown as Template[]; + const parsed = engine.parse(rawOutput) as unknown as Template[]; parsed.forEach((template: Template) => { if (isOutputToken(template)) { - const props = extractValidProps(template); - if (props.length > 0) { - variables.add(props.join('.')); + const result = extractProps(template); + + if (result.valid && result.props.length > 0) { + validVariables.push({ name: result.props.join('.'), template: rawOutput }); + } + + if (!result.valid) { + invalidVariables.push({ name: template?.token?.input, message: result.error, template: rawOutput }); } } }); - return variables; + return { validVariables, invalidVariables }; } function isOutputToken(template: Template): boolean { return template.token?.constructor.name === 'OutputToken'; } -function extractValidProps(template: any): string[] { +function extractProps(template: any): { valid: boolean; props: string[]; error?: string } { const initial = template.value?.initial; - if (!initial?.postfix?.[0]?.props) return []; + if (!initial?.postfix?.[0]?.props) return { valid: true, props: [] }; + + /** + * If initial.postfix length is greater than 1, it means the variable contains spaces + * which is not supported in Novu's variable syntax. + * + * Example: + * Valid: {{user.firstName}} + * Invalid: {{user.first name}} - postfix length would be 2 due to space + */ + if (initial.postfix.length > 1) { + return { valid: false, props: [], error: 'Novu does not support variables with spaces' }; + } const validProps: string[] = []; + for (const prop of initial.postfix[0].props) { if (prop.constructor.name !== 'IdentifierToken') break; validProps.push(prop.content); } - return validProps; + /** + * If validProps length is 1, it means the variable has no namespace which is not + * supported in Novu's variable syntax. Variables must be namespaced. + * + * Example: + * Valid: {{user.firstName}} - Has namespace 'user' + * Invalid: {{firstName}} - No namespace + */ + if (validProps.length === 1) { + return { valid: false, props: [], error: 'Novu variables must include a namespace (e.g. user.firstName)' }; + } + + return { valid: true, props: validProps }; } diff --git a/libs/application-generic/src/utils/index.ts b/libs/application-generic/src/utils/index.ts index 11f207996b1..a10c9bfa848 100644 --- a/libs/application-generic/src/utils/index.ts +++ b/libs/application-generic/src/utils/index.ts @@ -12,3 +12,4 @@ export * from './subscriber'; export * from './variants'; export * from './deepmerge'; export * from './generate-id'; +export * from './sanitize-preview-control-values'; diff --git a/libs/application-generic/src/utils/sanitize-preview-control-values.ts b/libs/application-generic/src/utils/sanitize-preview-control-values.ts new file mode 100644 index 00000000000..03d19c4e6fc --- /dev/null +++ b/libs/application-generic/src/utils/sanitize-preview-control-values.ts @@ -0,0 +1,222 @@ +const EMPTY_TIP_TAP_OBJECT = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: 'left' }, + content: [{ type: 'text', text: ' ' }], + }, + ], +}); +const WHITESPACE = ' '; + +type Redirect = { + url: string; + target: '_self' | '_blank' | '_parent' | '_top' | '_unfencedTop'; +}; + +type Action = { + label?: string; + redirect?: Redirect; +}; + +type LookBackWindow = { + amount: number; + unit: string; +}; + +function sanitizeRedirect(redirect: Redirect) { + if (!redirect.url || !redirect.target) { + return undefined; + } + + return { + url: redirect.url || 'https://example.com', + target: redirect.target || '_self', + }; +} + +function sanitizeAction(action: Action) { + if (!action?.label) { + return undefined; + } + + return { + label: action.label, + redirect: sanitizeRedirect(action.redirect), + }; +} + +function sanitizeInApp(controlValues: Record) { + if (!controlValues) return controlValues; + + const normalized: Record = { + subject: controlValues.subject || null, + body: + (controlValues.body as string)?.length === 0 + ? WHITESPACE + : controlValues.body, + avatar: controlValues.avatar || null, + primaryAction: null, + secondaryAction: null, + redirect: null, + data: controlValues.data || null, + }; + + if (controlValues.primaryAction) { + normalized.primaryAction = sanitizeAction( + controlValues.primaryAction as Action, + ); + } + + if (controlValues.secondaryAction) { + normalized.secondaryAction = sanitizeAction( + controlValues.secondaryAction as Action, + ); + } + + if (controlValues.redirect) { + normalized.redirect = sanitizeRedirect(controlValues.redirect as Redirect); + } + + if (typeof normalized === 'object' && normalized !== null) { + return Object.fromEntries( + Object.entries(normalized).filter(([_, value]) => value !== null), + ); + } + + return normalized; +} + +function sanitizeEmail(controlValues: Record) { + if (!controlValues) return controlValues; + + const emailControls: Record = {}; + + /* + * if (controlValues.body != null) { + * emailControls.body = controlValues.body || ''; + * } + */ + emailControls.subject = controlValues.subject || ''; + emailControls.body = controlValues.body || EMPTY_TIP_TAP_OBJECT; + emailControls.data = controlValues.data || null; + + return emailControls; +} + +function sanitizeSms(controlValues: Record) { + if (!controlValues) return controlValues; + + return { + body: controlValues.body || '', + data: controlValues.data || null, + }; +} + +function sanitizePush(controlValues: Record) { + if (!controlValues) return controlValues; + + const mappedValues = { + subject: controlValues.subject || '', + body: controlValues.body || '', + data: controlValues.data || null, + }; + + if (typeof mappedValues === 'object' && mappedValues !== null) { + return Object.fromEntries( + Object.entries(mappedValues).filter(([_, value]) => value !== null), + ); + } + + return mappedValues; +} + +function sanitizeChat(controlValues: Record) { + if (!controlValues) return controlValues; + + return { + body: controlValues.body || '', + data: controlValues.data || null, + }; +} + +function sanitizeDigest(controlValues: Record) { + if (!controlValues) return controlValues; + + const mappedValues = { + cron: controlValues.cron || '', + amount: controlValues.amount || 0, + unit: controlValues.unit || '', + digestKey: controlValues.digestKey || '', + data: controlValues.data || null, + lookBackWindow: controlValues.lookBackWindow + ? { + amount: (controlValues.lookBackWindow as LookBackWindow).amount || 0, + unit: (controlValues.lookBackWindow as LookBackWindow).unit || '', + } + : null, + }; + + if (typeof mappedValues === 'object' && mappedValues !== null) { + return Object.fromEntries( + Object.entries(mappedValues).filter(([_, value]) => value !== null), + ); + } + + return mappedValues; +} + +/** + * Sanitizes control values received from client-side forms into a clean minimal object. + * This function processes potentially invalid form data that may contain default/placeholder values + * and transforms it into a standardized format suitable for preview generation. + * + * @example + * // Input from form with default values: + * { + * subject: "Hello", + * body: null, + * unusedField: "test" + * } + * + * // Normalized output: + * { + * subject: "Hello", + * body: " " + * } + * + */ +export function sanitizePreviewControlValues( + controlValues: Record, + stepType: string, +): Record | null { + if (!controlValues) { + return null; + } + let normalizedValues: Record; + switch (stepType) { + case 'in_app': + normalizedValues = sanitizeInApp(controlValues); + break; + case 'email': + normalizedValues = sanitizeEmail(controlValues); + break; + case 'sms': + normalizedValues = sanitizeSms(controlValues); + break; + case 'push': + normalizedValues = sanitizePush(controlValues); + break; + case 'chat': + normalizedValues = sanitizeChat(controlValues); + break; + case 'digest': + normalizedValues = sanitizeDigest(controlValues); + break; + default: + normalizedValues = controlValues; + } + + return normalizedValues; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2427c182cbd..9181613d4c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20506,7 +20506,7 @@ packages: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} compose-function@3.0.3: - resolution: {integrity: sha512-xzhzTJ5eC+gmIzvZq+C3kCJHsp9os6tJkrigDRZclyGtOKINbZtE8n1Tzmeh32jW+BUDPbvZpibwvJHBLGMVwg==} + resolution: {integrity: sha1-ntZ18TzFRQHTCVCkhv9qe6OrGF8=} compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} @@ -23410,7 +23410,7 @@ packages: optional: true fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} engines: {node: '>= 0.6'} from2@2.3.0: @@ -31839,7 +31839,7 @@ packages: engines: {node: '>=10'} serve-favicon@2.5.0: - resolution: {integrity: sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA==} + resolution: {integrity: sha1-k10kDN/g9YBTB/3+ln2IlCosvPA=} engines: {node: '>= 0.8.0'} serve-index@1.9.1: @@ -33254,7 +33254,7 @@ packages: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} toposort@2.0.2: - resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + resolution: {integrity: sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=} totalist@1.1.0: resolution: {integrity: sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==} @@ -35837,8 +35837,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36039,8 +36039,8 @@ snapshots: '@aws-crypto/sha1-browser': 3.0.0 '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-bucket-endpoint': 3.575.0 @@ -36266,11 +36266,11 @@ snapshots: - aws-crt optional: true - '@aws-sdk/client-sso-oidc@3.575.0': + '@aws-sdk/client-sso-oidc@3.575.0(@aws-sdk/client-sts@3.575.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36309,6 +36309,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.7.0 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.637.0(@aws-sdk/client-sts@3.637.0)': @@ -36693,11 +36694,11 @@ snapshots: - aws-crt optional: true - '@aws-sdk/client-sts@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)': + '@aws-sdk/client-sts@3.575.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/core': 3.575.0 '@aws-sdk/credential-provider-node': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0) '@aws-sdk/middleware-host-header': 3.575.0 @@ -36736,7 +36737,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.7.0 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.637.0': @@ -36966,7 +36966,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)(@aws-sdk/client-sts@3.575.0)': dependencies: - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/credential-provider-env': 3.575.0 '@aws-sdk/credential-provider-process': 3.575.0 '@aws-sdk/credential-provider-sso': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) @@ -37277,7 +37277,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.575.0(@aws-sdk/client-sts@3.575.0)': dependencies: - '@aws-sdk/client-sts': 3.575.0(@aws-sdk/client-sso-oidc@3.575.0) + '@aws-sdk/client-sts': 3.575.0 '@aws-sdk/types': 3.575.0 '@smithy/property-provider': 3.1.3 '@smithy/types': 3.3.0 @@ -37798,7 +37798,7 @@ snapshots: '@aws-sdk/token-providers@3.575.0(@aws-sdk/client-sso-oidc@3.575.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/types': 3.575.0 '@smithy/property-provider': 3.1.3 '@smithy/shared-ini-file-loader': 3.1.4 @@ -37807,7 +37807,7 @@ snapshots: '@aws-sdk/token-providers@3.614.0(@aws-sdk/client-sso-oidc@3.575.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.575.0 + '@aws-sdk/client-sso-oidc': 3.575.0(@aws-sdk/client-sts@3.575.0) '@aws-sdk/types': 3.609.0 '@smithy/property-provider': 3.1.3 '@smithy/shared-ini-file-loader': 3.1.4 @@ -51338,16 +51338,16 @@ snapshots: '@rjsf/validator-ajv8@5.17.1(@rjsf/utils@5.20.0(react@18.3.1))': dependencies: '@rjsf/utils': 5.20.0(react@18.3.1) - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) lodash: 4.17.21 lodash-es: 4.17.21 '@rjsf/validator-ajv8@5.17.1(@rjsf/utils@5.20.1(react@18.3.1))': dependencies: '@rjsf/utils': 5.20.1(react@18.3.1) - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) lodash: 4.17.21 lodash-es: 4.17.21 @@ -53965,12 +53965,6 @@ snapshots: '@stdlib/utils-constructor-name': 0.0.8 '@stdlib/utils-global': 0.0.7 - '@stoplight/better-ajv-errors@1.0.3(ajv@8.12.0)': - dependencies: - ajv: 8.12.0 - jsonpointer: 5.0.1 - leven: 3.1.0 - '@stoplight/better-ajv-errors@1.0.3(ajv@8.13.0)': dependencies: ajv: 8.13.0 @@ -54036,7 +54030,7 @@ snapshots: '@stoplight/spectral-core@1.18.3(encoding@0.1.13)': dependencies: - '@stoplight/better-ajv-errors': 1.0.3(ajv@8.12.0) + '@stoplight/better-ajv-errors': 1.0.3(ajv@8.13.0) '@stoplight/json': 3.21.0 '@stoplight/path': 1.3.2 '@stoplight/spectral-parsers': 1.0.3 @@ -54045,9 +54039,9 @@ snapshots: '@stoplight/types': 13.6.0 '@types/es-aggregate-error': 1.0.6 '@types/json-schema': 7.0.15 - ajv: 8.12.0 - ajv-errors: 3.0.0(ajv@8.12.0) - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-errors: 3.0.0(ajv@8.13.0) + ajv-formats: 2.1.1(ajv@8.13.0) es-aggregate-error: 1.0.11 jsonpath-plus: 7.1.0 lodash: 4.17.21 @@ -54149,7 +54143,7 @@ snapshots: '@stoplight/types': 13.20.0 '@stoplight/yaml': 4.2.3 '@types/node': 20.16.5 - ajv: 8.12.0 + ajv: 8.13.0 ast-types: 0.14.2 astring: 1.8.6 reserved: 0.1.2 @@ -54161,7 +54155,7 @@ snapshots: '@stoplight/spectral-rulesets@1.18.1(encoding@0.1.13)': dependencies: '@asyncapi/specs': 4.3.1 - '@stoplight/better-ajv-errors': 1.0.3(ajv@8.12.0) + '@stoplight/better-ajv-errors': 1.0.3(ajv@8.13.0) '@stoplight/json': 3.21.0 '@stoplight/spectral-core': 1.18.3(encoding@0.1.13) '@stoplight/spectral-formats': 1.6.0(encoding@0.1.13) @@ -54169,8 +54163,8 @@ snapshots: '@stoplight/spectral-runtime': 1.1.2(encoding@0.1.13) '@stoplight/types': 13.20.0 '@types/json-schema': 7.0.15 - ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv: 8.13.0 + ajv-formats: 2.1.1(ajv@8.13.0) json-schema-traverse: 1.0.0 lodash: 4.17.21 tslib: 2.7.0 @@ -59749,10 +59743,6 @@ snapshots: optionalDependencies: ajv: 8.13.0 - ajv-errors@3.0.0(ajv@8.12.0): - dependencies: - ajv: 8.12.0 - ajv-errors@3.0.0(ajv@8.13.0): dependencies: ajv: 8.13.0 From d38d9ba61d04deb4e9eb92b5863f466d8adb9b85 Mon Sep 17 00:00:00 2001 From: Pawan Jain Date: Wed, 18 Dec 2024 14:18:10 +0530 Subject: [PATCH 08/12] fix(web): add show bridge menu for all orgs (#7307) --- apps/api/src/app/subscribers/subscribers.controller.ts | 2 +- apps/web/src/components/layout/components/v2/HeaderNav.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index 5aca32dc7bb..b7e9f274792 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -444,7 +444,7 @@ export class SubscribersController { type: String, enum: PreferenceLevelEnum, required: true, - description: 'the preferences level to be retrieved (template / global) ', + description: 'Fetch global or per workflow channel preferences', }) @ApiQuery({ name: 'includeInactiveChannels', diff --git a/apps/web/src/components/layout/components/v2/HeaderNav.tsx b/apps/web/src/components/layout/components/v2/HeaderNav.tsx index eff7310ea87..f6f860960fc 100644 --- a/apps/web/src/components/layout/components/v2/HeaderNav.tsx +++ b/apps/web/src/components/layout/components/v2/HeaderNav.tsx @@ -28,7 +28,6 @@ import { SupportModal } from '../SupportModal'; export function HeaderNav() { const { currentUser, currentOrganization } = useAuth(); const [isSupportModalOpened, setIsSupportModalOpened] = useState(false); - const isV2Enabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_ENABLED); useBootIntercom(); // variable to check if it's the first render for. Needed for Plain live chat initialization @@ -113,7 +112,7 @@ export function HeaderNav() { - {isV2Enabled && } + {} toggleColorScheme()}>
From 7294ed65c38cc22b47cd88639a6777c5e910d442 Mon Sep 17 00:00:00 2001 From: Adam Chmara Date: Wed, 18 Dec 2024 09:59:30 +0100 Subject: [PATCH 09/12] feat(dashboard): sms step (#7316) --- .../public/images/phones/iphone-sms.svg | 215 ++++++++++++++++++ .../workflow-editor/add-step-menu.tsx | 24 +- .../workflow-editor/steps/component-utils.tsx | 18 +- .../steps/configure-step-form.tsx | 30 +-- .../steps/configure-step-template-form.tsx | 23 +- .../steps/sms/configure-sms-step-preview.tsx | 35 +++ .../steps/sms/sms-editor-preview.tsx | 111 +++++++++ .../workflow-editor/steps/sms/sms-editor.tsx | 19 ++ .../workflow-editor/steps/sms/sms-phone.tsx | 61 +++++ .../workflow-editor/steps/sms/sms-preview.tsx | 44 ++++ .../steps/sms/sms-tabs-section.tsx | 8 + .../workflow-editor/steps/sms/sms-tabs.tsx | 36 +++ 12 files changed, 581 insertions(+), 43 deletions(-) create mode 100644 apps/dashboard/public/images/phones/iphone-sms.svg create mode 100644 apps/dashboard/src/components/workflow-editor/steps/sms/configure-sms-step-preview.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/sms/sms-phone.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/sms/sms-preview.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs-section.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx diff --git a/apps/dashboard/public/images/phones/iphone-sms.svg b/apps/dashboard/public/images/phones/iphone-sms.svg new file mode 100644 index 00000000000..09521b553bf --- /dev/null +++ b/apps/dashboard/public/images/phones/iphone-sms.svg @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx b/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx index 1474e8b9d00..46fffdf9f17 100644 --- a/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx +++ b/apps/dashboard/src/components/workflow-editor/add-step-menu.tsx @@ -1,15 +1,15 @@ +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 { PopoverPortal } from '@radix-ui/react-popover'; import React, { ReactNode, useState } from 'react'; import { RiAddLine } from 'react-icons/ri'; -import { PopoverPortal } from '@radix-ui/react-popover'; -import { Node } from './base-node'; -import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'; import { STEP_TYPE_TO_ICON } from '../icons/utils'; import { Badge } from '../primitives/badge'; -import { cn } from '@/utils/ui'; -import { StepTypeEnum } from '@/utils/enums'; -import { STEP_TYPE_TO_COLOR } from '@/utils/color'; -import { useFeatureFlag } from '@/hooks/use-feature-flag'; -import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'; +import { Node } from './base-node'; const noop = () => {}; @@ -132,7 +132,13 @@ export const AddStepMenu = ({ Push Chat - SMS + handleMenuItemClick(StepTypeEnum.SMS)} + > + SMS + 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 27e630e55f2..9b0a22ea798 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx @@ -1,15 +1,15 @@ import { UiComponentEnum } from '@novu/shared'; -import { InAppAction } from '@/components/workflow-editor/steps/in-app/in-app-action'; -import { InAppSubject } from '@/components/workflow-editor/steps/in-app/in-app-subject'; -import { InAppBody } from '@/components/workflow-editor/steps/in-app/in-app-body'; -import { InAppAvatar } from '@/components/workflow-editor/steps/in-app/in-app-avatar'; -import { InAppRedirect } from '@/components/workflow-editor/steps/in-app/in-app-redirect'; import { DelayAmount } from '@/components/workflow-editor/steps/delay/delay-amount'; -import { Maily } from '@/components/workflow-editor/steps/email/maily'; -import { EmailSubject } from '@/components/workflow-editor/steps/email/email-subject'; import { DigestKey } from '@/components/workflow-editor/steps/digest/digest-key'; import { DigestWindow } from '@/components/workflow-editor/steps/digest/digest-window'; +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'; +import { InAppRedirect } from '@/components/workflow-editor/steps/in-app/in-app-redirect'; +import { InAppSubject } from '@/components/workflow-editor/steps/in-app/in-app-subject'; import { BaseBody } from './base/base-body'; import { BaseSubject } from './base/base-subject'; @@ -55,7 +55,9 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum case UiComponentEnum.PUSH_SUBJECT: { return ; } - + case UiComponentEnum.SMS_BODY: { + return ; + } default: { return null; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx index c66bfa63ab9..be8ee2174aa 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx @@ -9,12 +9,12 @@ import { WorkflowOriginEnum, WorkflowResponseDto, } from '@novu/shared'; +import merge from 'lodash.merge'; import { AnimatePresence, motion } from 'motion/react'; -import { useEffect, useCallback, useMemo, useState, HTMLAttributes, ReactNode } from 'react'; +import { HTMLAttributes, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { RiArrowLeftSLine, RiArrowRightSLine, RiCloseFill, RiDeleteBin2Line, RiPencilRuler2Fill } from 'react-icons/ri'; import { Link, useNavigate } from 'react-router-dom'; -import merge from 'lodash.merge'; import { ConfirmationModal } from '@/components/confirmation-modal'; import { PageMeta } from '@/components/page-meta'; @@ -32,24 +32,24 @@ import { getFirstControlsErrorMessage, updateStepInWorkflow, } from '@/components/workflow-editor/step-utils'; +import { ConfigureStepTemplateIssueCta } from '@/components/workflow-editor/steps/configure-step-template-issue-cta'; +import { DelayControlValues } from '@/components/workflow-editor/steps/delay/delay-control-values'; +import { DigestControlValues } from '@/components/workflow-editor/steps/digest/digest-control-values'; +import { ConfigureEmailStepPreview } from '@/components/workflow-editor/steps/email/configure-email-step-preview'; +import { ConfigureInAppStepPreview } from '@/components/workflow-editor/steps/in-app/configure-in-app-step-preview'; +import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context'; import { SdkBanner } from '@/components/workflow-editor/steps/sdk-banner'; -import { buildRoute, ROUTES } from '@/utils/routes'; +import { ConfigureSmsStepPreview } from '@/components/workflow-editor/steps/sms/configure-sms-step-preview'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { useFormAutosave } from '@/hooks/use-form-autosave'; import { AUTOCOMPLETE_PASSWORD_MANAGERS_OFF, INLINE_CONFIGURABLE_STEP_TYPES, - TEMPLATE_CONFIGURABLE_STEP_TYPES, STEP_TYPE_LABELS, + TEMPLATE_CONFIGURABLE_STEP_TYPES, } from '@/utils/constants'; -import { useFormAutosave } from '@/hooks/use-form-autosave'; -import { buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema'; -import { buildDefaultValues } from '@/utils/schema'; -import { DelayControlValues } from '@/components/workflow-editor/steps/delay/delay-control-values'; -import { ConfigureStepTemplateIssueCta } from '@/components/workflow-editor/steps/configure-step-template-issue-cta'; -import { ConfigureInAppStepPreview } from '@/components/workflow-editor/steps/in-app/configure-in-app-step-preview'; -import { ConfigureEmailStepPreview } from '@/components/workflow-editor/steps/email/configure-email-step-preview'; -import { useFeatureFlag } from '@/hooks/use-feature-flag'; -import { DigestControlValues } from '@/components/workflow-editor/steps/digest/digest-control-values'; -import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context'; +import { buildRoute, ROUTES } from '@/utils/routes'; +import { buildDefaultValues, buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema'; const STEP_TYPE_TO_INLINE_CONTROL_VALUES: Record React.JSX.Element | null> = { [StepTypeEnum.DELAY]: DelayControlValues, @@ -66,7 +66,7 @@ const STEP_TYPE_TO_INLINE_CONTROL_VALUES: Record React.JSX.E const STEP_TYPE_TO_PREVIEW: Record) => ReactNode) | null> = { [StepTypeEnum.IN_APP]: ConfigureInAppStepPreview, [StepTypeEnum.EMAIL]: ConfigureEmailStepPreview, - [StepTypeEnum.SMS]: null, + [StepTypeEnum.SMS]: ConfigureSmsStepPreview, [StepTypeEnum.CHAT]: null, [StepTypeEnum.PUSH]: null, [StepTypeEnum.CUSTOM]: null, diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx index bcdc813ae36..21ca629e138 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx @@ -1,7 +1,3 @@ -import { useCallback, useEffect, useMemo } from 'react'; -import merge from 'lodash.merge'; -import isEqual from 'lodash.isequal'; -import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { type StepDataDto, @@ -10,23 +6,28 @@ import { UpdateWorkflowDto, type WorkflowResponseDto, } from '@novu/shared'; +import isEqual from 'lodash.isequal'; +import merge from 'lodash.merge'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { Form } from '@/components/primitives/form/form'; import { flattenIssues, updateStepInWorkflow } from '@/components/workflow-editor/step-utils'; +import { EmailTabs } from '@/components/workflow-editor/steps/email/email-tabs'; import { InAppTabs } from '@/components/workflow-editor/steps/in-app/in-app-tabs'; -import { buildDefaultValues, buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema'; -import { OtherStepTabs } from './other-steps-tabs'; -import { Form } from '@/components/primitives/form/form'; -import { useFormAutosave } from '@/hooks/use-form-autosave'; +import { PushTabs } from '@/components/workflow-editor/steps/push/push-tabs'; import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context'; -import { EmailTabs } from '@/components/workflow-editor/steps/email/email-tabs'; +import { SmsTabs } from '@/components/workflow-editor/steps/sms/sms-tabs'; +import { useFormAutosave } from '@/hooks/use-form-autosave'; +import { buildDefaultValues, buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema'; import { CommonCustomControlValues } from './common/common-custom-control-values'; -import { PushTabs } from '@/components/workflow-editor/steps/push/push-tabs'; +import { OtherStepTabs } from './other-steps-tabs'; const STEP_TYPE_TO_TEMPLATE_FORM: Record React.JSX.Element | null> = { [StepTypeEnum.EMAIL]: EmailTabs, [StepTypeEnum.CHAT]: OtherStepTabs, [StepTypeEnum.IN_APP]: InAppTabs, - [StepTypeEnum.SMS]: OtherStepTabs, + [StepTypeEnum.SMS]: SmsTabs, [StepTypeEnum.PUSH]: PushTabs, [StepTypeEnum.DIGEST]: CommonCustomControlValues, [StepTypeEnum.DELAY]: CommonCustomControlValues, diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/configure-sms-step-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/configure-sms-step-preview.tsx new file mode 100644 index 00000000000..69aa09bf82a --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/configure-sms-step-preview.tsx @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/react'; +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; + +import { SmsPreview } from '@/components/workflow-editor/steps/sms/sms-preview'; +import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; +import { usePreviewStep } from '@/hooks/use-preview-step'; + +export const ConfigureSmsStepPreview = () => { + const { + previewStep, + data: previewData, + isPending: isPreviewPending, + } = usePreviewStep({ + onError: (error) => Sentry.captureException(error), + }); + const { step, isPending } = useWorkflow(); + + const { workflowSlug, stepSlug } = useParams<{ + workflowSlug: string; + stepSlug: string; + }>(); + + useEffect(() => { + if (!workflowSlug || !stepSlug || !step || isPending) return; + + previewStep({ + workflowSlug, + stepSlug, + previewData: { controlValues: step.controls.values, previewPayload: {} }, + }); + }, [workflowSlug, stepSlug, previewStep, step, isPending]); + + return ; +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx new file mode 100644 index 00000000000..94d2b72faae --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx @@ -0,0 +1,111 @@ +import { type StepDataDto, type WorkflowResponseDto } from '@novu/shared'; +import { CSSProperties, useEffect, useRef, useState } from 'react'; + +import { Sms } from '@/components/icons'; +import { Code2 } from '@/components/icons/code-2'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; +import { Button } from '@/components/primitives/button'; +import { Editor } from '@/components/primitives/editor'; +import { SmsPreview } from '@/components/workflow-editor/steps/sms/sms-preview'; +import { SmsTabsSection } from '@/components/workflow-editor/steps/sms/sms-tabs-section'; +import { loadLanguage } from '@uiw/codemirror-extensions-langs'; +import { useEditorPreview } from '../use-editor-preview'; + +const getInitialAccordionValue = (value: string) => { + try { + return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; + } catch (e) { + return undefined; + } +}; + +type SmsEditorPreviewProps = { + workflow: WorkflowResponseDto; + step: StepDataDto; + formValues: Record; +}; + +const extensions = [loadLanguage('json')?.extension ?? []]; + +export const SmsEditorPreview = ({ workflow, step, formValues }: SmsEditorPreviewProps) => { + const workflowSlug = workflow.workflowId; + const stepSlug = step.stepId; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug, + stepSlug, + controlValues: formValues, + }); + const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); + const [payloadError, setPayloadError] = useState(''); + const [height, setHeight] = useState(0); + const contentRef = useRef(null); + + useEffect(() => { + setAccordionValue(getInitialAccordionValue(editorValue)); + }, [editorValue]); + + useEffect(() => { + const timeout = setTimeout(() => { + if (contentRef.current) { + const rect = contentRef.current.getBoundingClientRect(); + setHeight(rect.height); + } + }, 0); + + return () => clearTimeout(timeout); + }, [editorValue]); + + return ( + +
+
+ + SMS template editor +
+
+ +
+ + + +
+ + Configure preview +
+
+ + + {payloadError &&

{payloadError}

} + +
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor.tsx new file mode 100644 index 00000000000..246a88a4b2f --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor.tsx @@ -0,0 +1,19 @@ +import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; +import { SmsTabsSection } from '@/components/workflow-editor/steps/sms/sms-tabs-section'; +import { type UiSchema } from '@novu/shared'; + +type SmsEditorProps = { uiSchema: UiSchema }; +export const SmsEditor = (props: SmsEditorProps) => { + const { uiSchema } = props; + const { body } = uiSchema.properties ?? {}; + + return ( +
+ +
+ {getComponentByType({ component: body.component })} +
+
+
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-phone.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-phone.tsx new file mode 100644 index 00000000000..2be43ebafbc --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-phone.tsx @@ -0,0 +1,61 @@ +import { motion } from 'motion/react'; + +const SmsChatBubble = ({ children }: { children: React.ReactNode }) => ( + +
{children}
+
+); + +const ErrorChatBubble = ({ children }: { children: React.ReactNode }) => ( + +
{children}
+
+); + +const TypingIndicator = () => ( + +
+
+
+
+
+
+); + +export const SmsPhone = ({ + smsBody, + isLoading = false, + error = false, +}: { + smsBody: string; + isLoading?: boolean; + error?: boolean; +}) => ( +
+
+ {isLoading ? ( + + ) : error ? ( + {smsBody} + ) : ( + {smsBody} + )} +
+ SMS Phone +
+); diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-preview.tsx new file mode 100644 index 00000000000..6a8d1d41a94 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-preview.tsx @@ -0,0 +1,44 @@ +import { SmsPhone } from '@/components/workflow-editor/steps/sms/sms-phone'; +import { ChannelTypeEnum, type GeneratePreviewResponseDto } from '@novu/shared'; +import { ReactNode } from 'react'; + +const SmsPreviewContainer = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +export const SmsPreview = ({ + isPreviewPending, + previewData, +}: { + isPreviewPending: boolean; + previewData?: GeneratePreviewResponseDto; +}) => { + const previewResult = previewData?.result; + + if (isPreviewPending || previewData === undefined) { + return ( + + + + ); + } + + const isValidSmsPreview = + previewResult && previewResult.type === ChannelTypeEnum.SMS && previewResult.preview.body.length > 0; + + if (!isValidSmsPreview) { + return ( + + + + ); + } + + const smsBody = previewResult.preview.body; + + return ( + + + + ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs-section.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs-section.tsx new file mode 100644 index 00000000000..c5968c0b72c --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs-section.tsx @@ -0,0 +1,8 @@ +import { cn } from '@/utils/ui'; +import { HTMLAttributes } from 'react'; + +type SmsTabsSectionProps = HTMLAttributes; +export const SmsTabsSection = (props: SmsTabsSectionProps) => { + const { className, ...rest } = props; + return
; +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx new file mode 100644 index 00000000000..5bda1b6e89f --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx @@ -0,0 +1,36 @@ +import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; +import { CustomStepControls } from '@/components/workflow-editor/steps/controls/custom-step-controls'; +import { SmsEditor } from '@/components/workflow-editor/steps/sms/sms-editor'; +import { SmsEditorPreview } from '@/components/workflow-editor/steps/sms/sms-editor-preview'; +import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; +import { WorkflowOriginEnum } from '@novu/shared'; +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +export const SmsTabs = (props: StepEditorProps) => { + const { workflow, step } = props; + const { dataSchema, uiSchema } = step.controls; + const form = useFormContext(); + const [tabsValue, setTabsValue] = useState('editor'); + + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; + const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + + const editorContent = ( + <> + {isNovuCloud && } + {isExternal && } + + ); + + const previewContent = ; + + return ( + + ); +}; From 7ac6cedda2b1e8060bb8268416caaacdc81c3346 Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 18 Dec 2024 15:55:06 +0530 Subject: [PATCH 10/12] feat(dashboard): Nv 4884 push mini preview (#7318) --- .../public/images/phones/iphone-push.svg | 1 + .../steps/configure-step-form.tsx | 3 +- .../push/configure-push-step-preview.tsx | 37 +++++ .../steps/push/push-editor-preview.tsx | 116 ++++++++++++++++ .../steps/push/push-preview.tsx | 129 ++++++++++++++++++ .../workflow-editor/steps/push/push-tabs.tsx | 6 +- 6 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/public/images/phones/iphone-push.svg create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/configure-push-step-preview.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/push-editor-preview.tsx create mode 100644 apps/dashboard/src/components/workflow-editor/steps/push/push-preview.tsx diff --git a/apps/dashboard/public/images/phones/iphone-push.svg b/apps/dashboard/public/images/phones/iphone-push.svg new file mode 100644 index 00000000000..491443825fe --- /dev/null +++ b/apps/dashboard/public/images/phones/iphone-push.svg @@ -0,0 +1 @@ + diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx index be8ee2174aa..1ac0f911535 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx @@ -50,6 +50,7 @@ import { } from '@/utils/constants'; import { buildRoute, ROUTES } from '@/utils/routes'; import { buildDefaultValues, buildDefaultValuesOfDataSchema, buildDynamicZodSchema } from '@/utils/schema'; +import { ConfigurePushStepPreview } from './push/configure-push-step-preview'; const STEP_TYPE_TO_INLINE_CONTROL_VALUES: Record React.JSX.Element | null> = { [StepTypeEnum.DELAY]: DelayControlValues, @@ -68,7 +69,7 @@ const STEP_TYPE_TO_PREVIEW: Record { + Sentry.captureException(error); + }, + }); + + const { step, isPending } = useWorkflow(); + + const { workflowSlug, stepSlug } = useParams<{ + workflowSlug: string; + stepSlug: string; + }>(); + + useEffect(() => { + if (!workflowSlug || !stepSlug || !step || isPending) return; + + previewStep({ + workflowSlug, + stepSlug, + previewData: { controlValues: step.controls.values, previewPayload: {} }, + }); + }, [workflowSlug, stepSlug, previewStep, step, isPending]); + + return ; +} diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor-preview.tsx new file mode 100644 index 00000000000..c9031dfc436 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-editor-preview.tsx @@ -0,0 +1,116 @@ +import { CSSProperties, useEffect, useRef, useState } from 'react'; +import { type StepDataDto, type WorkflowResponseDto } from '@novu/shared'; +import { Code2 } from '@/components/icons/code-2'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; +import { Button } from '@/components/primitives/button'; +import { Editor } from '@/components/primitives/editor'; +import { loadLanguage } from '@uiw/codemirror-extensions-langs'; +import { useEditorPreview } from '../use-editor-preview'; +import { PushPreview } from './push-preview'; +import { RiCellphoneFill } from 'react-icons/ri'; +import { PushTabsSection } from './push-tabs-section'; + +const getInitialAccordionValue = (value: string) => { + try { + return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; + } catch (e) { + return undefined; + } +}; + +type PushEditorPreviewProps = { + workflow: WorkflowResponseDto; + step: StepDataDto; + formValues: Record; +}; + +const extensions = [loadLanguage('json')?.extension ?? []]; + +export const PushEditorPreview = ({ workflow, step, formValues }: PushEditorPreviewProps) => { + const workflowSlug = workflow.workflowId; + const stepSlug = step.stepId; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug, + stepSlug, + controlValues: formValues, + }); + const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); + const [payloadError, setPayloadError] = useState(''); + const [height, setHeight] = useState(0); + const contentRef = useRef(null); + + useEffect(() => { + setAccordionValue(getInitialAccordionValue(editorValue)); + }, [editorValue]); + + useEffect(() => { + const timeout = setTimeout(() => { + if (contentRef.current) { + const rect = contentRef.current.getBoundingClientRect(); + setHeight(rect.height); + } + }, 0); + + return () => clearTimeout(timeout); + }, [editorValue]); + + return ( + +
+
+ + Push template editor +
+
+ +
+ + + This preview shows how your message will appear on mobile. Actual rendering may vary by device. + +
+
+ + + +
+ + Configure preview +
+
+ + + {payloadError &&

{payloadError}

} + +
+
+
+
+
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-preview.tsx new file mode 100644 index 00000000000..b2a09e7cb28 --- /dev/null +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-preview.tsx @@ -0,0 +1,129 @@ +import { HTMLAttributes } from 'react'; +import { HTMLMotionProps, motion } from 'motion/react'; +import { ChannelTypeEnum, GeneratePreviewResponseDto } from '@novu/shared'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { cn } from '@/utils/ui'; + +export function PushPreview({ + isPreviewPending, + previewData, +}: { + isPreviewPending: boolean; + previewData?: GeneratePreviewResponseDto; +}) { + if (isPreviewPending) { + return ( + + + + + + + + + ); + } + + if (previewData?.result.type !== ChannelTypeEnum.PUSH) { + return ( + + + + + + + + ); + } + + return ( + + + + + + + + + + + ); +} + +type PushSubjectPreviewProps = HTMLAttributes & { + subject?: string; + isPending: boolean; +}; +export const PushSubjectPreview = ({ subject, isPending, className, ...rest }: PushSubjectPreviewProps) => { + if (isPending) { + return ; + } + + return ( +
+
+ {subject} +
+ now +
+ ); +}; + +type PushBodyPreviewProps = HTMLAttributes & { + body?: string; + isPending: boolean; +}; +export const PushBodyPreview = ({ body, isPending, className, ...rest }: PushBodyPreviewProps) => { + if (isPending) { + return ( +
+ + +
+ ); + } + + return ( +
+ {body} +
+ ); +}; + +export const PushContentContainerPreview = ({ children, className, ...rest }: HTMLMotionProps<'div'>) => { + return ( + + {children} + + ); +}; + +export const PushBackgroundWithPhone = ({ children, className, ...rest }: HTMLAttributes) => { + return ( +
+ {children} +
+ ); +}; + +export const PushNotificationContainer = ({ children, className, ...rest }: HTMLAttributes) => { + return ( +
+ {children} +
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx index 44bde28b18b..ad9fb2a1e39 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx @@ -4,12 +4,14 @@ import { StepEditorProps } from '@/components/workflow-editor/steps/configure-st import { PushEditor } from '@/components/workflow-editor/steps/push/push-editor'; import { CustomStepControls } from '../controls/custom-step-controls'; import { TemplateTabs } from '../template-tabs'; +import { PushEditorPreview } from './push-editor-preview'; +import { useFormContext } from 'react-hook-form'; export const PushTabs = (props: StepEditorProps) => { const { workflow, step } = props; const { dataSchema, uiSchema } = step.controls; const [tabsValue, setTabsValue] = useState('editor'); - + const form = useFormContext(); const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; @@ -20,7 +22,7 @@ export const PushTabs = (props: StepEditorProps) => { ); - const previewContent = <>TODO; + const previewContent = ; return ( Date: Wed, 18 Dec 2024 17:22:55 +0530 Subject: [PATCH 11/12] feat(dashboard): update node styles (#7321) --- .../src/components/workflow-editor/base-node.tsx | 8 ++++---- apps/dashboard/src/components/workflow-editor/nodes.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/base-node.tsx b/apps/dashboard/src/components/workflow-editor/base-node.tsx index 430c2263222..5c0c0554bcd 100644 --- a/apps/dashboard/src/components/workflow-editor/base-node.tsx +++ b/apps/dashboard/src/components/workflow-editor/base-node.tsx @@ -66,7 +66,7 @@ export const NodeBody = ({ children }: { children: ReactNode }) => { {children} - +
); }; @@ -98,11 +98,11 @@ export const NODE_WIDTH = 300; export const NODE_HEIGHT = 86; const nodeVariants = cva( - `relative border-neutral-alpha-200 transition-colors aria-selected:border-primary bg-foreground-0 flex w-[300px] flex-col gap-1 border p-1 shadow-xs`, + `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`, { variants: { variant: { - default: 'rounded-xl', + default: 'rounded-lg', sm: 'text-neutral-400 w-min rounded-lg', }, }, @@ -118,7 +118,7 @@ export const Node = (props: BaseNodeProps) => { const { children, variant, className, ...rest } = props; return (
- {children} + {children}
); }; diff --git a/apps/dashboard/src/components/workflow-editor/nodes.tsx b/apps/dashboard/src/components/workflow-editor/nodes.tsx index 32816bcd7c6..3e54b3a2ae8 100644 --- a/apps/dashboard/src/components/workflow-editor/nodes.tsx +++ b/apps/dashboard/src/components/workflow-editor/nodes.tsx @@ -37,8 +37,8 @@ export const TriggerNode = ({ data }: NodeProps - -
+ +
TRIGGER
From 59a32273e7e9450a3d9fc2c114b394cf52744f6c Mon Sep 17 00:00:00 2001 From: Biswajeet Das Date: Wed, 18 Dec 2024 18:09:57 +0530 Subject: [PATCH 12/12] feat(dashboard): Nv 5066 dashboard add inline tips primitive component (#7326) --- .../steps/controls/custom-step-controls.tsx | 11 +++++------ .../steps/push/push-editor-preview.tsx | 11 +++++------ .../workflow-editor/steps/sms/sms-editor-preview.tsx | 7 ++++++- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx index d54f54d74c9..b3430817112 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx @@ -16,6 +16,7 @@ import { buildDefaultValuesOfDataSchema } from '@/utils/schema'; import { SidebarContent } from '@/components/side-navigation/sidebar'; import { ConfirmationModal } from '@/components/confirmation-modal'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; +import { InlineToast } from '@/components/primitives/inline-toast'; type CustomStepControlsProps = { dataSchema: ControlsMetadata['dataSchema']; @@ -167,12 +168,10 @@ const OverrideMessage = ({ isOverridden }: { isOverridden: boolean }) => { return ( {isOverridden ? ( -
- - - Custom controls defined in the code have been overridden. Disable overrides to restore the original. - -
+ ) : ( { try { @@ -63,12 +64,10 @@ export const PushEditorPreview = ({ workflow, step, formValues }: PushEditorPrev
-
- - - This preview shows how your message will appear on mobile. Actual rendering may vary by device. - -
+
diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx index 94d2b72faae..5026a7e7a0d 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx @@ -10,6 +10,7 @@ import { SmsPreview } from '@/components/workflow-editor/steps/sms/sms-preview'; import { SmsTabsSection } from '@/components/workflow-editor/steps/sms/sms-tabs-section'; import { loadLanguage } from '@uiw/codemirror-extensions-langs'; import { useEditorPreview } from '../use-editor-preview'; +import { InlineToast } from '@/components/primitives/inline-toast'; const getInitialAccordionValue = (value: string) => { try { @@ -62,8 +63,12 @@ export const SmsEditorPreview = ({ workflow, step, formValues }: SmsEditorPrevie SMS template editor
-
+
+