From caeeedd764caea038aa05348c1f2d92e693f6dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Alby?= Date: Mon, 17 Oct 2022 12:14:24 +0200 Subject: [PATCH] Merge pull request #9794 from overleaf/ab-endpoint-add-remove-tag-multiple-projects [web] Handle adding/removing multiple projects from a tag at once GitOrigin-RevId: 7d052fa9930035286f8ce41433d6c3959817148a --- .../app/src/Features/Tags/TagsController.js | 18 ++ .../web/app/src/Features/Tags/TagsHandler.js | 28 +- services/web/app/src/router.js | 40 +++ .../components/modals/create-tag-modal.tsx | 2 +- .../project-tools/buttons/tags-dropdown.tsx | 17 +- .../features/project-list/hooks/use-tag.tsx | 7 +- .../js/features/project-list/util/api.ts | 16 +- .../components/project-list-root.test.tsx | 255 ++++++++++++++---- .../components/sidebar/tags-list.test.tsx | 3 +- .../archive-project-button.test.tsx | 21 +- .../copy-project-button.test.tsx | 22 +- .../delete-project-button.test.tsx | 20 +- .../leave-project-button.test.tsx | 20 +- .../trash-project-button.test.tsx | 21 +- .../unarchive-project-button.test.tsx | 13 +- .../untrash-project-button.test.tsx | 17 +- .../rename-project-modal.test.tsx | 35 ++- .../helpers/render-with-context.tsx | 5 + .../test/unit/src/Tags/TagsControllerTests.js | 51 ++++ 19 files changed, 469 insertions(+), 142 deletions(-) diff --git a/services/web/app/src/Features/Tags/TagsController.js b/services/web/app/src/Features/Tags/TagsController.js index aeeca4bea8..ac2ef36684 100644 --- a/services/web/app/src/Features/Tags/TagsController.js +++ b/services/web/app/src/Features/Tags/TagsController.js @@ -35,6 +35,14 @@ async function addProjectToTag(req, res) { res.status(204).end() } +async function addProjectsToTag(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { tagId } = req.params + const { projectIds } = req.body + await TagsHandler.promises.addProjectsToTag(userId, tagId, projectIds) + res.status(204).end() +} + async function removeProjectFromTag(req, res, next) { const userId = SessionManager.getLoggedInUserId(req.session) const { tagId, projectId } = req.params @@ -42,6 +50,14 @@ async function removeProjectFromTag(req, res, next) { res.status(204).end() } +async function removeProjectsFromTag(req, res, next) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { tagId } = req.params + const { projectIds } = req.body + await TagsHandler.promises.removeProjectsFromTag(userId, tagId, projectIds) + res.status(204).end() +} + async function deleteTag(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) const { tagId } = req.params @@ -65,7 +81,9 @@ module.exports = { getAllTags: expressify(getAllTags), createTag: expressify(createTag), addProjectToTag: expressify(addProjectToTag), + addProjectsToTag: expressify(addProjectsToTag), removeProjectFromTag: expressify(removeProjectFromTag), + removeProjectsFromTag: expressify(removeProjectsFromTag), deleteTag: expressify(deleteTag), renameTag: expressify(renameTag), } diff --git a/services/web/app/src/Features/Tags/TagsHandler.js b/services/web/app/src/Features/Tags/TagsHandler.js index 2b516b3f57..50bbd02390 100644 --- a/services/web/app/src/Features/Tags/TagsHandler.js +++ b/services/web/app/src/Features/Tags/TagsHandler.js @@ -79,6 +79,18 @@ function removeProjectFromTag(userId, tagId, projectId, callback) { Tag.updateOne(searchOps, deleteOperation, callback) } +function removeProjectsFromTag(userId, tagId, projectIds, callback) { + if (!callback) { + callback = function () {} + } + const searchOps = { + _id: tagId, + user_id: userId, + } + const deleteOperation = { $pullAll: { project_ids: projectIds } } + Tag.updateOne(searchOps, deleteOperation, callback) +} + function addProjectToTag(userId, tagId, projectId, callback) { if (!callback) { callback = function () {} @@ -91,6 +103,18 @@ function addProjectToTag(userId, tagId, projectId, callback) { Tag.findOneAndUpdate(searchOps, insertOperation, callback) } +function addProjectsToTag(userId, tagId, projectIds, callback) { + if (!callback) { + callback = function () {} + } + const searchOps = { + _id: tagId, + user_id: userId, + } + const insertOperation = { $addToSet: { project_ids: { $each: projectIds } } } + Tag.findOneAndUpdate(searchOps, insertOperation, callback) +} + function addProjectToTagName(userId, name, projectId, callback) { if (!callback) { callback = function () {} @@ -115,8 +139,10 @@ const TagsHandler = { renameTag, deleteTag, updateTagUserIds, - removeProjectFromTag, addProjectToTag, + addProjectsToTag, + removeProjectFromTag, + removeProjectsFromTag, addProjectToTagName, removeProjectFromAllTags, } diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 3671809302..ce683b5a17 100644 --- a/services/web/app/src/router.js +++ b/services/web/app/src/router.js @@ -791,6 +791,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { maxRequests: 30, timeInterval: 60, }), + validate({ + body: Joi.object({ + name: Joi.string().required(), + }), + }), TagsController.createTag ) webRouter.post( @@ -801,6 +806,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { maxRequests: 30, timeInterval: 60, }), + validate({ + body: Joi.object({ + name: Joi.string().required(), + }), + }), TagsController.renameTag ) webRouter.delete( @@ -823,6 +833,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { }), TagsController.addProjectToTag ) + webRouter.post( + '/tag/:tagId/projects', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit({ + endpointName: 'add-projects-to-tag', + maxRequests: 30, + timeInterval: 60, + }), + validate({ + body: Joi.object({ + projectIds: Joi.array().items(Joi.string()).required(), + }), + }), + TagsController.addProjectsToTag + ) webRouter.delete( '/tag/:tagId/project/:projectId', AuthenticationController.requireLogin(), @@ -833,6 +858,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { }), TagsController.removeProjectFromTag ) + webRouter.delete( + '/tag/:tagId/projects', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit({ + endpointName: 'remove-projects-from-tag', + maxRequests: 30, + timeInterval: 60, + }), + validate({ + body: Joi.object({ + projectIds: Joi.array().items(Joi.string()).required(), + }), + }), + TagsController.removeProjectsFromTag + ) webRouter.get( '/notifications', diff --git a/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx index 6ecee4bfab..daa177aec5 100644 --- a/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx @@ -75,7 +75,7 @@ export default function CreateTagModal({ className="form-control" type="text" placeholder="New Tag Name" - name="new-tag-name" + name="new-tag-form-name" required onChange={e => setTagName(e.target.value)} /> diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx index 908b5d4c0d..96740e19aa 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx @@ -6,7 +6,7 @@ import ControlledDropdown from '../../../../../../shared/components/controlled-d import Icon from '../../../../../../shared/components/icon' import { useProjectListContext } from '../../../../context/project-list-context' import useTag from '../../../../hooks/use-tag' -import { addProjectToTag, removeProjectFromTag } from '../../../../util/api' +import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api' function TagsDropdown() { const { @@ -30,12 +30,14 @@ function TagsDropdown() { (e, tagId) => { e.preventDefault() const tag = tags.find(tag => tag._id === tagId) + const projectIds = [] for (const selectedProject of selectedProjects) { if (!tag?.project_ids?.includes(selectedProject.id)) { addProjectToTagInView(tagId, selectedProject.id) - addProjectToTag(tagId, selectedProject.id) + projectIds.push(selectedProject.id) } } + addProjectsToTag(tagId, projectIds) }, [tags, selectedProjects, addProjectToTagInView] ) @@ -45,8 +47,11 @@ function TagsDropdown() { e.preventDefault() for (const selectedProject of selectedProjects) { removeProjectFromTagInView(tagId, selectedProject.id) - removeProjectFromTag(tagId, selectedProject.id) } + removeProjectsFromTag( + tagId, + selectedProjects.map(project => project.id) + ) }, [selectedProjects, removeProjectFromTagInView] ) @@ -78,7 +83,11 @@ function TagsDropdown() { return ( <> - + diff --git a/services/web/frontend/js/features/project-list/hooks/use-tag.tsx b/services/web/frontend/js/features/project-list/hooks/use-tag.tsx index adc4189f67..cb88f2eeda 100644 --- a/services/web/frontend/js/features/project-list/hooks/use-tag.tsx +++ b/services/web/frontend/js/features/project-list/hooks/use-tag.tsx @@ -6,7 +6,7 @@ import RenameTagModal from '../components/modals/rename-tag-modal' import DeleteTagModal from '../components/modals/delete-tag-modal' import EditTagModal from '../components/modals/edit-tag-modal' import { find } from 'lodash' -import { addProjectToTag } from '../util/api' +import { addProjectsToTag } from '../util/api' function useTag() { const { @@ -41,8 +41,11 @@ function useTag() { addTag(tag) for (const selectedProject of selectedProjects) { addProjectToTagInView(tag._id, selectedProject.id) - addProjectToTag(tag._id, selectedProject.id) } + addProjectsToTag( + tag._id, + selectedProjects.map(project => project.id) + ) }, [addTag, selectedProjects, addProjectToTagInView] ) diff --git a/services/web/frontend/js/features/project-list/util/api.ts b/services/web/frontend/js/features/project-list/util/api.ts index 1405b7ccdb..48b5e14215 100644 --- a/services/web/frontend/js/features/project-list/util/api.ts +++ b/services/web/frontend/js/features/project-list/util/api.ts @@ -25,14 +25,26 @@ export function deleteTag(tagId: string) { return deleteJSON(`/tag/${tagId}`) } -export function addProjectToTag(tagId: string, projectId: string) { - return postJSON(`/tag/${tagId}/project/${projectId}`) +export function addProjectsToTag(tagId: string, projectIds: string[]) { + return postJSON(`/tag/${tagId}/projects`, { + body: { + projectIds, + }, + }) } export function removeProjectFromTag(tagId: string, projectId: string) { return deleteJSON(`/tag/${tagId}/project/${projectId}`) } +export function removeProjectsFromTag(tagId: string, projectIds: string[]) { + return deleteJSON(`/tag/${tagId}/projects`, { + body: { + projectIds, + }, + }) +} + export function archiveProject(projectId: string) { return postJSON(`/project/${projectId}/archive`) } diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx index 00aff8b8ac..38c6d8de34 100644 --- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx +++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx @@ -6,6 +6,7 @@ import ProjectListRoot from '../../../../../frontend/js/features/project-list/co import { renderWithProjectListContext } from '../helpers/render-with-context' import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking' import { + projectsData, owner, archivedProjects, makeLongProjectList, @@ -23,7 +24,15 @@ describe('', function () { global.localStorage.clear() sendSpy = sinon.spy(eventTracking, 'send') window.metaAttributesCache = new Map() - window.metaAttributesCache.set('ol-tags', []) + this.tagId = '999fff999fff' + this.tagName = 'First tag name' + window.metaAttributesCache.set('ol-tags', [ + { + _id: this.tagId, + name: this.tagName, + project_ids: [projectsData[0].id, projectsData[1].id], + }, + ]) window.metaAttributesCache.set('ol-ExposedSettings', { templateLinks: [], }) @@ -138,16 +147,16 @@ describe('', function () { fireEvent.click(confirmBtn) expect(confirmBtn.disabled).to.be.true - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true - - const requests = fetchMock.calls() - const [projectRequest1Url, projectRequest1Headers] = requests[2] - expect(projectRequest1Url).to.equal(`/project/${project1Id}/archive`) - expect(projectRequest1Headers?.method).to.equal('POST') - const [projectRequest2Url, projectRequest2Headers] = requests[3] - expect(projectRequest2Url).to.equal(`/project/${project2Id}/archive`) - expect(projectRequest2Headers?.method).to.equal('POST') + await waitFor( + () => + expect(fetchMock.called(`/project/${project1Id}/archive`)).to.be + .true + ) + await waitFor( + () => + expect(fetchMock.called(`/project/${project2Id}/archive`)).to.be + .true + ) }) it('opens trash modal for all selected projects and trashes all', async function () { @@ -173,16 +182,16 @@ describe('', function () { fireEvent.click(confirmBtn) expect(confirmBtn.disabled).to.be.true - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true - - const requests = fetchMock.calls() - const [projectRequest1Url, projectRequest1Headers] = requests[2] - expect(projectRequest1Url).to.equal(`/project/${project1Id}/trash`) - expect(projectRequest1Headers?.method).to.equal('POST') - const [projectRequest2Url, projectRequest2Headers] = requests[3] - expect(projectRequest2Url).to.equal(`/project/${project2Id}/trash`) - expect(projectRequest2Headers?.method).to.equal('POST') + await waitFor( + () => + expect(fetchMock.called(`/project/${project1Id}/trash`)).to.be + .true + ) + await waitFor( + () => + expect(fetchMock.called(`/project/${project2Id}/trash`)).to.be + .true + ) }) it('only checks the projects that are viewable when there is a load more button', async function () { @@ -354,6 +363,141 @@ describe('', function () { }) }) + describe('tags dropdown', function () { + beforeEach(async function () { + allCheckboxes = screen.getAllByRole('checkbox') + // first one is the select all checkbox + fireEvent.click(allCheckboxes[1]) + fireEvent.click(allCheckboxes[2]) + actionsToolbar = screen.getAllByRole('toolbar')[0] + + this.newTagName = 'Some tag name' + this.newTagId = 'abc123def456' + }) + + it('opens the tags dropdown and creates a new tag', async function () { + fetchMock.post(`express:/tag`, { + status: 200, + body: { + _id: this.newTagId, + name: this.newTagName, + project_ids: [], + }, + }) + fetchMock.post(`express:/tag/:id/projects`, { + status: 204, + }) + + await waitFor(() => { + const tagsDropdown = within(actionsToolbar).getByLabelText('Tags') + fireEvent.click(tagsDropdown) + }) + screen.getByText('Add to folder') + + const newTagButton = screen.getByText('Create New Folder') + fireEvent.click(newTagButton) + + const modal = screen.getAllByRole('dialog')[0] + const input = within(modal).getByRole('textbox') + fireEvent.change(input, { + target: { value: this.newTagName }, + }) + const createButton = within(modal).getByRole('button', { + name: 'Create', + }) + fireEvent.click(createButton) + + await waitFor( + () => + expect(fetchMock.called('/tag', { name: this.newTagName })).to.be + .true + ) + await waitFor( + () => + expect( + fetchMock.called(`/tag/${this.newTagId}/projects`, { + body: { + projectIds: [projectsData[0].id, projectsData[1].id], + }, + }) + ).to.be.true + ) + + screen.getByRole('button', { name: `${this.newTagName} (2)` }) + }) + + it('opens the tags dropdown and remove a tag from selected projects', async function () { + const deleteProjectsFromTagMock = fetchMock.delete( + `express:/tag/:id/projects`, + { + status: 204, + } + ) + + screen.getByRole('button', { name: `${this.tagName} (2)` }) + + const tagsDropdown = within(actionsToolbar).getByLabelText('Tags') + fireEvent.click(tagsDropdown) + screen.getByText('Add to folder') + + const tagButton = screen.getByLabelText( + `Add or remove project from tag ${this.tagName}` + ) + fireEvent.click(tagButton) + + await waitFor( + () => + expect( + deleteProjectsFromTagMock.called( + `/tag/${this.tagId}/projects`, + { + body: { + projectIds: [projectsData[0].id, projectsData[1].id], + }, + } + ) + ).to.be.true + ) + + screen.getByRole('button', { name: `${this.tagName} (0)` }) + }) + + it('select another project, opens the tags dropdown and add a tag only to the untagged project', async function () { + const addProjectsToTagMock = fetchMock.post( + `express:/tag/:id/projects`, + { + status: 204, + } + ) + + fireEvent.click(allCheckboxes[3]) + + screen.getByRole('button', { name: `${this.tagName} (2)` }) + + const tagsDropdown = within(actionsToolbar).getByLabelText('Tags') + fireEvent.click(tagsDropdown) + screen.getByText('Add to folder') + + const tagButton = screen.getByLabelText( + `Add or remove project from tag ${this.tagName}` + ) + fireEvent.click(tagButton) + + await waitFor( + () => + expect( + addProjectsToTagMock.called(`/tag/${this.tagId}/projects`, { + body: { + projectIds: [projectsData[2].id], + }, + }) + ).to.be.true + ) + + screen.getByRole('button', { name: `${this.tagName} (3)` }) + }) + }) + describe('project tools "More" dropdown', function () { beforeEach(async function () { const filterButton = screen.getAllByText('All Projects')[0] @@ -374,9 +518,12 @@ describe('', function () { }) it('opens the rename modal, and can rename the project, and view updated', async function () { - fetchMock.post(`express:/project/:id/rename`, { - status: 200, - }) + const renameProjectMock = fetchMock.post( + `express:/project/:id/rename`, + { + status: 200, + } + ) await waitFor(() => { const moreDropdown = @@ -384,7 +531,8 @@ describe('', function () { fireEvent.click(moreDropdown) }) - const renameButton = screen.getByText('Rename') + const renameButton = + screen.getAllByText('Rename')[1] // first one is for the tag in the sidebar fireEvent.click(renameButton) const modal = screen.getAllByRole('dialog')[0] @@ -419,8 +567,14 @@ describe('', function () { expect(confirmButton.disabled).to.be.false fireEvent.click(confirmButton) - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true + await waitFor( + () => + expect( + renameProjectMock.called( + `/project/${projectsData[1].id}/rename` + ) + ).to.be.true + ) screen.findByText(newProjectName) expect(screen.queryByText(oldName)).to.be.null @@ -435,24 +589,27 @@ describe('', function () { const tableRows = screen.getAllByRole('row') const linkForProjectToCopy = within(tableRows[1]).getByRole('link') const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking - screen.findByText(projectNameToCopy) // make sure not just empty string + screen.getByText(projectNameToCopy) // make sure not just empty string const copiedProjectName = `${projectNameToCopy} (Copy)` - fetchMock.post(`express:/project/:id/clone`, { - status: 200, - body: { - name: copiedProjectName, - lastUpdated: new Date(), - project_id: userId, - owner_ref: userId, - owner, - id: '6328e14abec0df019fce0be5', - lastUpdatedBy: owner, - accessLevel: 'owner', - source: 'owner', - trashed: false, - archived: false, - }, - }) + const cloneProjectMock = fetchMock.post( + `express:/project/:id/clone`, + { + status: 200, + body: { + name: copiedProjectName, + lastUpdated: new Date(), + project_id: userId, + owner_ref: userId, + owner, + id: '6328e14abec0df019fce0be5', + lastUpdatedBy: owner, + accessLevel: 'owner', + source: 'owner', + trashed: false, + archived: false, + }, + } + ) await waitFor(() => { const moreDropdown = @@ -470,13 +627,17 @@ describe('', function () { ) as HTMLElement fireEvent.click(copyConfirmButton) - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true + await waitFor( + () => + expect( + cloneProjectMock.called(`/project/${projectsData[1].id}/clone`) + ).to.be.true + ) expect(sendSpy).to.be.calledOnce expect(sendSpy).calledWith('project-list-page-interaction') - screen.findByText(copiedProjectName) + screen.getByText(copiedProjectName) }) }) }) diff --git a/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx b/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx index e88bc145f5..329f74804f 100644 --- a/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx +++ b/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx @@ -27,6 +27,7 @@ describe('', function () { name: 'New Tag', project_ids: [], }) + fetchMock.post('express:/tag/:tagId/projects', 200) fetchMock.post('express:/tag/:tagId/rename', 200) fetchMock.delete('express:/tag/:tagId', 200) @@ -145,7 +146,7 @@ describe('', function () { await fireEvent.click(createButton) - await waitFor(() => expect(fetchMock.called(`/tag`))) + await waitFor(() => expect(fetchMock.called(`/tag`)).to.be.true) expect(screen.queryByRole('dialog', { hidden: false })).to.be.null diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx index e67918b0cb..ba663c70ba 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { ArchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button' import { archiveableProject, @@ -44,8 +44,8 @@ describe('', function () { it('should archive the projects', async function () { const project = Object.assign({}, archiveableProject) - fetchMock.post( - `express:/project/${project.id}/archive`, + const archiveProjectMock = fetchMock.post( + `express:/project/:projectId/archive`, { status: 200, }, @@ -62,14 +62,11 @@ describe('', function () { const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement fireEvent.click(confirmBtn) expect(confirmBtn.disabled).to.be.true - // verify archived - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true - const requests = fetchMock.calls() - // first mock call is to get list of projects in projectlistcontext - const [requestUrl, requestHeaders] = requests[1] - expect(requestUrl).to.equal(`/project/${project.id}/archive`) - expect(requestHeaders?.method).to.equal('POST') - fetchMock.reset() + + await waitFor( + () => + expect(archiveProjectMock.called(`/project/${project.id}/archive`)).to + .be.true + ) }) }) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx index 48e44c90ff..e29d088e67 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { CopyProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button' import { archivedProject, @@ -16,6 +16,7 @@ describe('', function () { afterEach(function () { resetProjectListContextFetch() }) + it('renders tooltip for button', function () { renderWithProjectListContext( @@ -40,8 +41,8 @@ describe('', function () { }) it('opens the modal and copies the project ', async function () { - fetchMock.post( - `express:/project/${copyableProject.id}/clone`, + const copyProjectMock = fetchMock.post( + `express:/project/:projectId/clone`, { status: 200, }, @@ -58,14 +59,11 @@ describe('', function () { const copyBtn = screen.getByText('Copy') as HTMLButtonElement fireEvent.click(copyBtn) expect(copyBtn.disabled).to.be.true - // verify cloned - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true - const requests = fetchMock.calls() - // first mock call is to get list of projects in projectlistcontext - const [requestUrl, requestHeaders] = requests[1] - expect(requestUrl).to.equal(`/project/${copyableProject.id}/clone`) - expect(requestHeaders?.method).to.equal('POST') - fetchMock.reset() + + await waitFor( + () => + expect(copyProjectMock.called(`/project/${copyableProject.id}/clone`)) + .to.be.true + ) }) }) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx index 73bd95b758..fd365cc393 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { DeleteProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button' import { archiveableProject, @@ -46,8 +46,8 @@ describe('', function () { it('opens the modal and deletes the project', async function () { window.user_id = trashedProject?.owner?.id const project = Object.assign({}, trashedProject) - fetchMock.delete( - `express:/project/${project.id}`, + const deleteProjectMock = fetchMock.delete( + `express:/project/:projectId`, { status: 200, }, @@ -64,14 +64,10 @@ describe('', function () { const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement fireEvent.click(confirmBtn) expect(confirmBtn.disabled).to.be.true - // verify trashed - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true - const requests = fetchMock.calls() - // first request is project list api in projectlistcontext - const [requestUrl, requestHeaders] = requests[1] - expect(requestUrl).to.equal(`/project/${project.id}`) - expect(requestHeaders?.method).to.equal('DELETE') - fetchMock.reset() + + await waitFor( + () => + expect(deleteProjectMock.called(`/project/${project.id}`)).to.be.true + ) }) }) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx index 1da2a16046..1a4028120c 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { LeaveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button' import { trashedProject, @@ -17,6 +17,7 @@ describe('', function () { afterEach(function () { resetProjectListContextFetch() }) + it('renders tooltip for button', function () { renderWithProjectListContext( @@ -51,7 +52,7 @@ describe('', function () { it('opens the modal and leaves the project', async function () { const project = Object.assign({}, trashedAndNotOwnedProject) - fetchMock.post( + const leaveProjectMock = fetchMock.post( `express:/project/${project.id}/leave`, { status: 200, @@ -69,14 +70,11 @@ describe('', function () { const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement fireEvent.click(confirmBtn) expect(confirmBtn.disabled).to.be.true - // verify trashed - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true - const requests = fetchMock.calls() - // first request is project list api in projectlistcontext - const [requestUrl, requestHeaders] = requests[1] - expect(requestUrl).to.equal(`/project/${project.id}/leave`) - expect(requestHeaders?.method).to.equal('POST') - fetchMock.reset() + + await waitFor( + () => + expect(leaveProjectMock.called(`/project/${project.id}/leave`)).to.be + .true + ) }) }) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx index 6da100e745..6506b86e25 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { TrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button' import { archivedProject, @@ -34,8 +34,8 @@ describe('', function () { it('opens the modal and trashes the project', async function () { const project = Object.assign({}, archivedProject) - fetchMock.post( - `express:/project/${project.id}/trash`, + const trashProjectMock = fetchMock.post( + `express:/project/:projectId/trash`, { status: 200, }, @@ -52,14 +52,11 @@ describe('', function () { const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement fireEvent.click(confirmBtn) expect(confirmBtn.disabled).to.be.true - // verify trashed - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true - const requests = fetchMock.calls() - // first request is to get list of projects in projectlistcontext - const [requestUrl, requestHeaders] = requests[1] - expect(requestUrl).to.equal(`/project/${project.id}/trash`) - expect(requestHeaders?.method).to.equal('POST') - fetchMock.reset() + + await waitFor( + () => + expect(trashProjectMock.called(`/project/${project.id}/trash`)).to.be + .true + ) }) }) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx index be77809966..aa7f9429be 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { UnarchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button' import { archiveableProject, @@ -42,8 +42,8 @@ describe('', function () { it('unarchive the project and updates the view data', async function () { const project = Object.assign({}, archivedProject) - fetchMock.delete( - `express:/project/${project.id}/archive`, + const unarchiveProjectMock = fetchMock.delete( + `express:/project/:projectId/archive`, { status: 200, }, @@ -55,7 +55,10 @@ describe('', function () { const btn = screen.getByLabelText('Restore') fireEvent.click(btn) - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true + await waitFor( + () => + expect(unarchiveProjectMock.called(`/project/${project.id}/archive`)).to + .be.true + ) }) }) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx index be07239e94..5ea8f21a07 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import fetchMock from 'fetch-mock' import { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button' import { @@ -12,10 +12,6 @@ import { } from '../../../../helpers/render-with-context' describe('', function () { - beforeEach(function () { - fetchMock.reset() - }) - afterEach(function () { resetProjectListContextFetch() }) @@ -38,8 +34,8 @@ describe('', function () { it('untrashes the project and updates the view data', async function () { const project = Object.assign({}, trashedProject) - fetchMock.delete( - `express:/project/${project.id}/trash`, + const untrashProjectMock = fetchMock.delete( + `express:/project/:projectId/trash`, { status: 200, }, @@ -51,7 +47,10 @@ describe('', function () { const btn = screen.getByLabelText('Restore') fireEvent.click(btn) - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true + await waitFor( + () => + expect(untrashProjectMock.called(`/project/${project.id}/trash`)).to.be + .true + ) }) }) diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx index 8ff9e9c6b5..2843b29ac1 100644 --- a/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, within } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect } from 'chai' import RenameProjectModal from '../../../../../../../frontend/js/features/project-list/components/modals/rename-project-modal' import { @@ -9,14 +9,21 @@ import { currentProjects } from '../../../fixtures/projects-data' import fetchMock from 'fetch-mock' describe('', function () { + beforeEach(function () { + resetProjectListContextFetch() + }) + afterEach(function () { resetProjectListContextFetch() }) it('renders the modal and validates new name', async function () { - fetchMock.post('express:/project/:projectId/rename', { - status: 200, - }) + const renameProjectMock = fetchMock.post( + 'express:/project/:projectId/rename', + { + status: 200, + } + ) renderWithProjectListContext( {}} @@ -44,14 +51,21 @@ describe('', function () { fireEvent.click(submitButton) expect(submitButton.disabled).to.be.true - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true + await waitFor( + () => + expect( + renameProjectMock.called(`/project/${currentProjects[0].id}/rename`) + ).to.be.true + ) }) it('shows error message from API', async function () { - fetchMock.post('express:/project/:projectId/rename', { - status: 500, - }) + const postRenameMock = fetchMock.post( + 'express:/project/:projectId/rename', + { + status: 500, + } + ) renderWithProjectListContext( {}} @@ -70,8 +84,7 @@ describe('', function () { const submitButton = within(modal).getByText('Rename') as HTMLButtonElement fireEvent.click(submitButton) - await fetchMock.flush(true) - expect(fetchMock.done()).to.be.true + await waitFor(() => expect(postRenameMock.called()).to.be.true) screen.getByText('Something went wrong. Please try again.') }) diff --git a/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx b/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx index 65c419e4c1..41a119acec 100644 --- a/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx +++ b/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx @@ -24,6 +24,11 @@ export function renderWithProjectListContext( body: { projects, totalSize: projects.length }, }) + fetchMock.get('express:/system/messages', { + status: 200, + body: [], + }) + const ProjectListProviderWrapper = ({ children, }: { diff --git a/services/web/test/unit/src/Tags/TagsControllerTests.js b/services/web/test/unit/src/Tags/TagsControllerTests.js index f73db66ddf..b173d4f80b 100644 --- a/services/web/test/unit/src/Tags/TagsControllerTests.js +++ b/services/web/test/unit/src/Tags/TagsControllerTests.js @@ -14,7 +14,9 @@ describe('TagsController', function () { this.TagsHandler = { promises: { addProjectToTag: sinon.stub().resolves(), + addProjectsToTag: sinon.stub().resolves(), removeProjectFromTag: sinon.stub().resolves(), + removeProjectsFromTag: sinon.stub().resolves(), deleteTag: sinon.stub().resolves(), renameTag: sinon.stub().resolves(), createTag: sinon.stub().resolves(), @@ -40,6 +42,7 @@ describe('TagsController', function () { _id: userId, }, }, + body: {}, } this.res = {} @@ -163,6 +166,30 @@ describe('TagsController', function () { }) }) + it('add projects to a tag', function (done) { + this.req.params.tagId = this.tagId = 'tag-id-123' + this.req.body.projectIds = this.projectIds = [ + 'project-id-123', + 'project-id-234', + ] + this.req.session.user._id = this.userId = 'user-id-123' + this.TagsController.addProjectsToTag(this.req, { + status: code => { + assert.equal(code, 204) + sinon.assert.calledWith( + this.TagsHandler.promises.addProjectsToTag, + this.userId, + this.tagId, + this.projectIds + ) + done() + return { + end: () => {}, + } + }, + }) + }) + it('remove a project from a tag', function (done) { this.req.params.tagId = this.tagId = 'tag-id-123' this.req.params.projectId = this.projectId = 'project-id-123' @@ -183,4 +210,28 @@ describe('TagsController', function () { }, }) }) + + it('remove projects from a tag', function (done) { + this.req.params.tagId = this.tagId = 'tag-id-123' + this.req.body.projectIds = this.projectIds = [ + 'project-id-123', + 'project-id-234', + ] + this.req.session.user._id = this.userId = 'user-id-123' + this.TagsController.removeProjectsFromTag(this.req, { + status: code => { + assert.equal(code, 204) + sinon.assert.calledWith( + this.TagsHandler.promises.removeProjectsFromTag, + this.userId, + this.tagId, + this.projectIds + ) + done() + return { + end: () => {}, + } + }, + }) + }) })