Просмотр исходного кода

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
Timothée Alby 3 лет назад
Родитель
Сommit
caeeedd764
19 измененных файлов с 469 добавлено и 142 удалено
  1. 18 0
      services/web/app/src/Features/Tags/TagsController.js
  2. 27 1
      services/web/app/src/Features/Tags/TagsHandler.js
  3. 40 0
      services/web/app/src/router.js
  4. 1 1
      services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx
  5. 13 4
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
  6. 5 2
      services/web/frontend/js/features/project-list/hooks/use-tag.tsx
  7. 14 2
      services/web/frontend/js/features/project-list/util/api.ts
  8. 208 47
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  9. 2 1
      services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx
  10. 9 12
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx
  11. 10 12
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx
  12. 8 12
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx
  13. 9 11
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx
  14. 9 12
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx
  15. 8 5
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx
  16. 8 9
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx
  17. 24 11
      services/web/test/frontend/features/project-list/components/table/project-tools/rename-project-modal.test.tsx
  18. 5 0
      services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
  19. 51 0
      services/web/test/unit/src/Tags/TagsControllerTests.js

+ 18 - 0
services/web/app/src/Features/Tags/TagsController.js

@@ -35,6 +35,14 @@ async function addProjectToTag(req, res) {
   res.status(204).end()
   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) {
 async function removeProjectFromTag(req, res, next) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const userId = SessionManager.getLoggedInUserId(req.session)
   const { tagId, projectId } = req.params
   const { tagId, projectId } = req.params
@@ -42,6 +50,14 @@ async function removeProjectFromTag(req, res, next) {
   res.status(204).end()
   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) {
 async function deleteTag(req, res) {
   const userId = SessionManager.getLoggedInUserId(req.session)
   const userId = SessionManager.getLoggedInUserId(req.session)
   const { tagId } = req.params
   const { tagId } = req.params
@@ -65,7 +81,9 @@ module.exports = {
   getAllTags: expressify(getAllTags),
   getAllTags: expressify(getAllTags),
   createTag: expressify(createTag),
   createTag: expressify(createTag),
   addProjectToTag: expressify(addProjectToTag),
   addProjectToTag: expressify(addProjectToTag),
+  addProjectsToTag: expressify(addProjectsToTag),
   removeProjectFromTag: expressify(removeProjectFromTag),
   removeProjectFromTag: expressify(removeProjectFromTag),
+  removeProjectsFromTag: expressify(removeProjectsFromTag),
   deleteTag: expressify(deleteTag),
   deleteTag: expressify(deleteTag),
   renameTag: expressify(renameTag),
   renameTag: expressify(renameTag),
 }
 }

+ 27 - 1
services/web/app/src/Features/Tags/TagsHandler.js

@@ -79,6 +79,18 @@ function removeProjectFromTag(userId, tagId, projectId, callback) {
   Tag.updateOne(searchOps, deleteOperation, 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) {
 function addProjectToTag(userId, tagId, projectId, callback) {
   if (!callback) {
   if (!callback) {
     callback = function () {}
     callback = function () {}
@@ -91,6 +103,18 @@ function addProjectToTag(userId, tagId, projectId, callback) {
   Tag.findOneAndUpdate(searchOps, insertOperation, 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) {
 function addProjectToTagName(userId, name, projectId, callback) {
   if (!callback) {
   if (!callback) {
     callback = function () {}
     callback = function () {}
@@ -115,8 +139,10 @@ const TagsHandler = {
   renameTag,
   renameTag,
   deleteTag,
   deleteTag,
   updateTagUserIds,
   updateTagUserIds,
-  removeProjectFromTag,
   addProjectToTag,
   addProjectToTag,
+  addProjectsToTag,
+  removeProjectFromTag,
+  removeProjectsFromTag,
   addProjectToTagName,
   addProjectToTagName,
   removeProjectFromAllTags,
   removeProjectFromAllTags,
 }
 }

+ 40 - 0
services/web/app/src/router.js

@@ -791,6 +791,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
       maxRequests: 30,
       maxRequests: 30,
       timeInterval: 60,
       timeInterval: 60,
     }),
     }),
+    validate({
+      body: Joi.object({
+        name: Joi.string().required(),
+      }),
+    }),
     TagsController.createTag
     TagsController.createTag
   )
   )
   webRouter.post(
   webRouter.post(
@@ -801,6 +806,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
       maxRequests: 30,
       maxRequests: 30,
       timeInterval: 60,
       timeInterval: 60,
     }),
     }),
+    validate({
+      body: Joi.object({
+        name: Joi.string().required(),
+      }),
+    }),
     TagsController.renameTag
     TagsController.renameTag
   )
   )
   webRouter.delete(
   webRouter.delete(
@@ -823,6 +833,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
     }),
     }),
     TagsController.addProjectToTag
     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(
   webRouter.delete(
     '/tag/:tagId/project/:projectId',
     '/tag/:tagId/project/:projectId',
     AuthenticationController.requireLogin(),
     AuthenticationController.requireLogin(),
@@ -833,6 +858,21 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
     }),
     }),
     TagsController.removeProjectFromTag
     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(
   webRouter.get(
     '/notifications',
     '/notifications',

+ 1 - 1
services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx

@@ -75,7 +75,7 @@ export default function CreateTagModal({
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder="New Tag Name"
             placeholder="New Tag Name"
-            name="new-tag-name"
+            name="new-tag-form-name"
             required
             required
             onChange={e => setTagName(e.target.value)}
             onChange={e => setTagName(e.target.value)}
           />
           />

+ 13 - 4
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 Icon from '../../../../../../shared/components/icon'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import { useProjectListContext } from '../../../../context/project-list-context'
 import useTag from '../../../../hooks/use-tag'
 import useTag from '../../../../hooks/use-tag'
-import { addProjectToTag, removeProjectFromTag } from '../../../../util/api'
+import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api'
 
 
 function TagsDropdown() {
 function TagsDropdown() {
   const {
   const {
@@ -30,12 +30,14 @@ function TagsDropdown() {
     (e, tagId) => {
     (e, tagId) => {
       e.preventDefault()
       e.preventDefault()
       const tag = tags.find(tag => tag._id === tagId)
       const tag = tags.find(tag => tag._id === tagId)
+      const projectIds = []
       for (const selectedProject of selectedProjects) {
       for (const selectedProject of selectedProjects) {
         if (!tag?.project_ids?.includes(selectedProject.id)) {
         if (!tag?.project_ids?.includes(selectedProject.id)) {
           addProjectToTagInView(tagId, selectedProject.id)
           addProjectToTagInView(tagId, selectedProject.id)
-          addProjectToTag(tagId, selectedProject.id)
+          projectIds.push(selectedProject.id)
         }
         }
       }
       }
+      addProjectsToTag(tagId, projectIds)
     },
     },
     [tags, selectedProjects, addProjectToTagInView]
     [tags, selectedProjects, addProjectToTagInView]
   )
   )
@@ -45,8 +47,11 @@ function TagsDropdown() {
       e.preventDefault()
       e.preventDefault()
       for (const selectedProject of selectedProjects) {
       for (const selectedProject of selectedProjects) {
         removeProjectFromTagInView(tagId, selectedProject.id)
         removeProjectFromTagInView(tagId, selectedProject.id)
-        removeProjectFromTag(tagId, selectedProject.id)
       }
       }
+      removeProjectsFromTag(
+        tagId,
+        selectedProjects.map(project => project.id)
+      )
     },
     },
     [selectedProjects, removeProjectFromTagInView]
     [selectedProjects, removeProjectFromTagInView]
   )
   )
@@ -78,7 +83,11 @@ function TagsDropdown() {
   return (
   return (
     <>
     <>
       <ControlledDropdown id="tags">
       <ControlledDropdown id="tags">
-        <Dropdown.Toggle bsStyle="default" title={t('tags')}>
+        <Dropdown.Toggle
+          bsStyle="default"
+          title={t('tags')}
+          aria-label={t('tags')}
+        >
           <Icon type="folder-open" />
           <Icon type="folder-open" />
         </Dropdown.Toggle>
         </Dropdown.Toggle>
         <Dropdown.Menu className="dropdown-menu-right">
         <Dropdown.Menu className="dropdown-menu-right">

+ 5 - 2
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 DeleteTagModal from '../components/modals/delete-tag-modal'
 import EditTagModal from '../components/modals/edit-tag-modal'
 import EditTagModal from '../components/modals/edit-tag-modal'
 import { find } from 'lodash'
 import { find } from 'lodash'
-import { addProjectToTag } from '../util/api'
+import { addProjectsToTag } from '../util/api'
 
 
 function useTag() {
 function useTag() {
   const {
   const {
@@ -41,8 +41,11 @@ function useTag() {
       addTag(tag)
       addTag(tag)
       for (const selectedProject of selectedProjects) {
       for (const selectedProject of selectedProjects) {
         addProjectToTagInView(tag._id, selectedProject.id)
         addProjectToTagInView(tag._id, selectedProject.id)
-        addProjectToTag(tag._id, selectedProject.id)
       }
       }
+      addProjectsToTag(
+        tag._id,
+        selectedProjects.map(project => project.id)
+      )
     },
     },
     [addTag, selectedProjects, addProjectToTagInView]
     [addTag, selectedProjects, addProjectToTagInView]
   )
   )

+ 14 - 2
services/web/frontend/js/features/project-list/util/api.ts

@@ -25,14 +25,26 @@ export function deleteTag(tagId: string) {
   return deleteJSON(`/tag/${tagId}`)
   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) {
 export function removeProjectFromTag(tagId: string, projectId: string) {
   return deleteJSON(`/tag/${tagId}/project/${projectId}`)
   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) {
 export function archiveProject(projectId: string) {
   return postJSON(`/project/${projectId}/archive`)
   return postJSON(`/project/${projectId}/archive`)
 }
 }

+ 208 - 47
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 { renderWithProjectListContext } from '../helpers/render-with-context'
 import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
 import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
 import {
 import {
+  projectsData,
   owner,
   owner,
   archivedProjects,
   archivedProjects,
   makeLongProjectList,
   makeLongProjectList,
@@ -23,7 +24,15 @@ describe('<ProjectListRoot />', function () {
     global.localStorage.clear()
     global.localStorage.clear()
     sendSpy = sinon.spy(eventTracking, 'send')
     sendSpy = sinon.spy(eventTracking, 'send')
     window.metaAttributesCache = new Map()
     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', {
     window.metaAttributesCache.set('ol-ExposedSettings', {
       templateLinks: [],
       templateLinks: [],
     })
     })
@@ -138,16 +147,16 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(confirmBtn)
           fireEvent.click(confirmBtn)
           expect(confirmBtn.disabled).to.be.true
           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 () {
         it('opens trash modal for all selected projects and trashes all', async function () {
@@ -173,16 +182,16 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(confirmBtn)
           fireEvent.click(confirmBtn)
           expect(confirmBtn.disabled).to.be.true
           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 () {
         it('only checks the projects that are viewable when there is a load more button', async function () {
@@ -354,6 +363,141 @@ describe('<ProjectListRoot />', function () {
         })
         })
       })
       })
 
 
+      describe('tags dropdown', function () {
+        beforeEach(async function () {
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('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<HTMLInputElement>('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 () {
       describe('project tools "More" dropdown', function () {
         beforeEach(async function () {
         beforeEach(async function () {
           const filterButton = screen.getAllByText('All Projects')[0]
           const filterButton = screen.getAllByText('All Projects')[0]
@@ -374,9 +518,12 @@ describe('<ProjectListRoot />', function () {
         })
         })
 
 
         it('opens the rename modal, and can rename the project, and view updated', async 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(() => {
           await waitFor(() => {
             const moreDropdown =
             const moreDropdown =
@@ -384,7 +531,8 @@ describe('<ProjectListRoot />', function () {
             fireEvent.click(moreDropdown)
             fireEvent.click(moreDropdown)
           })
           })
 
 
-          const renameButton = screen.getByText<HTMLInputElement>('Rename')
+          const renameButton =
+            screen.getAllByText<HTMLInputElement>('Rename')[1] // first one is for the tag in the sidebar
           fireEvent.click(renameButton)
           fireEvent.click(renameButton)
 
 
           const modal = screen.getAllByRole('dialog')[0]
           const modal = screen.getAllByRole('dialog')[0]
@@ -419,8 +567,14 @@ describe('<ProjectListRoot />', function () {
           expect(confirmButton.disabled).to.be.false
           expect(confirmButton.disabled).to.be.false
           fireEvent.click(confirmButton)
           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)
           screen.findByText(newProjectName)
           expect(screen.queryByText(oldName)).to.be.null
           expect(screen.queryByText(oldName)).to.be.null
@@ -435,24 +589,27 @@ describe('<ProjectListRoot />', function () {
           const tableRows = screen.getAllByRole('row')
           const tableRows = screen.getAllByRole('row')
           const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
           const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
           const projectNameToCopy = linkForProjectToCopy.textContent || '' // needed for type checking
           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)`
           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(() => {
           await waitFor(() => {
             const moreDropdown =
             const moreDropdown =
@@ -470,13 +627,17 @@ describe('<ProjectListRoot />', function () {
           ) as HTMLElement
           ) as HTMLElement
           fireEvent.click(copyConfirmButton)
           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).to.be.calledOnce
           expect(sendSpy).calledWith('project-list-page-interaction')
           expect(sendSpy).calledWith('project-list-page-interaction')
 
 
-          screen.findByText(copiedProjectName)
+          screen.getByText(copiedProjectName)
         })
         })
       })
       })
     })
     })

+ 2 - 1
services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx

@@ -27,6 +27,7 @@ describe('<TagsList />', function () {
       name: 'New Tag',
       name: 'New Tag',
       project_ids: [],
       project_ids: [],
     })
     })
+    fetchMock.post('express:/tag/:tagId/projects', 200)
     fetchMock.post('express:/tag/:tagId/rename', 200)
     fetchMock.post('express:/tag/:tagId/rename', 200)
     fetchMock.delete('express:/tag/:tagId', 200)
     fetchMock.delete('express:/tag/:tagId', 200)
 
 
@@ -145,7 +146,7 @@ describe('<TagsList />', function () {
 
 
       await fireEvent.click(createButton)
       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
       expect(screen.queryByRole('dialog', { hidden: false })).to.be.null
 
 

+ 9 - 12
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 { 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 { ArchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button'
 import {
 import {
   archiveableProject,
   archiveableProject,
@@ -44,8 +44,8 @@ describe('<ArchiveProjectButton />', function () {
 
 
   it('should archive the projects', async function () {
   it('should archive the projects', async function () {
     const project = Object.assign({}, archiveableProject)
     const project = Object.assign({}, archiveableProject)
-    fetchMock.post(
-      `express:/project/${project.id}/archive`,
+    const archiveProjectMock = fetchMock.post(
+      `express:/project/:projectId/archive`,
       {
       {
         status: 200,
         status: 200,
       },
       },
@@ -62,14 +62,11 @@ describe('<ArchiveProjectButton />', function () {
     const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
     const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
     fireEvent.click(confirmBtn)
     fireEvent.click(confirmBtn)
     expect(confirmBtn.disabled).to.be.true
     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
+    )
   })
   })
 })
 })

+ 10 - 12
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 { 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 { CopyProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button'
 import {
 import {
   archivedProject,
   archivedProject,
@@ -16,6 +16,7 @@ describe('<CopyProjectButton />', function () {
   afterEach(function () {
   afterEach(function () {
     resetProjectListContextFetch()
     resetProjectListContextFetch()
   })
   })
+
   it('renders tooltip for button', function () {
   it('renders tooltip for button', function () {
     renderWithProjectListContext(
     renderWithProjectListContext(
       <CopyProjectButtonTooltip project={copyableProject} />
       <CopyProjectButtonTooltip project={copyableProject} />
@@ -40,8 +41,8 @@ describe('<CopyProjectButton />', function () {
   })
   })
 
 
   it('opens the modal and copies the project ', async 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,
         status: 200,
       },
       },
@@ -58,14 +59,11 @@ describe('<CopyProjectButton />', function () {
     const copyBtn = screen.getByText('Copy') as HTMLButtonElement
     const copyBtn = screen.getByText('Copy') as HTMLButtonElement
     fireEvent.click(copyBtn)
     fireEvent.click(copyBtn)
     expect(copyBtn.disabled).to.be.true
     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
+    )
   })
   })
 })
 })

+ 8 - 12
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 { 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 { DeleteProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button'
 import {
 import {
   archiveableProject,
   archiveableProject,
@@ -46,8 +46,8 @@ describe('<DeleteProjectButton />', function () {
   it('opens the modal and deletes the project', async function () {
   it('opens the modal and deletes the project', async function () {
     window.user_id = trashedProject?.owner?.id
     window.user_id = trashedProject?.owner?.id
     const project = Object.assign({}, trashedProject)
     const project = Object.assign({}, trashedProject)
-    fetchMock.delete(
-      `express:/project/${project.id}`,
+    const deleteProjectMock = fetchMock.delete(
+      `express:/project/:projectId`,
       {
       {
         status: 200,
         status: 200,
       },
       },
@@ -64,14 +64,10 @@ describe('<DeleteProjectButton />', function () {
     const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
     const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
     fireEvent.click(confirmBtn)
     fireEvent.click(confirmBtn)
     expect(confirmBtn.disabled).to.be.true
     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
+    )
   })
   })
 })
 })

+ 9 - 11
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 { 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 { LeaveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button'
 import {
 import {
   trashedProject,
   trashedProject,
@@ -17,6 +17,7 @@ describe('<LeaveProjectButtton />', function () {
   afterEach(function () {
   afterEach(function () {
     resetProjectListContextFetch()
     resetProjectListContextFetch()
   })
   })
+
   it('renders tooltip for button', function () {
   it('renders tooltip for button', function () {
     renderWithProjectListContext(
     renderWithProjectListContext(
       <LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} />
       <LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} />
@@ -51,7 +52,7 @@ describe('<LeaveProjectButtton />', function () {
 
 
   it('opens the modal and leaves the project', async function () {
   it('opens the modal and leaves the project', async function () {
     const project = Object.assign({}, trashedAndNotOwnedProject)
     const project = Object.assign({}, trashedAndNotOwnedProject)
-    fetchMock.post(
+    const leaveProjectMock = fetchMock.post(
       `express:/project/${project.id}/leave`,
       `express:/project/${project.id}/leave`,
       {
       {
         status: 200,
         status: 200,
@@ -69,14 +70,11 @@ describe('<LeaveProjectButtton />', function () {
     const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
     const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
     fireEvent.click(confirmBtn)
     fireEvent.click(confirmBtn)
     expect(confirmBtn.disabled).to.be.true
     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
+    )
   })
   })
 })
 })

+ 9 - 12
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 { 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 { TrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button'
 import {
 import {
   archivedProject,
   archivedProject,
@@ -34,8 +34,8 @@ describe('<TrashProjectButton />', function () {
 
 
   it('opens the modal and trashes the project', async function () {
   it('opens the modal and trashes the project', async function () {
     const project = Object.assign({}, archivedProject)
     const project = Object.assign({}, archivedProject)
-    fetchMock.post(
-      `express:/project/${project.id}/trash`,
+    const trashProjectMock = fetchMock.post(
+      `express:/project/:projectId/trash`,
       {
       {
         status: 200,
         status: 200,
       },
       },
@@ -52,14 +52,11 @@ describe('<TrashProjectButton />', function () {
     const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
     const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
     fireEvent.click(confirmBtn)
     fireEvent.click(confirmBtn)
     expect(confirmBtn.disabled).to.be.true
     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
+    )
   })
   })
 })
 })

+ 8 - 5
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 { 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 { UnarchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button'
 import {
 import {
   archiveableProject,
   archiveableProject,
@@ -42,8 +42,8 @@ describe('<UnarchiveProjectButton />', function () {
 
 
   it('unarchive the project and updates the view data', async function () {
   it('unarchive the project and updates the view data', async function () {
     const project = Object.assign({}, archivedProject)
     const project = Object.assign({}, archivedProject)
-    fetchMock.delete(
-      `express:/project/${project.id}/archive`,
+    const unarchiveProjectMock = fetchMock.delete(
+      `express:/project/:projectId/archive`,
       {
       {
         status: 200,
         status: 200,
       },
       },
@@ -55,7 +55,10 @@ describe('<UnarchiveProjectButton />', function () {
     const btn = screen.getByLabelText('Restore')
     const btn = screen.getByLabelText('Restore')
     fireEvent.click(btn)
     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
+    )
   })
   })
 })
 })

+ 8 - 9
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 { expect } from 'chai'
-import { fireEvent, screen } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import fetchMock from 'fetch-mock'
 import { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button'
 import { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button'
 import {
 import {
@@ -12,10 +12,6 @@ import {
 } from '../../../../helpers/render-with-context'
 } from '../../../../helpers/render-with-context'
 
 
 describe('<UntrashProjectButton />', function () {
 describe('<UntrashProjectButton />', function () {
-  beforeEach(function () {
-    fetchMock.reset()
-  })
-
   afterEach(function () {
   afterEach(function () {
     resetProjectListContextFetch()
     resetProjectListContextFetch()
   })
   })
@@ -38,8 +34,8 @@ describe('<UntrashProjectButton />', function () {
 
 
   it('untrashes the project and updates the view data', async function () {
   it('untrashes the project and updates the view data', async function () {
     const project = Object.assign({}, trashedProject)
     const project = Object.assign({}, trashedProject)
-    fetchMock.delete(
-      `express:/project/${project.id}/trash`,
+    const untrashProjectMock = fetchMock.delete(
+      `express:/project/:projectId/trash`,
       {
       {
         status: 200,
         status: 200,
       },
       },
@@ -51,7 +47,10 @@ describe('<UntrashProjectButton />', function () {
     const btn = screen.getByLabelText('Restore')
     const btn = screen.getByLabelText('Restore')
     fireEvent.click(btn)
     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
+    )
   })
   })
 })
 })

+ 24 - 11
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 { expect } from 'chai'
 import RenameProjectModal from '../../../../../../../frontend/js/features/project-list/components/modals/rename-project-modal'
 import RenameProjectModal from '../../../../../../../frontend/js/features/project-list/components/modals/rename-project-modal'
 import {
 import {
@@ -9,14 +9,21 @@ import { currentProjects } from '../../../fixtures/projects-data'
 import fetchMock from 'fetch-mock'
 import fetchMock from 'fetch-mock'
 
 
 describe('<RenameProjectModal />', function () {
 describe('<RenameProjectModal />', function () {
+  beforeEach(function () {
+    resetProjectListContextFetch()
+  })
+
   afterEach(function () {
   afterEach(function () {
     resetProjectListContextFetch()
     resetProjectListContextFetch()
   })
   })
 
 
   it('renders the modal and validates new name', async function () {
   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(
     renderWithProjectListContext(
       <RenameProjectModal
       <RenameProjectModal
         handleCloseModal={() => {}}
         handleCloseModal={() => {}}
@@ -44,14 +51,21 @@ describe('<RenameProjectModal />', function () {
     fireEvent.click(submitButton)
     fireEvent.click(submitButton)
     expect(submitButton.disabled).to.be.true
     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 () {
   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(
     renderWithProjectListContext(
       <RenameProjectModal
       <RenameProjectModal
         handleCloseModal={() => {}}
         handleCloseModal={() => {}}
@@ -70,8 +84,7 @@ describe('<RenameProjectModal />', function () {
     const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
     const submitButton = within(modal).getByText('Rename') as HTMLButtonElement
     fireEvent.click(submitButton)
     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.')
     screen.getByText('Something went wrong. Please try again.')
   })
   })

+ 5 - 0
services/web/test/frontend/features/project-list/helpers/render-with-context.tsx

@@ -24,6 +24,11 @@ export function renderWithProjectListContext(
     body: { projects, totalSize: projects.length },
     body: { projects, totalSize: projects.length },
   })
   })
 
 
+  fetchMock.get('express:/system/messages', {
+    status: 200,
+    body: [],
+  })
+
   const ProjectListProviderWrapper = ({
   const ProjectListProviderWrapper = ({
     children,
     children,
   }: {
   }: {

+ 51 - 0
services/web/test/unit/src/Tags/TagsControllerTests.js

@@ -14,7 +14,9 @@ describe('TagsController', function () {
     this.TagsHandler = {
     this.TagsHandler = {
       promises: {
       promises: {
         addProjectToTag: sinon.stub().resolves(),
         addProjectToTag: sinon.stub().resolves(),
+        addProjectsToTag: sinon.stub().resolves(),
         removeProjectFromTag: sinon.stub().resolves(),
         removeProjectFromTag: sinon.stub().resolves(),
+        removeProjectsFromTag: sinon.stub().resolves(),
         deleteTag: sinon.stub().resolves(),
         deleteTag: sinon.stub().resolves(),
         renameTag: sinon.stub().resolves(),
         renameTag: sinon.stub().resolves(),
         createTag: sinon.stub().resolves(),
         createTag: sinon.stub().resolves(),
@@ -40,6 +42,7 @@ describe('TagsController', function () {
           _id: userId,
           _id: userId,
         },
         },
       },
       },
+      body: {},
     }
     }
 
 
     this.res = {}
     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) {
   it('remove a project from a tag', function (done) {
     this.req.params.tagId = this.tagId = 'tag-id-123'
     this.req.params.tagId = this.tagId = 'tag-id-123'
     this.req.params.projectId = this.projectId = 'project-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: () => {},
+        }
+      },
+    })
+  })
 })
 })