diff --git a/.eslintrc.js b/.eslintrc.js index ce3de561e47..eb5537025dd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,7 +27,7 @@ module.exports = { '@typescript-eslint/lines-between-class-members': 'off', 'react/jsx-wrap-multilines': 'off', 'react/jsx-filename-extension': 'off', - 'multiline-comment-style': ['error', 'starred-block'], + 'multiline-comment-style': ['warn', 'starred-block'], 'promise/catch-or-return': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-unused-expressions': 'off', diff --git a/.source b/.source index 1c620bbe71c..e4a7b124de8 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 1c620bbe71c855d33d93ba5943661266d0239389 +Subproject commit e4a7b124de85d0d5cd2d66cd34c88e0262ad5649 diff --git a/apps/api/src/app/change/usecases/get-changes/get-changes.usecase.ts b/apps/api/src/app/change/usecases/get-changes/get-changes.usecase.ts index 36dafe5ef75..8e403a3ad3c 100644 --- a/apps/api/src/app/change/usecases/get-changes/get-changes.usecase.ts +++ b/apps/api/src/app/change/usecases/get-changes/get-changes.usecase.ts @@ -11,6 +11,8 @@ import { import { ChangeEntityTypeEnum } from '@novu/shared'; import { ChangesResponseDto } from '../../dtos/change-response.dto'; import { GetChangesCommand } from './get-changes.command'; +import { ApiException } from '../../../shared/exceptions/api.exception'; +import { ModuleRef } from '@nestjs/core'; interface IViewEntity { templateName: string; @@ -32,7 +34,8 @@ export class GetChanges { private messageTemplateRepository: MessageTemplateRepository, private notificationGroupRepository: NotificationGroupRepository, private feedRepository: FeedRepository, - private layoutRepository: LayoutRepository + private layoutRepository: LayoutRepository, + protected moduleRef: ModuleRef ) {} async execute(command: GetChangesCommand): Promise { @@ -65,6 +68,12 @@ export class GetChanges { if (change.type === ChangeEntityTypeEnum.DEFAULT_LAYOUT) { item = await this.getTemplateDataForDefaultLayout(change._entityId, command.environmentId); } + if (change.type === ChangeEntityTypeEnum.TRANSLATION) { + item = await this.getTemplateDataForTranslation(change._entityId, command.environmentId); + } + if (change.type === ChangeEntityTypeEnum.TRANSLATION_GROUP) { + item = await this.getTemplateDataForTranslationGroup(change._entityId, command.environmentId); + } list.push({ ...change, @@ -133,6 +142,54 @@ export class GetChanges { }; } + private async getTemplateDataForTranslationGroup( + entityId: string, + environmentId: string + ): Promise> { + try { + if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { + if (!require('@novu/ee-translation')?.TranslationsService) { + throw new ApiException('Translation module is not loaded'); + } + const service = this.moduleRef.get(require('@novu/ee-translation')?.TranslationsService, { strict: false }); + const { name, identifier } = await service.getTranslationGroupData(environmentId, entityId); + + return { + templateId: identifier, + templateName: name, + }; + } + } catch (e) { + Logger.error(e, `Unexpected error while importing enterprise modules`, 'TranslationsService'); + } + + return {}; + } + + private async getTemplateDataForTranslation( + entityId: string, + environmentId: string + ): Promise> { + try { + if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { + if (!require('@novu/ee-translation')?.TranslationsService) { + throw new ApiException('Translation module is not loaded'); + } + const service = this.moduleRef.get(require('@novu/ee-translation')?.TranslationsService, { strict: false }); + const { name, group } = await service.getTranslationData(environmentId, entityId); + + return { + templateName: name, + translationGroup: group, + }; + } + } catch (e) { + Logger.error(e, `Unexpected error while importing enterprise modules`, 'TranslationsService'); + } + + return {}; + } + private async getTemplateDataForNotificationGroup( entityId: string, environmentId: string diff --git a/apps/api/src/app/content-templates/content-templates.controller.ts b/apps/api/src/app/content-templates/content-templates.controller.ts index e9b9146d118..d338a4b44b1 100644 --- a/apps/api/src/app/content-templates/content-templates.controller.ts +++ b/apps/api/src/app/content-templates/content-templates.controller.ts @@ -34,7 +34,8 @@ export class ContentTemplatesController { @Body('contentType') contentType: MessageTemplateContentType, @Body('payload') payload: any, @Body('subject') subject: string, - @Body('layoutId') layoutId: string + @Body('layoutId') layoutId: string, + @Body('locale') locale?: string ) { return this.compileEmailTemplateUsecase.execute( CompileEmailTemplateCommand.create({ @@ -46,6 +47,7 @@ export class ContentTemplatesController { payload, subject, layoutId, + locale, }), this.initiateTranslations.bind(this) ); @@ -56,7 +58,8 @@ export class ContentTemplatesController { @UserSession() user: IJwtPayload, @Body('content') content: string, @Body('payload') payload: any, - @Body('cta') cta: IMessageCTA + @Body('cta') cta: IMessageCTA, + @Body('locale') locale?: string ) { return this.compileInAppTemplate.execute( CompileInAppTemplateCommand.create({ @@ -66,13 +69,19 @@ export class ContentTemplatesController { content, payload, cta, + locale, }), this.initiateTranslations.bind(this) ); } // TODO: refactor this to use params and single endpoint to manage all the channels @Post('/preview/sms') - public previewSms(@UserSession() user: IJwtPayload, @Body('content') content: string, @Body('payload') payload: any) { + public previewSms( + @UserSession() user: IJwtPayload, + @Body('content') content: string, + @Body('payload') payload: any, + @Body('locale') locale?: string + ) { return this.compileStepTemplate.execute( CompileStepTemplateCommand.create({ userId: user._id, @@ -80,6 +89,7 @@ export class ContentTemplatesController { environmentId: user.environmentId, content, payload, + locale, }), this.initiateTranslations.bind(this) ); @@ -89,7 +99,8 @@ export class ContentTemplatesController { public previewChat( @UserSession() user: IJwtPayload, @Body('content') content: string, - @Body('payload') payload: any + @Body('payload') payload: any, + @Body('locale') locale?: string ) { return this.compileStepTemplate.execute( CompileStepTemplateCommand.create({ @@ -98,6 +109,7 @@ export class ContentTemplatesController { environmentId: user.environmentId, content, payload, + locale, }), this.initiateTranslations.bind(this) ); @@ -107,7 +119,9 @@ export class ContentTemplatesController { public previewPush( @UserSession() user: IJwtPayload, @Body('content') content: string, - @Body('payload') payload: any + @Body('title') title: string, + @Body('payload') payload: any, + @Body('locale') locale?: string ) { return this.compileStepTemplate.execute( CompileStepTemplateCommand.create({ @@ -116,6 +130,8 @@ export class ContentTemplatesController { environmentId: user.environmentId, content, payload, + locale, + title, }), this.initiateTranslations.bind(this) ); @@ -128,7 +144,10 @@ export class ContentTemplatesController { throw new ApiException('Translation module is not loaded'); } const service = this.moduleRef.get(require('@novu/ee-translation')?.TranslationsService, { strict: false }); - const { namespaces, resources } = await service.getTranslationsList(environmentId, organizationId); + const { namespaces, resources, defaultLocale } = await service.getTranslationsList( + environmentId, + organizationId + ); await i18next.init({ resources, @@ -137,6 +156,7 @@ export class ContentTemplatesController { nsSeparator: '.', lng: locale || 'en', compatibilityJSON: 'v2', + fallbackLng: defaultLocale, interpolation: { formatSeparator: ',', format: function (value, formatting, lng) { diff --git a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts index 1f6b4cd19b1..8da75725d19 100644 --- a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts +++ b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { MessageTemplateEntity, MessageTemplateRepository } from '@novu/dal'; -import { ChangeEntityTypeEnum, IMessageAction } from '@novu/shared'; +import { MessageTemplateEntity, MessageTemplateRepository, LayoutEntity, LayoutRepository } from '@novu/dal'; +import { ChangeEntityTypeEnum, IMessageAction, StepTypeEnum } from '@novu/shared'; import { CreateMessageTemplateCommand } from './create-message-template.command'; import { sanitizeMessageContent } from '../../shared/sanitizer.service'; @@ -13,6 +13,7 @@ import { ApiException, CreateChange, CreateChangeCommand } from '@novu/applicati export class CreateMessageTemplate { constructor( private messageTemplateRepository: MessageTemplateRepository, + private layoutRepository: LayoutRepository, private createChange: CreateChange, private updateChange: UpdateChange ) {} @@ -22,6 +23,14 @@ export class CreateMessageTemplate { throw new ApiException('Please provide a valid CTA action'); } + let layoutId: string | undefined | null; + if (command.type === StepTypeEnum.EMAIL && !command.layoutId) { + const defaultLayout = await this.layoutRepository.findDefault(command.environmentId, command.organizationId); + layoutId = defaultLayout?._id; + } else { + layoutId = command.layoutId; + } + let item: MessageTemplateEntity = await this.messageTemplateRepository.create({ cta: command.cta, name: command.name, @@ -32,7 +41,7 @@ export class CreateMessageTemplate { title: command.title, type: command.type, _feedId: command.feedId ? command.feedId : null, - _layoutId: command.layoutId || null, + _layoutId: layoutId, _organizationId: command.organizationId, _environmentId: command.environmentId, _creatorId: command.userId, diff --git a/apps/api/src/app/testing/translations/get-locales-from-content.e2e-ee.ts b/apps/api/src/app/testing/translations/get-locales-from-content.e2e-ee.ts new file mode 100644 index 00000000000..13f6e714fa1 --- /dev/null +++ b/apps/api/src/app/testing/translations/get-locales-from-content.e2e-ee.ts @@ -0,0 +1,36 @@ +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; + +const createTranslationGroup = { + name: 'test', + identifier: 'test', + locales: ['hi_IN', 'en_US'], +}; + +const content = 'Hello {{i18n "test.key1"}}, {{i18n "test.key2"}}, {{i18n "test.key3"}}'; + +describe('Get locales from content - /translations/groups/:identifier/locales/:locale (PATCH)', async () => { + let session: UserSession; + + before(async () => { + session = new UserSession(); + await session.initialize(); + await session.testAgent.put(`/v1/organizations/language`).send({ + locale: createTranslationGroup.locales[0], + }); + + await session.testAgent.post('/v1/translations/groups').send(createTranslationGroup); + }); + + it('should get locales from the content', async () => { + const { body } = await session.testAgent.post('/v1/translations/groups/preview/locales').send({ + content, + }); + + const locales = body.data; + + expect(locales.length).to.equal(2); + expect(locales[0].langIso).to.equal(createTranslationGroup.locales[0]); + expect(locales[1].langIso).to.equal(createTranslationGroup.locales[1]); + }); +}); diff --git a/apps/web/cypress/fixtures/translation.json b/apps/web/cypress/fixtures/translation.json new file mode 100644 index 00000000000..63b42eb37ee --- /dev/null +++ b/apps/web/cypress/fixtures/translation.json @@ -0,0 +1,19 @@ +{ + "key": "value", + "keyDeep": { + "inner": "value" + }, + "keyNesting": "reuse $t(keyDeep.inner)", + "keyInterpolate": "replace this {{value}}", + "keyInterpolateUnescaped": "replace this {{- value}}", + "keyContext_male": "the male variant", + "keyContext_female": "the female variant", + "keyPluralSimple": "the singular", + "keyPluralSimple_plural": "the plural", + "keyPluralMultipleEgArabic_0": "the plural form 0", + "keyPluralMultipleEgArabic_1": "the plural form 1", + "keyPluralMultipleEgArabic_2": "the plural form 2", + "keyPluralMultipleEgArabic_3": "the plural form 3", + "keyPluralMultipleEgArabic_11": "the plural form 4", + "keyPluralMultipleEgArabic_100": "the plural form 5" +} diff --git a/apps/web/cypress/support/commands.ts b/apps/web/cypress/support/commands.ts index 766c9f46d4f..d773531671c 100644 --- a/apps/web/cypress/support/commands.ts +++ b/apps/web/cypress/support/commands.ts @@ -3,6 +3,7 @@ import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared'; import 'cypress-wait-until'; +import 'cypress-file-upload'; Cypress.Commands.add('getByTestId', (selector, ...args) => { return cy.get(`[data-test-id=${selector}]`, ...args); @@ -13,8 +14,8 @@ Cypress.Commands.add('getBySelectorLike', (selector, ...args) => { }); Cypress.Commands.add('waitLoadEnv', (beforeWait: () => void): void => { - cy.intercept('GET', 'http://127.0.0.1:1336/v1/environments').as('environments'); - cy.intercept('GET', 'http://127.0.0.1:1336/v1/environments/me').as('environments-me'); + cy.intercept('GET', '**/v1/environments').as('environments'); + cy.intercept('GET', '**/v1/environments/me').as('environments-me'); beforeWait && beforeWait(); @@ -22,13 +23,13 @@ Cypress.Commands.add('waitLoadEnv', (beforeWait: () => void): void => { }); Cypress.Commands.add('waitLoadTemplatePage', (beforeWait: () => void): void => { - cy.intercept('GET', 'http://127.0.0.1:1336/v1/environments').as('environments'); - cy.intercept('GET', 'http://127.0.0.1:1336/v1/organizations').as('organizations'); - cy.intercept('GET', 'http://127.0.0.1:1336/v1/environments/me').as('environments-me'); - cy.intercept('GET', 'http://127.0.0.1:1336/v1/notification-groups').as('notification-groups'); - cy.intercept('GET', 'http://127.0.0.1:1336/v1/changes/count').as('changes-count'); - cy.intercept('GET', 'http://127.0.0.1:1336/v1/integrations/active').as('active-integrations'); - cy.intercept('GET', 'http://127.0.0.1:1336/v1/users/me').as('me'); + cy.intercept('GET', '**/v1/environments').as('environments'); + cy.intercept('GET', '**/v1/organizations').as('organizations'); + cy.intercept('GET', '**/v1/environments/me').as('environments-me'); + cy.intercept('GET', '**/v1/notification-groups').as('notification-groups'); + cy.intercept('GET', '**/v1/changes/count').as('changes-count'); + cy.intercept('GET', '**/v1/integrations/active').as('active-integrations'); + cy.intercept('GET', '**/v1/users/me').as('me'); beforeWait && beforeWait(); diff --git a/apps/web/cypress/tests/changes.spec.ts b/apps/web/cypress/tests/changes.spec.ts index 025a122634a..8c66b5d0427 100644 --- a/apps/web/cypress/tests/changes.spec.ts +++ b/apps/web/cypress/tests/changes.spec.ts @@ -1,4 +1,4 @@ -import { dragAndDrop } from './notification-editor'; +import { dragAndDrop, editChannel } from './notification-editor'; import { goBack } from './notification-editor'; describe('Changes Screen', function () { @@ -122,7 +122,7 @@ function createNotification() { cy.visit('/workflows/create'); cy.waitForNetworkIdle(500); - cy.getByTestId('title').clear().type('Test Notification Title'); + cy.getByTestId('name-input').clear().type('Test Notification Title'); cy.getByTestId('settings-page').click(); cy.waitForNetworkIdle(500); @@ -135,7 +135,7 @@ function createNotification() { dragAndDrop('email'); cy.waitForNetworkIdle(500); - cy.clickWorkflowNode(`node-emailSelector`); + editChannel('email'); cy.waitForNetworkIdle(500); cy.getByTestId('emailSubject').type('this is email subject'); diff --git a/apps/web/cypress/tests/digest-playground.spec.ts b/apps/web/cypress/tests/digest-playground.spec.ts index c97ae4e38bd..58fbeca2cfe 100644 --- a/apps/web/cypress/tests/digest-playground.spec.ts +++ b/apps/web/cypress/tests/digest-playground.spec.ts @@ -16,7 +16,7 @@ describe('Digest Playground Workflow Page', function () { cy.url().should('include', '/digest-playground'); cy.contains('Digest Workflow Playground'); - cy.get('a[href="https://docs.novu.co/workflows/digest"]').contains('Learn more in docs'); + cy.get('a[href="https://docs.novu.co/workflows/digest?utm_campaign=in-app"]').contains('Learn more in docs'); }); it('the set up digest workflow should redirect to template edit page', function () { diff --git a/apps/web/cypress/tests/integrations-list-modal.spec.ts b/apps/web/cypress/tests/integrations-list-modal.spec.ts index 5310510f31c..e423c9b615b 100644 --- a/apps/web/cypress/tests/integrations-list-modal.spec.ts +++ b/apps/web/cypress/tests/integrations-list-modal.spec.ts @@ -29,10 +29,10 @@ describe('Integrations List Modal', function () { .as('session'); }); - const navigateToGetStarted = () => { + const navigateToGetStarted = (card = 'channel-card-email') => { cy.visit('/get-started'); cy.location('pathname').should('equal', '/get-started'); - cy.getByTestId('channel-card-email').find('button').contains('Change Provider').click(); + cy.getByTestId(card).find('button').contains('Change Provider').click(); cy.getByTestId('integrations-list-modal').should('be.visible').contains('Integrations Store'); }; @@ -128,7 +128,7 @@ describe('Integrations List Modal', function () { cy.intercept('*/environments', async () => { await new Promise((resolve) => setTimeout(resolve, 3500)); }).as('getEnvironments'); - navigateToGetStarted(); + navigateToGetStarted('channel-card-sms'); cy.getByTestId('select-provider-sidebar').should('be.visible'); cy.getByTestId('sidebar-close').should('be.visible').click(); @@ -152,7 +152,7 @@ describe('Integrations List Modal', function () { await new Promise((resolve) => setTimeout(resolve, 3500)); }).as('getIntegrations'); - navigateToGetStarted(); + navigateToGetStarted('channel-card-sms'); cy.getByTestId('select-provider-sidebar').should('be.visible'); cy.getByTestId('sidebar-close').should('be.visible').click(); @@ -773,7 +773,7 @@ describe('Integrations List Modal', function () { 'Select a framework to set up credentials to start sending notifications.' ); cy.getByTestId('update-provider-sidebar') - .find('a[href="https://docs.novu.co/notification-center/introduction"]') + .find('a[href="https://docs.novu.co/notification-center/introduction?utm_campaign=in-app"]') .contains('Explore set-up guide'); cy.getByTestId('is_active_id').should('have.value', 'true'); cy.window().then((win) => { diff --git a/apps/web/cypress/tests/integrations-list-page.spec.ts b/apps/web/cypress/tests/integrations-list-page.spec.ts index 1adb8f2c79f..0d0fe14524a 100644 --- a/apps/web/cypress/tests/integrations-list-page.spec.ts +++ b/apps/web/cypress/tests/integrations-list-page.spec.ts @@ -924,7 +924,7 @@ describe('Integrations List Page', function () { 'Select a framework to set up credentials to start sending notifications.' ); cy.getByTestId('update-provider-sidebar') - .find('a[href="https://docs.novu.co/notification-center/introduction"]') + .find('a[href="https://docs.novu.co/notification-center/introduction?utm_campaign=in-app"]') .contains('Explore set-up guide'); cy.getByTestId('is_active_id').should('have.value', 'true'); cy.window().then((win) => { diff --git a/apps/web/cypress/tests/layout/side-menu.spec.ts b/apps/web/cypress/tests/layout/side-menu.spec.ts index edfb5853e2c..fb887a683b1 100644 --- a/apps/web/cypress/tests/layout/side-menu.spec.ts +++ b/apps/web/cypress/tests/layout/side-menu.spec.ts @@ -17,7 +17,7 @@ describe('Side Menu', function () { cy.getByTestId('side-nav-bottom-link-support').should('have.attr', 'href').should('eq', 'https://discord.novu.co'); cy.getByTestId('side-nav-bottom-link-documentation') .should('have.attr', 'href') - .should('eq', 'https://docs.novu.co'); + .should('eq', 'https://docs.novu.co?utm_campaign=in-app'); cy.getByTestId('side-nav-bottom-link-share-feedback') .should('have.attr', 'href') diff --git a/apps/web/cypress/tests/notification-editor/create-notification.spec.ts b/apps/web/cypress/tests/notification-editor/create-notification.spec.ts index 7ac26e1965b..21eef7d3d2a 100644 --- a/apps/web/cypress/tests/notification-editor/create-notification.spec.ts +++ b/apps/web/cypress/tests/notification-editor/create-notification.spec.ts @@ -25,13 +25,13 @@ describe('Creation functionality', function () { .parent() .click() .find('textarea') - .type('

{{firstName}} someone assigned you to {{taskName}}', { + .type('

{{firstName}} someone assigned you to {{taskName}}

', { parseSpecialCharSequences: false, force: true, }); cy.getByTestId('inAppRedirect').type('/example/test'); cy.getByTestId('editor-mode-switch').find('label').last().click(); - cy.getByTestId('in-app-content-preview').contains('firstName someone assigned you to taskName'); + cy.getByTestId('in-app-content-preview').contains('firstName someone assigned you to taskName', { timeout: 1000 }); goBack(); cy.getByTestId('notification-template-submit-btn').click(); @@ -203,6 +203,9 @@ describe('Creation functionality', function () { cy.clickWorkflowNode('node-emailSelector'); cy.waitForNetworkIdle(500); + cy.getByTestId('edit-action').click(); + cy.waitForNetworkIdle(500); + cy.getByTestId('emailSubject').type('this is email subject'); cy.getByTestId('email-editor').getByTestId('editor-row').click(); cy.getByTestId('editable-text-content').clear().type('This text is written from a test {{firstName}}', { @@ -223,16 +226,26 @@ describe('Creation functionality', function () { cy.waitForNetworkIdle(500); cy.clickWorkflowNode('node-smsSelector'); + cy.getByTestId('edit-action').click(); cy.waitForNetworkIdle(500); - cy.getByTestId('smsNotificationContent').type('This text is written from a test {{var}}', { - parseSpecialCharSequences: false, - }); + cy.get('.monaco-editor textarea:first', { timeout: 7000 }) + .parent() + .click() + .find('textarea') + .type('This text is written from a test {{var}}', { + parseSpecialCharSequences: false, + force: true, + }); + cy.getByTestId('open-variable-management').click(); + cy.getByTestId('open-edit-variables-btn').click(); cy.getByTestId('variable-default-value').type('Test'); + cy.getByTestId('close-var-manager-modal').click(); cy.getByTestId('notification-template-submit-btn').click(); cy.waitForNetworkIdle(500); - + cy.getByTestId('open-variable-management').click(); + cy.getByTestId('open-edit-variables-btn').click(); cy.getByTestId('variable-default-value').should('have.value', 'Test'); }); @@ -240,11 +253,12 @@ describe('Creation functionality', function () { cy.waitLoadTemplatePage(() => { cy.visit('/workflows/create'); }); + cy.waitForNetworkIdle(500); dragAndDrop('email'); cy.waitForNetworkIdle(500); - cy.clickWorkflowNode('node-emailSelector'); + editChannel('email'); cy.waitForNetworkIdle(500); cy.getByTestId('emailSubject').type('this is email subject'); diff --git a/apps/web/cypress/tests/notification-editor/index.ts b/apps/web/cypress/tests/notification-editor/index.ts index dc963f5569b..38cb273ef5b 100644 --- a/apps/web/cypress/tests/notification-editor/index.ts +++ b/apps/web/cypress/tests/notification-editor/index.ts @@ -18,6 +18,9 @@ export function dragAndDrop(channel: Channel, dropTestId = 'addNodeButton') { export function editChannel(channel: Channel, last = false) { cy.clickWorkflowNode(`node-${channel}Selector`, last); + if (['inApp', 'email', 'sms', 'chat', 'push'].includes(channel)) { + cy.getByTestId('edit-action').click(); + } } export function goBack() { diff --git a/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts b/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts index 81e1b615cec..09318a97952 100644 --- a/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts +++ b/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts @@ -85,7 +85,7 @@ describe('Workflow Editor - Main Functionality', function () { cy.getByTestId('settings-page').click(); cy.waitForNetworkIdle(500); - cy.getByTestId('title').first().should('have.value', template.name); + cy.getByTestId('name-input').first().should('have.value', template.name); editChannel('inApp'); cy.waitForNetworkIdle(500); @@ -95,7 +95,7 @@ describe('Workflow Editor - Main Functionality', function () { goBack(); cy.waitForNetworkIdle(500); - cy.getByTestId('title').clear().type('This is the new notification title'); + cy.getByTestId('name-input').clear().type('This is the new notification title'); editChannel('inApp', true); cy.waitForNetworkIdle(500); @@ -211,7 +211,7 @@ describe('Workflow Editor - Main Functionality', function () { dragAndDrop('email'); cy.waitForNetworkIdle(500); - cy.clickWorkflowNode(`node-emailSelector`); + editChannel(`email`); cy.getByTestId(`step-active-switch`).should('have.value', 'on'); cy.getByTestId(`step-active-switch`).click({ force: true }); @@ -221,8 +221,8 @@ describe('Workflow Editor - Main Functionality', function () { goBack(); dragAndDrop('inApp'); + editChannel('inApp'); - cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId(`step-active-switch`).should('have.value', 'on'); }); @@ -316,9 +316,15 @@ describe('Workflow Editor - Main Functionality', function () { addAndEditChannel('sms'); cy.waitForNetworkIdle(500); - cy.getByTestId('smsNotificationContent').type('{{firstName}} someone assigned you to {{taskName}}', { - parseSpecialCharSequences: false, - }); + cy.get('.monaco-editor textarea:first', { timeout: 7000 }) + .parent() + .click() + .find('textarea') + .type('{{firstName}} someone assigned you to {{taskName}}', { + parseSpecialCharSequences: false, + force: true, + }); + goBack(); cy.waitForNetworkIdle(500); cy.getByTestId('notification-template-submit-btn').click(); diff --git a/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts b/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts index a6d53a05b8c..abd459104ff 100644 --- a/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts +++ b/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts @@ -25,7 +25,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.get('.react-flow__node').should('have.length', 4); cy.clickWorkflowNode(`node-inAppSelector`); cy.waitForNetworkIdle(500); - cy.getByTestId('editor-sidebar-delete').click(); + cy.getByTestId('step-actions-menu').click().getByTestId('delete-step-action').click(); cy.get('.mantine-Modal-modal button').contains('Delete step').click(); cy.getByTestId(`node-inAppSelector`).should('not.exist'); cy.get('.react-flow__node').should('have.length', 3); @@ -87,7 +87,11 @@ describe('Workflow Editor - Steps Actions', function () { dragAndDrop('sms'); editChannel('sms'); - cy.getByTestId('smsNotificationContent').type('new content for sms'); + cy.get('.monaco-editor textarea:first').parent().click().find('textarea').type('new content for sms', { + parseSpecialCharSequences: false, + force: true, + }); + cy.getByTestId('notification-template-submit-btn').click(); cy.visit('/workflows/edit/' + template._id); @@ -112,12 +116,13 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - cy.clickWorkflowNode(`node-inAppSelector`); + editChannel(`inApp`); cy.getByTestId(`step-active-switch`).get('label').contains('Active'); cy.getByTestId(`step-active-switch`).click({ force: true }); goBack(); - cy.clickWorkflowNode(`node-inAppSelector`); + editChannel(`inApp`); + cy.getByTestId(`step-active-switch`).get('label').contains('Inactive'); }); @@ -128,12 +133,12 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - cy.clickWorkflowNode(`node-inAppSelector`); + editChannel(`inApp`); cy.getByTestId(`step-should-stop-on-fail-switch`).get('label').contains('Stop if step fails'); cy.getByTestId(`step-should-stop-on-fail-switch`).click({ force: true }); goBack(); - cy.clickWorkflowNode(`node-inAppSelector`); + editChannel(`inApp`); cy.getByTestId(`step-should-stop-on-fail-switch`).should('be.checked'); }); @@ -214,7 +219,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId('editor-sidebar-add-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('add-new-condition').click(); cy.getByTestId('conditions-form-on').click(); @@ -227,7 +232,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('apply-conditions-btn').click(); - cy.getByTestId('editor-sidebar-edit-conditions').contains('1'); + cy.getByTestId('add-conditions-action').contains('1'); }); it('should be able to add read/seen filters to a particular step', function () { @@ -239,7 +244,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.clickWorkflowNode(`node-emailSelector`); - cy.getByTestId('editor-sidebar-add-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('add-new-condition').click(); cy.getByTestId('conditions-form-on').click(); @@ -252,7 +257,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('apply-conditions-btn').click(); - cy.getByTestId('editor-sidebar-edit-conditions').contains('1'); + cy.getByTestId('add-conditions-action').contains('1'); }); it('should be able to not add read/seen filters to first step', function () { @@ -264,7 +269,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId('editor-sidebar-add-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('add-new-condition').click(); cy.getByTestId('conditions-form-on').click(); @@ -280,7 +285,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId('editor-sidebar-add-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('add-new-condition').click(); @@ -291,15 +296,15 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('apply-conditions-btn').click(); - cy.getByTestId('editor-sidebar-edit-conditions').contains('1'); + cy.getByTestId('add-conditions-action').contains('1'); - cy.getByTestId('editor-sidebar-edit-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('conditions-row-btn').click(); cy.getByTestId('conditions-row-delete').click(); cy.getByTestId('apply-conditions-btn').click(); - cy.getByTestId('editor-sidebar-add-conditions').should('not.contain', '1'); + cy.getByTestId('add-conditions-action').should('not.contain', '1'); }); it('should be able to add webhook filter for a particular step', function () { @@ -311,7 +316,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId('editor-sidebar-add-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('add-new-condition').click(); @@ -326,7 +331,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('apply-conditions-btn').click(); - cy.getByTestId('editor-sidebar-edit-conditions').contains('1'); + cy.getByTestId('add-conditions-action').contains('1'); }); it('should be able to add online right now filter for a particular step', function () { @@ -338,7 +343,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId('editor-sidebar-add-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('add-new-condition').click(); @@ -349,7 +354,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('apply-conditions-btn').click(); - cy.getByTestId('editor-sidebar-edit-conditions').contains('1'); + cy.getByTestId('add-conditions-action').contains('1'); }); it('should be able to add online in the last X time period filter for a particular step', function () { @@ -361,7 +366,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId('editor-sidebar-add-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('add-new-condition').click(); @@ -373,7 +378,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('apply-conditions-btn').click(); - cy.getByTestId('editor-sidebar-edit-conditions').contains('1'); + cy.getByTestId('add-conditions-action').contains('1'); }); it('should be able to add multiple filters to a particular step', function () { @@ -385,7 +390,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId('editor-sidebar-add-conditions').click(); + cy.getByTestId('add-conditions-action').click(); cy.getByTestId('add-new-condition').click(); cy.getByTestId('conditions-form-on').click(); @@ -404,7 +409,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('apply-conditions-btn').click(); - cy.getByTestId('editor-sidebar-edit-conditions').contains('2'); + cy.getByTestId('add-conditions-action').contains('2'); }); it('should re-render content on between step click', function () { @@ -425,15 +430,23 @@ describe('Workflow Editor - Steps Actions', function () { const lastContent = 'last content for sms'; cy.clickWorkflowNode(`node-smsSelector`); - cy.getByTestId('smsNotificationContent').type(firstContent); + cy.getByTestId('edit-action').click(); - cy.clickWorkflowNode(`node-smsSelector`, true); - cy.getByTestId('smsNotificationContent').type(lastContent); + cy.get('.monaco-editor textarea:first').parent().click().find('textarea').type(firstContent, { + force: true, + }); + cy.clickWorkflowNode(`node-smsSelector`, true); + cy.getByTestId('edit-action').click(); + cy.get('.monaco-editor textarea:first').parent().click().find('textarea').type(lastContent, { + force: true, + }); cy.clickWorkflowNode(`node-smsSelector`); - cy.getByTestId('smsNotificationContent').should('have.text', firstContent); + cy.getByTestId('edit-action').click(); + cy.get('.monaco-editor textarea:first').parent().click().contains(firstContent); cy.clickWorkflowNode(`node-smsSelector`, true); - cy.getByTestId('smsNotificationContent').should('have.text', lastContent); + cy.getByTestId('edit-action').click(); + cy.get('.monaco-editor textarea:first').parent().click().contains(lastContent); }); }); diff --git a/apps/web/cypress/tests/notification-editor/variants.spec.ts b/apps/web/cypress/tests/notification-editor/variants.spec.ts index 02fda30e0ab..e282017a323 100644 --- a/apps/web/cypress/tests/notification-editor/variants.spec.ts +++ b/apps/web/cypress/tests/notification-editor/variants.spec.ts @@ -17,7 +17,7 @@ describe('Workflow Editor - Variants', function () { cy.visit('/workflows/create'); }); cy.wait('@getWorkflow'); - cy.getByTestId('title').first().clear().type(title).blur(); + cy.getByTestId('name-input').first().clear().type(title).blur(); }; const fillInAppEditorContentWith = (text: string) => { @@ -38,28 +38,38 @@ describe('Workflow Editor - Variants', function () { }; const fillSmsEditorContentWith = (content: string) => { - cy.getByTestId('smsNotificationContent').clear().type(content, { + cy.get('.monaco-editor textarea:first').parent().click().find('textarea').clear({ force: true }).type(content, { parseSpecialCharSequences: false, force: true, }); }; const fillChatEditorContentWith = (content: string) => { - cy.getByTestId('chatNotificationContent').clear().type(content, { + cy.get('.monaco-editor textarea:first').parent().click().find('textarea').clear({ force: true }).type(content, { parseSpecialCharSequences: false, force: true, }); }; const fillPushEditorContentWith = (title: string, content: string) => { - cy.getByTestId('pushNotificationTitle').clear().type(title, { - parseSpecialCharSequences: false, - force: true, - }); - cy.getByTestId('pushNotificationContent').clear().type(content, { - parseSpecialCharSequences: false, - force: true, - }); + cy.get('[data-test-id=push-title-container] .monaco-editor textarea:first') + .parent() + .click() + .find('textarea') + .clear({ force: true }) + .type(title, { + parseSpecialCharSequences: false, + force: true, + }); + cy.get('[data-test-id=push-content-container] .monaco-editor textarea:first') + .parent() + .click() + .find('textarea') + .clear({ force: true }) + .type(content, { + parseSpecialCharSequences: false, + force: true, + }); }; const showStepActions = (channel: Channel) => { @@ -78,7 +88,7 @@ describe('Workflow Editor - Variants', function () { cy.getByTestId('add-new-condition').click(); cy.getByTestId('conditions-form-key').last().type('test'); cy.getByTestId('conditions-form-value').last().type('test'); - cy.getByTestId('apply-conditions-btn').click(); + cy.getByTestId('apply-conditions-btn').click({ force: true }); }; const checkTheVariantsList = (title: string, content: string) => { @@ -122,14 +132,26 @@ describe('Workflow Editor - Variants', function () { cy.getByTestId('email-editor').contains(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT); break; case 'sms': - cy.getByTestId('smsNotificationContent').should('have.value', isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT); + cy.get('.monaco-editor textarea:first') + .parent() + .click() + .contains(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT); break; case 'chat': - cy.getByTestId('chatNotificationContent').should('have.value', isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT); + cy.get('.monaco-editor textarea:first') + .parent() + .click() + .contains(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT); break; case 'push': - cy.getByTestId('pushNotificationTitle').should('have.value', PUSH_TITLE); - cy.getByTestId('pushNotificationContent').should('have.value', isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT); + cy.get('[data-test-id=push-title-container] .monaco-editor textarea:first') + .parent() + .click() + .contains(PUSH_TITLE); + cy.get('[data-test-id=push-content-container] .monaco-editor textarea:first') + .parent() + .click() + .contains(isVariant ? VARIANT_EDITOR_TEXT : EDITOR_TEXT); break; } }; @@ -146,14 +168,33 @@ describe('Workflow Editor - Variants', function () { cy.getByTestId('email-editor').clear(); break; case 'sms': - cy.getByTestId('smsNotificationContent').clear(); + cy.get('.monaco-editor textarea:first').parent().click().type('{cmd}a').find('textarea').clear({ + force: true, + }); break; case 'chat': - cy.getByTestId('chatNotificationContent').clear(); + cy.get('.monaco-editor textarea:first').parent().click().type('{cmd}a').find('textarea').clear({ + force: true, + }); break; case 'push': - cy.getByTestId('pushNotificationTitle').clear(); - cy.getByTestId('pushNotificationContent').clear(); + cy.get('[data-test-id=push-title-container] .monaco-editor textarea:first') + .parent() + .click() + .find('textarea') + .type(Cypress.platform === 'darwin' ? '{cmd}a' : '{ctrl}a', { force: true }) + .clear({ + force: true, + }); + + cy.get('[data-test-id=push-content-container] .monaco-editor textarea:first') + .parent() + .click() + .find('textarea') + .type(Cypress.platform === 'darwin' ? '{cmd}a' : '{ctrl}a', { force: true }) + .clear({ + force: true, + }); break; } }; @@ -309,7 +350,7 @@ describe('Workflow Editor - Variants', function () { fillEditorContent(channel, true); goBack(); - cy.getByTestId('editor-sidebar-add-variant').should('be.visible').click(); + cy.getByTestId('variant-sidebar-add-variant').should('be.visible').click(); addConditions(); fillEditorContent(channel, true); goBack(); @@ -322,7 +363,7 @@ describe('Workflow Editor - Variants', function () { cy.wait('@getWorkflow'); cy.getByTestId(`node-${channel}Selector`).getByTestId('variants-count').contains('2 variants'); - editChannel(channel); + cy.clickWorkflowNode(`node-${channel}Selector`); checkVariantListCard({ selector: 'variant-item-card-1', message: VARIANT_EDITOR_TEXT }); checkVariantConditions({ selector: 'variant-item-card-1', contains: '1' }); @@ -551,11 +592,14 @@ describe('Workflow Editor - Variants', function () { addConditions(); goBack(); - cy.getByTestId('variants-list-sidebar').getByTestId('editor-sidebar-add-conditions').should('be.visible').click(); + cy.getByTestId('variants-list-sidebar') + .getByTestId('variant-sidebar-add-conditions') + .should('be.visible') + .click(); addConditions(); cy.getByTestId('variants-list-sidebar') - .getByTestId('editor-sidebar-edit-conditions') + .getByTestId('variant-sidebar-edit-conditions') .should('be.visible') .contains('1'); }); @@ -647,7 +691,7 @@ describe('Workflow Editor - Variants', function () { navigateAndOpenFirstWorkflow(); - editChannel(channel); + cy.clickWorkflowNode(`node-${channel}Selector`); cy.getByTestId('variant-item-card-0').getByTestId('conditions-action').should('be.visible').contains('1'); cy.getByTestId('variant-item-card-0').trigger('mouseover'); @@ -678,7 +722,7 @@ describe('Workflow Editor - Variants', function () { navigateAndOpenFirstWorkflow(); - editChannel(channel); + cy.clickWorkflowNode(`node-${channel}Selector`); cy.getByTestId('variant-root-card').getByTestId('conditions-action').should('be.visible').contains('No'); cy.getByTestId('variant-root-card').trigger('mouseover'); @@ -797,7 +841,7 @@ describe('Workflow Editor - Variants', function () { cy.reload(); cy.wait('@getWorkflow'); - cy.getByTestId('editor-sidebar-delete').click(); + cy.getByTestId('variant-sidebar-delete').click(); cy.get('.mantine-Modal-modal').contains('Delete step?'); cy.get('.mantine-Modal-modal button').contains('Delete step').click(); @@ -907,12 +951,10 @@ describe('Workflow Editor - Variants', function () { checkVariantListCard({ selector: 'variant-item-card-0', message: messageTitleMissing, hasBorder: true }); cy.getByTestId('variants-list-errors-down').click(); - checkCurrentError({ message: messageContentMissing, count: '2/2' }); checkVariantListCard({ selector: 'variant-item-card-0', message: messageContentMissing, hasBorder: true }); cy.getByTestId('variants-list-errors-up').click(); - checkCurrentError({ message: messageTitleMissing, count: '1/2' }); checkVariantListCard({ selector: 'variant-item-card-0', message: messageTitleMissing, hasBorder: true }); }); diff --git a/apps/web/cypress/tests/tenants-page.spec.ts b/apps/web/cypress/tests/tenants-page.spec.ts index 8d8aba413b2..2df27625a43 100644 --- a/apps/web/cypress/tests/tenants-page.spec.ts +++ b/apps/web/cypress/tests/tenants-page.spec.ts @@ -25,12 +25,14 @@ describe('Tenants Page', function () { cy.getByTestId('tenants-list-table') .find('tr') .eq(1) - .click() + .click({ force: true }) .then(() => { cy.getByTestId('tenant-name').clear().type('New Name'); cy.getByTestId('update-tenant-sidebar-submit').click(); }); + cy.waitForNetworkIdle(500); + cy.getByTestId('tenants-list-table').find('td:nth-child(1)').contains('New Name'); cy.getByTestId('tenants-list-table').find('td:nth-child(2)').contains('test-tenant'); @@ -38,7 +40,7 @@ describe('Tenants Page', function () { cy.getByTestId('tenants-list-table') .find('tr') .eq(1) - .click() + .click({ force: true }) .then(() => { cy.getByTestId('tenant-identifier').clear().type('new-identifier'); cy.getByTestId('update-tenant-sidebar-submit').click(); diff --git a/apps/web/cypress/tests/translations/translation-group.spec-ee.ts b/apps/web/cypress/tests/translations/translation-group.spec-ee.ts index 01ee02c7940..497914472e2 100644 --- a/apps/web/cypress/tests/translations/translation-group.spec-ee.ts +++ b/apps/web/cypress/tests/translations/translation-group.spec-ee.ts @@ -10,31 +10,48 @@ describe('Translations Group Page', function () { }); it('should add a new translations group', function () { - cy.visit('/translations'); - cy.waitForNetworkIdle(500); - cy.getByTestId('translation-title').should('have.text', 'Translations'); - cy.getByTestId('add-group-btn').click(); - cy.getByTestId('default-language-select').click(); - cy.get('.mantine-Select-item').first().click(); - cy.getByTestId('default-language-submit-btn').click(); - cy.getByTestId('group-name-input').type('Test Group'); - cy.getByTestId('group-identifier-input').should('have.value', 'test-group'); - cy.getByTestId('add-group-submit-btn').click(); + createTranslationGroup(); + cy.getByTestId('test-group').click({ force: true }); + cy.getByTestId('translation-filename').should('have.text', 'Empty...'); + cy.getByTestId('translation-keys-value').should('have.text', ''); }); it('should delete a translations group', function () { - cy.visit('/translations'); - cy.waitForNetworkIdle(500); - cy.getByTestId('translation-title').should('have.text', 'Translations'); - cy.getByTestId('add-group-btn').click(); - cy.getByTestId('default-language-select').click(); - cy.get('.mantine-Select-item').first().click(); - cy.getByTestId('default-language-submit-btn').click(); - cy.getByTestId('group-name-input').type('Test Group'); - cy.getByTestId('group-identifier-input').should('have.value', 'test-group'); - cy.getByTestId('add-group-submit-btn').click(); + createTranslationGroup(); cy.getByTestId('delete-group-btn').click(); cy.getByTestId('delete-group-submit-btn').should('have.text', 'Delete group').click(); cy.getByTestId('add-group-btn').should('exist'); }); + + it('should upload translation file', function () { + createTranslationGroup(); + + cy.getByTestId('upload-files-container').find('input').attachFile('translation.json'); + cy.getByTestId('upload-submit-btn').click(); + cy.visit('/translations'); + cy.getByTestId('test-group').click(); + cy.getByTestId('translation-filename').should('have.text', 'translation.json'); + cy.getByTestId('translation-keys-value').should('have.text', 15); + }); }); + +function createTranslationGroup() { + const identifier = 'test-group'; + + cy.visit('/translations'); + cy.waitForNetworkIdle(500); + cy.getByTestId('translation-title').should('have.text', 'Translations'); + cy.getByTestId('add-group-btn').click(); + cy.waitForNetworkIdle(500); + + cy.getByTestId('default-language-select').click().type('Hindi'); + + cy.get('.mantine-Select-item').first().click(); + cy.getByTestId('default-language-submit-btn').click(); + cy.waitForNetworkIdle(500); + + cy.getByTestId('group-name-input').type('Test Group'); + cy.getByTestId('group-identifier-input').should('have.value', identifier); + cy.getByTestId('add-group-submit-btn').click({ force: true }); + cy.waitForNetworkIdle(1000); +} diff --git a/apps/web/package.json b/apps/web/package.json index 2a94cee32c7..ca0b0e76320 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -148,6 +148,7 @@ "@types/react": "^17.0.0", "@types/testing-library__jest-dom": "^5.14.5", "cypress": "^13.3.1", + "cypress-file-upload": "^5.0.8", "cypress-localstorage-commands": "^2.2.4", "cypress-network-idle": "^1.14.2", "cypress-wait-until": "^2.0.1", diff --git a/apps/web/public/index.html b/apps/web/public/index.html index a9cf4b7ee17..99cd67813b7 100644 --- a/apps/web/public/index.html +++ b/apps/web/public/index.html @@ -39,11 +39,6 @@ > <% } %> - <% if ( process.env.REACT_APP_HUBSPOT_EMBED ) { %> - - - - <% } %> diff --git a/apps/web/public/static/images/mobilePreview/android.webp b/apps/web/public/static/images/mobilePreview/android.webp new file mode 100644 index 00000000000..f231b5496a1 Binary files /dev/null and b/apps/web/public/static/images/mobilePreview/android.webp differ diff --git a/apps/web/public/static/images/mobilePreview/iphone.webp b/apps/web/public/static/images/mobilePreview/iphone.webp new file mode 100644 index 00000000000..d00552e0803 Binary files /dev/null and b/apps/web/public/static/images/mobilePreview/iphone.webp differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3c4f971a63c..c99c5e476be 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -59,6 +59,7 @@ import { SSO, UserAccess, Cloud } from '@novu/design-system'; import { BrandingForm, LayoutsListPage } from './pages/brand/tabs'; import { TranslationRoutes } from './pages/TranslationPages'; import { VariantsPage } from './pages/templates/components/VariantsPage'; +import { ChannelPreview } from './pages/templates/components/ChannelPreview'; import { BillingRoutes } from './pages/BillingPages'; library.add(far, fas); @@ -160,7 +161,8 @@ function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/api/content-templates.ts b/apps/web/src/api/content-templates.ts index 4aebf00c119..faec1880804 100644 --- a/apps/web/src/api/content-templates.ts +++ b/apps/web/src/api/content-templates.ts @@ -1,4 +1,4 @@ -import { IEmailBlock, MessageTemplateContentType } from '@novu/shared'; +import { IEmailBlock, IMessageButton, IMessageCTA, MessageTemplateContentType } from '@novu/shared'; import { api } from './api.client'; export async function previewEmail({ @@ -7,16 +7,66 @@ export async function previewEmail({ payload, subject, layoutId, + locale, }: { content?: string | IEmailBlock[]; contentType?: MessageTemplateContentType; payload: string; subject?: string; layoutId?: string; + locale?: string; }) { - return api.post('/v1/content-templates/preview/email', { content, contentType, payload, subject, layoutId }); + return api.post('/v1/content-templates/preview/email', { content, contentType, payload, subject, layoutId, locale }); } -export async function previewInApp({ content, cta, payload }: { content?: string; cta: any; payload: string }) { - return api.post('/v1/content-templates/preview/in-app', { content, payload, cta }); +export async function previewInApp({ + content, + cta, + payload, + locale, +}: { + content?: string; + cta?: IMessageCTA; + payload: string; + locale?: string; +}) { + return api.post('/v1/content-templates/preview/in-app', { content, payload, cta, locale }); +} + +export async function previewChat({ + content, + payload, + locale, +}: { + content?: string; + payload: string; + locale?: string; +}) { + return api.post('/v1/content-templates/preview/chat', { content, payload, locale }); +} + +export async function previewSms({ + content, + payload, + locale, +}: { + content?: string | IEmailBlock[]; + payload: string; + locale?: string; +}): Promise<{ content: string }> { + return api.post('/v1/content-templates/preview/sms', { content, payload, locale }); +} + +export async function previewPush({ + content, + payload, + locale, + title, +}: { + content?: string | IEmailBlock[]; + title?: string; + payload?: string; + locale?: string; +}): Promise<{ content: string; title: string }> { + return api.post('/v1/content-templates/preview/push', { content, payload, locale, title }); } diff --git a/apps/web/src/api/hooks/index.ts b/apps/web/src/api/hooks/index.ts index 9ff0778944c..8d5472f5b9d 100644 --- a/apps/web/src/api/hooks/index.ts +++ b/apps/web/src/api/hooks/index.ts @@ -3,3 +3,9 @@ export * from './useInAppActivated'; export * from './useDeleteIntegration'; export * from './useWebhookSupportStatus'; export * from './notification-templates'; +export * from './useGetLocalesFromContent'; +export * from './usePreviewEmail'; +export * from './usePreviewSms'; +export * from './usePreviewPush'; +export * from './usePreviewChat'; +export * from './usePreviewInApp'; diff --git a/apps/web/src/api/hooks/notification-templates/useCreateDigestDemoWorkflow.ts b/apps/web/src/api/hooks/notification-templates/useCreateDigestDemoWorkflow.ts index 6b2ef8a16eb..d778b592dd9 100644 --- a/apps/web/src/api/hooks/notification-templates/useCreateDigestDemoWorkflow.ts +++ b/apps/web/src/api/hooks/notification-templates/useCreateDigestDemoWorkflow.ts @@ -1,7 +1,8 @@ import { useCallback } from 'react'; import { useMutation } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { ICreateNotificationTemplateDto, INotificationTemplate, StepTypeEnum } from '@novu/shared'; +import { StepTypeEnum } from '@novu/shared'; +import type { IResponseError, ICreateNotificationTemplateDto, INotificationTemplate } from '@novu/shared'; import { createTemplate } from '../../notification-templates'; import { parseUrl } from '../../../utils/routeUtils'; @@ -10,13 +11,14 @@ import { errorMessage } from '../../../utils/notifications'; import { useNotificationGroup, useTemplates } from '../../../hooks'; import { v4 as uuid4 } from 'uuid'; import { TemplateCreationSourceEnum } from '../../../pages/templates/shared'; +import { FIRST_100_WORKFLOWS } from '../../../constants/workflowConstants'; export const useCreateDigestDemoWorkflow = () => { const navigate = useNavigate(); const { groups, loading: areNotificationGroupLoading } = useNotificationGroup(); const { mutateAsync: createNotificationTemplate, isLoading: isCreating } = useMutation< INotificationTemplate & { __source?: string }, - { error: string; message: string; statusCode: number }, + IResponseError, { template: ICreateNotificationTemplateDto; params: { __source?: string } } >((data) => createTemplate(data.template, data.params), { onSuccess: (template) => { @@ -26,7 +28,7 @@ export const useCreateDigestDemoWorkflow = () => { errorMessage('Failed to create Digest Workflow'); }, }); - const { templates = [], loading: templatesLoading } = useTemplates(); + const { templates = [], loading: templatesLoading } = useTemplates(FIRST_100_WORKFLOWS); const digestOnboardingTemplate = 'Digest Workflow Example'; const createDigestDemoWorkflow = useCallback(() => { diff --git a/apps/web/src/api/hooks/notification-templates/useCreateOnboardingExperimentWorkflow.ts b/apps/web/src/api/hooks/notification-templates/useCreateOnboardingExperimentWorkflow.ts new file mode 100644 index 00000000000..3e2c95b6bae --- /dev/null +++ b/apps/web/src/api/hooks/notification-templates/useCreateOnboardingExperimentWorkflow.ts @@ -0,0 +1,120 @@ +import { useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { v4 as uuid4 } from 'uuid'; +import { EmailProviderIdEnum, StepTypeEnum } from '@novu/shared'; +import type { IResponseError, ICreateNotificationTemplateDto, INotificationTemplate } from '@novu/shared'; +import { QueryKeys } from '@novu/shared-web'; + +import { createTemplate } from '../../notification-templates'; +import { parseUrl } from '../../../utils/routeUtils'; +import { ROUTES } from '../../../constants/routes.enum'; +import { errorMessage } from '../../../utils/notifications'; +import { useNotificationGroup, useTemplates, useIntegrations } from '../../../hooks'; +import { FIRST_100_WORKFLOWS } from '../../../constants/workflowConstants'; +import { IntegrationEntity } from '../../../pages/integrations/types'; +import { setIntegrationAsPrimary } from '../../../api/integration'; + +export const useCreateOnboardingExperimentWorkflow = () => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { groups, loading: areNotificationGroupLoading } = useNotificationGroup(); + + const { mutateAsync: createNotificationTemplate, isLoading: isCreating } = useMutation< + INotificationTemplate & { __source?: string }, + IResponseError, + { template: ICreateNotificationTemplateDto; params: { __source?: string } } + >((data) => createTemplate(data.template, data.params), { + onSuccess: (template) => { + navigate(parseUrl(ROUTES.WORKFLOWS_EDIT_TEMPLATEID, { templateId: template._id as string })); + }, + onError: () => { + errorMessage('Failed to create onboarding experiment Workflow'); + }, + }); + + const { mutate: makePrimaryIntegration, isLoading: isPrimaryEmailIntegrationLoading } = useMutation< + IntegrationEntity, + IResponseError, + { id: string } + >(({ id }) => setIntegrationAsPrimary(id), { + onSuccess: () => { + queryClient.refetchQueries({ + predicate: ({ queryKey }) => + queryKey.includes(QueryKeys.integrationsList) || queryKey.includes(QueryKeys.activeIntegrations), + }); + }, + onError: () => { + errorMessage("Failed to update integration's primary status"); + }, + }); + + const { templates = [], loading: templatesLoading } = useTemplates(FIRST_100_WORKFLOWS); + + const { integrations, loading: isIntegrationsLoading } = useIntegrations(); + + const onboardingExperimentWorkflow = 'Onboarding Workflow'; + + const createOnboardingExperimentWorkflow = useCallback(() => { + if (templatesLoading) return; + + const onboardingExperimentWorkflowExists = templates.find((template) => + template.name.includes(onboardingExperimentWorkflow) + ); + + const novuEmailIntegration = integrations?.find( + (integration) => integration.providerId === EmailProviderIdEnum.Novu + ); + + if (novuEmailIntegration && !novuEmailIntegration?.primary) { + makePrimaryIntegration({ id: novuEmailIntegration?._id as string }); + } + + if (onboardingExperimentWorkflowExists) { + navigate( + parseUrl(ROUTES.WORKFLOWS_EDIT_TEMPLATEID, { templateId: onboardingExperimentWorkflowExists._id as string }) + ); + } else { + const payload = { + name: onboardingExperimentWorkflow, + notificationGroupId: groups[0]._id, + active: true, + draft: false, + critical: false, + tags: ['onboarding'], + steps: [ + { + template: { + subject: 'Your first email notification from Novu!', + senderName: 'Novu Onboarding', + type: StepTypeEnum.EMAIL, + contentType: 'customHtml', + content: + // eslint-disable-next-line max-len + 'It\'s that simple!
Learn more about creating workflows here.', + }, + uuid: uuid4(), + active: true, + }, + ], + }; + + createNotificationTemplate({ + template: payload as any, + params: { __source: 'Onboarding Experiment Workflow' }, + }); + } + }, [templatesLoading, templates, integrations, makePrimaryIntegration, navigate, groups, createNotificationTemplate]); + + return { + createOnboardingExperimentWorkflow, + isLoading: isPrimaryEmailIntegrationLoading || isCreating, + isDisabled: + areNotificationGroupLoading || + templatesLoading || + isIntegrationsLoading || + isPrimaryEmailIntegrationLoading || + isCreating, + }; +}; diff --git a/apps/web/src/api/hooks/notification-templates/useUpdateTemplate.ts b/apps/web/src/api/hooks/notification-templates/useUpdateTemplate.ts index 3f8fdc40e5b..a08c08d2587 100644 --- a/apps/web/src/api/hooks/notification-templates/useUpdateTemplate.ts +++ b/apps/web/src/api/hooks/notification-templates/useUpdateTemplate.ts @@ -1,5 +1,5 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'; -import { INotificationTemplate, IUpdateNotificationTemplateDto } from '@novu/shared'; +import type { IResponseError, INotificationTemplate, IUpdateNotificationTemplateDto } from '@novu/shared'; import { updateTemplate } from '../../notification-templates'; import { QueryKeys } from '../../query.keys'; @@ -7,7 +7,7 @@ import { QueryKeys } from '../../query.keys'; export const useUpdateTemplate = ( options: UseMutationOptions< INotificationTemplate, - { error: string; message: string; statusCode: number }, + IResponseError, { id: string; data: Partial } > = {} ) => { @@ -15,7 +15,7 @@ export const useUpdateTemplate = ( const { mutateAsync: updateTemplateMutation, ...rest } = useMutation< INotificationTemplate, - { error: string; message: string; statusCode: number }, + IResponseError, { id: string; data: Partial } >(({ id, data }) => updateTemplate(id, data), { ...options, diff --git a/apps/web/src/api/hooks/useDeleteIntegration.ts b/apps/web/src/api/hooks/useDeleteIntegration.ts index bd13801ab7c..5bdc4bca47f 100644 --- a/apps/web/src/api/hooks/useDeleteIntegration.ts +++ b/apps/web/src/api/hooks/useDeleteIntegration.ts @@ -1,38 +1,28 @@ import { MutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { IResponseError } from '@novu/shared'; import { deleteIntegration } from '../integration'; import { QueryKeys } from '../query.keys'; export const useDeleteIntegration = ( - options: MutationOptions< - {}, - { error: string; message: string; statusCode: number }, - { - id: string; - name: string; - } - > = {} + options: MutationOptions<{}, IResponseError, { id: string; name: string }> = {} ) => { const queryClient = useQueryClient(); - const { mutate: deleteIntegrationMutate, ...rest } = useMutation< - {}, - { error: string; message: string; statusCode: number }, + const { mutate: deleteIntegrationMutate, ...rest } = useMutation<{}, IResponseError, { id: string; name: string }>( + ({ id }) => deleteIntegration(id), { - id: string; - name: string; - } - >(({ id }) => deleteIntegration(id), { - ...options, - onSuccess: async (data, variables, context) => { - options?.onSuccess?.(data, variables, context); + ...options, + onSuccess: async (data, variables, context) => { + options?.onSuccess?.(data, variables, context); - await queryClient.refetchQueries({ - predicate: ({ queryKey }) => - queryKey.includes(QueryKeys.integrationsList) || queryKey.includes(QueryKeys.activeIntegrations), - }); - }, - }); + await queryClient.refetchQueries({ + predicate: ({ queryKey }) => + queryKey.includes(QueryKeys.integrationsList) || queryKey.includes(QueryKeys.activeIntegrations), + }); + }, + } + ); return { deleteIntegration: deleteIntegrationMutate, diff --git a/apps/web/src/api/hooks/useGetLocalesFromContent.ts b/apps/web/src/api/hooks/useGetLocalesFromContent.ts new file mode 100644 index 00000000000..40498f01526 --- /dev/null +++ b/apps/web/src/api/hooks/useGetLocalesFromContent.ts @@ -0,0 +1,54 @@ +import { errorMessage } from '@novu/design-system'; +import type { IResponseError, IEmailBlock } from '@novu/shared'; +import { IS_DOCKER_HOSTED } from '@novu/shared-web'; +import { useMutation } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import { getLocalesFromContent } from '../translations'; + +export interface ILocale { + name: string; + officialName: string | null; + numeric: string; + alpha2: string; + alpha3: string; + currencyName: string | null; + currencyAlphabeticCode: string | null; + langName: string; + langIso: string; +} + +type Payload = { + content?: string | IEmailBlock[]; +}; + +export const useGetLocalesFromContent = () => { + const { + mutateAsync: getLocalesFromContentMutation, + isLoading, + data, + } = useMutation(({ content }) => getLocalesFromContent({ content }), { + onError: (e) => { + errorMessage(e.message || 'Unexpected error'); + }, + }); + + const getLocalesFromContentCallback = useCallback( + async ({ content }: Payload) => { + if (IS_DOCKER_HOSTED) { + return; + } + + await getLocalesFromContentMutation({ + content, + }); + }, + [getLocalesFromContentMutation] + ); + + return { + getLocalesFromContent: getLocalesFromContentCallback, + isLoading, + data: data || [], + }; +}; diff --git a/apps/web/src/api/hooks/useMakePrimaryIntegration.ts b/apps/web/src/api/hooks/useMakePrimaryIntegration.ts index 6f3eb0148fb..8d3c31b072c 100644 --- a/apps/web/src/api/hooks/useMakePrimaryIntegration.ts +++ b/apps/web/src/api/hooks/useMakePrimaryIntegration.ts @@ -1,4 +1,5 @@ import { useMutation, useQueryClient, UseMutationOptions } from '@tanstack/react-query'; +import type { IResponseError } from '@novu/shared'; import { errorMessage } from '../../utils/notifications'; import type { IntegrationEntity } from '../../pages/integrations/types'; @@ -7,37 +8,28 @@ import { QueryKeys } from '../query.keys'; import { setIntegrationAsPrimary } from '../integration'; export const useMakePrimaryIntegration = ( - options: UseMutationOptions< - IntegrationEntity, - { error: string; message: string; statusCode: number }, - { - id: string; - } - > = {} + options: UseMutationOptions = {} ) => { const queryClient = useQueryClient(); - const { mutate: makePrimaryIntegration, ...rest } = useMutation< - IntegrationEntity, - { error: string; message: string; statusCode: number }, + const { mutate: makePrimaryIntegration, ...rest } = useMutation( + ({ id }) => setIntegrationAsPrimary(id), { - id: string; + ...options, + onSuccess: (integration, variables, context) => { + successMessage(`${integration.name} provider instance is activated and marked as the primary instance`); + queryClient.refetchQueries({ + predicate: ({ queryKey }) => + queryKey.includes(QueryKeys.integrationsList) || queryKey.includes(QueryKeys.activeIntegrations), + }); + options?.onSuccess?.(integration, variables, context); + }, + onError: (e: any, variables, context) => { + errorMessage(e.message || 'Unexpected error'); + options?.onError?.(e, variables, context); + }, } - >(({ id }) => setIntegrationAsPrimary(id), { - ...options, - onSuccess: (integration, variables, context) => { - successMessage(`${integration.name} provider instance is activated and marked as the primary instance`); - queryClient.refetchQueries({ - predicate: ({ queryKey }) => - queryKey.includes(QueryKeys.integrationsList) || queryKey.includes(QueryKeys.activeIntegrations), - }); - options?.onSuccess?.(integration, variables, context); - }, - onError: (e: any, variables, context) => { - errorMessage(e.message || 'Unexpected error'); - options?.onError?.(e, variables, context); - }, - }); + ); return { makePrimaryIntegration, diff --git a/apps/web/src/api/hooks/usePreviewChat.ts b/apps/web/src/api/hooks/usePreviewChat.ts new file mode 100644 index 00000000000..1365d90d0f1 --- /dev/null +++ b/apps/web/src/api/hooks/usePreviewChat.ts @@ -0,0 +1,45 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { errorMessage } from '@novu/design-system'; +import type { IResponseError } from '@novu/shared'; +import { IS_DOCKER_HOSTED } from '@novu/shared-web'; + +import { previewChat } from '../content-templates'; + +type PayloadType = { + content?: string; + payload: string; + locale?: string; +}; + +type ResultType = { content: string }; + +export const usePreviewChat = (options: UseMutationOptions = {}) => { + const { mutateAsync, isLoading } = useMutation( + ({ content, payload, locale }) => previewChat({ content, payload, locale }), + { + onError: (e) => { + errorMessage(e.message || 'Unexpected error'); + }, + onSuccess: (result, variables, context) => { + options?.onSuccess?.(result, variables, context); + }, + } + ); + + const getChatPreview = useCallback( + async ({ content, payload, locale }: PayloadType) => { + await mutateAsync({ + content, + payload, + locale, + }); + }, + [mutateAsync] + ); + + return { + getChatPreview, + isLoading, + }; +}; diff --git a/apps/web/src/api/hooks/usePreviewEmail.ts b/apps/web/src/api/hooks/usePreviewEmail.ts new file mode 100644 index 00000000000..f0d6798663d --- /dev/null +++ b/apps/web/src/api/hooks/usePreviewEmail.ts @@ -0,0 +1,52 @@ +import { errorMessage } from '@novu/design-system'; +import type { IResponseError, IEmailBlock, MessageTemplateContentType } from '@novu/shared'; +import { IS_DOCKER_HOSTED } from '@novu/shared-web'; +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { previewEmail } from '../content-templates'; + +export type PayloadType = { + content?: string | IEmailBlock[]; + contentType?: MessageTemplateContentType; + payload: string; + subject?: string; + layoutId?: string; + locale?: string; +}; + +export type ResultType = { html: string; subject: string }; + +export const usePreviewEmail = (options: UseMutationOptions = {}) => { + const { mutateAsync, isLoading } = useMutation( + ({ content, payload, contentType, layoutId, locale, subject }) => + previewEmail({ content, payload, contentType, layoutId, locale, subject }), + + { + onError: (e: any) => { + errorMessage(e.message || 'Unexpected error'); + }, + onSuccess: (result, variables, context) => { + options?.onSuccess?.(result, variables, context); + }, + } + ); + + const getEmailPreviewCallback = useCallback( + async ({ content, payload, contentType, layoutId, locale, subject }: PayloadType) => { + await mutateAsync({ + content, + payload, + contentType, + layoutId, + locale, + subject, + }); + }, + [mutateAsync] + ); + + return { + getEmailPreview: getEmailPreviewCallback, + isLoading, + }; +}; diff --git a/apps/web/src/api/hooks/usePreviewInApp.tsx b/apps/web/src/api/hooks/usePreviewInApp.tsx new file mode 100644 index 00000000000..027ab383175 --- /dev/null +++ b/apps/web/src/api/hooks/usePreviewInApp.tsx @@ -0,0 +1,48 @@ +import { errorMessage } from '@novu/design-system'; +import { IMessageButton, IMessageCTA } from '@novu/shared'; +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { previewInApp } from '../content-templates'; + +type PayloadType = { + content?: string; + cta?: IMessageCTA; + payload: string; + locale?: string; +}; + +type ResultType = { content: string; ctaButtons: IMessageButton[] }; + +type ErrorType = { error: string; message: string; statusCode: number }; + +export const usePreviewInApp = (options: UseMutationOptions = {}) => { + const { mutateAsync, isLoading } = useMutation( + ({ content, payload, locale, cta }) => previewInApp({ content, payload, locale, cta }), + + { + onError: (e: any) => { + errorMessage(e.message || 'Unexpected error'); + }, + onSuccess: (result, variables, context) => { + options?.onSuccess?.(result, variables, context); + }, + } + ); + + const getInAppPreviewCallback = useCallback( + async ({ content, payload, locale, cta }: PayloadType) => { + await mutateAsync({ + content, + payload, + locale, + cta, + }); + }, + [mutateAsync] + ); + + return { + getInAppPreview: getInAppPreviewCallback, + isLoading, + }; +}; diff --git a/apps/web/src/api/hooks/usePreviewPush.tsx b/apps/web/src/api/hooks/usePreviewPush.tsx new file mode 100644 index 00000000000..5ea5c047834 --- /dev/null +++ b/apps/web/src/api/hooks/usePreviewPush.tsx @@ -0,0 +1,48 @@ +import { errorMessage } from '@novu/design-system'; +import { IEmailBlock } from '@novu/shared'; +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { previewPush } from '../content-templates'; + +type PayloadType = { + content?: string | IEmailBlock[]; + payload: string; + title?: string; + locale?: string; +}; + +type ResultType = { content: string; title: string }; + +type ErrorType = { error: string; message: string; statusCode: number }; + +export const usePreviewPush = (options: UseMutationOptions = {}) => { + const { mutateAsync, isLoading } = useMutation( + ({ content, payload, locale, title }) => previewPush({ content, payload, locale, title }), + + { + onError: (e: any) => { + errorMessage(e.message || 'Unexpected error'); + }, + onSuccess: (result, variables, context) => { + options?.onSuccess?.(result, variables, context); + }, + } + ); + + const getPushPreviewCallback = useCallback( + async ({ content, payload, locale, title }: PayloadType) => { + await mutateAsync({ + content, + payload: JSON.parse(payload), + locale, + title, + }); + }, + [mutateAsync] + ); + + return { + getPushPreview: getPushPreviewCallback, + isLoading, + }; +}; diff --git a/apps/web/src/api/hooks/usePreviewSms.ts b/apps/web/src/api/hooks/usePreviewSms.ts new file mode 100644 index 00000000000..ea0756b08c8 --- /dev/null +++ b/apps/web/src/api/hooks/usePreviewSms.ts @@ -0,0 +1,45 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { errorMessage } from '@novu/design-system'; +import type { IEmailBlock, IResponseError } from '@novu/shared'; +import { IS_DOCKER_HOSTED } from '@novu/shared-web'; + +import { previewSms } from '../content-templates'; + +type PayloadType = { + content?: string | IEmailBlock[]; + payload: string; + locale?: string; +}; + +type ResultType = { content: string }; + +export const usePreviewSms = (options: UseMutationOptions = {}) => { + const { mutateAsync, isLoading } = useMutation( + ({ content, payload, locale }) => previewSms({ content, payload, locale }), + { + onError: (e) => { + errorMessage(e.message || 'Unexpected error'); + }, + onSuccess: (result, variables, context) => { + options?.onSuccess?.(result, variables, context); + }, + } + ); + + const getSmsPreview = useCallback( + async ({ content, payload, locale }: PayloadType) => { + await mutateAsync({ + content, + payload: JSON.parse(payload), + locale, + }); + }, + [mutateAsync] + ); + + return { + getSmsPreview, + isLoading, + }; +}; diff --git a/apps/web/src/api/hooks/useUpdateIntegration.ts b/apps/web/src/api/hooks/useUpdateIntegration.ts index 2f58172f371..e391604bc63 100644 --- a/apps/web/src/api/hooks/useUpdateIntegration.ts +++ b/apps/web/src/api/hooks/useUpdateIntegration.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { IUpdateIntegrationBodyDto } from '@novu/shared'; +import type { IResponseError, IUpdateIntegrationBodyDto } from '@novu/shared'; import { errorMessage } from '../../utils/notifications'; import { updateIntegration } from '../integration'; @@ -12,7 +12,7 @@ export const useUpdateIntegration = (integrationId: string) => { const { mutateAsync: updateIntegrationMutation, isLoading: isLoadingUpdate } = useMutation< IntegrationEntity, - { error: string; message: string; statusCode: number }, + IResponseError, { id: string; data: IUpdateIntegrationBodyDto; diff --git a/apps/web/src/api/translations.ts b/apps/web/src/api/translations.ts new file mode 100644 index 00000000000..ba7473442a6 --- /dev/null +++ b/apps/web/src/api/translations.ts @@ -0,0 +1,5 @@ +import { api } from './api.client'; + +export async function getLocalesFromContent({ content }) { + return api.post('/v1/translations/groups/preview/locales', { content }); +} diff --git a/apps/web/src/components/layout/components/OrganizationSelect.tsx b/apps/web/src/components/layout/components/OrganizationSelect.tsx index 125c77a28c9..a717f527d7d 100644 --- a/apps/web/src/components/layout/components/OrganizationSelect.tsx +++ b/apps/web/src/components/layout/components/OrganizationSelect.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as capitalize from 'lodash.capitalize'; import styled from '@emotion/styled'; -import { IOrganizationEntity } from '@novu/shared'; +import type { IResponseError, IOrganizationEntity } from '@novu/shared'; import { Select } from '@novu/design-system'; import { addOrganization, switchOrganization } from '../../../api/organization'; @@ -20,15 +20,13 @@ export default function OrganizationSelect() { const { isLoading: loadingAddOrganization, mutateAsync: createOrganization } = useMutation< IOrganizationEntity, - { error: string; message: string; statusCode: number }, + IResponseError, string >((name) => addOrganization(name)); - const { mutateAsync: changeOrganization } = useMutation< - string, - { error: string; message: string; statusCode: number }, - string - >((id) => switchOrganization(id)); + const { mutateAsync: changeOrganization } = useMutation((id) => + switchOrganization(id) + ); const switchOrgCallback = useCallback( async (organizationId: string | string[] | null) => { diff --git a/apps/web/src/components/layout/components/SideNav.tsx b/apps/web/src/components/layout/components/SideNav.tsx index 42a233c4b19..94ed93ad6a4 100644 --- a/apps/web/src/components/layout/components/SideNav.tsx +++ b/apps/web/src/components/layout/components/SideNav.tsx @@ -30,7 +30,7 @@ import { currentOnboardingStep } from '../../../pages/quick-start/components/rou import { useSpotlightContext } from '../../providers/SpotlightProvider'; import { ChangesCountBadge } from './ChangesCountBadge'; import OrganizationSelect from './OrganizationSelect'; -import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { FeatureFlagsKeysEnum, UTM_CAMPAIGN_QUERY_PARAM } from '@novu/shared'; const usePopoverStyles = createStyles(({ colorScheme }) => ({ dropdown: { @@ -218,7 +218,7 @@ export function SideNav({}: Props) { Docs diff --git a/apps/web/src/components/quick-start/digest-demo-flow/consts.ts b/apps/web/src/components/quick-start/digest-demo-flow/consts.ts index 623c5854117..04671bbdb4b 100644 --- a/apps/web/src/components/quick-start/digest-demo-flow/consts.ts +++ b/apps/web/src/components/quick-start/digest-demo-flow/consts.ts @@ -1,3 +1,5 @@ +import { UTM_CAMPAIGN_QUERY_PARAM } from '@novu/shared'; + export enum GuideTitleEnum { TRIGGER_PREVIEW = 'Trigger', TRIGGER_PLAYGROUND = 'Run trigger multiple times', @@ -26,7 +28,7 @@ export const guidePreview: Record = { trigger: { title: GuideTitleEnum.TRIGGER_PREVIEW, description: 'Use the server SDK in your app for a specific trigger. ', - docsUrl: 'https://docs.novu.co/api-reference/events/trigger-event', + docsUrl: `https://docs.novu.co/api-reference/events/trigger-event${UTM_CAMPAIGN_QUERY_PARAM}`, sequence: { 1: { open: false, opacity: HINT_HIDDEN_OPACITY }, 2: { open: true, opacity: HINT_VISIBLE_OPACITY }, @@ -38,7 +40,7 @@ export const guidePreview: Record = { digest: { title: GuideTitleEnum.DIGEST_PREVIEW, description: 'Aggregates multiple events into a precise notification. ', - docsUrl: 'https://docs.novu.co/workflows/digest', + docsUrl: `https://docs.novu.co/workflows/digest${UTM_CAMPAIGN_QUERY_PARAM}`, sequence: { 1: { open: false, opacity: HINT_HIDDEN_OPACITY }, 2: { open: false, opacity: HINT_HIDDEN_OPACITY }, @@ -50,7 +52,7 @@ export const guidePreview: Record = { email: { title: GuideTitleEnum.CHANNELS_PREVIEW, description: 'Build desired order of channels. ', - docsUrl: 'https://docs.novu.co/channels-and-providers/integration-store', + docsUrl: `https://docs.novu.co/channels-and-providers/integration-store${UTM_CAMPAIGN_QUERY_PARAM}`, sequence: { 1: { open: false, opacity: HINT_HIDDEN_OPACITY }, 2: { open: false, opacity: HINT_HIDDEN_OPACITY }, diff --git a/apps/web/src/components/quick-start/in-app-onboarding/TriggerNode.tsx b/apps/web/src/components/quick-start/in-app-onboarding/TriggerNode.tsx index 35c10e0d5c2..557bfe20b75 100644 --- a/apps/web/src/components/quick-start/in-app-onboarding/TriggerNode.tsx +++ b/apps/web/src/components/quick-start/in-app-onboarding/TriggerNode.tsx @@ -1,13 +1,13 @@ import { Handle, Position } from 'react-flow-renderer'; - import { Button, colors, shadows, Text, Title, BoltOutlinedGradient, Playground } from '@novu/design-system'; - import styled from '@emotion/styled'; import { createStyles, Group, Popover, Stack, useMantineColorScheme } from '@mantine/core'; -import { ActorTypeEnum, INotificationTemplate, StepTypeEnum, SystemAvatarIconEnum } from '@novu/shared'; +import { ActorTypeEnum, StepTypeEnum, SystemAvatarIconEnum } from '@novu/shared'; +import type { IResponseError, INotificationTemplate } from '@novu/shared'; import { useMutation } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; + import { createTemplate, testTrigger } from '../../../api/notification-templates'; import { useEffectOnce, useNotificationGroup, useTemplates } from '../../../hooks'; import { @@ -19,6 +19,7 @@ import { NodeStep } from '../../workflow'; import { useSegment } from '../../providers/SegmentProvider'; import { errorMessage } from '../../../utils/notifications'; import { TemplateCreationSourceEnum } from '../../../pages/templates/shared'; +import { FIRST_100_WORKFLOWS } from '../../../constants/workflowConstants'; const useStyles = createStyles((theme) => ({ dropdown: { @@ -57,7 +58,7 @@ export function TriggerNode({ data }: { data: { label: string; email?: string } function TriggerButton({ setOpened }: { setOpened: (value: boolean) => void }) { const [notificationNumber, setNotificationNumber] = useState(1); - const { templates = [], loading: templatesLoading } = useTemplates(); + const { templates = [], loading: templatesLoading } = useTemplates(FIRST_100_WORKFLOWS); const segment = useSegment(); @@ -65,7 +66,7 @@ function TriggerButton({ setOpened }: { setOpened: (value: boolean) => void }) { const { mutate: createNotificationTemplate, isLoading: createTemplateLoading } = useMutation< INotificationTemplate & { __source?: string }, - { error: string; message: string; statusCode: number }, + IResponseError, { template: ICreateNotificationTemplateDto; params: { __source?: string } } >((data) => createTemplate(data.template, data.params), { onError: (error) => { diff --git a/apps/web/src/components/utils/Spotlight.tsx b/apps/web/src/components/utils/Spotlight.tsx index a51b6934978..75c1fac6a53 100644 --- a/apps/web/src/components/utils/Spotlight.tsx +++ b/apps/web/src/components/utils/Spotlight.tsx @@ -2,8 +2,10 @@ import { SpotlightProvider } from '@mantine/spotlight'; import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Activity, Bolt, Box, Settings, Repeat, Team, Brand, Chat } from '@novu/design-system'; -import { useSpotlightContext } from '../providers/SpotlightProvider'; +import { UTM_CAMPAIGN_QUERY_PARAM } from '@novu/shared'; + import { ROUTES } from '../../constants/routes.enum'; +import { useSpotlightContext } from '../providers/SpotlightProvider'; export const SpotLight = ({ children }) => { const navigate = useNavigate(); @@ -57,7 +59,7 @@ export const SpotLight = ({ children }) => { id: 'navigate-docs', title: 'Go to Documentation', onTrigger: () => { - window?.open('https://docs.novu.co/', '_blank')?.focus(); + window?.open(`https://docs.novu.co${UTM_CAMPAIGN_QUERY_PARAM}`, '_blank')?.focus(); }, }, { diff --git a/apps/web/src/components/workflow/FlowEditor.tsx b/apps/web/src/components/workflow/FlowEditor.tsx index a940a895102..38a4fc74883 100644 --- a/apps/web/src/components/workflow/FlowEditor.tsx +++ b/apps/web/src/components/workflow/FlowEditor.tsx @@ -1,12 +1,4 @@ -import { - ComponentType, - MouseEvent, - MouseEvent as ReactMouseEvent, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; +import { ComponentType, MouseEvent, MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef } from 'react'; import ReactFlow, { addEdge, Background, @@ -63,6 +55,7 @@ export interface IFlowEditorProps extends ReactFlowProps { onStepInit?: (step: IFlowStep) => Promise; onGetStepError?: (i: number, errors: any) => string; addStep?: (channelType: StepTypeEnum, id: string, index?: number) => void; + sidebarOpen?: boolean; } export function FlowEditor({ @@ -90,6 +83,7 @@ export function FlowEditor({ onEdit, onDelete, onAddVariant, + sidebarOpen, ...restProps }: IFlowEditorProps) { const { colorScheme } = useMantineColorScheme(); @@ -101,11 +95,12 @@ export function FlowEditor({ useEffect(() => { const clientWidth = reactFlowWrapper.current?.clientWidth; - const middle = clientWidth ? clientWidth / 2 - 100 : 0; + const sub = sidebarOpen ? 300 : 100; + const middle = clientWidth ? clientWidth / 2 - sub : 0; const zoomView = 1; reactFlowInstance.setViewport({ x: middle, y: 10, zoom: zoomView }); - }, [reactFlowInstance]); + }, [reactFlowInstance, sidebarOpen]); useEffect(() => { setTimeout(() => { diff --git a/apps/web/src/components/workflow/preview/chat/ChatContent.tsx b/apps/web/src/components/workflow/preview/chat/ChatContent.tsx new file mode 100644 index 00000000000..1b566aaa6d4 --- /dev/null +++ b/apps/web/src/components/workflow/preview/chat/ChatContent.tsx @@ -0,0 +1,81 @@ +import styled from '@emotion/styled'; +import { Group, Skeleton, Stack, useMantineColorScheme } from '@mantine/core'; +import { colors, Text } from '@novu/design-system'; + +import { NovuGreyIcon, PreviewEditOverlay } from '../common'; +import { When } from '../../../utils/When'; +import { useHover } from '../../../../hooks'; + +export function ChatContent({ isLoading, content, errorMsg, showOverlay = true }) { + const { isHovered, onMouseEnter, onMouseLeave } = useHover(); + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + return ( + + {isHovered && showOverlay && } + + + + + + + + + +
+ +
+ + + + Your App + + APP + now + + {errorMsg ? ( + {errorMsg} + ) : ( + {content} + )} + +
+
+
+ ); +} + +const PillStyled = styled.div<{ isDark: boolean }>` + background-color: ${({ isDark }) => (isDark ? colors.B40 : colors.BGLight)}; + border-radius: 0.25rem; + padding: 0px 6px; + align-items: center; + color: ${({ isDark }) => (isDark ? colors.B80 : colors.B40)}; + font-size: 10px; + font-weight: 400; + line-height: 1.25rem; +`; + +const ContentAndOVerlayWrapperStyled = styled.div` + position: relative; + padding-top: 1.5rem; + padding-bottom: 1.25rem; + border-radius: 0.5rem; + overflow: hidden; +`; diff --git a/apps/web/src/components/workflow/preview/chat/ChatInput.tsx b/apps/web/src/components/workflow/preview/chat/ChatInput.tsx new file mode 100644 index 00000000000..c07679926e8 --- /dev/null +++ b/apps/web/src/components/workflow/preview/chat/ChatInput.tsx @@ -0,0 +1,30 @@ +import styled from '@emotion/styled'; +import { Group } from '@mantine/core'; +import { colors, Text } from '@novu/design-system'; +import { EmojiIcon, SendIcon } from '../common'; + +export function ChatInput() { + return ( + + Message Bot + + + + + + ); +} + +const InputStyled = styled.div` + display: flex; + padding: 16px; + justify-content: space-between; + align-items: center; + align-self: stretch; + + border-radius: 8px; + border: 1px solid ${colors.B40}; + opacity: 0.2; + background: transparent; + margin-top: 20px; +`; diff --git a/apps/web/src/components/workflow/preview/chat/ChatPreview.tsx b/apps/web/src/components/workflow/preview/chat/ChatPreview.tsx new file mode 100644 index 00000000000..54bf3c1abcb --- /dev/null +++ b/apps/web/src/components/workflow/preview/chat/ChatPreview.tsx @@ -0,0 +1,68 @@ +import styled from '@emotion/styled'; +import { Divider, Flex, useMantineColorScheme } from '@mantine/core'; +import { colors, Text } from '@novu/design-system'; +import { useFormContext } from 'react-hook-form'; +import { useLocation } from 'react-router-dom'; + +import { IForm } from '../../../../pages/templates/components/formTypes'; +import { useStepFormPath } from '../../../../pages/templates/hooks/useStepFormPath'; +import { LocaleSelect } from '../common'; +import { ChatContent } from './ChatContent'; +import { ChatInput } from './ChatInput'; +import { useTemplateLocales } from '../../../../pages/templates/hooks/useTemplateLocales'; +import { usePreviewChatTemplate } from '../../../../pages/templates/hooks/usePreviewChatTemplate'; + +const ChatPreviewContainer = styled.div` + width: 100%; + max-width: 37.5em; +`; + +export function ChatPreview({ showLoading = false }: { showLoading?: boolean }) { + const { colorScheme } = useMantineColorScheme(); + const isDark = colorScheme === 'dark'; + + const { watch } = useFormContext(); + const path = useStepFormPath(); + const content = watch(`${path}.template.content`); + const { pathname } = useLocation(); + const isPreviewPath = pathname.endsWith('/preview'); + + const { selectedLocale, locales, areLocalesLoading, onLocaleChange } = useTemplateLocales({ + content: content as string, + disabled: showLoading, + }); + + const { isPreviewContentLoading, previewContent, templateError } = usePreviewChatTemplate({ + locale: selectedLocale, + disabled: showLoading, + }); + + return ( + + + + + + Today + + } + labelPosition="center" + /> + + + + ); +} diff --git a/apps/web/src/components/workflow/preview/chat/index.ts b/apps/web/src/components/workflow/preview/chat/index.ts new file mode 100644 index 00000000000..4fdde8ad750 --- /dev/null +++ b/apps/web/src/components/workflow/preview/chat/index.ts @@ -0,0 +1 @@ +export * from './ChatPreview'; diff --git a/apps/web/src/pages/templates/editor/Mobile.tsx b/apps/web/src/components/workflow/preview/common/EmailMobile.tsx similarity index 94% rename from apps/web/src/pages/templates/editor/Mobile.tsx rename to apps/web/src/components/workflow/preview/common/EmailMobile.tsx index b71049a5b36..719c3e20c09 100644 --- a/apps/web/src/pages/templates/editor/Mobile.tsx +++ b/apps/web/src/components/workflow/preview/common/EmailMobile.tsx @@ -23,7 +23,7 @@ const useStyles = createStyles((theme) => ({ }, })); -export const Mobile = ({ children }) => { +export const EmailMobile = ({ children }) => { const { classes } = useStyles(); return ( diff --git a/apps/web/src/components/workflow/preview/common/EmojiIcon.tsx b/apps/web/src/components/workflow/preview/common/EmojiIcon.tsx new file mode 100644 index 00000000000..023dceef392 --- /dev/null +++ b/apps/web/src/components/workflow/preview/common/EmojiIcon.tsx @@ -0,0 +1,24 @@ +/* eslint-disable max-len */ +export const EmojiIcon = (props) => { + return ( + + + + + + + + + ); +}; diff --git a/apps/web/src/components/workflow/preview/common/LocaleSelect.tsx b/apps/web/src/components/workflow/preview/common/LocaleSelect.tsx new file mode 100644 index 00000000000..685fcd80d8b --- /dev/null +++ b/apps/web/src/components/workflow/preview/common/LocaleSelect.tsx @@ -0,0 +1,85 @@ +import styled from '@emotion/styled'; +import { SelectItemProps, Group } from '@mantine/core'; +import { Select, ISelectProps, Text } from '@novu/design-system'; +import { forwardRef } from 'react'; +import { IS_DOCKER_HOSTED } from '../../../../config'; + +const rightSectionWidth = 20; + +export function LocaleSelect({ + locales, + value, + isLoading, + onLocaleChange, + className, + dropdownPosition, +}: { + locales: { langName: string; langIso: string }[]; + value?: string; + isLoading?: boolean; + onLocaleChange: (val: string) => void; + className?: string; + dropdownPosition?: ISelectProps['dropdownPosition']; +}) { + // Do not render locale select if self-hosted or no locale or only one locale + if (IS_DOCKER_HOSTED || locales.length < 2) { + return null; + } + + return ( + +