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/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/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/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/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/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/package.json b/apps/dashboard/package.json index 57f5b059470..68bc93ff6ef 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", "merge-refs": "^1.3.0", @@ -103,6 +104,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/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/api/workflows.ts b/apps/dashboard/src/api/workflows.ts index 2c751b354ef..1494ccfb24d 100644 --- a/apps/dashboard/src/api/workflows.ts +++ b/apps/dashboard/src/api/workflows.ts @@ -60,7 +60,7 @@ export async function triggerWorkflow({ payload, to, }: { - environment: IEnvironment; + environment: Pick; name: string; payload: unknown; to: unknown; 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/welcome/inbox-connected-guide.tsx b/apps/dashboard/src/components/welcome/inbox-connected-guide.tsx index f9e8d4de8cf..64214bc670d 100644 --- a/apps/dashboard/src/components/welcome/inbox-connected-guide.tsx +++ b/apps/dashboard/src/components/welcome/inbox-connected-guide.tsx @@ -6,14 +6,16 @@ import { useTriggerWorkflow } from '@/hooks/use-trigger-workflow'; import { ROUTES } from '../../utils/routes'; import { useNavigate } from 'react-router-dom'; import { ONBOARDING_DEMO_WORKFLOW_ID } from '../../config'; +import { IEnvironment } from '@novu/shared'; -interface InboxConnectedGuideProps { +type InboxConnectedGuideProps = { subscriberId: string; -} + environment: IEnvironment; +}; -export function InboxConnectedGuide({ subscriberId }: InboxConnectedGuideProps) { +export function InboxConnectedGuide({ subscriberId, environment }: InboxConnectedGuideProps) { const navigate = useNavigate(); - const { triggerWorkflow, isPending } = useTriggerWorkflow(); + const { triggerWorkflow, isPending } = useTriggerWorkflow(environment); async function handleSendNotification() { try { diff --git a/apps/dashboard/src/components/welcome/inbox-embed.tsx b/apps/dashboard/src/components/welcome/inbox-embed.tsx index 3ae159888d4..3f4aeb619e6 100644 --- a/apps/dashboard/src/components/welcome/inbox-embed.tsx +++ b/apps/dashboard/src/components/welcome/inbox-embed.tsx @@ -14,13 +14,17 @@ export function InboxEmbed(): JSX.Element | null { const { integrations } = useFetchIntegrations({ refetchInterval: 1000, refetchOnWindowFocus: true }); const { environments } = useFetchEnvironments({ organizationId: auth?.currentOrganization?._id }); const [searchParams] = useSearchParams(); + const environmentHint = searchParams.get('environmentId'); - const currentEnvironment = environments?.find((env) => !env._parentId); + // If hint provided, use it, otherwise use the first dev environment + const selectedEnvironment = environments?.find((env) => + environmentHint ? env._id === environmentHint : !env._parentId + ); const subscriberId = auth?.currentUser?._id; const foundIntegration = integrations?.find( (integration) => - integration._environmentId === environments?.[0]?._id && integration.channel === ChannelTypeEnum.IN_APP + integration._environmentId === selectedEnvironment?._id && integration.channel === ChannelTypeEnum.IN_APP ); const primaryColor = searchParams.get('primaryColor') || '#DD2450'; @@ -30,6 +34,7 @@ export function InboxEmbed(): JSX.Element | null { if (foundIntegration?.connected) { setShowConfetti(true); const timer = setTimeout(() => setShowConfetti(false), 10000); + return () => clearTimeout(timer); } }, [foundIntegration?.connected]); @@ -42,14 +47,16 @@ export function InboxEmbed(): JSX.Element | null { {!foundIntegration?.connected && ( )} - {foundIntegration?.connected && } + {foundIntegration?.connected && ( + + )} ); } 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..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 = () => {}; @@ -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,9 +124,21 @@ export const AddStepMenu = ({ > In-App - Push + handleMenuItemClick(StepTypeEnum.PUSH)} + > + Push + Chat - SMS + handleMenuItemClick(StepTypeEnum.SMS)} + > + 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/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/component-utils.tsx b/apps/dashboard/src/components/workflow-editor/steps/component-utils.tsx index 91f42bbbbba..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,17 @@ 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'; export const getComponentByType = ({ component }: { component?: UiComponentEnum }) => { switch (component) { @@ -47,6 +49,15 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum case UiComponentEnum.DIGEST_CRON: { return ; } + case UiComponentEnum.PUSH_BODY: { + return ; + } + 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 812794ffbb7..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 { motion } from 'motion/react'; -import { useEffect, useCallback, useMemo, useState, HTMLAttributes, ReactNode } from 'react'; +import merge from 'lodash.merge'; +import { AnimatePresence, motion } from 'motion/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, @@ -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..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,6 +1,3 @@ -import { useCallback, useEffect, useMemo } from 'react'; -import merge from 'lodash.merge'; -import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { type StepDataDto, @@ -9,24 +6,31 @@ 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 { PushTabs } from '@/components/workflow-editor/steps/push/push-tabs'; +import { SaveFormContext } from '@/components/workflow-editor/steps/save-form-context'; +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 { OtherStepTabs } from './other-steps-tabs'; -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'; 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.DIGEST]: () => null, - [StepTypeEnum.DELAY]: () => null, + [StepTypeEnum.SMS]: SmsTabs, + [StepTypeEnum.PUSH]: PushTabs, + [StepTypeEnum.DIGEST]: CommonCustomControlValues, + [StepTypeEnum.DELAY]: CommonCustomControlValues, [StepTypeEnum.TRIGGER]: () => null, [StepTypeEnum.CUSTOM]: () => null, }; @@ -67,9 +71,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 ? null : 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..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,12 +1,11 @@ +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'; 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'; export const EmailTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -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/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/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/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 ( + + ); +}; 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/apps/dashboard/src/hooks/use-trigger-workflow.ts b/apps/dashboard/src/hooks/use-trigger-workflow.ts index f98e970457b..64de4acd1e0 100644 --- a/apps/dashboard/src/hooks/use-trigger-workflow.ts +++ b/apps/dashboard/src/hooks/use-trigger-workflow.ts @@ -1,12 +1,11 @@ import { useMutation } from '@tanstack/react-query'; import { triggerWorkflow } from '@/api/workflows'; -import { useEnvironment } from '@/context/environment/hooks'; +import { IEnvironment } from '@novu/shared'; -export const useTriggerWorkflow = () => { - const { currentEnvironment } = useEnvironment(); +export const useTriggerWorkflow = (environment?: IEnvironment) => { const { mutateAsync, isPending, error, data } = useMutation({ mutationFn: async ({ name, to, payload }: { name: string; to: unknown; payload: unknown }) => - triggerWorkflow({ environment: currentEnvironment!, name, to, payload }), + triggerWorkflow({ environment: environment!, name, to, payload }), }); return { diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 9f83c834e52..0ad0d39892e 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -31,6 +31,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(); @@ -132,16 +133,17 @@ const router = createBrowserRouter([ path: ROUTES.TEST_WORKFLOW, element: , }, - { - path: ROUTES.INTEGRATIONS, - element: , - }, + { path: '*', element: , }, ], }, + { + path: ROUTES.INTEGRATIONS, + element: , + }, { path: ROUTES.SETTINGS, element: , @@ -162,6 +164,10 @@ const router = createBrowserRouter([ path: ROUTES.SETTINGS_BILLING, element: , }, + { + path: ROUTES.LOCAL_STUDIO_AUTH, + element: , + }, { path: '*', element: , diff --git a/apps/dashboard/src/pages/integrations/components/integration-card.tsx b/apps/dashboard/src/pages/integrations/components/integration-card.tsx index 35c3de2355d..37cc0d748d4 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-card.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-card.tsx @@ -1,40 +1,46 @@ import { Badge } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; import { RiCheckboxCircleFill, RiGitBranchFill, RiSettings4Line, RiStarSmileLine } from 'react-icons/ri'; -import { ITableIntegration } from '../types'; -import type { IEnvironment, IIntegration, IProviderConfig } from '@novu/shared'; +import { TableIntegration } from '../types'; +import { + ChannelTypeEnum, + EmailProviderIdEnum, + SmsProviderIdEnum, + type IEnvironment, + type IIntegration, + type IProviderConfig, +} from '@novu/shared'; import { useNavigate } from 'react-router-dom'; import { ROUTES } from '@/utils/routes'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { ProviderIcon } from './provider-icon'; import { cn } from '../../../utils/ui'; -interface IntegrationCardProps { +type IntegrationCardProps = { integration: IIntegration; provider: IProviderConfig; environment: IEnvironment; - onRowClickCallback: (item: { original: ITableIntegration }) => void; -} + onRowClickCallback: (item: TableIntegration) => void; +}; export function IntegrationCard({ integration, provider, environment, onRowClickCallback }: IntegrationCardProps) { const navigate = useNavigate(); - const tableIntegration: ITableIntegration = { - integrationId: integration._id ?? '', - name: integration.name, - identifier: integration.identifier, - provider: provider.displayName, - channel: integration.channel, - environment: environment.name, - active: integration.active, - }; - const handleConfigureClick = (e: React.MouseEvent) => { - if (integration.channel === 'in_app' && !integration.connected) { + if (integration.channel === ChannelTypeEnum.IN_APP && !integration.connected) { e.stopPropagation(); - navigate(ROUTES.INBOX_EMBED); + + navigate(ROUTES.INBOX_EMBED + `?environmentId=${environment._id}`); } else { - onRowClickCallback({ original: tableIntegration }); + onRowClickCallback({ + integrationId: integration._id ?? '', + name: integration.name, + identifier: integration.identifier, + provider: provider.displayName, + channel: integration.channel, + environment: environment.name, + active: integration.active, + }); } }; @@ -43,7 +49,7 @@ export function IntegrationCard({ integration, provider, environment, onRowClick return (
- {integration.channel === 'in_app' && !integration.connected ? ( + {integration.channel === ChannelTypeEnum.IN_APP && !integration.connected ? ( ) : ( @@ -112,6 +118,6 @@ export function IntegrationCard({ integration, provider, environment, onRowClick ); } -export function isDemoIntegration(providerId: string) { - return providerId === 'novu-email' || providerId === 'novu-sms'; +function isDemoIntegration(providerId: string) { + return providerId === EmailProviderIdEnum.Novu || providerId === SmsProviderIdEnum.Novu; } diff --git a/apps/dashboard/src/pages/integrations/components/integration-channel-group.tsx b/apps/dashboard/src/pages/integrations/components/integration-channel-group.tsx index 439b82a2620..0510298ce16 100644 --- a/apps/dashboard/src/pages/integrations/components/integration-channel-group.tsx +++ b/apps/dashboard/src/pages/integrations/components/integration-channel-group.tsx @@ -1,5 +1,5 @@ import { ChannelTypeEnum, IEnvironment, IIntegration, IProviderConfig } from '@novu/shared'; -import { ITableIntegration } from '../types'; +import { TableIntegration } from '../types'; import { IntegrationCard } from './integration-card'; import { CHANNEL_TYPE_TO_STRING } from '@/utils/channels'; @@ -8,7 +8,7 @@ type IntegrationChannelGroupProps = { integrations: IIntegration[]; providers: IProviderConfig[]; environments?: IEnvironment[]; - onRowClickCallback: (item: { original: ITableIntegration }) => void; + onRowClickCallback: (item: TableIntegration) => void; }; export function IntegrationChannelGroup({ diff --git a/apps/dashboard/src/pages/integrations/components/integrations-list.tsx b/apps/dashboard/src/pages/integrations/components/integrations-list.tsx index 47ecc391ac2..bc90a34b3b1 100644 --- a/apps/dashboard/src/pages/integrations/components/integrations-list.tsx +++ b/apps/dashboard/src/pages/integrations/components/integrations-list.tsx @@ -1,13 +1,14 @@ import { ChannelTypeEnum, providers as novuProviders } from '@novu/shared'; import { useEnvironment } from '@/context/environment/hooks'; import { useFetchIntegrations } from '../../../hooks/use-fetch-integrations'; -import { ITableIntegration } from '../types'; +import { TableIntegration } from '../types'; import { IntegrationChannelGroup } from './integration-channel-group'; import { Skeleton } from '@/components/primitives/skeleton'; +import { useMemo } from 'react'; -interface IntegrationsListProps { - onRowClickCallback: (item: { original: ITableIntegration }) => void; -} +type IntegrationsListProps = { + onRowClickCallback: (item: TableIntegration) => void; +}; function IntegrationCardSkeleton() { return ( @@ -50,10 +51,26 @@ function IntegrationChannelGroupSkeleton() { export function IntegrationsList({ onRowClickCallback }: IntegrationsListProps) { const { currentEnvironment, environments } = useEnvironment(); - const { integrations } = useFetchIntegrations(); + const { integrations, isLoading } = useFetchIntegrations(); const availableIntegrations = novuProviders; - if (!integrations || !availableIntegrations || !currentEnvironment) { + const groupedIntegrations = useMemo(() => { + return integrations?.reduce( + (acc, integration) => { + const channel = integration.channel; + if (!acc[channel]) { + acc[channel] = []; + } + + acc[channel].push(integration); + + return acc; + }, + {} as Record + ); + }, [integrations]); + + if (isLoading || !currentEnvironment) { return (
@@ -62,21 +79,9 @@ export function IntegrationsList({ onRowClickCallback }: IntegrationsListProps) ); } - const groupedIntegrations = integrations.reduce( - (acc, integration) => { - const channel = integration.channel; - if (!acc[channel]) { - acc[channel] = []; - } - acc[channel].push(integration); - return acc; - }, - {} as Record - ); - return (
- {Object.entries(groupedIntegrations).map(([channel, channelIntegrations]) => ( + {Object.entries(groupedIntegrations || {}).map(([channel, channelIntegrations]) => ( Providers - - Data{' '} - - SOON - - + + + + Data{' '} + + SOON + + + + +

+ Data warehouse connectors for syncing user data and triggering notifications. +

+
+