diff --git a/.cspell.json b/.cspell.json index ebc70cecf14..8936bd9940c 100644 --- a/.cspell.json +++ b/.cspell.json @@ -435,7 +435,9 @@ "Millis", "sscan", "vishnudxb", - "autopipelining" + "autopipelining", + "maxtimeout", + "clsx" ], "flagWords": [], "patterns": [ @@ -502,6 +504,7 @@ "pnpm-lock.yaml", "pnpm-workspace.yaml", "novu.code-workspace", + "packages/application-generic/src/.env.test", "packages/notification-center/src/i18n/languages/**", "apps/widget/public/iframeResizer.contentWindow.js", ".eslintrc.js", diff --git a/.eslintrc.js b/.eslintrc.js index 028075a3759..6082c6c1c17 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,7 +7,6 @@ module.exports = { 'prettier', 'plugin:prettier/recommended', 'plugin:promise/recommended', - 'plugin:@cspell/recommended', ], ignorePatterns: ['.eslintrc.js', '*.json', 'jest.config.js'], plugins: ['import', 'promise', '@typescript-eslint', 'prettier'], diff --git a/.github/workflows/reusable-web-e2e.yml b/.github/workflows/reusable-web-e2e.yml index 67d86a9421f..71d46a17415 100644 --- a/.github/workflows/reusable-web-e2e.yml +++ b/.github/workflows/reusable-web-e2e.yml @@ -91,6 +91,11 @@ jobs: if: steps.setup-project.outputs.cypress_cache_hit != 'true' working-directory: apps/web run: pnpm cypress install + + + - uses: browser-actions/setup-chrome@latest + - run: | + echo "BROWSER_PATH=$(which chrome)" >> $GITHUB_ENV - name: Cypress run e2e uses: cypress-io/github-action@v4 @@ -103,7 +108,7 @@ jobs: CYPRESS_IS_CI: true with: working-directory: apps/web - browser: chrome + browser: "${{ env.BROWSER_PATH }}" record: true parallel: true install: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000000..477e99cccdc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,77 @@ +name: Testing + +on: + # Execute it on pushing to next branches + push: + branches: + - next + - main + # Execute it on opening any pull request + pull_request: +jobs: + # Get branch info + branch-info: + runs-on: ubuntu-latest + steps: + # Get current branch name + - name: Get branch name + id: branch-name + uses: tj-actions/branch-names@v5.2 + # Get base branch name to compare with. Base branch on a PR, "main" branch on pushing. + - name: Get base branch name + id: get-base-branch-name + run: | + if [[ "${{github.event.pull_request.base.ref}}" != "" ]]; then + echo "::set-output name=branch::${{github.event.pull_request.base.ref}}" + else + echo "::set-output name=branch::main" + fi + outputs: + # Export the branch names as output to be able to use it in other jobs + base-branch-name: ${{ steps.get-base-branch-name.outputs.branch }} + branch-name: ${{ steps.branch-name.outputs.current_branch }} + get-affected: + needs: [ branch-info ] + name: Nx Affected + runs-on: ubuntu-latest + outputs: + test-unit: ${{ steps.get-projects-arrays.outputs.test-unit }} + test-e2e: ${{ steps.get-projects-arrays.outputs.test-e2e }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup-project + + # Configure Nx to be able to detect changes between branches when we are in a PR + - name: Derive appropriate SHAs for base and head for `nx affected` commands + uses: nrwl/nx-set-shas@v2 + with: + main-branch-name: ${{needs.branch-info.outputs.base-branch-name}} + + - name: Get affected + id: get-projects-arrays + # When not in a PR and the current branch is main, pass --all flag. Otherwise pass the base branch + run: | + if [[ "${{github.event.pull_request.base.ref}}" == "" && "${{needs.branch-info.outputs.branch-name}}" == "main" ]]; then + echo "Running ALL" + echo "::set-output name=test-unit::$(node scripts/print-affected-array.js test:unit --all)" + echo "::set-output name=test-e2e::$(node scripts/print-affected-array.js test:e2e --all)" + else + echo "Running PR origin/${{needs.branch-info.outputs.base-branch-name}}" + echo "::set-output name=test-unit::$(node scripts/print-affected-array.js test:unit origin/${{needs.branch-info.outputs.base-branch-name}})" + echo "::set-output name=test-e2e::$(node scripts/print-affected-array.js test:e2e origin/${{needs.branch-info.outputs.base-branch-name}})" + fi + test: + runs-on: ubuntu-latest + needs: [get-affected] + if: ${{ fromJson(needs.get-affected.outputs.test-e2e)[0] }} + strategy: + # Run in parallel + max-parallel: 4 + # One job for each different project and node version + matrix: + projectName: ${{ fromJson(needs.get-affected.outputs.test-e2e) }} + steps: + - run: echo ${{ fromJson(needs.get-affected.outputs.test-e2e) }} + diff --git a/.npmrc b/.npmrc index 4c2f52b3be7..8b914cfbbc3 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ auto-install-peers=true strict-peer-dependencies=false +fetch-retry-maxtimeout=30000 diff --git a/README.md b/README.md index 7298aa9eb03..39a84511092 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,10 @@ Novu provides a single API to manage providers across multiple channels with a s - [x] [Mandrill](https://github.com/novuhq/novu/tree/main/providers/mandrill) - [x] [SendinBlue](https://github.com/novuhq/novu/tree/main/providers/sendinblue) - [x] [MailerSend](https://github.com/novuhq/novu/tree/main/providers/mailersend) +- [x] [Infobip](https://github.com/novuhq/novu/tree/main/providers/infobip) - [x] [Resend](https://github.com/novuhq/novu/tree/main/providers/resend) -- [ ] SparkPost +- [x] [SparkPost](https://github.com/novuhq/novu/tree/main/providers/sparkpost) +- [x] [Outlook 365](https://github.com/novuhq/novu/tree/main/providers/outlook365) #### 📞 SMS @@ -156,6 +158,14 @@ Novu provides a single API to manage providers across multiple channels with a s - [x] [Telnyx](https://github.com/novuhq/novu/tree/main/providers/telnyx) - [x] [Termii](https://github.com/novuhq/novu/tree/main/providers/termii) - [x] [Gupshup](https://github.com/novuhq/novu/tree/main/providers/gupshup) +- [x] [SMS Central](https://github.com/novuhq/novu/tree/main/providers/sms-central) +- [x] [Maqsam](https://github.com/novuhq/novu/tree/main/providers/maqsam) +- [x] [46elks](https://github.com/novuhq/novu/tree/main/providers/forty-six-elks) +- [x] [Clickatell](https://github.com/novuhq/novu/tree/main/providers/clickatell) +- [x] [Burst SMS](https://github.com/novuhq/novu/tree/main/providers/burst-sms) +- [x] [Firetext](https://github.com/novuhq/novu/tree/main/providers/firetext) +- [x] [Infobip](https://github.com/novuhq/novu/tree/main/providers/infobip) +- [x] [SNS](https://github.com/novuhq/novu/tree/main/providers/sns) - [ ] Bandwidth - [ ] RingCentral @@ -163,7 +173,8 @@ Novu provides a single API to manage providers across multiple channels with a s - [x] [FCM](https://github.com/novuhq/novu/tree/main/providers/fcm) - [x] [Expo](https://github.com/novuhq/novu/tree/main/providers/expo) -- [x] [SNS](https://github.com/novuhq/novu/tree/main/providers/sns) +- [x] [APNS](https://github.com/novuhq/novu/tree/main/providers/apns) +- [ ] OneSignal - [ ] Pushwoosh #### 👇 Chat diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js index a57fabcc485..04324a0ca4c 100644 --- a/apps/api/.eslintrc.js +++ b/apps/api/.eslintrc.js @@ -3,5 +3,11 @@ module.exports = { rules: { 'func-names': 'off', }, + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2020, + sourceType: 'module', + tsconfigRootDir: __dirname, + }, ignorePatterns: '*.spec.ts', }; diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index d5b9a87d728..c48e5c6e582 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -48,6 +48,7 @@ RUN rm -rf node_modules && pnpm recursive exec -- rm -rf ./src ./node_modules # ------- PRODUCTION BUILD ---------- FROM dev_base AS prod + ARG PACKAGE_PATH ENV CI=true diff --git a/apps/api/package.json b/apps/api/package.json index 19817a821cc..50da9a6a4b6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,7 +30,7 @@ "@nestjs/axios": "~2.0.0", "@nestjs/common": "9.3.12", "@nestjs/core": "9.3.12", - "@nestjs/graphql": "10.2.0", + "@nestjs/graphql": "10.2.1", "@nestjs/jwt": "9.0.0", "@nestjs/passport": "9.0.3", "@nestjs/platform-express": "9.3.12", @@ -63,7 +63,7 @@ "handlebars": "^4.7.7", "hat": "^0.0.3", "helmet": "^6.0.1", - "ioredis": "5.3.1", + "ioredis": "5.3.2", "jsonwebtoken": "9.0.0", "lodash": "^4.17.15", "nanoid": "^3.1.20", @@ -87,7 +87,7 @@ }, "devDependencies": { "@faker-js/faker": "^6.0.0", - "@nestjs/cli": "9.3.0", + "@nestjs/cli": "9.4.0", "@nestjs/schematics": "9.0.4", "@nestjs/testing": "9.3.12", "@types/bcrypt": "^3.0.0", @@ -111,7 +111,7 @@ }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ - "eslint --fix" + "eslint" ] } } diff --git a/apps/api/src/.env.development b/apps/api/src/.env.development index 00d6ae3e8a0..2d4fae6c0e3 100644 --- a/apps/api/src/.env.development +++ b/apps/api/src/.env.development @@ -23,6 +23,7 @@ REDIS_CACHE_CONNECTION_TIMEOUT= REDIS_CACHE_KEEP_ALIVE= REDIS_CACHE_FAMILY= REDIS_CACHE_KEY_PREFIX= +REDIS_CACHE_ENABLE_AUTOPIPELINING=true IN_MEMORY_CLUSTER_MODE_ENABLED=true ELASTICACHE_CLUSTER_SERVICE_HOST= diff --git a/apps/api/src/.env.production b/apps/api/src/.env.production index fd5fe1eaa17..c7d6e88645e 100644 --- a/apps/api/src/.env.production +++ b/apps/api/src/.env.production @@ -13,7 +13,6 @@ REDIS_PORT=6379 REDIS_PREFIX= REDIS_DB_INDEX=2 - IN_MEMORY_CLUSTER_MODE_ENABLED=false ELASTICACHE_CLUSTER_SERVICE_HOST= ELASTICACHE_CLUSTER_SERVICE_PORT= diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test index 7283dd56e07..258720aa5ab 100644 --- a/apps/api/src/.env.test +++ b/apps/api/src/.env.test @@ -21,6 +21,7 @@ REDIS_CACHE_CONNECTION_TIMEOUT= REDIS_CACHE_KEEP_ALIVE= REDIS_CACHE_FAMILY= REDIS_CACHE_KEY_PREFIX= +REDIS_CACHE_ENABLE_AUTOPIPELINING=false IN_MEMORY_CLUSTER_MODE_ENABLED=false ELASTICACHE_CLUSTER_SERVICE_HOST= diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index 84ac3dfe4af..1b7ceb5448c 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -20,6 +20,7 @@ REDIS_CACHE_CONNECTION_TIMEOUT= REDIS_CACHE_KEEP_ALIVE= REDIS_CACHE_FAMILY= REDIS_CACHE_KEY_PREFIX= +REDIS_CACHE_ENABLE_AUTOPIPELINING= IN_MEMORY_CLUSTER_MODE_ENABLED=false REDIS_CLUSTER_SERVICE_HOST= diff --git a/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts b/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts index 14cd27e6a08..2ce5c95c7de 100644 --- a/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts +++ b/apps/api/src/app/change/usecases/promote-notification-template-change/promote-notification-template-change.usecase.ts @@ -63,7 +63,9 @@ export class PromoteNotificationTemplateChange { return step; }; - const steps = newItem.steps ? newItem.steps.map(mapNewStepItem).filter((step) => step !== undefined) : []; + const steps = newItem.steps + ? newItem.steps.map(mapNewStepItem).filter((step): step is NotificationStepEntity => step !== undefined) + : []; if (missingMessages.length > 0 && steps.length > 0 && item) { Logger.error( @@ -108,7 +110,11 @@ export class PromoteNotificationTemplateChange { } if (!item) { - return this.notificationTemplateRepository.create({ + if (newItem.deleted === true) { + return; + } + + const newNotificationTemplate: Partial = { name: newItem.name, active: newItem.active, draft: newItem.draft, @@ -125,7 +131,9 @@ export class PromoteNotificationTemplateChange { _notificationGroupId: notificationGroup._id, isBlueprint: command.organizationId === this.blueprintOrganizationId, blueprintId: newItem.blueprintId, - }); + }; + + return this.notificationTemplateRepository.create(newNotificationTemplate as NotificationTemplateEntity); } const count = await this.notificationTemplateRepository.count({ diff --git a/apps/api/src/app/events/dtos/trigger-event-request.dto.ts b/apps/api/src/app/events/dtos/trigger-event-request.dto.ts index e62ab1e1cdd..3c43235dd2c 100644 --- a/apps/api/src/app/events/dtos/trigger-event-request.dto.ts +++ b/apps/api/src/app/events/dtos/trigger-event-request.dto.ts @@ -1,37 +1,17 @@ -import { - ArrayMaxSize, - ArrayNotEmpty, - IsArray, - IsDefined, - IsObject, - IsOptional, - IsString, - MinLength, -} from 'class-validator'; +import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsDefined, IsObject, IsOptional, IsString } from 'class-validator'; import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { TriggerRecipientSubscriber, TriggerRecipients } from '@novu/node'; -import { TopicId, TopicKey, TriggerRecipientsTypeEnum } from '@novu/shared'; +import { TopicKey, TriggerRecipientsTypeEnum } from '@novu/shared'; +import { CreateSubscriberRequestDto } from '../../subscribers/dtos/create-subscriber-request.dto'; -export class SubscriberPayloadDto { - @ApiProperty() - firstName?: string; - @ApiProperty() - lastName?: string; - @ApiProperty() - email?: string; - @ApiProperty() - phone?: string; - @ApiProperty() - avatar?: string; - @ApiProperty() - locale?: string; -} +export class SubscriberPayloadDto extends CreateSubscriberRequestDto {} export class TopicPayloadDto { @ApiProperty() topicKey: TopicKey; - @ApiProperty() - type: TriggerRecipientsTypeEnum.TOPIC; + + @ApiProperty({ example: 'Topic', enum: TriggerRecipientsTypeEnum }) + type: TriggerRecipientsTypeEnum; } export class BulkTriggerEventDto { @@ -72,7 +52,9 @@ export class TriggerEventRequestDto { description: 'This could be used to override provider specific configurations', example: { fcm: { - color: '#fff', + data: { + key: 'value', + }, }, }, }) @@ -87,22 +69,15 @@ export class TriggerEventRequestDto { $ref: getSchemaPath(SubscriberPayloadDto), }, { - type: '[SubscriberPayloadDto]', - description: 'List of subscriber objects', - }, - { type: 'string', description: 'Unique identifier of a subscriber in your systems' }, - { - type: '[string]', - description: 'List of subscriber identifiers', + type: 'string', + description: 'Unique identifier of a subscriber in your systems', + example: 'SUBSCRIBER_ID', }, { $ref: getSchemaPath(TopicPayloadDto), }, - { - type: '[TopicPayloadDto]', - description: 'List of topics', - }, ], + isArray: true, }) @IsDefined() to: TriggerRecipients; diff --git a/apps/api/src/app/events/dtos/trigger-event-to-all-request.dto.ts b/apps/api/src/app/events/dtos/trigger-event-to-all-request.dto.ts index 5b4d3104ea3..0ffb541b81f 100644 --- a/apps/api/src/app/events/dtos/trigger-event-to-all-request.dto.ts +++ b/apps/api/src/app/events/dtos/trigger-event-to-all-request.dto.ts @@ -30,7 +30,9 @@ export class TriggerEventToAllRequestDto { description: 'This could be used to override provider specific configurations', example: { fcm: { - color: '#fff', + data: { + key: 'value', + }, }, }, }) diff --git a/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts b/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts index e3dff027581..75a8e936576 100644 --- a/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts +++ b/apps/api/src/app/invites/usecases/invite-member/invite-member.usecase.ts @@ -37,7 +37,6 @@ export class InviteMember { if (process.env.NOVU_API_KEY && (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'production')) { const novu = new Novu(process.env.NOVU_API_KEY); - // eslint-disable-next-line @cspell/spellchecker // cspell:disable-next await novu.trigger(process.env.NOVU_TEMPLATEID_INVITE_TO_ORGANISATION || 'invite-to-organization-wBnO8NpDn', { to: { diff --git a/apps/api/src/app/invites/usecases/resend-invite/resend-invite.usecase.ts b/apps/api/src/app/invites/usecases/resend-invite/resend-invite.usecase.ts index 90d34b51d1c..3086440a848 100644 --- a/apps/api/src/app/invites/usecases/resend-invite/resend-invite.usecase.ts +++ b/apps/api/src/app/invites/usecases/resend-invite/resend-invite.usecase.ts @@ -36,7 +36,6 @@ export class ResendInvite { if (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'production') { const novu = new Novu(process.env.NOVU_API_KEY ?? ''); - // eslint-disable-next-line @cspell/spellchecker // cspell:disable-next await novu.trigger(process.env.NOVU_TEMPLATEID_INVITE_TO_ORGANISATION || 'invite-to-organization-wBnO8NpDn', { to: { diff --git a/apps/api/src/app/notification-template/e2e/create-notification-templates.e2e.ts b/apps/api/src/app/notification-template/e2e/create-notification-templates.e2e.ts index 4284490efde..a2485b596b8 100644 --- a/apps/api/src/app/notification-template/e2e/create-notification-templates.e2e.ts +++ b/apps/api/src/app/notification-template/e2e/create-notification-templates.e2e.ts @@ -22,7 +22,7 @@ import { isSameDay } from 'date-fns'; import { CreateNotificationTemplateRequestDto } from '../dto'; import axios from 'axios'; -// import { SendMessageEmail } from '../../events/usecases/send-message/send-message-email.usecase'; +// import { SendMessageEmail } from '../../events/usecases/send-message'; describe('Create Notification template - /notification-templates (POST)', async () => { let session: UserSession; @@ -103,21 +103,25 @@ describe('Create Notification template - /notification-templates (POST)', async expect(template._notificationGroupId).to.equal(testTemplate.notificationGroupId); const message = template.steps[0]; + const filters = message?.filters ? message?.filters[0] : null; - const children: IFieldFilterPart = testTemplate.steps[0].filters[0].children[0] as IFieldFilterPart; + const messageTest = testTemplate?.steps ? testTemplate?.steps[0] : null; + const filtersTest = messageTest?.filters ? messageTest.filters[0] : null; - expect(message.template.name).to.equal(`${testTemplate.steps[0].template.name}`); - expect(message.template.active).to.equal(defaultMessageIsActive); - expect(message.template.subject).to.equal(`${testTemplate.steps[0].template.subject}`); - expect(message.template.preheader).to.equal(`${testTemplate.steps[0].template.preheader}`); - expect(message.filters[0].type).to.equal(testTemplate.steps[0].filters[0].type); - expect(message.filters[0].children.length).to.equal(testTemplate.steps[0].filters[0].children.length); + const children: IFieldFilterPart = filtersTest?.children[0] as IFieldFilterPart; + + expect(message?.template?.name).to.equal(`${messageTest?.template?.name}`); + expect(message?.template?.active).to.equal(defaultMessageIsActive); + expect(message?.template?.subject).to.equal(`${messageTest?.template?.subject}`); + expect(message?.template?.preheader).to.equal(`${messageTest?.template?.preheader}`); + expect(filters?.type).to.equal(filtersTest?.type); + expect(filters?.children.length).to.equal(filtersTest?.children?.length); expect(children.value).to.equal(children.value); expect(children.operator).to.equal(children.operator); expect(template.tags[0]).to.equal('test-tag'); - if (Array.isArray(message.template.content) && Array.isArray(testTemplate.steps[0].template.content)) { - expect(message.template.content[0].type).to.equal(testTemplate.steps[0].template.content[0].type); + if (Array.isArray(message?.template?.content) && Array.isArray(messageTest?.template?.content)) { + expect(message?.template?.content[0].type).to.equal(messageTest?.template?.content[0].type); } else { throw new Error('content must be an array'); } @@ -126,10 +130,10 @@ describe('Create Notification template - /notification-templates (POST)', async _environmentId: session.environment._id, _entityId: message._templateId, }); - await session.testAgent.post(`/v1/changes/${change._id}/apply`); + await session.testAgent.post(`/v1/changes/${change?._id}/apply`); change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: template._id }); - await session.testAgent.post(`/v1/changes/${change._id}/apply`); + await session.testAgent.post(`/v1/changes/${change?._id}/apply`); const prodEnv = await getProductionEnvironment(); @@ -138,25 +142,25 @@ describe('Create Notification template - /notification-templates (POST)', async _parentId: template._id, }); - expect(prodVersionNotification.tags[0]).to.equal(template.tags[0]); - expect(prodVersionNotification.steps.length).to.equal(template.steps.length); - expect(prodVersionNotification.triggers[0].type).to.equal(template.triggers[0].type); - expect(prodVersionNotification.triggers[0].identifier).to.equal(template.triggers[0].identifier); - expect(prodVersionNotification.active).to.equal(template.active); - expect(prodVersionNotification.draft).to.equal(template.draft); - expect(prodVersionNotification.name).to.equal(template.name); - expect(prodVersionNotification.description).to.equal(template.description); + expect(prodVersionNotification?.tags[0]).to.equal(template.tags[0]); + expect(prodVersionNotification?.steps.length).to.equal(template.steps.length); + expect(prodVersionNotification?.triggers[0].type).to.equal(template.triggers[0].type); + expect(prodVersionNotification?.triggers[0].identifier).to.equal(template.triggers[0].identifier); + expect(prodVersionNotification?.active).to.equal(template.active); + expect(prodVersionNotification?.draft).to.equal(template.draft); + expect(prodVersionNotification?.name).to.equal(template.name); + expect(prodVersionNotification?.description).to.equal(template.description); const prodVersionMessage = await messageTemplateRepository.findOne({ _environmentId: prodEnv._id, _parentId: message._templateId, }); - expect(message.template.name).to.equal(prodVersionMessage.name); - expect(message.template.subject).to.equal(prodVersionMessage.subject); - expect(message.template.type).to.equal(prodVersionMessage.type); - expect(message.template.content).to.deep.equal(prodVersionMessage.content); - expect(message.template.active).to.equal(prodVersionMessage.active); + expect(message?.template?.name).to.equal(prodVersionMessage?.name); + expect(message?.template?.subject).to.equal(prodVersionMessage?.subject); + expect(message?.template?.type).to.equal(prodVersionMessage?.type); + expect(message?.template?.content).to.deep.equal(prodVersionMessage?.content); + expect(message?.template?.active).to.equal(prodVersionMessage?.active); }); it('should create a valid notification', async () => { @@ -191,12 +195,12 @@ describe('Create Notification template - /notification-templates (POST)', async expect(template.name).to.equal(testTemplate.name); expect(template.draft).to.equal(true); expect(template.active).to.equal(false); - expect(isSameDay(new Date(template.createdAt), new Date())); + expect(isSameDay(new Date(template?.createdAt ? template?.createdAt : '1970'), new Date())); expect(template.steps.length).to.equal(1); - expect(template.steps[0].template.type).to.equal(ChannelTypeEnum.IN_APP); - expect(template.steps[0].template.content).to.equal(testTemplate.steps[0].template.content); - expect(template.steps[0].template.cta.data.url).to.equal(testTemplate.steps[0].template.cta.data.url); + expect(template?.steps?.[0]?.template?.type).to.equal(ChannelTypeEnum.IN_APP); + expect(template?.steps?.[0]?.template?.content).to.equal(testTemplate?.steps?.[0]?.template?.content); + expect(template?.steps?.[0]?.template?.cta?.data.url).to.equal(testTemplate?.steps?.[0]?.template?.cta?.data.url); }); it('should create event trigger', async () => { @@ -394,6 +398,35 @@ describe('Create Notification template - /notification-templates (POST)', async expect(result.credentials.senderName).to.equal('senderName'); }); + it('should not promote deleted template that is not existing in prod', async function () { + const testTemplate: Partial = { + name: 'test email template', + description: 'This is a test description', + tags: ['test-tag'], + notificationGroupId: session.notificationGroups[0]._id, + steps: [], + }; + + const { body } = await session.testAgent.post(`/v1/notification-templates`).send(testTemplate); + + expect(body.data).to.be.ok; + const template: INotificationTemplate = body.data; + + await session.testAgent.delete(`/v1/notification-templates/${template._id}`).send(); + + const change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: template._id }); + await session.testAgent.post(`/v1/changes/${change?._id}/apply`); + + const prodEnv = await getProductionEnvironment(); + + const prodVersionNotification = await notificationTemplateRepository.findOne({ + _environmentId: prodEnv._id, + _parentId: template._id, + }); + + expect(prodVersionNotification).to.equal(null); + }); + async function getProductionEnvironment() { return await environmentRepository.findOne({ _parentId: session.environment._id, diff --git a/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts b/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts index cc9d394843a..ae8c66f7165 100644 --- a/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts +++ b/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts @@ -91,6 +91,7 @@ export class CreateNotificationTemplate { shouldStopOnFail: message.shouldStopOnFail, replyCallback: message.replyCallback, uuid: message.uuid, + name: message.name, }); if (stepId) { diff --git a/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts b/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts index b54152a971b..69c7111c03d 100644 --- a/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts +++ b/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts @@ -283,6 +283,10 @@ export class UpdateNotificationTemplate { partialNotificationStep.uuid = message.uuid; } + if (message.name) { + partialNotificationStep.name = message.name; + } + return partialNotificationStep; } diff --git a/apps/api/src/app/partner-integrations/usecases/complete-vercel-integration/complete-vercel-integration.usecase.ts b/apps/api/src/app/partner-integrations/usecases/complete-vercel-integration/complete-vercel-integration.usecase.ts index d708319499b..989817e65b1 100644 --- a/apps/api/src/app/partner-integrations/usecases/complete-vercel-integration/complete-vercel-integration.usecase.ts +++ b/apps/api/src/app/partner-integrations/usecases/complete-vercel-integration/complete-vercel-integration.usecase.ts @@ -1,7 +1,7 @@ import { HttpService } from '@nestjs/axios'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { lastValueFrom } from 'rxjs'; -import { EnvironmentRepository, EnvironmentEntity, OrganizationRepository } from '@novu/dal'; +import { EnvironmentEntity, EnvironmentRepository, OrganizationRepository } from '@novu/dal'; import { AnalyticsService } from '@novu/application-generic'; import { CompleteVercelIntegrationCommand } from './complete-vercel-integration.command'; @@ -79,18 +79,15 @@ export class CompleteVercelIntegration { } private mapProjectKeys(envData: EnvironmentEntity[], projectData: Record) { - const mappedData = envData.reduce>((acc, curr) => { - const newData = { + return envData.reduce>((acc, curr) => { + acc[curr._organizationId] = { privateKey: curr.apiKeys[0].key, clientKey: curr.identifier, projectIds: projectData[curr._organizationId], }; - acc[curr._organizationId] = newData; return acc; }, {}); - - return mappedData; } private async setEnvironments({ clientKey, projectIds, privateKey, teamId, token }: ISetEnvironment): Promise { @@ -99,6 +96,12 @@ export class CompleteVercelIntegration { const type = 'encrypted'; const apiKeys = [ + { + target, + type, + value: clientKey, + key: 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID', + }, { target, type, diff --git a/apps/api/src/app/partner-integrations/usecases/update-vercel-configuration/update-vercel-configuration.usecase.ts b/apps/api/src/app/partner-integrations/usecases/update-vercel-configuration/update-vercel-configuration.usecase.ts index 29510ccad6c..8c84769d03e 100644 --- a/apps/api/src/app/partner-integrations/usecases/update-vercel-configuration/update-vercel-configuration.usecase.ts +++ b/apps/api/src/app/partner-integrations/usecases/update-vercel-configuration/update-vercel-configuration.usecase.ts @@ -139,20 +139,18 @@ export class UpdateVercelConfiguration { projectDetails: NewAndUpdatedProjectData ) { const { addProjectIds, updateProjectDetails } = projectDetails; - const mappedData = envData.reduce>((acc, curr) => { + + return envData.reduce>((acc, curr) => { const projectIds = projectData[curr._organizationId]; - const newData = { + acc[curr._organizationId] = { privateKey: curr.apiKeys[0].key, clientKey: curr.identifier, updateProjectDetails: updateProjectDetails.filter((detail) => projectIds.includes(detail.projectId)), addProjectIds: projectIds.filter((id) => addProjectIds.includes(id)), }; - acc[curr._organizationId] = newData; return acc; }, {}); - - return mappedData; } private async setEnvironmentVariables({ @@ -179,6 +177,12 @@ export class UpdateVercelConfiguration { value: privateKey, key: 'NOVU_API_SECRET', }, + { + target, + type, + value: clientKey, + key: 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID', + }, ]; await Promise.all( diff --git a/apps/api/src/app/shared/dtos/notification-step.ts b/apps/api/src/app/shared/dtos/notification-step.ts index 7be65d84509..e070905a532 100644 --- a/apps/api/src/app/shared/dtos/notification-step.ts +++ b/apps/api/src/app/shared/dtos/notification-step.ts @@ -43,6 +43,9 @@ export class NotificationStep { @ApiPropertyOptional() uuid?: string; + @ApiPropertyOptional() + name?: string; + @ApiPropertyOptional() @ApiProperty() _templateId?: string; diff --git a/apps/api/src/app/shared/dtos/pagination-request.ts b/apps/api/src/app/shared/dtos/pagination-request.ts new file mode 100644 index 00000000000..79c500df04d --- /dev/null +++ b/apps/api/src/app/shared/dtos/pagination-request.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export type Constructor = new (...args: any[]) => I; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export function PaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor { + class PaginationRequest { + @ApiPropertyOptional({ + type: Number, + required: false, + }) + page?: number = 0; + + @ApiPropertyOptional({ + type: Number, + required: false, + default: defaultLimit, + maximum: maxLimit, + }) + limit?: number = 10; + } + + return PaginationRequest; +} diff --git a/apps/api/src/app/shared/dtos/pagination-response.ts b/apps/api/src/app/shared/dtos/pagination-response.ts new file mode 100644 index 00000000000..5b5b023e36e --- /dev/null +++ b/apps/api/src/app/shared/dtos/pagination-response.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PaginatedResponseDto { + @ApiProperty({ + description: 'The current page of the paginated response', + }) + page: number; + + @ApiProperty({ + description: 'Total count of items matching the query', + }) + totalCount: number; + + @ApiProperty({ + description: 'Number of items on each page', + }) + pageSize: number; + + @ApiProperty({ + description: 'The list of items matching the query', + isArray: true, + }) + data: T[]; +} diff --git a/apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts b/apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts new file mode 100644 index 00000000000..077274a0267 --- /dev/null +++ b/apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts @@ -0,0 +1,24 @@ +import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger'; +import { PaginatedResponseDto } from '../dtos/pagination-response'; +import { Type, applyDecorators } from '@nestjs/common'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ApiOkPaginatedResponse = >(dataDto: DataDto) => + applyDecorators( + ApiExtraModels(PaginatedResponseDto, dataDto), + ApiOkResponse({ + schema: { + allOf: [ + { $ref: getSchemaPath(PaginatedResponseDto) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(dataDto) }, + }, + }, + }, + ], + }, + }) + ); diff --git a/apps/api/src/app/shared/shared.module.ts b/apps/api/src/app/shared/shared.module.ts index 7129176bab5..5f3aa37a5b5 100644 --- a/apps/api/src/app/shared/shared.module.ts +++ b/apps/api/src/app/shared/shared.module.ts @@ -78,15 +78,17 @@ const dalService = new DalService(); const inMemoryProviderService = { provide: InMemoryProviderService, - useFactory: () => { - return new InMemoryProviderService(); + useFactory: (enableAutoPipelining?: boolean) => { + return new InMemoryProviderService(enableAutoPipelining); }, }; const cacheService = { provide: CacheService, useFactory: () => { - const factoryInMemoryProviderService = inMemoryProviderService.useFactory(); + // TODO: Temporary to test in Dev. Should be removed. + const enableAutoPipelining = process.env.REDIS_CACHE_ENABLE_AUTOPIPELINING === 'true'; + const factoryInMemoryProviderService = inMemoryProviderService.useFactory(enableAutoPipelining); return new CacheService(factoryInMemoryProviderService); }, diff --git a/apps/api/src/app/subscribers/dtos/get-subscribers.dto.ts b/apps/api/src/app/subscribers/dtos/get-subscribers.dto.ts new file mode 100644 index 00000000000..05a64f20e95 --- /dev/null +++ b/apps/api/src/app/subscribers/dtos/get-subscribers.dto.ts @@ -0,0 +1,8 @@ +import { PaginationRequestDto } from '../../shared/dtos/pagination-request'; + +const LIMIT = { + DEFAULT: 10, + MAX: 100, +}; + +export class GetSubscribersDto extends PaginationRequestDto(LIMIT.DEFAULT, LIMIT.MAX) {} diff --git a/apps/api/src/app/subscribers/dtos/subscriber-response.dto.ts b/apps/api/src/app/subscribers/dtos/subscriber-response.dto.ts index 7ad5b180ffe..af76e400186 100644 --- a/apps/api/src/app/subscribers/dtos/subscriber-response.dto.ts +++ b/apps/api/src/app/subscribers/dtos/subscriber-response.dto.ts @@ -1,23 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { ChannelCredentials } from '../../shared/dtos/subscriber-channel'; -import { ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; +import { UpdateSubscriberChannelRequestDto } from './update-subscriber-channel-request.dto'; -class ChannelSettings { +class ChannelSettings extends UpdateSubscriberChannelRequestDto { @ApiProperty({ description: 'Id of the integration that is used for this channel', }) _integrationId: string; - - @ApiProperty({ - enum: { ...ChatProviderIdEnum, ...PushProviderIdEnum }, - description: 'Subscriber credentials for channel', - }) - providerId: ChatProviderIdEnum | PushProviderIdEnum; - - @ApiProperty({ - description: 'Subscriber credentials for channel', - }) - credentials: ChannelCredentials; } export class SubscriberResponseDto { diff --git a/apps/api/src/app/subscribers/dtos/subscribers-response.dto.ts b/apps/api/src/app/subscribers/dtos/subscribers-response.dto.ts index 26497dae75e..7b75e26fa50 100644 --- a/apps/api/src/app/subscribers/dtos/subscribers-response.dto.ts +++ b/apps/api/src/app/subscribers/dtos/subscribers-response.dto.ts @@ -1,26 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; import { SubscriberResponseDto } from './subscriber-response.dto'; +import { PaginatedResponseDto } from '../../shared/dtos/pagination-response'; -export class SubscribersResponseDto { - @ApiProperty({ - description: 'The current page of the paginated response', - }) - page: number; - - @ApiProperty({ - description: 'Total count of subscribers matching the query', - }) - totalCount: number; - - @ApiProperty({ - description: 'Number of subscribers on each page', - }) - pageSize: number; - - @ApiProperty({ - description: 'The list of subscribers matching the query', - isArray: true, - type: SubscriberResponseDto, - }) - data: SubscriberResponseDto[]; -} +export class SubscribersResponseDto extends PaginatedResponseDto {} diff --git a/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts b/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts index 83038ded80d..8c07241cabc 100644 --- a/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts +++ b/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsObject, IsOptional, IsString } from 'class-validator'; +import { IsDefined, IsObject, IsString } from 'class-validator'; import { ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; import { ChannelCredentials } from '../../shared/dtos/subscriber-channel'; diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index 0d7743b97e8..7580af4624d 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -26,7 +26,6 @@ import { CreateSubscriberRequestDto, DeleteSubscriberResponseDto, SubscriberResponseDto, - SubscribersResponseDto, UpdateSubscriberChannelRequestDto, UpdateSubscriberRequestDto, } from './dtos'; @@ -66,6 +65,9 @@ import { } from './usecases/update-subscriber-online-flag'; import { MarkMessageAsRequestDto } from '../widgets/dtos/mark-message-as-request.dto'; import { MarkMessageActionAsSeenDto } from '../widgets/dtos/mark-message-action-as-seen.dto'; +import { ApiOkPaginatedResponse } from '../shared/framework/paginated-ok-response.decorator'; +import { PaginatedResponseDto } from '../shared/dtos/pagination-response'; +import { GetSubscribersDto } from './dtos/get-subscribers.dto'; @Controller('/subscribers') @ApiTags('Subscribers') @@ -89,25 +91,21 @@ export class SubscribersController { @Get('') @ExternalApiAccessible() @UseGuards(JwtAuthGuard) - @ApiOkResponse({ - type: SubscribersResponseDto, - }) + @ApiOkPaginatedResponse(SubscriberResponseDto) @ApiOperation({ summary: 'Get subscribers', - description: 'Returns a list of subscribers, could paginated using the `page` query parameter', + description: 'Returns a list of subscribers, could paginated using the `page` and `limit` query parameter', }) - @ApiQuery({ name: 'page', type: Number, required: false, description: 'The page to fetch, defaults to 0' }) async getSubscribers( @UserSession() user: IJwtPayload, - @Query('page') page = 0, - @Query('limit') limit = 10 - ): Promise { + @Query() query: GetSubscribersDto + ): Promise> { return await this.getSubscribersUsecase.execute( GetSubscribersCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, - page: page ? Number(page) : 0, - limit: limit ? Number(limit) : 10, + page: query.page ? Number(query.page) : 0, + limit: query.limit ? Number(query.limit) : 10, }) ); } @@ -328,9 +326,7 @@ export class SubscribersController { @ApiOperation({ summary: 'Get a notification feed for a particular subscriber', }) - @ApiOkResponse({ - type: MessagesResponseDto, - }) + @ApiOkPaginatedResponse(MessageResponseDto) @ApiQuery({ name: 'seen', type: Boolean, @@ -347,7 +343,7 @@ export class SubscribersController { @Query('page') page?: string, @Query('feedIdentifier') feedId?: string, @Query() query: StoreQuery = {} - ) { + ): Promise> { let feedsQuery: string[] | undefined; if (feedId) { feedsQuery = Array.isArray(feedId) ? feedId : [feedId]; @@ -472,7 +468,7 @@ export class SubscribersController { @Param('type') type: ButtonTypeEnum, @Body() body: MarkMessageActionAsSeenDto, @Param('subscriberId') subscriberId: string - ): Promise { + ): Promise { return await this.updateMessageActionsUsecase.execute( UpdateMessageActionsCommand.create({ organizationId: user.organizationId, diff --git a/apps/api/src/app/widgets/dtos/message-response.dto.ts b/apps/api/src/app/widgets/dtos/message-response.dto.ts index f0e8a8fcb93..a89064328ab 100644 --- a/apps/api/src/app/widgets/dtos/message-response.dto.ts +++ b/apps/api/src/app/widgets/dtos/message-response.dto.ts @@ -135,6 +135,7 @@ export class MessageResponseDto { @ApiProperty() transactionId: string; + @ApiProperty() subject?: string; @ApiProperty({ diff --git a/apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts b/apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts index c9eac880301..1bebb6dc750 100644 --- a/apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts +++ b/apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts @@ -67,15 +67,17 @@ export class MarkMessageAs { } private async updateServices(command: MarkMessageAsCommand, subscriber, messages, marked: string) { - const admin = await this.memberRepository.getOrganizationAdminAccount(command.organizationId); - const count = await this.messageRepository.getCount(command.environmentId, subscriber._id, ChannelTypeEnum.IN_APP, { - [marked]: false, - }); + const [admin, count] = await Promise.all([ + this.memberRepository.getOrganizationAdminAccount(command.organizationId), + this.messageRepository.getCount(command.environmentId, subscriber._id, ChannelTypeEnum.IN_APP, { + [marked]: false, + }), + ]); this.updateSocketCount(subscriber, count, marked); - for (const message of messages) { - if (admin) { + if (admin) { + for (const message of messages) { this.analyticsService.track(`Mark as ${marked} - [Notification Center]`, admin._userId, { _subscriber: message._subscriberId, _organization: command.organizationId, diff --git a/apps/inbound-mail/.eslintrc.js b/apps/inbound-mail/.eslintrc.js index 52c29b96e7c..54639bcc25e 100644 --- a/apps/inbound-mail/.eslintrc.js +++ b/apps/inbound-mail/.eslintrc.js @@ -1,4 +1,10 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: {}, + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2020, + sourceType: 'module', + tsconfigRootDir: __dirname, + }, }; diff --git a/apps/web/cypress/tests/changes.spec.ts b/apps/web/cypress/tests/changes.spec.ts index f6ba72d144f..d630f99fe25 100644 --- a/apps/web/cypress/tests/changes.spec.ts +++ b/apps/web/cypress/tests/changes.spec.ts @@ -1,3 +1,6 @@ +import { dragAndDrop } from './notification-editor'; +import { goBack } from './notification-editor/index'; + describe('Changes Screen', function () { beforeEach(function () { cy.initializeSession().as('session'); @@ -59,22 +62,37 @@ describe('Changes Screen', function () { cy.getByTestId('promote-btn').should('be.disabled'); }); - it('should promote all changes with promote all btn', function () { + it('should promote all changes with promote all btn 2', function () { + cy.intercept('**/v1/changes?promoted=false&page=0&limit=10').as('changes'); + cy.intercept('**/v1/changes/bulk/apply').as('bulk-apply'); + cy.intercept('**/notification-templates**').as('notificationTemplates'); + createNotification(); + cy.waitForNetworkIdle(500); createNotification(); + cy.waitForNetworkIdle(500); - cy.visit('/changes'); - cy.getByTestId('pending-changes-table').find('tbody tr').should('have.length', 2); - - cy.getByTestId('promote-all-btn').click({ force: true }); + cy.waitLoadTemplatePage(() => { + cy.visit('/changes'); + cy.waitForNetworkIdle(500); + cy.wait(['@changes']); + cy.awaitAttachedGetByTestId('pending-changes-table').find('tbody tr').should('have.length', 2); + cy.wait(['@changes']); + cy.intercept('**/v1/changes?promoted=false&page=0&limit=10').as('changes-2'); + cy.awaitAttachedGetByTestId('promote-all-btn').click({ force: true }); + cy.wait(['@bulk-apply']); + cy.wait(['@changes-2']); + cy.wait(['@changes-2']); - cy.getByTestId('pending-changes-table').find('tbody tr').should('not.exist'); + cy.awaitAttachedGetByTestId('pending-changes-table').find('tbody tr').should('not.exist'); - switchEnvironment('Production'); + switchEnvironment('Production'); + cy.waitForNetworkIdle(500); + }); cy.visit('/templates'); - cy.waitForNetworkIdle(500); - cy.getByTestId('notifications-template').find('tbody tr').should('have.length', 2); + cy.wait('@notificationTemplates'); + cy.awaitAttachedGetByTestId('notifications-template').find('tbody tr').should('have.length', 2); }); }); @@ -84,28 +102,30 @@ function switchEnvironment(environment: 'Production' | 'Development') { } function createNotification() { - const dataTransfer = new DataTransfer(); + cy.intercept('**/notification-groups').as('getNotificationGroups'); cy.visit('/templates/create'); cy.waitForNetworkIdle(500); - cy.getByTestId('title').type('Test Notification Title'); - cy.getByTestId('description').type('This is a test description for a test title'); - cy.get('body').click(); + cy.getByTestId('title').clear().type('Test Notification Title'); + + cy.getByTestId('settings-page').click(); + cy.waitForNetworkIdle(500); - cy.getByTestId('workflowButton').click(); + cy.getByTestId('description').clear().type('This is a test description for a test title'); + cy.get('body').click(); - cy.getByTestId('dnd-emailSelector').trigger('dragstart', { dataTransfer }); + goBack(); - cy.get('.react-flow__node-addNode').trigger('drop', { dataTransfer }); + dragAndDrop('email'); + cy.waitForNetworkIdle(500); - cy.getByTestId('node-emailSelector').parent().click({ force: true }); - cy.getByTestId('edit-template-channel').click({ force: true }); + cy.clickWorkflowNode(`node-emailSelector`); + cy.waitForNetworkIdle(500); cy.getByTestId('emailSubject').type('this is email subject'); + goBack(); cy.getByTestId('notification-template-submit-btn').click(); - cy.waitForNetworkIdle(500); - cy.getByTestId('trigger-snippet-btn').click(); } function promoteNotification() { diff --git a/apps/web/cypress/tests/digest-playground.spec.ts b/apps/web/cypress/tests/digest-playground.spec.ts index 48b6542cac5..21de8c6b14a 100644 --- a/apps/web/cypress/tests/digest-playground.spec.ts +++ b/apps/web/cypress/tests/digest-playground.spec.ts @@ -50,7 +50,6 @@ describe('Digest Playground Workflow Page', function () { // in the template workflow editor cy.url().should('include', '/templates/edit'); - cy.url().should('include', '?tour=digest'); // check the digest hint cy.getByTestId('digest-workflow-tooltip').contains('Set-up time interval'); @@ -64,8 +63,8 @@ describe('Digest Playground Workflow Page', function () { // check if has digest step cy.getByTestId('node-digestSelector').should('be.visible'); // check if digest step settings opened - cy.getByTestId('step-properties-side-menu').should('be.visible'); - cy.getByTestId('step-properties-side-menu').contains('Digest Properties'); + cy.getByTestId('step-page-wrapper').should('be.visible'); + cy.getByTestId('step-page-wrapper').contains('Digest'); // click next on hint cy.getByTestId('digest-workflow-tooltip-primary-button').contains('Next').click(); @@ -79,11 +78,9 @@ describe('Digest Playground Workflow Page', function () { cy.getByTestId('digest-workflow-tooltip-skip-button').contains('Skip tour'); cy.getByTestId('digest-workflow-tooltip-dots-navigation').should('be.visible'); - // check if has email step - cy.getByTestId('node-digestSelector').should('be.visible'); // check if email step settings opened - cy.getByTestId('step-properties-side-menu').should('be.visible'); - cy.getByTestId('step-properties-side-menu').contains('Email Properties'); + cy.getByTestId('step-page-wrapper').should('be.visible'); + cy.getByTestId('step-page-wrapper').contains('Email'); // click next on hint cy.getByTestId('digest-workflow-tooltip-primary-button').contains('Next').click(); @@ -98,7 +95,8 @@ describe('Digest Playground Workflow Page', function () { cy.getByTestId('digest-workflow-tooltip-dots-navigation').should('be.visible'); // the step settings should be hidden - cy.getByTestId('drag-side-menu').contains('Steps to add'); + cy.getByTestId('step-page-wrapper').should('be.visible'); + cy.getByTestId('step-page-wrapper').contains('Trigger'); // click got it should hide the hint cy.getByTestId('digest-workflow-tooltip-primary-button').contains('Got it').click(); @@ -123,7 +121,6 @@ describe('Digest Playground Workflow Page', function () { // in the template workflow editor cy.url().should('include', '/templates/edit'); - cy.url().should('include', '?tour=digest'); // check the digest hint cy.getByTestId('digest-workflow-tooltip').contains('Set-up time interval'); 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 25b673127d5..59cfb57d1ef 100644 --- a/apps/web/cypress/tests/notification-editor/create-notification.spec.ts +++ b/apps/web/cypress/tests/notification-editor/create-notification.spec.ts @@ -9,36 +9,33 @@ describe('Creation functionality', function () { cy.waitLoadTemplatePage(() => { cy.visit('/templates/create'); }); - cy.getByTestId('title').type('Test Notification Title'); + cy.getByTestId('settings-page').click(); + cy.waitForNetworkIdle(500); + cy.getByTestId('title').clear().first().type('Test Notification Title'); cy.getByTestId('description').type('This is a test description for a test title'); cy.get('body').click(); cy.getByTestId('trigger-code-snippet').should('not.exist'); cy.getByTestId('groupSelector').should('have.value', 'General'); addAndEditChannel('inApp'); + cy.waitForNetworkIdle(500); cy.get('.ace_text-input').first().type('{{firstName}} someone assigned you to {{taskName}}', { parseSpecialCharSequences: false, force: true, }); cy.getByTestId('inAppRedirect').type('/example/test'); - cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('success-trigger-modal').should('be.visible'); - cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('test-notification'); - cy.getByTestId('success-trigger-modal') - .getByTestId('trigger-code-snippet') - .contains("import { Novu } from '@novu/node'"); + goBack(); + cy.getByTestId('notification-template-submit-btn').click(); + cy.getByTestId('get-snippet-btn').click(); + cy.getByTestId('trigger-code-snippet').should('be.visible'); + cy.getByTestId('trigger-code-snippet').contains('test-notification-title'); + cy.getByTestId('trigger-code-snippet').contains("import { Novu } from '@novu/node'"); cy.get('.mantine-Tabs-tabsList').contains('Curl').click(); - cy.getByTestId('success-trigger-modal') - .getByTestId('trigger-curl-snippet') - .contains("--header 'Authorization: ApiKey"); - cy.getByTestId('success-trigger-modal').getByTestId('trigger-curl-snippet').contains('taskName'); - - cy.getByTestId('trigger-snippet-btn').click(); - - cy.location('pathname').should('equal', '/templates'); + cy.getByTestId('trigger-curl-snippet').contains("--header 'Authorization: ApiKey"); + cy.getByTestId('trigger-curl-snippet').contains('taskName'); }); it('should create multiline in-app notification, send it and receive', function () { @@ -46,11 +43,14 @@ describe('Creation functionality', function () { cy.visit('/templates/create'); }); - cy.getByTestId('title').type('Test Notification Title'); + cy.getByTestId('settings-page').click(); + + cy.getByTestId('title').first().clear().type('Test Notification Title'); cy.getByTestId('description').type('This is a test description for a test title'); cy.get('body').click(); addAndEditChannel('inApp'); + cy.waitForNetworkIdle(500); // put the multiline notification message cy.get('.ace_text-input') @@ -63,11 +63,9 @@ describe('Creation functionality', function () { force: true, }); cy.getByTestId('inAppRedirect').type('/example/test'); - cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('trigger-snippet-btn').click(); - - cy.location('pathname').should('equal', '/templates'); + goBack(); + cy.getByTestId('notification-template-submit-btn').click(); // trigger the notification cy.task('createNotifications', { @@ -92,11 +90,13 @@ describe('Creation functionality', function () { cy.waitLoadTemplatePage(() => { cy.visit('/templates/create'); }); - cy.getByTestId('title').type('Test Notification Title'); + cy.getByTestId('settings-page').click(); + cy.getByTestId('title').clear().first().type('Test Notification Title'); cy.getByTestId('description').type('This is a test description for a test title'); cy.get('body').click(); addAndEditChannel('email'); + cy.waitForNetworkIdle(500); cy.getByTestId('email-editor').getByTestId('editor-row').click(); cy.getByTestId('control-add').click(); @@ -180,11 +180,14 @@ describe('Creation functionality', function () { cy.waitLoadTemplatePage(() => { cy.visit('/templates/create'); }); - cy.getByTestId('title').type('Test Notification Title'); + cy.getByTestId('settings-page').click(); + + cy.getByTestId('title').first().clear().type('Test Notification Title'); cy.getByTestId('description').type('This is a test description for a test title'); cy.get('body').click(); addAndEditChannel('email'); + cy.waitForNetworkIdle(500); cy.getByTestId('email-editor').getByTestId('editor-row').click(); cy.getByTestId('control-add').click(); @@ -213,23 +216,27 @@ describe('Creation functionality', function () { cy.getByTestId('emailSubject').type('this is email subject'); + goBack(); cy.getByTestId('notification-template-submit-btn').click(); - - cy.getByTestId('success-trigger-modal').should('be.visible'); - cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('test-notification'); - cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('firstName:'); - cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('customVariable:'); + cy.getByTestId('get-snippet-btn').click(); + cy.getByTestId('trigger-code-snippet').should('be.visible'); + cy.getByTestId('trigger-code-snippet').contains('test-notification-title'); + cy.getByTestId('trigger-code-snippet').contains('firstName:'); + cy.getByTestId('trigger-code-snippet').contains('customVariable:'); }); it('should add digest node', function () { cy.waitLoadTemplatePage(() => { cy.visit('/templates/create'); }); - cy.getByTestId('title').type('Test Notification Title'); + + cy.getByTestId('settings-page').click(); + cy.getByTestId('title').first().clear().type('Test Notification Title'); cy.getByTestId('description').type('This is a test description for a test title'); cy.get('body').click(); addAndEditChannel('email'); + cy.waitForNetworkIdle(500); cy.getByTestId('email-editor').getByTestId('editor-row').click(); cy.getByTestId('control-add').click(); @@ -259,54 +266,42 @@ describe('Creation functionality', function () { cy.getByTestId('emailSubject').type('this is email subject'); goBack(); + cy.waitForNetworkIdle(500); dragAndDrop('digest'); + cy.waitForNetworkIdle(500); cy.clickWorkflowNode('node-digestSelector'); + cy.waitForNetworkIdle(500); - cy.getByTestId('time-unit').click(); - cy.get('.mantine-Select-dropdown .mantine-Select-item').contains('Minutes').click(); + cy.getByTestId('time-unit-minutes').click(); cy.getByTestId('time-amount').type('20'); cy.getByTestId('batch-key').type('id'); - cy.getByTestId('digest-type').click(); - cy.get('.mantine-Select-dropdown .mantine-Select-item').contains('Backoff').click(); + cy.getByTestId('digest-type').contains('Backoff').click(); cy.getByTestId('backoff-amount').type('20'); - cy.getByTestId('backoff-unit').click(); - cy.get('.mantine-Select-dropdown .mantine-Select-item').contains('Minutes').click(); - - cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('success-trigger-modal').should('be.visible'); - cy.getByTestId('trigger-snippet-btn').click(); - - cy.intercept('GET', '/v1/notification-templates?page=0&limit=10').as('notification-templates'); - cy.visit('/templates'); - cy.wait('@notification-templates'); + cy.getByTestId('backoff-unit-minutes').click(); - awaitGetContains('tbody', 'Test Notification Title').click({ force: true }); - - cy.waitLoadTemplatePage(() => { - clickWorkflow(); + goBack(); - cy.clickWorkflowNode('node-digestSelector'); + cy.clickWorkflowNode('node-digestSelector'); - cy.getByTestId('time-amount').should('have.value', '20'); - cy.getByTestId('batch-key').should('have.value', 'id'); - cy.getByTestId('backoff-amount').should('have.value', '20'); - cy.getByTestId('time-unit').should('have.value', 'Minutes'); - cy.getByTestId('digest-type').should('have.value', 'Backoff'); - cy.getByTestId('backoff-unit').should('have.value', 'Minutes'); - // cy.getByTestId('updateMode').should('be.checked'); - }); + cy.getByTestId('time-amount').should('have.value', '20'); + cy.getByTestId('batch-key').should('have.value', 'id'); + cy.getByTestId('backoff-amount').should('have.value', '20'); + cy.getByTestId('time-unit-minutes').should('be.checked'); + cy.getByTestId('digest-type').contains('Backoff').should('have.class', 'mantine-SegmentedControl-labelActive'); + cy.getByTestId('backoff-unit-minutes').should('be.checked'); }); it('should create and edit group id', function () { const template = this.session.templates[0]; cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); - + cy.getByTestId('settings-page').click(); + cy.waitForNetworkIdle(500); cy.getByTestId('groupSelector').click(); cy.getByTestId('groupSelector').clear(); cy.getByTestId('groupSelector').type('New Test Category'); @@ -315,12 +310,15 @@ describe('Creation functionality', function () { cy.getByTestId('groupSelector').should('have.value', 'New Test Category'); + goBack(); cy.getByTestId('notification-template-submit-btn').click(); + cy.waitForNetworkIdle(500); cy.visit('/templates'); cy.getByTestId('template-edit-link'); cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); + cy.getByTestId('settings-page').click(); cy.getByTestId('groupSelector').should('have.value', 'New Test Category'); }); @@ -329,7 +327,8 @@ describe('Creation functionality', function () { cy.visit('/templates/create'); }); fillBasicNotificationDetails('Test Added Delay'); - clickWorkflow(); + goBack(); + cy.waitForNetworkIdle(500); cy.getByTestId('button-add').click(); cy.getByTestId('add-delay-node').click(); cy.clickWorkflowNode('node-delaySelector'); diff --git a/apps/web/cypress/tests/notification-editor/debugging-test-trigger.spec.ts b/apps/web/cypress/tests/notification-editor/debugging-test-trigger.spec.ts index 50f8f38cb16..1b4d654affb 100644 --- a/apps/web/cypress/tests/notification-editor/debugging-test-trigger.spec.ts +++ b/apps/web/cypress/tests/notification-editor/debugging-test-trigger.spec.ts @@ -1,5 +1,3 @@ -import { addAndEditChannel, clickWorkflow, fillBasicNotificationDetails, goBack } from '.'; - describe('Debugging - test trigger', function () { beforeEach(function () { cy.initializeSession().as('session'); @@ -17,62 +15,23 @@ describe('Debugging - test trigger', function () { cy.wait('@notification-templates'); - cy.getByTestId('test-workflow-btn').click(); - cy.getByTestId('test-trigger-modal').should('be.visible'); - cy.getByTestId('test-trigger-modal').getByTestId('test-trigger-to-param').contains(`"subscriberId": "${userId}"`); - }); - - it('should create template before opening test trigger modal', function () { - cy.intercept('POST', '*/notification-templates').as('createTemplate'); - const { id: userId, email: userEmail } = this.session.user; - - cy.visit('/templates/create'); - cy.waitForNetworkIdle(500); - - fillBasicNotificationDetails('Test workflow'); - - clickWorkflow(); - - addAndEditChannel('email'); - - cy.getByTestId('emailSubject').type('Hello world {{newVar}}', { - parseSpecialCharSequences: false, - }); - - goBack(); - - cy.getByTestId('test-workflow-btn').click(); - cy.getByTestId('save-changes-modal').get('button').contains('Save').click(); - - cy.wait('@createTemplate').then((res) => { - const createdTemplateId = res.response?.body.data._id; - cy.get('.mantine-Notification-root').contains('Template saved successfully'); - cy.getByTestId('test-trigger-modal').should('be.visible'); - cy.getByTestId('test-trigger-modal').getByTestId('test-trigger-to-param').contains(`"subscriberId": "${userId}"`); - cy.getByTestId('test-trigger-modal') - .getByTestId('test-trigger-to-param') - .should('have.value', `{\n "subscriberId": "${userId}",\n "email": "${userEmail}"\n}`); - - cy.getByTestId('test-trigger-modal') - .getByTestId('test-trigger-payload-param') - .should('have.value', '{\n "newVar": ""\n}'); - cy.getByTestId('test-trigger-modal').getByTestId('test-trigger-btn').click(); - cy.location('pathname').should('equal', `/templates/edit/${createdTemplateId}`); - }); + cy.getByTestId('node-triggerSelector').click({ force: true }); + cy.getByTestId('step-page-wrapper').should('be.visible'); + cy.getByTestId('step-page-wrapper').getByTestId('test-trigger-to-param').contains(`"subscriberId": "${userId}"`); }); it('should not test trigger on error ', function () { const template = this.session.templates[0]; cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); - cy.getByTestId('test-workflow-btn').click(); - - cy.getByTestId('test-trigger-modal').should('be.visible'); - cy.getByTestId('test-trigger-modal').getByTestId('test-trigger-to-param').type('{backspace}'); - cy.getByTestId('test-trigger-modal').getByTestId('test-trigger-payload-param').click(); - cy.getByTestId('test-trigger-modal').getByTestId('test-trigger-btn').should('be.disabled'); - cy.getByTestId('test-trigger-modal').should('be.visible'); - cy.getByTestId('test-trigger-modal') + cy.getByTestId('node-triggerSelector').click({ force: true }); + + cy.getByTestId('step-page-wrapper').should('be.visible'); + cy.getByTestId('step-page-wrapper').getByTestId('test-trigger-to-param').type('{backspace}'); + cy.getByTestId('step-page-wrapper').getByTestId('test-trigger-payload-param').click(); + cy.getByTestId('step-page-wrapper').getByTestId('test-trigger-btn').should('be.disabled'); + cy.getByTestId('step-page-wrapper').should('be.visible'); + cy.getByTestId('step-page-wrapper') .getByTestId('test-trigger-to-param') .should('have.class', 'mantine-JsonInput-invalid'); }); diff --git a/apps/web/cypress/tests/notification-editor/drag-and-drop.spec.ts b/apps/web/cypress/tests/notification-editor/drag-and-drop.spec.ts index b32b5d640b3..0ba6b4e9c90 100644 --- a/apps/web/cypress/tests/notification-editor/drag-and-drop.spec.ts +++ b/apps/web/cypress/tests/notification-editor/drag-and-drop.spec.ts @@ -1,4 +1,4 @@ -import { clickWorkflow, dragAndDrop, fillBasicNotificationDetails } from '.'; +import { clickWorkflow, dragAndDrop, fillBasicNotificationDetails, goBack } from '.'; describe('Workflow Editor - Drag and Drop', function () { beforeEach(function () { @@ -10,12 +10,11 @@ describe('Workflow Editor - Drag and Drop', function () { cy.visit('/templates/create'); }); fillBasicNotificationDetails('Test drag and drop channel'); - clickWorkflow(); + goBack(); dragAndDrop('inApp'); dragAndDrop('inApp'); cy.getByTestId('node-inAppSelector').last().parent().click(); - cy.getByTestId('edit-template-channel').click(); }); it('should not be able to drop when not on last node', function () { @@ -23,33 +22,19 @@ describe('Workflow Editor - Drag and Drop', function () { cy.visit('/templates/create'); }); fillBasicNotificationDetails('Test only drop on last node'); - clickWorkflow(); + goBack(); dragAndDrop('inApp'); dragAndDrop('email'); cy.getByTestId('node-emailSelector').should('not.exist'); cy.get('.react-flow__node').should('have.length', 3); }); - it('should be able to select a step', function () { - const template = this.session.templates[0]; - cy.waitLoadTemplatePage(() => { - cy.visit('/templates/edit/' + template._id); - }); - fillBasicNotificationDetails('Test SMS Notification Title'); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`)?.parent().should('have.class', 'selected'); - cy.getByTestId(`step-properties-side-menu`).should('be.visible'); - cy.clickWorkflowNode(`node-triggerSelector`); - cy.getByTestId(`drag-side-menu`).should('be.visible'); - cy.getByTestId(`node-inAppSelector`).parent().should('not.have.class', 'selected'); - }); - it('should add a step with plus button', function () { cy.waitLoadTemplatePage(() => { cy.visit('/templates/create'); }); fillBasicNotificationDetails('Test Plus Button'); - clickWorkflow(); + goBack(); cy.getByTestId('button-add').click(); cy.getByTestId('add-sms-node').click(); cy.get('.react-flow__node').should('have.length', 3); diff --git a/apps/web/cypress/tests/notification-editor/index.ts b/apps/web/cypress/tests/notification-editor/index.ts index 5455cc31f95..7c6d2dc7846 100644 --- a/apps/web/cypress/tests/notification-editor/index.ts +++ b/apps/web/cypress/tests/notification-editor/index.ts @@ -2,9 +2,10 @@ type Channel = 'inApp' | 'email' | 'sms' | 'digest'; export function addAndEditChannel(channel: Channel) { cy.waitForNetworkIdle(500); - clickWorkflow(); + goBack(); dragAndDrop(channel); + cy.waitForNetworkIdle(500); editChannel(channel, true); } @@ -17,15 +18,18 @@ export function dragAndDrop(channel: Channel, dropTestId = 'addNodeButton') { export function editChannel(channel: Channel, last = false) { cy.clickWorkflowNode(`node-${channel}Selector`, last); - cy.getByTestId('edit-template-channel').click(); } export function goBack() { - cy.getByTestId('go-back-button').click(); + cy.getByTestId('close-step-page').click(); + cy.waitForNetworkIdle(500); } export function fillBasicNotificationDetails(title?: string) { + cy.getByTestId('settings-page').click(); cy.getByTestId('title') + .first() + .clear() .type(title || 'Test Notification Title') .blur(); cy.getByTestId('description').type('This is a test description for a test title').blur(); 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 4bf231cc03e..d899e743173 100644 --- a/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts +++ b/apps/web/cypress/tests/notification-editor/main-functionality.spec.ts @@ -10,27 +10,35 @@ describe('Workflow Editor - Main Functionality', function () { cy.visit('/templates/create'); }); fillBasicNotificationDetails('Test not reset data when switching channel types'); - + cy.waitForNetworkIdle(500); addAndEditChannel('inApp'); + cy.waitForNetworkIdle(500); cy.get('.ace_text-input').first().type('{{firstName}} someone assigned you to {{taskName}}', { parseSpecialCharSequences: false, force: true, }); goBack(); + cy.waitForNetworkIdle(500); dragAndDrop('email'); + cy.waitForNetworkIdle(500); editChannel('email'); + cy.waitForNetworkIdle(500); cy.getByTestId('editable-text-content').clear().type('This text is written from a test {{firstName}}', { parseSpecialCharSequences: false, }); cy.getByTestId('emailSubject').type('this is email subject'); cy.getByTestId('emailPreheader').type('this is email preheader'); + cy.waitForNetworkIdle(500); goBack(); editChannel('inApp'); + cy.waitForNetworkIdle(500); cy.get('.ace_text-layer').first().contains('{{firstName}} someone assigned you to {{taskName}}'); goBack(); + cy.waitForNetworkIdle(500); editChannel('email'); + cy.waitForNetworkIdle(500); cy.getByTestId('editable-text-content').contains('This text is written from a test'); cy.getByTestId('emailSubject').should('have.value', 'this is email subject'); @@ -43,23 +51,22 @@ describe('Workflow Editor - Main Functionality', function () { cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); - cy.getByTestId('title').should('have.value', template.name); + cy.getByTestId('settings-page').click(); + cy.waitForNetworkIdle(500); + cy.getByTestId('title').first().should('have.value', template.name); - addAndEditChannel('inApp'); - cy.getByTestId('notification-template-submit-btn').should('not.be.disabled'); - goBack(); editChannel('inApp'); + cy.waitForNetworkIdle(500); cy.get('.ace_text-layer').first().contains('Test content for {{firstName}}'); goBack(); + cy.waitForNetworkIdle(500); - cy.getByTestId('settingsButton').click(); cy.getByTestId('title').clear().type('This is the new notification title'); - clickWorkflow(); editChannel('inApp', true); + cy.waitForNetworkIdle(500); cy.getByTestId('use-feeds-checkbox').click(); cy.getByTestId('feed-button-1').click({ force: true }); @@ -72,8 +79,8 @@ describe('Workflow Editor - Main Functionality', function () { .type('new content for notification', { force: true, }); + goBack(); cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); cy.visit('/templates'); cy.waitForNetworkIdle(500); @@ -88,38 +95,26 @@ describe('Workflow Editor - Main Functionality', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - editChannel('inApp', true); + cy.waitForNetworkIdle(500); - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); cy.getByTestId('feed-button-1-checked'); cy.getByTestId('create-feed-input').type('test4'); cy.getByTestId('add-feed-button').click(); cy.getByTestId('feed-button-2-checked'); - cy.getByTestId('notification-template-submit-btn').should('not.be.disabled'); }); it('should edit email notification', function () { const template = this.session.templates[0]; cy.visit('/templates/edit/' + template._id); + cy.waitForNetworkIdle(500); - // edit email step - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); - clickWorkflow(); editChannel('email'); - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); // edit email editor content cy.getByTestId('email-editor').getByTestId('editor-row').first().click().type('{selectall}{backspace}Hello world!'); - cy.getByTestId('notification-template-submit-btn').should('not.be.disabled'); - - // go back and update - goBack(); - cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); }); it('should update notification active status', function () { @@ -127,14 +122,20 @@ describe('Workflow Editor - Main Functionality', function () { cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); - cy.getByTestId('active-toggle-switch').get('label').contains('Enabled'); + cy.getByTestId('settings-page').click(); + cy.waitForNetworkIdle(500); + + cy.getByTestId('active-toggle-switch').get('label').contains('Active'); cy.getByTestId('active-toggle-switch').click({ force: true }); - cy.getByTestId('active-toggle-switch').get('label').contains('Disabled'); + cy.getByTestId('active-toggle-switch').get('label').contains('Inactive'); cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); - cy.getByTestId('active-toggle-switch').get('label').contains('Disabled'); + cy.getByTestId('settings-page').click(); + cy.waitForNetworkIdle(500); + + cy.getByTestId('active-toggle-switch').get('label').contains('Inactive'); }); it('should toggle active states of channels', function () { @@ -142,9 +143,11 @@ describe('Workflow Editor - Main Functionality', function () { cy.waitForNetworkIdle(500); fillBasicNotificationDetails('Test toggle active states of channels'); + + goBack(); // Enable email from button click - clickWorkflow(); dragAndDrop('email'); + cy.waitForNetworkIdle(500); cy.clickWorkflowNode(`node-emailSelector`); @@ -153,7 +156,7 @@ describe('Workflow Editor - Main Functionality', function () { // enable email selector cy.getByTestId(`step-active-switch`).click({ force: true }); - cy.getByTestId(`close-side-menu-btn`).click(); + goBack(); dragAndDrop('inApp'); @@ -166,54 +169,28 @@ describe('Workflow Editor - Main Functionality', function () { cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); - cy.getByTestId('triggerCodeSelector').click(); + cy.getByTestId('get-snippet-btn').click(); cy.getByTestId('trigger-code-snippet').contains('test-event'); }); - it('should validate form inputs', function () { - cy.visit('/templates/create'); - cy.waitForNetworkIdle(500); - - cy.getByTestId('description').type('this is a notification template description'); - cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('title').should('have.class', 'mantine-TextInput-invalid'); - fillBasicNotificationDetails('Test SMS Notification Title'); - clickWorkflow(); - dragAndDrop('inApp'); - - cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('workflowButton').getByTestId('error-circle').should('be.visible'); - cy.getByTestId('settingsButton').getByTestId('error-circle').should('be.visible'); - }); - it('should show error on node if message field is missing ', function () { cy.visit('/templates/create'); cy.waitForNetworkIdle(500); fillBasicNotificationDetails(); - clickWorkflow(); + goBack(); dragAndDrop('email'); - cy.getByTestId('notification-template-submit-btn').click(); + cy.waitForNetworkIdle(500); + cy.getByTestId('node-emailSelector').getByTestId('error-circle').should('be.visible'); editChannel('email'); + cy.waitForNetworkIdle(500); cy.getByTestId('emailSubject').should('have.class', 'mantine-TextInput-invalid'); cy.getByTestId('emailSubject').type('this is email subject'); goBack(); - cy.getByTestId('node-emailSelector').getByTestId('error-circle').should('not.exist'); - }); - - it('should fill required settings before workflow btn is clickable', function () { - cy.visit('/templates/create'); cy.waitForNetworkIdle(500); - - cy.getByTestId('description').type('this is a notification template description'); - clickWorkflow(); - cy.getByTestId('title').should('have.class', 'mantine-TextInput-invalid'); - cy.getByTestId('title').type('filled title'); - clickWorkflow(); - - cy.get('.react-flow__node').should('exist'); + cy.getByTestId('node-emailSelector').getByTestId('error-circle').should('not.exist'); }); it('should allow uploading a logo from email editor', function () { @@ -230,11 +207,12 @@ describe('Workflow Editor - Main Functionality', function () { cy.waitForNetworkIdle(500); fillBasicNotificationDetails('Test allow uploading a logo from email editor'); + addAndEditChannel('email'); cy.getByTestId('upload-image-button').click(); - cy.get('.mantine-Modal-modal button').contains('Yes').click(); + cy.location('pathname').should('equal', '/brand'); }); @@ -255,7 +233,8 @@ describe('Workflow Editor - Main Functionality', function () { cy.waitForNetworkIdle(500); fillBasicNotificationDetails('Test support RTL text content'); - clickWorkflow(); + goBack(); + cy.waitForNetworkIdle(500); dragAndDrop('email'); editChannel('email'); @@ -270,25 +249,28 @@ describe('Workflow Editor - Main Functionality', function () { cy.waitForNetworkIdle(500); fillBasicNotificationDetails('Test SMS Notification Title'); + cy.waitForNetworkIdle(500); + addAndEditChannel('sms'); + cy.waitForNetworkIdle(500); cy.getByTestId('smsNotificationContent').type('{{firstName}} someone assigned you to {{taskName}}', { parseSpecialCharSequences: false, }); + goBack(); + cy.waitForNetworkIdle(500); cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('success-trigger-modal').should('be.visible'); - cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('test-sms-notification'); - cy.getByTestId('success-trigger-modal') + cy.getByTestId('get-snippet-btn').click(); + cy.getByTestId('step-page-wrapper').should('be.visible'); + cy.getByTestId('step-page-wrapper').getByTestId('trigger-code-snippet').contains('test-sms-notification-title'); + cy.getByTestId('step-page-wrapper') .getByTestId('trigger-code-snippet') .contains("import { Novu } from '@novu/node'"); - cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('taskName'); - - cy.getByTestId('success-trigger-modal').getByTestId('trigger-code-snippet').contains('firstName'); + cy.getByTestId('step-page-wrapper').getByTestId('trigger-code-snippet').contains('taskName'); - cy.getByTestId('trigger-snippet-btn').click(); - cy.location('pathname').should('equal', '/templates'); + cy.getByTestId('step-page-wrapper').getByTestId('trigger-code-snippet').contains('firstName'); }); it('should save HTML template email', function () { @@ -306,15 +288,8 @@ describe('Workflow Editor - Main Functionality', function () { .click(); cy.get('#codeEditor').type('Hello world code {{name}}
Test', { parseSpecialCharSequences: false }); - cy.intercept('GET', '/v1/notification-templates?page=0&limit=10').as('notification-templates'); - cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('trigger-snippet-btn').click(); - - cy.wait('@notification-templates', { timeout: 60000 }); - cy.get('tbody').contains('Custom Code HTM').click(); - cy.waitForNetworkIdle(500); + goBack(); - clickWorkflow(); editChannel('email'); cy.get('#codeEditor').contains('Hello world code {{name}}
Test
'); }); @@ -326,10 +301,8 @@ describe('Workflow Editor - Main Functionality', function () { }); fillBasicNotificationDetails(); - cy.getByTestId('notification-template-submit-btn').click(); cy.wait('@createTemplate').then((res) => { - cy.getByTestId('trigger-snippet-btn').click(); cy.intercept('GET', '/v1/changes?promoted=false').as('unpromoted-changes'); cy.visit('/changes'); @@ -371,7 +344,9 @@ describe('Workflow Editor - Main Functionality', function () { cy.waitForNetworkIdle(500); fillBasicNotificationDetails('In App CTA Button'); + cy.waitForNetworkIdle(500); addAndEditChannel('inApp'); + cy.waitForNetworkIdle(500); cy.get('.ace_text-input').first().type('Text content', { force: true, @@ -380,8 +355,9 @@ describe('Workflow Editor - Main Functionality', function () { cy.getByTestId('control-add').first().click(); cy.getByTestId('template-container-click-area').eq(0).click(); + goBack(); + cy.waitForNetworkIdle(500); cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); cy.visit('/templates'); cy.waitForNetworkIdle(500); @@ -394,21 +370,19 @@ describe('Workflow Editor - Main Functionality', function () { .click(); cy.waitForNetworkIdle(500); - clickWorkflow(); editChannel('inApp'); + cy.waitForNetworkIdle(500); - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); cy.getByTestId('template-container').first().find('input').should('have.length', 1); cy.getByTestId('remove-button-icon').click(); - cy.getByTestId('notification-template-submit-btn').click(); - cy.getByTestId('notification-template-submit-btn').should('be.disabled'); - goBack(); + cy.waitForNetworkIdle(500); editChannel('inApp'); + cy.waitForNetworkIdle(500); cy.getByTestId('control-add').first(); }); 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 3a0cf5f6268..4e6a65179a1 100644 --- a/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts +++ b/apps/web/cypress/tests/notification-editor/steps-actions.spec.ts @@ -1,4 +1,4 @@ -import { clickWorkflow, dragAndDrop, editChannel } from '.'; +import { clickWorkflow, dragAndDrop, editChannel, goBack } from '.'; describe('Workflow Editor - Steps Actions', function () { beforeEach(function () { @@ -22,11 +22,11 @@ describe('Workflow Editor - Steps Actions', function () { cy.visit('/templates/edit/' + template._id); waitForEditTemplateRequests(); - clickWorkflow(); - cy.get('.react-flow__node').should('have.length', 4); - cy.getByTestId('step-actions-dropdown').first().click().getByTestId('delete-step-action').click(); - cy.get('.mantine-Modal-modal button').contains('Yes').click(); + cy.clickWorkflowNode(`node-inAppSelector`); + cy.waitForNetworkIdle(500); + cy.getByTestId('delete-step-button').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); cy.get('.react-flow__node').first().should('contain', 'Trigger').next().should('contain', 'Email'); @@ -35,8 +35,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.get('.react-flow__node').should('have.length', 3); }); @@ -47,16 +45,17 @@ describe('Workflow Editor - Steps Actions', function () { cy.visit('/templates/edit/' + template._id); waitForEditTemplateRequests(); - cy.waitLoadEnv(() => { - clickWorkflow(); - }); - cy.get('.react-flow__node').should('have.length', 4); - cy.getByTestId('step-actions-dropdown').first().click().getByTestId('delete-step-action').click(); - cy.get('.mantine-Modal-modal button').contains('Yes').click(); + cy.getByTestId('node-inAppSelector') + .getByTestId('channel-node') + .first() + .trigger('mouseover', { force: true }) + .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); - cy.getByTestId('drag-side-menu').contains('Steps to add'); + cy.getByTestId('drag-side-menu').contains('Channels'); }); it('should keep steps order on reload', function () { @@ -66,8 +65,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.visit('/templates/edit/' + template._id); waitForEditTemplateRequests(); - clickWorkflow(); - dragAndDrop('sms'); editChannel('sms'); @@ -77,8 +74,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.visit('/templates/edit/' + template._id); cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.get('.react-flow__node').should('have.length', 5); cy.get('.react-flow__node') .first() @@ -98,15 +93,13 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId(`step-active-switch`).get('label').contains('Step is active'); + cy.getByTestId(`step-active-switch`).get('label').contains('Active'); cy.getByTestId(`step-active-switch`).click({ force: true }); - cy.getByTestId('notification-template-submit-btn').click(); + goBack(); cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId(`step-active-switch`).get('label').contains('Step is not active'); + cy.getByTestId(`step-active-switch`).get('label').contains('Inactive'); }); it('should be able to toggle ShouldStopOnFailSwitch', function () { @@ -116,12 +109,10 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); - cy.getByTestId(`step-should-stop-on-fail-switch`).get('label').contains('Stop workflow if this step fails?'); + 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 }); - cy.getByTestId('notification-template-submit-btn').click(); + goBack(); cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId(`step-should-stop-on-fail-switch`).should('be.checked'); @@ -134,8 +125,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId('add-filter-btn').click(); @@ -153,10 +142,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('filter-confirm-btn').click(); - cy.get('.filter-item').should('have.length', 1); - - cy.get('.filter-item').contains('subscriber filter-key equal'); - cy.get('.filter-item-value').contains('filter-value'); + cy.getByTestId('add-filter-btn').contains('1 filter'); }); it('should be able to add read/seen filters to a particular step', function () { @@ -166,8 +152,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-emailSelector`); cy.getByTestId('add-filter-btn').click(); @@ -185,10 +169,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('filter-confirm-btn').click(); - cy.get('.filter-item').should('have.length', 1); - - cy.get('.filter-item').contains('Previous step - In-App'); - cy.get('.filter-item-value').contains('read'); + cy.getByTestId('add-filter-btn').contains('1 filter'); }); it('should be able to not add read/seen filters to first step', function () { @@ -198,8 +179,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId('add-filter-btn').click(); @@ -218,8 +197,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId('add-filter-btn').click(); @@ -235,16 +212,13 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('filter-confirm-btn').click(); - cy.get('.filter-item').should('have.length', 1); - - cy.get('.filter-item').contains('payload filter-key equal'); - cy.get('.filter-item-value').contains('filter-value'); + cy.getByTestId('add-filter-btn').contains('1 filter'); cy.getByTestId('add-filter-btn').click(); cy.getByTestId('filter-remove-btn').click(); cy.getByTestId('filter-confirm-btn').click(); - cy.get('.filter-item').should('have.length', 0); + cy.getByTestId('add-filter-btn').contains('Add filter'); }); it('should be able to add webhook filter for a particular step', function () { @@ -254,8 +228,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId('add-filter-btn').click(); @@ -275,9 +247,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('filter-confirm-btn').click(); - cy.get('.filter-item').should('have.length', 1); - cy.get('.filter-item').contains('webhook filter-key equal'); - cy.get('.filter-item-value').contains('filter-value'); + cy.getByTestId('add-filter-btn').contains('1 filter'); }); it('should be able to add online right now filter for a particular step', function () { @@ -287,8 +257,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId('add-filter-btn').click(); @@ -304,9 +272,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('filter-confirm-btn').click(); - cy.get('.filter-item').should('have.length', 1); - cy.get('.filter-item').contains('is online right now equal'); - cy.get('.filter-item-value').contains('Yes'); + cy.getByTestId('add-filter-btn').contains('1 filter'); }); it('should be able to add online in the last X time period filter for a particular step', function () { @@ -316,8 +282,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId('add-filter-btn').click(); @@ -334,9 +298,7 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('filter-confirm-btn').click(); - cy.get('.filter-item').should('have.length', 1); - cy.get('.filter-item').contains('online in the last "X" hours'); - cy.get('.filter-item-value').contains('1'); + cy.getByTestId('add-filter-btn').contains('1 filter'); }); it('should be able to add multiple filters to a particular step', function () { @@ -346,8 +308,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.waitForNetworkIdle(500); - clickWorkflow(); - cy.clickWorkflowNode(`node-inAppSelector`); cy.getByTestId('add-filter-btn').click(); @@ -371,9 +331,6 @@ describe('Workflow Editor - Steps Actions', function () { cy.getByTestId('filter-confirm-btn').click(); - cy.get('.filter-item').should('have.length', 2); - - cy.get('.filter-item').contains('subscriber filter-key equal'); - cy.get('.filter-item-value').contains('filter-value'); + cy.getByTestId('add-filter-btn').contains('2 filters'); }); }); diff --git a/apps/web/package.json b/apps/web/package.json index f6c21256aa4..f59782a5d83 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -72,7 +72,7 @@ "date-fns": "^2.29.2", "graphql": "^15.4.0", "handlebars": "^4.7.7", - "html-webpack-plugin": "5.5.0", + "html-webpack-plugin": "5.5.1", "jwt-decode": "^3.1.2", "less": "^4.1.0", "lodash.capitalize": "^4.2.1", @@ -106,6 +106,7 @@ "react-table": "^7.7.0", "react-use-intercom": "^2.0.0", "rimraf": "^3.0.2", + "slugify": "^1.4.6", "storybook-dark-mode": "^1.0.8", "typescript": "4.9.5", "uniqid": "^5.3.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9b271db600f..822e0c59474 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -34,12 +34,17 @@ import { NotificationCenter } from './pages/quick-start/steps/NotificationCenter import { FrameworkSetup } from './pages/quick-start/steps/FrameworkSetup'; import { Setup } from './pages/quick-start/steps/Setup'; import { Trigger } from './pages/quick-start/steps/Trigger'; -import { TemplateEditorProvider } from './pages/templates/editor/TemplateEditorProvider'; -import { TemplateEditorFormProvider } from './pages/templates/components/TemplateEditorFormProvider'; import { RequiredAuth } from './components/layout/RequiredAuth'; import { GetStarted } from './pages/quick-start/steps/GetStarted'; import { DigestPreview } from './pages/quick-start/steps/DigestPreview'; import { TemplatesDigestPlaygroundPage } from './pages/templates/TemplatesDigestPlaygroundPage'; +import { Sidebar } from './pages/templates/workflow/SideBar/Sidebar'; +import { TemplateSettings } from './pages/templates/components/TemplateSettings'; +import { UserPreference } from './pages/templates/components/UserPreference'; +import { TestWorkflowPage } from './pages/templates/components/TestWorkflowPage'; +import { SnippetPage } from './pages/templates/components/SnippetPage'; +import { TemplateEditor } from './pages/templates/components/TemplateEditor'; +import { ProvidersPage } from './pages/templates/components/ProvidersPage'; if (LOGROCKET_ID && window !== undefined) { LogRocket.init(LOGROCKET_ID, { @@ -175,26 +180,16 @@ function App() { }> } /> } /> - - - - - - } - /> - - - - - - } - /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/api/hooks/notification-templates/useCreateDigestDemoWorkflow.ts b/apps/web/src/api/hooks/notification-templates/useCreateDigestDemoWorkflow.ts index e0ddbdff3ca..4ea6c621808 100644 --- a/apps/web/src/api/hooks/notification-templates/useCreateDigestDemoWorkflow.ts +++ b/apps/web/src/api/hooks/notification-templates/useCreateDigestDemoWorkflow.ts @@ -8,6 +8,7 @@ import { parseUrl } from '../../../utils/routeUtils'; import { ROUTES } from '../../../constants/routes.enum'; import { errorMessage } from '../../../utils/notifications'; import { useNotificationGroup, useTemplates } from '../../../hooks'; +import { v4 as uuid4 } from 'uuid'; export const useCreateDigestDemoWorkflow = () => { const navigate = useNavigate(); @@ -47,6 +48,7 @@ export const useCreateDigestDemoWorkflow = () => { template: { type: StepTypeEnum.DIGEST, content: '' }, metadata: { amount: '10', unit: 'seconds', type: 'regular', digestKey: '' }, active: true, + uuid: uuid4(), filters: [], }, { @@ -57,8 +59,9 @@ export const useCreateDigestDemoWorkflow = () => { contentType: 'customHtml', variables: [{ type: 'String', name: 'step.digest', defaultValue: '1', required: false }], preheader: '', - content: `Hi {{subscriber.firstName}}! 👋 You've sent {{step.total_count}} events!`, + content: "Hi {{subscriber.firstName}}! 👋 You've sent {{step.total_count}} events!", }, + uuid: uuid4(), active: true, }, ], diff --git a/apps/web/src/constants/editorEnums.ts b/apps/web/src/constants/editorEnums.ts deleted file mode 100644 index e6ceaeeed40..00000000000 --- a/apps/web/src/constants/editorEnums.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum ActivePageEnum { - SETTINGS = 'Settings', - WORKFLOW = 'Workflow', - USER_PREFERENCE = 'UserPreference', - SMS = 'Sms', - EMAIL = 'Email', - IN_APP = 'in_app', - PUSH = 'Push', - CHAT = 'Chat', - TRIGGER_SNIPPET = 'TriggerSnippet', -} diff --git a/apps/web/src/design-system/button/Button.tsx b/apps/web/src/design-system/button/Button.tsx index 32643e354d8..f901db08f4d 100644 --- a/apps/web/src/design-system/button/Button.tsx +++ b/apps/web/src/design-system/button/Button.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Button as MantineButton } from '@mantine/core'; +import React, { MouseEventHandler } from 'react'; +import { Button as MantineButton, Sx } from '@mantine/core'; import useStyles from './Button.styles'; import { SpacingProps } from '../shared/spacing.props'; @@ -15,8 +15,11 @@ interface IButtonProps extends JSX.ElementChildrenAttribute, SpacingProps { fullWidth?: boolean; submit?: boolean; onClick?: (e: any) => void; + onMouseEnter?: MouseEventHandler; + onMouseLeave?: MouseEventHandler; inherit?: boolean; pulse?: boolean; + sx?: Sx; } /** diff --git a/apps/web/src/design-system/icons/actions/Filter.tsx b/apps/web/src/design-system/icons/actions/Filter.tsx new file mode 100644 index 00000000000..855246f7e56 --- /dev/null +++ b/apps/web/src/design-system/icons/actions/Filter.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +/* eslint-disable */ +export function Filter(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} diff --git a/apps/web/src/design-system/icons/general/Drag.tsx b/apps/web/src/design-system/icons/general/Drag.tsx new file mode 100644 index 00000000000..106a23dd837 --- /dev/null +++ b/apps/web/src/design-system/icons/general/Drag.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +/* eslint-disable */ +export function Drag(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} diff --git a/apps/web/src/design-system/icons/general/InfoCircle.tsx b/apps/web/src/design-system/icons/general/InfoCircle.tsx new file mode 100644 index 00000000000..9a5b3d10c25 --- /dev/null +++ b/apps/web/src/design-system/icons/general/InfoCircle.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +/* eslint-disable */ +export function InfoCircle(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + + + + + + ); +} diff --git a/apps/web/src/design-system/icons/gradient/FilterGradient.tsx b/apps/web/src/design-system/icons/gradient/FilterGradient.tsx new file mode 100644 index 00000000000..ae07815e047 --- /dev/null +++ b/apps/web/src/design-system/icons/gradient/FilterGradient.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +/* eslint-disable */ +export function FilterGradient(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/design-system/icons/gradient/FilterOutlined.tsx b/apps/web/src/design-system/icons/gradient/FilterOutlined.tsx new file mode 100644 index 00000000000..27e26dacab4 --- /dev/null +++ b/apps/web/src/design-system/icons/gradient/FilterOutlined.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +/* eslint-disable */ +export function FilterOutlined(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + + + + + ); +} diff --git a/apps/web/src/design-system/input/Input.tsx b/apps/web/src/design-system/input/Input.tsx index 4fa12ecf6f1..707641453eb 100644 --- a/apps/web/src/design-system/input/Input.tsx +++ b/apps/web/src/design-system/input/Input.tsx @@ -1,5 +1,5 @@ import React, { ChangeEvent, FocusEvent } from 'react'; -import { TextInputProps, TextInput as MantineTextInput } from '@mantine/core'; +import { TextInputProps, TextInput as MantineTextInput, Styles } from '@mantine/core'; import { inputStyles } from '../config/inputs.styles'; import { SpacingProps } from '../shared/spacing.props'; @@ -18,6 +18,7 @@ interface IInputProps extends SpacingProps { min?: string | number; max?: string | number; onBlur?: (event: FocusEvent) => void; + styles?: Styles>; } /** diff --git a/apps/web/src/design-system/segmented-control/SegmentedControl.tsx b/apps/web/src/design-system/segmented-control/SegmentedControl.tsx index 4aea33c359f..cc016d09193 100644 --- a/apps/web/src/design-system/segmented-control/SegmentedControl.tsx +++ b/apps/web/src/design-system/segmented-control/SegmentedControl.tsx @@ -4,6 +4,7 @@ import { SegmentedControlProps, SegmentedControlItem, LoadingOverlay, + Sx, } from '@mantine/core'; import useStyles from './SegmentedControl.styles'; import { colors } from '../config'; @@ -15,6 +16,9 @@ interface ISegmentedControlProps { value?: string; onChange?(value: string): void; loading?: boolean; + fullWidth?: boolean; + sx?: Sx | (Sx | undefined)[]; + disabled?: boolean; } /** diff --git a/apps/web/src/design-system/template-button/DragButton.tsx b/apps/web/src/design-system/template-button/DragButton.tsx index 50028168737..5acdc65e4cd 100644 --- a/apps/web/src/design-system/template-button/DragButton.tsx +++ b/apps/web/src/design-system/template-button/DragButton.tsx @@ -1,10 +1,12 @@ -import React from 'react'; +import React, { useState } from 'react'; import { UnstyledButton } from '@mantine/core'; import styled from '@emotion/styled'; import { Text } from '../typography/text/Text'; import { useStyles } from './TemplateButton.styles'; import { colors } from '../config'; import { When } from '../../components/utils/When'; +import { Drag } from '../icons/general/Drag'; +import { useOutletContext } from 'react-router-dom'; interface IDragButtonProps { Icon: React.FC; @@ -14,6 +16,7 @@ interface IDragButtonProps { export function DragButton({ description, label, Icon }: IDragButtonProps) { const { cx, classes, theme } = useStyles(); + const [hover, setHover] = useState(false); return ( <> @@ -23,9 +26,26 @@ export function DragButton({ description, label, Icon }: IDragButtonProps) { background: theme.colorScheme === 'dark' ? colors.B17 : colors.white, border: `1px dashed ${theme.colorScheme === 'dark' ? colors.B30 : colors.B80}`, height: description.length > 0 ? '75px' : '50px', + position: 'relative', }} className={cx(classes.button, { [classes.active]: false })} + onMouseEnter={() => { + setHover(true); + }} + onMouseLeave={() => { + setHover(false); + }} > + + + diff --git a/apps/web/src/design-system/template-button/TemplateButton.tsx b/apps/web/src/design-system/template-button/TemplateButton.tsx index 44cf241011d..e3209a6bf54 100644 --- a/apps/web/src/design-system/template-button/TemplateButton.tsx +++ b/apps/web/src/design-system/template-button/TemplateButton.tsx @@ -9,7 +9,6 @@ import { useStyles } from './TemplateButton.styles'; import { colors } from '../config'; import { Button } from './Button'; import { IconWrapper } from './IconWrapper'; -import { ActivePageEnum } from '../../constants/editorEnums'; const usePopoverStyles = createStyles(() => ({ dropdown: { @@ -75,14 +74,6 @@ export function TemplateButton({ return; } - if (tabKey === ActivePageEnum.WORKFLOW) { - const valid = await trigger(['name', 'notificationGroupId'], { shouldFocus: true }); - - if (!valid) { - return; - } - } - changeTab(tabKey); }} data-test-id={testId} diff --git a/apps/web/src/hooks/useBlueprint.ts b/apps/web/src/hooks/useBlueprint.ts index 86d95c2a3f4..4f1ee5b6cf7 100644 --- a/apps/web/src/hooks/useBlueprint.ts +++ b/apps/web/src/hooks/useBlueprint.ts @@ -3,7 +3,6 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { useEffect } from 'react'; import { getToken } from './useAuthController'; import { useSegment } from '../components/providers/SegmentProvider'; -import { ActivePageEnum } from '../constants/editorEnums'; export const useBlueprint = () => { const searchParams = useSearchParams(); @@ -16,7 +15,7 @@ export const useBlueprint = () => { const token = getToken(); if (id && token !== null) { - navigate(`/templates/create?page=${ActivePageEnum.WORKFLOW}`, { + navigate('/templates/create', { replace: true, }); } diff --git a/apps/web/src/hooks/usePrompt.ts b/apps/web/src/hooks/usePrompt.ts index c4ce74d976c..345b1c22286 100644 --- a/apps/web/src/hooks/usePrompt.ts +++ b/apps/web/src/hooks/usePrompt.ts @@ -23,7 +23,12 @@ export function useBlocker(blocker, when = true) { }, [navigator, blocker, when]); } -export const usePrompt = (when: boolean): [boolean, () => void, () => void] => { +export const usePrompt = ( + when: boolean, + checkNextLocation = (nextLocation, location) => { + return nextLocation.location.pathname !== location.pathname; + } +): [boolean, () => void, () => void] => { const navigate = useNavigate(); const location = useLocation(); const [showPrompt, setShowPrompt] = useState(false); @@ -36,7 +41,7 @@ export const usePrompt = (when: boolean): [boolean, () => void, () => void] => { const handleBlockedNavigation = useCallback( (nextLocation) => { - if (!confirmedNavigation && nextLocation.location.pathname !== location.pathname) { + if (!confirmedNavigation && checkNextLocation(nextLocation, location)) { setShowPrompt(true); setLastLocation(nextLocation); @@ -45,7 +50,7 @@ export const usePrompt = (when: boolean): [boolean, () => void, () => void] => { return true; }, - [confirmedNavigation] + [confirmedNavigation, checkNextLocation] ); const confirmNavigation = useCallback(() => { diff --git a/apps/web/src/pages/activities/components/ActivityItem.tsx b/apps/web/src/pages/activities/components/ActivityItem.tsx index 9b9e8903fe0..909a06043ad 100644 --- a/apps/web/src/pages/activities/components/ActivityItem.tsx +++ b/apps/web/src/pages/activities/components/ActivityItem.tsx @@ -120,7 +120,8 @@ export const ActivityItem = ({ item, onClick }) => {
- Subscriber id: {item?.subscriber?.id ? item.subscriber.id : 'Deleted Subscriber'} + Subscriber id: + {item?.subscriber?.subscriberId ? item.subscriber.subscriberId : 'Deleted Subscriber'}
diff --git a/apps/web/src/pages/integrations/IntegrationsStoreModal.tsx b/apps/web/src/pages/integrations/IntegrationsStoreModal.tsx index dac9a33b7fd..e1a383c3cef 100644 --- a/apps/web/src/pages/integrations/IntegrationsStoreModal.tsx +++ b/apps/web/src/pages/integrations/IntegrationsStoreModal.tsx @@ -25,10 +25,12 @@ export function IntegrationsStoreModal({ scrollTo, openIntegration, closeIntegration, + selectedProvider = null, }: { scrollTo?: ChannelTypeEnum; openIntegration: boolean; closeIntegration: () => void; + selectedProvider?: IIntegratedProvider | null; }) { const segment = useSegment(); const { environment } = useEnvController(); @@ -42,6 +44,11 @@ export function IntegrationsStoreModal({ const { classes } = useModalStyles(); const { classes: drawerClasses } = useDrawerStyles(); + useEffect(() => { + setFormIsOpened(selectedProvider !== null); + setProvider(selectedProvider); + }, [selectedProvider]); + async function handleOnProviderClick( visible: boolean, createIntegrationModal: boolean, diff --git a/apps/web/src/pages/integrations/components/ConnectIntegrationForm.cy.tsx b/apps/web/src/pages/integrations/components/ConnectIntegrationForm.cy.tsx index f03410b56b7..d362d965374 100644 --- a/apps/web/src/pages/integrations/components/ConnectIntegrationForm.cy.tsx +++ b/apps/web/src/pages/integrations/components/ConnectIntegrationForm.cy.tsx @@ -68,10 +68,9 @@ it('close button calls onClose', () => { ); - cy.get('[data-test-id="connection-integration-form-close"]').should('have.attr', 'type', 'button'); - // eslint-disable-next-line cypress/unsafe-to-chain-command cy.get('[data-test-id="connection-integration-form-close"]') + .should('have.attr', 'type', 'button') .click() .then((e) => { expect(onCloseStub).to.be.called; diff --git a/apps/web/src/pages/templates/TemplatesDigestPlaygroundPage.tsx b/apps/web/src/pages/templates/TemplatesDigestPlaygroundPage.tsx index 887e471d52f..9efffead452 100644 --- a/apps/web/src/pages/templates/TemplatesDigestPlaygroundPage.tsx +++ b/apps/web/src/pages/templates/TemplatesDigestPlaygroundPage.tsx @@ -10,6 +10,7 @@ import { ROUTES } from '../../constants/routes.enum'; import { DigestDemoFlow } from '../../components'; import { useSegment } from '../../components/providers/SegmentProvider'; import { DigestPlaygroundAnalyticsEnum } from './constants'; +import { useTourStorage } from './hooks/useTourStorage'; const Heading = styled(Title)` color: ${colors.B40}; @@ -30,6 +31,7 @@ export const TemplatesDigestPlaygroundPage = () => { const segment = useSegment(); const { templateId = '' } = useParams<{ templateId: string }>(); const navigate = useNavigate(); + const tourStorage = useTourStorage(); const handleBackClick = () => { segment.track(DigestPlaygroundAnalyticsEnum.BACK_BUTTON_CLICK); @@ -38,7 +40,8 @@ export const TemplatesDigestPlaygroundPage = () => { const handleSetupDigestWorkflowClick = () => { segment.track(DigestPlaygroundAnalyticsEnum.SETUP_DIGEST_WORKFLOW_CLICK); - navigate(`${parseUrl(ROUTES.TEMPLATES_EDIT_TEMPLATEID, { templateId })}?tour=digest`); + tourStorage.setTour('digest', templateId, 0); + navigate(`${parseUrl(ROUTES.TEMPLATES_EDIT_TEMPLATEID, { templateId })}`); }; const handleLearnMoreClick = () => { diff --git a/apps/web/src/pages/templates/TemplatesListPage.tsx b/apps/web/src/pages/templates/TemplatesListPage.tsx index 12e9d6f7cef..4840933289b 100644 --- a/apps/web/src/pages/templates/TemplatesListPage.tsx +++ b/apps/web/src/pages/templates/TemplatesListPage.tsx @@ -129,7 +129,7 @@ function NotificationList() { icon={} data-test-id="create-template-btn" > - New + Create Workflow } /> diff --git a/apps/web/src/pages/templates/components/BlueprintModal.tsx b/apps/web/src/pages/templates/components/BlueprintModal.tsx index 4980939643b..c9b18d15e80 100644 --- a/apps/web/src/pages/templates/components/BlueprintModal.tsx +++ b/apps/web/src/pages/templates/components/BlueprintModal.tsx @@ -10,7 +10,6 @@ import { createTemplateFromBluePrintId, getBlueprintTemplateById } from '../../. import { errorMessage } from '../../../utils/notifications'; import { When } from '../../../components/utils/When'; import { useSegment } from '../../../components/providers/SegmentProvider'; -import { ActivePageEnum } from '../../../constants/editorEnums'; export function BlueprintModal() { const theme = useMantineTheme(); @@ -20,10 +19,10 @@ export function BlueprintModal() { segment.track('Blueprint canceled', { blueprintId: localStorage.getItem('blueprintId'), }); - localStorage.removeItem('blueprintId'); navigate('/templates', { replace: true, }); + localStorage.removeItem('blueprintId'); }; const { mutateAsync: updateOnBoardingStatus } = useMutation< @@ -54,13 +53,13 @@ export function BlueprintModal() { const { mutate, isLoading: isCreating } = useMutation(createTemplateFromBluePrintId, { onSuccess: (template) => { - localStorage.removeItem('blueprintId'); if (template) { disableOnboarding(); - navigate(`/templates/edit/${template?._id}?page=${ActivePageEnum.WORKFLOW}`, { + navigate(`/templates/edit/${template?._id}`, { replace: true, }); } + localStorage.removeItem('blueprintId'); }, onError: (err: any) => { if (err?.message) { diff --git a/apps/web/src/pages/templates/components/ChannelTitle.tsx b/apps/web/src/pages/templates/components/ChannelTitle.tsx new file mode 100644 index 00000000000..0f7ce4c3b65 --- /dev/null +++ b/apps/web/src/pages/templates/components/ChannelTitle.tsx @@ -0,0 +1,71 @@ +import { Group } from '@mantine/core'; +import { ChannelTypeEnum, StepTypeEnum } from '@novu/shared'; +import { Bell, Chat, DigestGradient, Mail, Mobile, Sms, TimerGradient } from '../../../design-system/icons'; + +export const ChannelTitle = ({ + channel, + spacing = 16, + color = undefined, +}: { + channel: StepTypeEnum | ChannelTypeEnum; + spacing?: number; + color?: any; +}) => { + if (channel === StepTypeEnum.EMAIL || channel === ChannelTypeEnum.EMAIL) { + return ( + + Email + + ); + } + + if (channel === StepTypeEnum.IN_APP || channel === ChannelTypeEnum.IN_APP) { + return ( + + In-App + + ); + } + + if (channel === StepTypeEnum.CHAT || channel === ChannelTypeEnum.CHAT) { + return ( + + Chat + + ); + } + + if (channel === StepTypeEnum.PUSH || channel === ChannelTypeEnum.PUSH) { + return ( + + Push + + ); + } + + if (channel === StepTypeEnum.SMS || channel === ChannelTypeEnum.SMS) { + return ( + + SMS + + ); + } + + if (channel === StepTypeEnum.DELAY) { + return ( + + Delay + + ); + } + + if (channel === StepTypeEnum.DIGEST) { + return ( + + Digest + + ); + } + + return <>{channel}; +}; diff --git a/apps/web/src/pages/templates/components/DeleteConfirmModal.tsx b/apps/web/src/pages/templates/components/DeleteConfirmModal.tsx index 745c67adb5e..0b276c06c0c 100644 --- a/apps/web/src/pages/templates/components/DeleteConfirmModal.tsx +++ b/apps/web/src/pages/templates/components/DeleteConfirmModal.tsx @@ -4,20 +4,29 @@ import { Button, colors, shadows, Title, Text } from '../../../design-system'; export function DeleteConfirmModal({ target, + title, + description, isOpen, cancel, confirm, + confirmButtonText = 'Yes', + cancelButtonText = 'No', isLoading, error, }: { - target: string; + target?: string; isOpen: boolean; cancel: () => void; confirm: () => void; isLoading?: boolean; error?: string; + title?: string; + description?: string; + confirmButtonText?: string; + cancelButtonText?: string; }) { const theme = useMantineTheme(); + const targetText = target ? ' ' + target : ''; return ( <> @@ -36,7 +45,7 @@ export function DeleteConfirmModal({ paddingTop: '180px', }, }} - title={Delete {target}} + title={{title ? title : `Delete${targetText}`}} sx={{ backdropFilter: 'blur(10px)' }} shadow={theme.colorScheme === 'dark' ? shadows.dark : shadows.medium} radius="md" @@ -56,13 +65,13 @@ export function DeleteConfirmModal({ {error} )} - Would you like to delete this {target}? + {description ? description : `Would you like to delete this${targetText}?`}
diff --git a/apps/web/src/pages/templates/components/DeleteStepRow.tsx b/apps/web/src/pages/templates/components/DeleteStepRow.tsx new file mode 100644 index 00000000000..c0c2b61f701 --- /dev/null +++ b/apps/web/src/pages/templates/components/DeleteStepRow.tsx @@ -0,0 +1,78 @@ +import { StepTypeEnum } from '@novu/shared'; +import { useEnvController } from '../../../hooks'; +import { useOutletContext, useParams } from 'react-router-dom'; +import { Button, colors } from '../../../design-system'; +import styled from '@emotion/styled'; +import { Trash } from '../../../design-system/icons'; +import { Group } from '@mantine/core'; +import { When } from '../../../components/utils/When'; + +export const DeleteStepRow = () => { + const { channel, stepUuid = '' } = useParams<{ + channel: StepTypeEnum; + stepUuid: string; + }>(); + const { readonly } = useEnvController(); + const { onDelete }: any = useOutletContext(); + + if (!channel) { + return null; + } + + return ( + + +
+ + + + Learn more in the docs + + + + + Learn more in the docs + + + { + onDelete(stepUuid); + }} + disabled={readonly} + > + + Delete Step + + + ); +}; + +const DeleteStepButton = styled(Button)` + background: rgba(229, 69, 69, 0.15); + color: ${colors.error}; + box-shadow: none; +`; diff --git a/apps/web/src/pages/templates/components/EditorPreviewSwitch.tsx b/apps/web/src/pages/templates/components/EditorPreviewSwitch.tsx index f01c0fc5f3f..d595b159b1a 100644 --- a/apps/web/src/pages/templates/components/EditorPreviewSwitch.tsx +++ b/apps/web/src/pages/templates/components/EditorPreviewSwitch.tsx @@ -13,6 +13,8 @@ export const EditorPreviewSwitch = ({ view, setView }) => { background: 'transparent', border: `1px solid ${theme.colorScheme === 'dark' ? colors.B40 : colors.B70}`, borderRadius: '30px', + width: '100%', + maxWidth: '300px', }, label: { fontSize: '14px', diff --git a/apps/web/src/pages/templates/components/ExecutionDetailsModalWrapper.tsx b/apps/web/src/pages/templates/components/ExecutionDetailsModalWrapper.tsx index d9baef37a67..8b6f10b185e 100644 --- a/apps/web/src/pages/templates/components/ExecutionDetailsModalWrapper.tsx +++ b/apps/web/src/pages/templates/components/ExecutionDetailsModalWrapper.tsx @@ -30,9 +30,9 @@ export const ExecutionDetailsModalWrapper = ({ transactionId, isOpen, onClose }: color: colors.error, }} /> - {notification?.data?.length && ( + {notification?.data?.length && notification?.data?.length > 0 ? ( - )} + ) : null} ); }; diff --git a/apps/web/src/pages/templates/components/LackIntegrationError.tsx b/apps/web/src/pages/templates/components/LackIntegrationError.tsx index bc75ce32f95..42216297bac 100644 --- a/apps/web/src/pages/templates/components/LackIntegrationError.tsx +++ b/apps/web/src/pages/templates/components/LackIntegrationError.tsx @@ -6,20 +6,22 @@ import { Text } from '../../../design-system'; import { DoubleArrowRight } from '../../../design-system/icons/arrows/CircleArrowRight'; import { IntegrationsStoreModal } from '../../integrations/IntegrationsStoreModal'; import { useSegment } from '../../../components/providers/SegmentProvider'; -import { TemplateEditorAnalyticsEnum } from '../constants'; +import { stepNames, TemplateEditorAnalyticsEnum } from '../constants'; const DoubleArrowRightStyled = styled(DoubleArrowRight)` cursor: pointer; `; export function LackIntegrationError({ - channel, channelType, text, + iconHeight = 18, + iconWidth = 18, }: { - channel: string; channelType: ChannelTypeEnum; text?: string; + iconHeight?: number | string | undefined; + iconWidth?: number | string | undefined; }) { const segment = useSegment(); const [isIntegrationsModalOpened, openIntegrationsModal] = useState(false); @@ -30,13 +32,17 @@ export function LackIntegrationError({ {text ? text - : `Looks like you haven’t configured your ${channel} provider yet, this channel will be disabled until you configure it.`} + : 'Looks like you haven’t configured your ' + + stepNames[channelType] + + ' provider yet, this channel will be disabled until you configure it.'} { openIntegrationsModal(true); segment.track(TemplateEditorAnalyticsEnum.CONFIGURE_PROVIDER_BANNER_CLICK); }} + height={iconHeight} + width={iconWidth} /> void; + setProvider: (provider: IIntegratedProvider) => void; +}) => { + const { colorScheme } = useMantineColorScheme(); + + return ( +
+
+ + + + +
+ provider.connected).length === 0}> +
+ +
+
+ {providers + .filter((provider) => provider.connected) + .map((provider) => { + return ( + { + setProvider(provider); + setConfigureChannel(provider.channel); + }} + > + + {provider.displayName} + + Active + Disabled + + + + ); + })} +
+ ); +}; diff --git a/apps/web/src/pages/templates/components/NavigateValidatorModal.tsx b/apps/web/src/pages/templates/components/NavigateValidatorModal.tsx index 40f060452d8..653350bd47a 100644 --- a/apps/web/src/pages/templates/components/NavigateValidatorModal.tsx +++ b/apps/web/src/pages/templates/components/NavigateValidatorModal.tsx @@ -5,22 +5,19 @@ import { Button, colors, shadows, Title, Text } from '../../../design-system'; export function NavigateValidatorModal({ isOpen, - setModalVisibility, - navigateRoute, - navigateName, + onCancel, + onConfirm, }: { isOpen: boolean; - setModalVisibility: (boolean) => void; - navigateRoute: string; - navigateName: string; + onCancel: () => void; + onConfirm: () => void; }) { const theme = useMantineTheme(); - const navigate = useNavigate(); return ( <> setModalVisibility(false)} + onClose={onCancel} opened={isOpen} overlayColor={theme.colorScheme === 'dark' ? colors.BGDark : colors.BGLight} overlayOpacity={0.7} @@ -35,7 +32,7 @@ export function NavigateValidatorModal({ paddingTop: '180px', }, }} - title={Navigate to the {navigateName}?} + title={Unsaved changes will be deleted} sx={{ backdropFilter: 'blur(10px)' }} shadow={theme.colorScheme === 'dark' ? shadows.dark : shadows.medium} radius="md" @@ -44,10 +41,10 @@ export function NavigateValidatorModal({
Any unsaved changes will be deleted. Proceed anyway? - - diff --git a/apps/web/src/pages/templates/components/ProvidersPage.tsx b/apps/web/src/pages/templates/components/ProvidersPage.tsx new file mode 100644 index 00000000000..75313d0159d --- /dev/null +++ b/apps/web/src/pages/templates/components/ProvidersPage.tsx @@ -0,0 +1,46 @@ +import { Center, Loader } from '@mantine/core'; +import { ChannelTypeEnum } from '@novu/shared'; +import { useState } from 'react'; +import { colors } from '../../../design-system'; +import { useIntegrations } from '../../../hooks'; +import { IIntegratedProvider, IntegrationsStoreModal } from '../../integrations/IntegrationsStoreModal'; +import { useProviders } from '../../integrations/useProviders'; +import { ListProviders } from './ListProviders'; +import { SubPageWrapper } from './SubPageWrapper'; +import { WorkflowSettingsTabs } from './WorkflowSettingsTabs'; + +export function ProvidersPage() { + const { loading: isLoading } = useIntegrations({ refetchOnMount: false }); + const { emailProviders, smsProvider, chatProvider, pushProvider } = useProviders(); + const [configureChannel, setConfigureChannel] = useState(undefined); + const [provider, setProvider] = useState(null); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> + + + + + + + + { + setProvider(null); + setConfigureChannel(undefined); + }} + scrollTo={configureChannel} + /> + + ); +} diff --git a/apps/web/src/pages/templates/components/SnippetPage.tsx b/apps/web/src/pages/templates/components/SnippetPage.tsx new file mode 100644 index 00000000000..ca06f4abcb1 --- /dev/null +++ b/apps/web/src/pages/templates/components/SnippetPage.tsx @@ -0,0 +1,24 @@ +import { Text } from '@mantine/core'; +import { colors } from '../../../design-system'; +import { TriggerSnippetTabs } from './TriggerSnippetTabs'; +import { useTemplateEditorForm } from './TemplateEditorFormProvider'; +import { SubPageWrapper } from './SubPageWrapper'; +import { TriggerSegmentControl } from './TriggerSegmentControl'; +import { When } from '../../../components/utils/When'; + +export function SnippetPage() { + const { trigger, isCreating, isUpdating } = useTemplateEditorForm(); + + return ( + + + Test trigger as if you sent it from your API or implement it by copy/pasting it into the codebase of your + application. + + + + {trigger && } + + + ); +} diff --git a/apps/web/src/pages/templates/components/StepName.tsx b/apps/web/src/pages/templates/components/StepName.tsx new file mode 100644 index 00000000000..fd79cfe89cb --- /dev/null +++ b/apps/web/src/pages/templates/components/StepName.tsx @@ -0,0 +1,33 @@ +import { Group } from '@mantine/core'; +import { ChannelTypeEnum, StepTypeEnum } from '@novu/shared'; +import { When } from '../../../components/utils/When'; +import { UpdateButton } from './UpdateButton'; +import { StepNameInput } from './StepNameInput'; +import { stepIcon, stepNames } from '../constants'; + +export const StepName = ({ + channel, + color = undefined, + index, +}: { + channel: StepTypeEnum | ChannelTypeEnum; + index: number; + color?: any; +}) => { + const Icon = stepIcon[channel]; + + return ( + + + +
+ +
+
+
+ ); +}; diff --git a/apps/web/src/pages/templates/components/StepNameInput.tsx b/apps/web/src/pages/templates/components/StepNameInput.tsx new file mode 100644 index 00000000000..2efa3ca516d --- /dev/null +++ b/apps/web/src/pages/templates/components/StepNameInput.tsx @@ -0,0 +1,72 @@ +import { TextInput, useMantineColorScheme } from '@mantine/core'; +import { Controller, useFormContext } from 'react-hook-form'; +import { useEnvController } from '../../../hooks'; +import { IForm } from './formTypes'; +import { colors } from '@novu/notification-center'; + +export const StepNameInput = ({ index, defaultValue }: { index: number; defaultValue: string }) => { + const { + control, + formState: { errors, isSubmitted }, + } = useFormContext(); + const { readonly } = useEnvController(); + const showErrors = isSubmitted && errors?.steps; + const { colorScheme } = useMantineColorScheme(); + + return ( + { + return ( + ({ + root: { + flex: '1 1 auto', + marginRight: 16, + }, + wrapper: { + background: 'transparent', + width: '100%', + }, + input: { + background: 'transparent', + borderStyle: 'solid', + borderColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[5], + borderWidth: '1px', + fontSize: '20px', + fontWeight: 'bolder', + padding: 9, + lineHeight: '28px', + minHeight: 'auto', + height: 'auto', + width: '100%', + textOverflow: 'ellipsis', + '&:not(:placeholder-shown)': { + borderStyle: 'none', + padding: 10, + }, + '&:hover, &:focus': { + borderStyle: 'solid', + padding: 9, + }, + '&:disabled': { + backgroundColor: colorScheme === 'dark' ? colors.B17 : theme.white, + color: colorScheme === 'dark' ? theme.white : theme.black, + opacity: 1, + }, + }, + })} + {...field} + value={field.value !== undefined ? field.value : defaultValue} + error={showErrors && fieldState.error?.message} + type="text" + data-test-id="step-name" + placeholder="Enter step name" + disabled={readonly} + /> + ); + }} + /> + ); +}; diff --git a/apps/web/src/pages/templates/components/SubPageWrapper.tsx b/apps/web/src/pages/templates/components/SubPageWrapper.tsx new file mode 100644 index 00000000000..54b6b23dbd5 --- /dev/null +++ b/apps/web/src/pages/templates/components/SubPageWrapper.tsx @@ -0,0 +1,72 @@ +import { Stack, Title, UnstyledButton, useMantineColorScheme } from '@mantine/core'; +import { CSSProperties } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { colors } from '../../../design-system'; +import { Close } from '../../../design-system/icons/actions/Close'; +import { useBasePath } from '../hooks/useBasePath'; + +export const SubPageWrapper = ({ + children, + title, + style, + color = colors.B60, +}: { + children: any; + title: string | any; + style?: CSSProperties | undefined; + color?: string; +}) => { + const navigate = useNavigate(); + const path = useBasePath(); + const { colorScheme } = useMantineColorScheme(); + + return ( +
+
+ + + {title} + + + { + navigate(path); + }} + data-test-id="close-step-page" + > + + +
+ {children} +
+ ); +}; diff --git a/apps/web/src/pages/templates/components/TemplateEditor.tsx b/apps/web/src/pages/templates/components/TemplateEditor.tsx index 4974d0c1cc6..552e5a8b603 100644 --- a/apps/web/src/pages/templates/components/TemplateEditor.tsx +++ b/apps/web/src/pages/templates/components/TemplateEditor.tsx @@ -1,6 +1,5 @@ import { useFormContext } from 'react-hook-form'; import { ChannelTypeEnum, StepTypeEnum } from '@novu/shared'; - import { EmailMessagesCards } from './email-editor/EmailMessagesCards'; import { TemplateInAppEditor } from './in-app-editor/TemplateInAppEditor'; import { TemplateSMSEditor } from './TemplateSMSEditor'; @@ -8,15 +7,21 @@ import type { IForm } from './formTypes'; import { TemplatePushEditor } from './TemplatePushEditor'; import { TemplateChatEditor } from './chat-editor/TemplateChatEditor'; import { useActiveIntegrations } from '../../../hooks'; -import { ActivePageEnum } from '../../../constants/editorEnums'; +import { useNavigate, useParams } from 'react-router-dom'; +import { SubPageWrapper } from './SubPageWrapper'; +import { DigestMetadata } from '../workflow/DigestMetadata'; +import { DelayMetadata } from '../workflow/DelayMetadata'; +import { colors } from '../../../design-system'; +import { useEffect, useMemo } from 'react'; +import { useBasePath } from '../hooks/useBasePath'; +import { StepName } from './StepName'; +import { DeleteStepRow } from './DeleteStepRow'; -export const TemplateEditor = ({ - activePage, - activeStepIndex, -}: { - activePage: ActivePageEnum; - activeStepIndex: number; -}) => { +export const TemplateEditor = () => { + const { channel, stepUuid = '' } = useParams<{ + channel: StepTypeEnum | undefined; + stepUuid: string; + }>(); const { integrations } = useActiveIntegrations(); const { control, @@ -25,81 +30,90 @@ export const TemplateEditor = ({ } = useFormContext(); const steps = watch('steps'); + const index = useMemo( + () => steps.findIndex((message) => message.template.type === channel && message.uuid === stepUuid), + [channel, stepUuid, steps] + ); + + const navigate = useNavigate(); + const basePath = useBasePath(); + + useEffect(() => { + if (index > -1 || steps.length === 0) { + return; + } + navigate(basePath); + }, [index, steps]); + + if (index === -1 || channel === undefined) { + return null; + } + + if (channel === StepTypeEnum.IN_APP) { + return ( + } + style={{ width: '100%', borderTopLeftRadius: 7, borderBottomLeftRadius: 7, paddingBottom: 96 }} + > + + + + ); + } + + if (channel === StepTypeEnum.EMAIL) { + return ( + } + style={{ width: '100%', borderTopLeftRadius: 7, borderBottomLeftRadius: 7, paddingBottom: 96 }} + > + integration.channel === ChannelTypeEnum.EMAIL)} + /> + + + ); + } + return ( -
- {activePage === ActivePageEnum.SMS && ( -
- {steps.map((message, index) => { - return message.template.type === StepTypeEnum.SMS && activeStepIndex === index ? ( - integration.channel === ChannelTypeEnum.SMS)} - /> - ) : null; - })} -
- )} - {activePage === ActivePageEnum.EMAIL && ( -
- {steps.map((message, index) => { - return message.template.type === StepTypeEnum.EMAIL && activeStepIndex === index ? ( - integration.channel === ChannelTypeEnum.EMAIL) - } - /> - ) : null; - })} -
- )} - {activePage === ActivePageEnum.IN_APP && ( - <> - {steps.map((message, index) => { - return message.template.type === StepTypeEnum.IN_APP && activeStepIndex === index ? ( - - ) : null; - })} - - )} - {activePage === ActivePageEnum.PUSH && ( -
- {steps.map((message, index) => { - return message.template.type === StepTypeEnum.PUSH && activeStepIndex === index ? ( - integration.channel === ChannelTypeEnum.PUSH) - } - /> - ) : null; - })} -
- )} - {activePage === ActivePageEnum.CHAT && ( -
- {steps.map((message, index) => { - return message.template.type === StepTypeEnum.CHAT && activeStepIndex === index ? ( - integration.channel === ChannelTypeEnum.CHAT) - } - /> - ) : null; - })} -
- )} -
+ <> + } + style={{ paddingBottom: 96 }} + > + {channel === StepTypeEnum.SMS && ( + integration.channel === ChannelTypeEnum.SMS)} + /> + )} + {channel === StepTypeEnum.PUSH && ( + integration.channel === ChannelTypeEnum.PUSH)} + /> + )} + {channel === StepTypeEnum.CHAT && ( + integration.channel === ChannelTypeEnum.CHAT)} + /> + )} + {channel === StepTypeEnum.DIGEST && } + {channel === StepTypeEnum.DELAY && } + + + ); }; diff --git a/apps/web/src/pages/templates/components/TemplateEditorFormProvider.tsx b/apps/web/src/pages/templates/components/TemplateEditorFormProvider.tsx index 53c6a72fd3e..6a928496442 100644 --- a/apps/web/src/pages/templates/components/TemplateEditorFormProvider.tsx +++ b/apps/web/src/pages/templates/components/TemplateEditorFormProvider.tsx @@ -1,4 +1,5 @@ import { createContext, useEffect, useMemo, useCallback, useContext, useState } from 'react'; +import slugify from 'slugify'; import { FormProvider, useForm, useFieldArray } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useParams } from 'react-router-dom'; @@ -9,9 +10,12 @@ import { StepTypeEnum, ActorTypeEnum, EmailBlockTypeEnum, IEmailBlock, TextAlign import type { IForm, IStepEntity } from './formTypes'; import { useTemplateController } from './useTemplateController'; import { mapNotificationTemplateToForm, mapFormToCreateNotificationTemplate } from './templateToFormMappers'; -import { errorMessage } from '../../../utils/notifications'; +import { errorMessage, successMessage } from '../../../utils/notifications'; import { schema } from './notificationTemplateSchema'; import { v4 as uuid4 } from 'uuid'; +import { useNotificationGroup } from '../../../hooks'; +import { useCreate } from '../hooks/useCreate'; +import { stepNames } from '../constants'; const defaultEmailBlocks: IEmailBlock[] = [ { @@ -23,41 +27,45 @@ const defaultEmailBlocks: IEmailBlock[] = [ }, ]; -const makeStep = (channelType: StepTypeEnum, id: string): IStepEntity => ({ - _id: id, - uuid: uuid4(), - template: { - type: channelType, - content: channelType === StepTypeEnum.EMAIL ? defaultEmailBlocks : '', - contentType: 'editor', - variables: [], - ...(channelType === StepTypeEnum.IN_APP && { - actor: { - type: ActorTypeEnum.NONE, - data: null, +const makeStep = (channelType: StepTypeEnum, id: string): IStepEntity => { + return { + _id: id, + uuid: uuid4(), + name: stepNames[channelType], + template: { + subject: '', + type: channelType, + content: channelType === StepTypeEnum.EMAIL ? defaultEmailBlocks : '', + contentType: 'editor', + variables: [], + ...(channelType === StepTypeEnum.IN_APP && { + actor: { + type: ActorTypeEnum.NONE, + data: null, + }, + enableAvatar: false, + }), + }, + active: true, + shouldStopOnFail: false, + filters: [], + ...(channelType === StepTypeEnum.EMAIL && { + replyCallback: { + active: false, }, - enableAvatar: false, }), - }, - active: true, - shouldStopOnFail: false, - filters: [], - ...(channelType === StepTypeEnum.EMAIL && { - replyCallback: { - active: false, - }, - }), - ...(channelType === StepTypeEnum.DIGEST && { - metadata: { - type: DigestTypeEnum.REGULAR, - }, - }), - ...(channelType === StepTypeEnum.DELAY && { - metadata: { - type: DigestTypeEnum.REGULAR, - }, - }), -}); + ...(channelType === StepTypeEnum.DIGEST && { + metadata: { + type: DigestTypeEnum.REGULAR, + }, + }), + ...(channelType === StepTypeEnum.DELAY && { + metadata: { + type: DigestTypeEnum.REGULAR, + }, + }), + }; +}; interface ITemplateEditorFormContext { template?: INotificationTemplate; @@ -65,10 +73,8 @@ interface ITemplateEditorFormContext { isCreating: boolean; isUpdating: boolean; isDeleting: boolean; - editMode: boolean; trigger?: INotificationTrigger; - createdTemplateId?: string; - onSubmit: (data: IForm, callbacks?: { onCreateSuccess: () => void }) => Promise; + onSubmit: (data: IForm) => Promise; addStep: (channelType: StepTypeEnum, id: string, stepIndex?: number) => void; deleteStep: (index: number) => void; } @@ -78,16 +84,14 @@ const TemplateEditorFormContext = createContext({ isCreating: false, isUpdating: false, isDeleting: false, - editMode: true, trigger: undefined, - createdTemplateId: undefined, onSubmit: (() => {}) as any, addStep: () => {}, deleteStep: () => {}, }); const defaultValues: IForm = { - name: '', + name: 'Untitled', notificationGroupId: '', description: '', identifier: '', @@ -110,43 +114,47 @@ const TemplateEditorFormProvider = ({ children }) => { defaultValues, mode: 'onChange', }); - const [{ editMode, trigger, createdTemplateId }, setState] = useState<{ - editMode: boolean; - trigger?: INotificationTrigger; - createdTemplateId?: string; - }>({ - editMode: !!templateId, - }); - - const setTrigger = useCallback( - (newTrigger: INotificationTrigger) => setState((old) => ({ ...old, trigger: newTrigger })), - [setState] - ); - - const setCreatedTemplateId = useCallback( - (newCreatedTemplateId: string) => setState((old) => ({ ...old, createdTemplateId: newCreatedTemplateId })), - [setState] - ); + const [trigger, setTrigger] = useState(); const { reset, - formState: { isDirty: isDirtyForm }, + formState: { isDirty: isDirtyForm, isValid }, + watch, } = methods; + const name = watch('name'); + const identifier = watch('identifier'); + const steps = useFieldArray({ control: methods.control, name: 'steps', }); - const { - template, - isLoading, - isCreating, - isUpdating, - isDeleting, - updateNotificationTemplate, - createNotificationTemplate, - } = useTemplateController(templateId); + useEffect(() => { + if (!template?.triggers[0].identifier.includes('untitled')) { + return; + } + const newIdentifier = slugify(name, { + lower: true, + strict: true, + }); + + if (newIdentifier === identifier) { + return; + } + + methods.setValue('identifier', newIdentifier); + if (trigger) { + setTrigger({ + ...trigger, + identifier: newIdentifier, + }); + } + }, [name]); + + const { template, isLoading, isCreating, isUpdating, isDeleting, updateNotificationTemplate } = + useTemplateController(templateId); + const { groups, loading: loadingGroups } = useNotificationGroup(); useEffect(() => { if (isDirtyForm) { @@ -154,33 +162,30 @@ const TemplateEditorFormProvider = ({ children }) => { } if (template && template.steps) { + setTrigger(template.triggers[0]); const form = mapNotificationTemplateToForm(template); reset(form); - setTrigger(template.triggers[0]); } }, [isDirtyForm, template]); + useCreate(templateId, groups, setTrigger, methods.getValues); + const onSubmit = useCallback( - async (form: IForm, { onCreateSuccess } = {}) => { + async (form: IForm, showMessage = true) => { const payloadToCreate = mapFormToCreateNotificationTemplate(form); try { - if (editMode) { - const response = await updateNotificationTemplate({ - id: templateId, - data: { - ...payloadToCreate, - identifier: form.identifier, - }, - }); - setTrigger(response.triggers[0]); - reset(form); - } else { - const response = await createNotificationTemplate({ ...payloadToCreate, active: true, draft: false }); - setTrigger(response.triggers[0]); - setCreatedTemplateId(response._id || ''); - reset(payloadToCreate); - onCreateSuccess?.(); + const response = await updateNotificationTemplate({ + id: templateId, + data: { + ...payloadToCreate, + identifier: form.identifier, + }, + }); + setTrigger(response.triggers[0]); + reset(payloadToCreate); + if (showMessage) { + successMessage('Trigger code is updated successfully', 'workflowSaved'); } } catch (e: any) { Sentry.captureException(e); @@ -188,15 +193,7 @@ const TemplateEditorFormProvider = ({ children }) => { errorMessage(e.message || 'Unexpected error occurred'); } }, - [ - templateId, - editMode, - updateNotificationTemplate, - createNotificationTemplate, - reset, - setTrigger, - setCreatedTemplateId, - ] + [templateId, updateNotificationTemplate, setTrigger] ); const addStep = useCallback( @@ -222,30 +219,16 @@ const TemplateEditorFormProvider = ({ children }) => { const value = useMemo( () => ({ template, - isLoading, + isLoading: isLoading || loadingGroups, isCreating, isUpdating, isDeleting, - editMode: editMode, trigger: trigger, - createdTemplateId: createdTemplateId, onSubmit, addStep, deleteStep, }), - [ - template, - isLoading, - isCreating, - isUpdating, - isDeleting, - editMode, - trigger, - createdTemplateId, - onSubmit, - addStep, - deleteStep, - ] + [template, isLoading, isCreating, isUpdating, isDeleting, trigger, onSubmit, addStep, deleteStep, loadingGroups] ); return ( diff --git a/apps/web/src/pages/templates/components/TemplatePageHeader.tsx b/apps/web/src/pages/templates/components/TemplatePageHeader.tsx deleted file mode 100644 index 59552b65c89..00000000000 --- a/apps/web/src/pages/templates/components/TemplatePageHeader.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { Center, Container, Grid, Group } from '@mantine/core'; - -import { Button, colors, Switch, Title, Text } from '../../../design-system'; -import { ArrowLeft } from '../../../design-system/icons'; -import { EditorPages } from '../editor/TemplateEditorPage'; -import { useEnvController } from '../../../hooks'; -import { When } from '../../../components/utils/When'; -import { useTemplateEditorForm } from './TemplateEditorFormProvider'; -import { useStatusChangeControllerHook } from './useStatusChangeController'; -import { ActivePageEnum } from '../../../constants/editorEnums'; - -const Header = ({ - activePage, - editMode, - name = 'Workflow Editor', -}: { - editMode: boolean; - activePage: ActivePageEnum; - name?: string; -}) => { - if (activePage === ActivePageEnum.SETTINGS) { - return <>{editMode ? 'Edit Template' : 'Create new template'}; - } - if (activePage === ActivePageEnum.WORKFLOW) { - return <>{name}; - } - - if (activePage === ActivePageEnum.USER_PREFERENCE) { - return <>{'User Preference Editor'}; - } - - if (activePage === ActivePageEnum.SMS) { - return <>{'Edit SMS Template'}; - } - - if (activePage === ActivePageEnum.EMAIL) { - return <>{'Edit Email Template'}; - } - - if (activePage === ActivePageEnum.PUSH) { - return <>{'Edit Push Template'}; - } - - if (activePage === ActivePageEnum.CHAT) { - return <>{'Edit Chat Template'}; - } - - if (activePage === ActivePageEnum.IN_APP) { - return <>{'Edit Notification Template'}; - } - - return <>{editMode ? 'Edit Template' : 'Create new template'}; -}; - -interface Props { - templateId: string; - loading: boolean; - disableSubmit: boolean; - setActivePage: (activePage: ActivePageEnum) => void; - activePage: ActivePageEnum; - onTestWorkflowClicked: () => void; -} - -export const TemplatePageHeader = ({ - templateId, - loading, - disableSubmit, - activePage, - setActivePage, - onTestWorkflowClicked, -}: Props) => { - const { template, editMode } = useTemplateEditorForm(); - const { readonly } = useEnvController(); - - const { isTemplateActive, changeActiveStatus, isStatusChangeLoading } = useStatusChangeControllerHook( - templateId, - template - ); - - return ( - - -
- - <Header editMode={editMode} activePage={activePage} name={template?.name} /> - - -
{ - setActivePage( - activePage === ActivePageEnum.WORKFLOW ? ActivePageEnum.SETTINGS : ActivePageEnum.WORKFLOW - ); - }} - inline - style={{ cursor: 'pointer' }} - > - - - Go Back - -
-
-
-
- - - {editMode && ( - - changeActiveStatus(e.target.checked)} - checked={isTemplateActive || false} - /> - - )} - - - - - - - - - - -
-
-
- ); -}; diff --git a/apps/web/src/pages/templates/components/TemplatePushEditor.tsx b/apps/web/src/pages/templates/components/TemplatePushEditor.tsx index 83d298dc0cd..36d568b8fc7 100644 --- a/apps/web/src/pages/templates/components/TemplatePushEditor.tsx +++ b/apps/web/src/pages/templates/components/TemplatePushEditor.tsx @@ -6,6 +6,7 @@ import type { IForm } from './formTypes'; import { Textarea } from '../../../design-system'; import { useEnvController, useVariablesManager } from '../../../hooks'; import { VariableManager } from './VariableManager'; +import { StepSettings } from '../workflow/SideBar/StepSettings'; const templateFields = ['content', 'title']; @@ -27,13 +28,18 @@ export function TemplatePushEditor({ return ( <> - {!isIntegrationActive ? : null} + {!isIntegrationActive ? ( + + ) : null} + (