Skip to content

Commit

Permalink
Merge pull request #5276 from novuhq/nv-2938-web-workflows-search
Browse files Browse the repository at this point in the history
feat(web): workflows allow searching by name or trigger identifier
  • Loading branch information
LetItRock committed Mar 13, 2024
2 parents fdcb6de + 34fa593 commit 3493254
Show file tree
Hide file tree
Showing 29 changed files with 693 additions and 442 deletions.
4 changes: 3 additions & 1 deletion .cspell.json
Expand Up @@ -606,7 +606,9 @@
"PYROSCOPE",
"usecases",
"usecase",
"zulip"
"zulip",
"uuidv",
"Vonage"
],
"flagWords": [],
"patterns": [
Expand Down
8 changes: 2 additions & 6 deletions apps/api/src/app/shared/dtos/pagination-request.ts
@@ -1,16 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Max, Min } from 'class-validator';
import { IPaginationParams } from '@novu/shared';

import { Constructor } from '../types';

export interface IPagination {
page: number;
limit: number;
}

// eslint-disable-next-line @typescript-eslint/naming-convention
export function PaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor<IPagination> {
export function PaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor<IPaginationParams> {
class PaginationRequest {
@ApiPropertyOptional({
type: Number,
Expand Down
@@ -1,12 +1,9 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { IPaginationWithQueryParams } from '@novu/shared';

import { Constructor } from '../types';
import { IPagination, PaginationRequestDto } from './pagination-request';

export interface IPaginationWithFilters extends IPagination {
query?: string;
}
import { PaginationRequestDto } from './pagination-request';

// eslint-disable-next-line @typescript-eslint/naming-convention
export function PaginationWithFiltersRequestDto({
Expand All @@ -17,7 +14,7 @@ export function PaginationWithFiltersRequestDto({
defaultLimit: number;
maxLimit: number;
queryDescription: string;
}): Constructor<IPaginationWithFilters> {
}): Constructor<IPaginationWithQueryParams> {
class PaginationWithFiltersRequest extends PaginationRequestDto(defaultLimit, maxLimit) {
@ApiPropertyOptional({
type: String,
Expand Down
1 change: 1 addition & 0 deletions apps/web/cypress.config.ts
Expand Up @@ -44,6 +44,7 @@ export default defineConfig({
GITHUB_USER_EMAIL: '',
GITHUB_USER_PASSWORD: '',
BLUEPRINT_CREATOR: '645b648b36dd6d25f8650d37',
MONGODB_URL: 'mongodb://127.0.0.1:27017/novu-test',
IS_CI: false,
coverage: false,
},
Expand Down
8 changes: 8 additions & 0 deletions apps/web/cypress/global.d.ts
Expand Up @@ -3,6 +3,7 @@
type IMountType = import('cypress/react').mount;
type ICreateNotificationTemplateDto = import('@novu/shared').ICreateNotificationTemplateDto;
type FeatureFlagsKeysEnum = import('@novu/shared').FeatureFlagsKeysEnum;
type CreateTemplatePayload = import('@novu/testing').CreateTemplatePayload;

declare namespace Cypress {
interface Chainable {
Expand Down Expand Up @@ -57,6 +58,13 @@ declare namespace Cypress {

makeBlueprints(): Chainable<any>;

createWorkflows(args: {
userId: string;
organizationId: string;
environmentId: string;
workflows: Partial<CreateTemplatePayload>[];
}): Chainable<any>;

mount: typeof IMountType;
}
}
34 changes: 27 additions & 7 deletions apps/web/cypress/plugins/index.ts
Expand Up @@ -10,6 +10,7 @@ import {
OrganizationService,
UserService,
EnvironmentService,
CreateTemplatePayload,
} from '@novu/testing';
import { JobsService } from '@novu/testing';
import {
Expand Down Expand Up @@ -67,13 +68,13 @@ module.exports = (on, config) => {
},
async clearDatabase() {
const dal = new DalService();
await dal.connect('mongodb://127.0.0.1:27017/novu-test');
await dal.connect(config.env.MONGODB_URL);
await dal.destroy();
return true;
},
async seedDatabase() {
const dal = new DalService();
await dal.connect('mongodb://127.0.0.1:27017/novu-test');
await dal.connect(config.env.MONGODB_URL);

const userService = new UserService();
await userService.createCypressTestUser();
Expand All @@ -82,7 +83,7 @@ module.exports = (on, config) => {
},
async passwordResetToken(id: string) {
const dal = new DalService();
await dal.connect('mongodb://127.0.0.1:27017/novu-test');
await dal.connect(config.env.MONGODB_URL);

const userService = new UserService();
const user = await userService.getUser(id);
Expand All @@ -91,7 +92,7 @@ module.exports = (on, config) => {
},
async addOrganization(userId: string) {
const dal = new DalService();
await dal.connect('mongodb://127.0.0.1:27017/novu-test');
await dal.connect(config.env.MONGODB_URL);
const organizationService = new OrganizationService();

const organization = await organizationService.createOrganization();
Expand All @@ -106,7 +107,7 @@ module.exports = (on, config) => {
organizationId: string;
}) {
const dal = new DalService();
await dal.connect('mongodb://127.0.0.1:27017/novu-test');
await dal.connect(config.env.MONGODB_URL);

const repository = new IntegrationRepository();

Expand All @@ -126,7 +127,7 @@ module.exports = (on, config) => {
} = {}
) {
const dal = new DalService();
await dal.connect('mongodb://127.0.0.1:27017/novu-test');
await dal.connect(config.env.MONGODB_URL);

const session = new UserSession('http://127.0.0.1:1336');
await session.initialize({
Expand Down Expand Up @@ -167,7 +168,7 @@ module.exports = (on, config) => {
},
async makeBlueprints() {
const dal = new DalService();
await dal.connect('mongodb://127.0.0.1:27017/novu-test');
await dal.connect(config.env.MONGODB_URL);

const userService = new UserService();
const user = await userService.createUser();
Expand Down Expand Up @@ -331,5 +332,24 @@ module.exports = (on, config) => {

return blueprintTemplates;
},

async createWorkflows({
userId,
organizationId,
environmentId,
workflows,
}: {
userId: string;
organizationId: string;
environmentId: string;
workflows: Partial<CreateTemplatePayload>[];
}) {
const dal = new DalService();
await dal.connect(config.env.MONGODB_URL);

const notificationTemplateService = new NotificationTemplateService(userId, organizationId, environmentId);

return Promise.all(workflows.map((workflow) => notificationTemplateService.createTemplate(workflow)));
},
});
};
4 changes: 4 additions & 0 deletions apps/web/cypress/support/commands.ts
Expand Up @@ -195,4 +195,8 @@ Cypress.Commands.add('mockFeatureFlags', (featureFlags: Partial<Record<FeatureFl
});
});

Cypress.Commands.add('createWorkflows', (args) => {
return cy.task('createWorkflows', args);
});

export {};
Expand Up @@ -368,29 +368,39 @@ describe('Workflow Editor - Main Functionality', function () {
cy.get('.monaco-editor textarea:first').parent().click().contains('Hello world code {{name}} <div>Test</div>');
});

it('should redirect to dev env for edit template', function () {
it('should redirect to the templates page when switching environments', function () {
cy.intercept('GET', '*/notification-templates?*').as('getTemplates');
cy.intercept('GET', '*/notification-templates/*').as('getTemplate');
cy.intercept('POST', '*/notification-templates?__source=editor').as('createTemplate');
cy.intercept('POST', '*/changes/*/apply').as('applyChanges');
const title = 'Environment Switching';

cy.waitLoadTemplatePage(() => {
cy.visit('/workflows/create');
});

fillBasicNotificationDetails();
fillBasicNotificationDetails(title);
goBack();
cy.getByTestId('notification-template-submit-btn').click();

cy.wait('@createTemplate').then((res) => {
cy.intercept('GET', '/v1/changes?promoted=false').as('unpromoted-changes');
cy.visit('/changes');

cy.waitLoadTemplatePage(() => {
cy.getByTestId('promote-btn').eq(0).click({ force: true });
cy.getByTestId('environment-switch').find(`input[value="Production"]`).click({ force: true });
cy.getByTestId('notifications-template').find('tbody tr').first().click();
cy.getByTestId('promote-btn').eq(0).click({ force: true });
cy.wait('@applyChanges');

cy.location('pathname').should('not.equal', `/workflows`);
cy.getByTestId('environment-switch').find(`input[value="Production"]`).click({ force: true });
cy.location('pathname').should('equal', `/workflows`);
cy.wait('@getTemplates');

cy.getByTestId('environment-switch').find(`input[value="Development"]`).click({ force: true });
cy.getByTestId('notifications-template').find('tbody tr').contains(title).click();
cy.wait('@getTemplate');
cy.waitForNetworkIdle(500);
cy.location('pathname').should('not.equal', `/workflows`);

cy.location('pathname').should('equal', `/workflows`);
});
cy.getByTestId('environment-switch').find(`input[value="Development"]`).click({ force: true });
cy.location('pathname').should('equal', `/workflows`);
});
});

Expand Down
61 changes: 61 additions & 0 deletions apps/web/cypress/tests/workflows.spec.ts
@@ -0,0 +1,61 @@
import { TriggerTypeEnum } from '@novu/shared';

describe('Workflows Page', function () {
beforeEach(function () {
cy.initializeSession().as('session');
});

it('should allow searching by name or identifier', function () {
cy.createWorkflows({
userId: this.session.user._id,
organizationId: this.session.organization._id,
environmentId: this.session.environment._id,
workflows: [
{ name: 'SMS Workflow' },
{ triggers: [{ identifier: 'sms-test', variables: [], type: TriggerTypeEnum.EVENT }] },
],
});

cy.intercept('GET', '**/v1/notification-groups').as('notification-groups');
cy.intercept('GET', '**/v1/notification-templates*').as('notification-templates');

cy.visit('/workflows');
cy.wait(['@notification-groups', '@notification-templates']);

cy.getByTestId('workflows-search-input').type('SMS');
cy.wait('@notification-templates');

cy.getByTestId('workflow-row-name').should('have.length', 2);
cy.getByTestId('workflow-row-name').contains('SMS Workflow');
cy.getByTestId('workflow-row-trigger-identifier').contains('sms-test');
});

it('should allow clearing the search', function () {
cy.intercept('GET', '**/v1/notification-groups').as('notification-groups');
cy.intercept('GET', '**/v1/notification-templates*').as('notification-templates');

cy.visit('/workflows');
cy.wait(['@notification-groups', '@notification-templates']);

cy.getByTestId('workflows-search-input').type('This template does not exist');
cy.wait('@notification-templates');
cy.getByTestId('workflows-no-matches').should('exist');

cy.getByTestId('search-input-clear').click();
cy.getByTestId('workflows-no-matches').should('not.exist');
});

it('should show no results view', function () {
cy.intercept('GET', '**/v1/notification-groups').as('notification-groups');
cy.intercept('GET', '**/v1/notification-templates*').as('notification-templates');

cy.visit('/workflows');
cy.wait(['@notification-groups', '@notification-templates']);

cy.getByTestId('workflows-search-input').type('This template does not exist');
cy.wait('@notification-templates');

cy.getByTestId('workflow-row-name').should('have.length', 0);
cy.getByTestId('workflows-no-matches').should('exist');
});
});
14 changes: 11 additions & 3 deletions apps/web/src/api/notification-templates.ts
@@ -1,11 +1,19 @@
import { ICreateNotificationTemplateDto, INotificationTemplate, IGroupedBlueprint } from '@novu/shared';
import {
ICreateNotificationTemplateDto,
INotificationTemplate,
IGroupedBlueprint,
IPaginationWithQueryParams,
} from '@novu/shared';

import { api } from './api.client';
import { BLUEPRINTS_API_URL } from '../config';

export function getNotificationsList(page = 0, limit = 10) {
return api.getFullResponse(`/v1/notification-templates`, { page, limit });
export function getNotificationsList({ page = 0, limit = 10, query }: IPaginationWithQueryParams) {
const params = { page, limit, ...(query && { query }) };

return api.getFullResponse(`/v1/notification-templates`, params);
}

export async function createTemplate(
data: ICreateNotificationTemplateDto,
params?: { __source?: string }
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/layout/components/OrganizationSelect.tsx
Expand Up @@ -2,18 +2,21 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import * as capitalize from 'lodash.capitalize';
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import type { IResponseError, IOrganizationEntity } from '@novu/shared';

import { Select } from '@novu/design-system';

import { addOrganization, switchOrganization } from '../../../api/organization';
import { useAuthContext } from '../../providers/AuthProvider';
import { useSpotlightContext } from '../../providers/SpotlightProvider';
import { ROUTES } from '@novu/shared-web';

export default function OrganizationSelect() {
const [value, setValue] = useState<string>('');
const [search, setSearch] = useState<string>('');
const [loadingSwitch, setLoadingSwitch] = useState<boolean>(false);
const { addItem, removeItems } = useSpotlightContext();
const navigate = useNavigate();

const queryClient = useQueryClient();
const { currentOrganization, organizations, setToken } = useAuthContext();
Expand Down Expand Up @@ -42,10 +45,11 @@ export default function OrganizationSelect() {
setLoadingSwitch(true);
const token = await changeOrganization(organizationId);
setToken(token);
await queryClient.refetchQueries();
await queryClient.clear();
await navigate(ROUTES.HOME);
setLoadingSwitch(false);
},
[currentOrganization, search, setToken, changeOrganization, queryClient]
[currentOrganization, search, setToken, changeOrganization, queryClient, navigate]
);

function addOrganizationItem(newOrganization: string): undefined {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/hooks/index.ts
Expand Up @@ -22,4 +22,5 @@ export * from './useInlineComponent';
export * from './useHoverOverItem';
export * from './useCreateWorkflowFromBlueprint';
export * from './useHover';
export * from './useDebouncedSearch';
export { useDataRef, useKeyDown, useLocalThemePreference } from '@novu/shared-web';
10 changes: 10 additions & 0 deletions apps/web/src/hooks/useDebouncedSearch.ts
@@ -0,0 +1,10 @@
import { useDebounce } from './useDebounce';

interface IDebouncedFunction {
(value: string): void;
cancel: () => void;
}

export const useDebouncedSearch = (setSearch: (newSearch: string) => void): IDebouncedFunction => {
return useDebounce((value: string) => setSearch(value), 500);
};

0 comments on commit 3493254

Please sign in to comment.