Browse Source

Merge pull request #9865 from overleaf/ab-display-notifications-welcome-page

[web] Display notifications on the react dashboard welcome page

GitOrigin-RevId: 29fb08bbac195c2766dd0e94dbe9e9a0c7065e76
Alexandre Bourdin 4 years ago
parent
commit
debe76baa6

+ 1 - 3
services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx

@@ -5,7 +5,6 @@ import Notification from '../notification'
 import Icon from '../../../../../shared/components/icon'
 import getMeta from '../../../../../utils/meta'
 import useAsyncDismiss from '../hooks/useAsyncDismiss'
-import { useProjectListContext } from '../../../context/project-list-context'
 import useAsync from '../../../../../shared/hooks/use-async'
 import { FetchError, postJSON } from '../../../../../infrastructure/fetch-json'
 import { ExposedSettings } from '../../../../../../../types/exposed-settings'
@@ -14,7 +13,6 @@ import { User } from '../../../../../../../types/user'
 
 function Common() {
   const { t } = useTranslation()
-  const { totalProjectsCount } = useProjectListContext()
   const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
   const notifications = getMeta('ol-notifications', []) as NotificationType[]
   const user = getMeta('ol-user', []) as Pick<User, 'features'>
@@ -33,7 +31,7 @@ function Common() {
     ).catch(console.error)
   }
 
-  if (!totalProjectsCount || !notifications.length) {
+  if (!notifications.length) {
     return null
   }
 

+ 5 - 0
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -135,6 +135,11 @@ function ProjectListPageContent() {
               mdOffset={2}
               className="project-list-empty-col"
             >
+              <Row>
+                <Col xs={12}>
+                  <UserNotifications />
+                </Col>
+              </Row>
               <WelcomeMessage />
             </Col>
           </Row>

+ 403 - 360
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -24,20 +24,17 @@ describe('<ProjectListRoot />', function () {
     sendSpy = sinon.spy(eventTracking, 'send')
     window.metaAttributesCache = new Map()
     window.metaAttributesCache.set('ol-tags', [])
-    window.metaAttributesCache.set('ol-ExposedSettings', { templateLinks: [] })
+    window.metaAttributesCache.set('ol-ExposedSettings', {
+      templateLinks: [],
+    })
+    window.metaAttributesCache.set('ol-userEmails', [
+      { email: 'test@overleaf.com', default: true },
+    ])
     window.user_id = userId
 
     Object.defineProperty(window, 'location', {
       value: { assign: locationStub },
     })
-
-    renderWithProjectListContext(<ProjectListRoot />, {
-      projects: fullList,
-    })
-    await fetchMock.flush(true)
-    await waitFor(() => {
-      screen.findByRole('table')
-    })
   })
 
   afterEach(function () {
@@ -49,360 +46,465 @@ describe('<ProjectListRoot />', function () {
     })
   })
 
-  describe('checkboxes', function () {
-    let allCheckboxes: Array<HTMLInputElement> = []
-    let actionsToolbar: HTMLElement
-    let project1Id: string | null, project2Id: string | null
-
-    describe('all projects', function () {
-      beforeEach(function () {
-        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        // first one is the select all checkbox
-        fireEvent.click(allCheckboxes[1])
-        fireEvent.click(allCheckboxes[2])
-
-        project1Id = allCheckboxes[1].getAttribute('data-project-id')
-        project2Id = allCheckboxes[2].getAttribute('data-project-id')
-        actionsToolbar = screen.getAllByRole('toolbar')[0]
+  describe('welcome page', function () {
+    beforeEach(async function () {
+      renderWithProjectListContext(<ProjectListRoot />, {
+        projects: [],
       })
+      await fetchMock.flush(true)
+    })
 
-      it('downloads all selected projects and then unselects them', async function () {
-        const downloadButton = within(actionsToolbar).getByLabelText('Download')
-        fireEvent.click(downloadButton)
-
-        await waitFor(() => {
-          expect(locationStub).to.have.been.called
-        })
+    it('the welcome page is displayed', async function () {
+      screen.getByRole('heading', { name: 'Welcome to Overleaf!' })
+    })
 
-        sinon.assert.calledWithMatch(
-          locationStub,
-          `/project/download/zip?project_ids=${project1Id},${project2Id}`
+    it('the email confirmation alert is not displayed', async function () {
+      expect(
+        screen.queryByText(
+          'Please confirm your email test@overleaf.com by clicking on the link in the confirmation email'
         )
+      ).to.be.null
+    })
+  })
 
-        const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
-        expect(allCheckboxesChecked.length).to.equal(0)
+  describe('project table', function () {
+    beforeEach(async function () {
+      renderWithProjectListContext(<ProjectListRoot />, {
+        projects: fullList,
       })
-
-      it('opens archive modal for all selected projects and archives all', async function () {
-        fetchMock.post(
-          `express:/project/${project1Id}/archive`,
-          {
-            status: 200,
-          },
-          { delay: 0 }
-        )
-        fetchMock.post(
-          `express:/project/${project2Id}/archive`,
-          {
-            status: 200,
-          },
-          { delay: 0 }
-        )
-
-        const archiveButton = within(actionsToolbar).getByLabelText('Archive')
-        fireEvent.click(archiveButton)
-
-        const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
-        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 fetchMock.flush(true)
+      await waitFor(() => {
+        screen.findByRole('table')
       })
+    })
 
-      it('opens trash modal for all selected projects and trashes all', async function () {
-        fetchMock.post(
-          `express:/project/${project1Id}/trash`,
-          {
-            status: 200,
-          },
-          { delay: 0 }
-        )
-        fetchMock.post(
-          `express:/project/${project2Id}/trash`,
-          {
-            status: 200,
-          },
-          { delay: 0 }
-        )
+    describe('checkboxes', function () {
+      let allCheckboxes: Array<HTMLInputElement> = []
+      let actionsToolbar: HTMLElement
+      let project1Id: string | null, project2Id: string | null
+
+      describe('all projects', function () {
+        beforeEach(function () {
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+          // first one is the select all checkbox
+          fireEvent.click(allCheckboxes[1])
+          fireEvent.click(allCheckboxes[2])
+
+          project1Id = allCheckboxes[1].getAttribute('data-project-id')
+          project2Id = allCheckboxes[2].getAttribute('data-project-id')
+          actionsToolbar = screen.getAllByRole('toolbar')[0]
+        })
 
-        const archiveButton = within(actionsToolbar).getByLabelText('Trash')
-        fireEvent.click(archiveButton)
+        it('downloads all selected projects and then unselects them', async function () {
+          const downloadButton =
+            within(actionsToolbar).getByLabelText('Download')
+          fireEvent.click(downloadButton)
 
-        const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
-        fireEvent.click(confirmBtn)
-        expect(confirmBtn.disabled).to.be.true
+          await waitFor(() => {
+            expect(locationStub).to.have.been.called
+          })
 
-        await fetchMock.flush(true)
-        expect(fetchMock.done()).to.be.true
+          sinon.assert.calledWithMatch(
+            locationStub,
+            `/project/download/zip?project_ids=${project1Id},${project2Id}`
+          )
 
-        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')
-      })
+          const allCheckboxes =
+            screen.getAllByRole<HTMLInputElement>('checkbox')
+          const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+          expect(allCheckboxesChecked.length).to.equal(0)
+        })
 
-      it('only checks the projects that are viewable when there is a load more button', async function () {
-        // first one is the select all checkbox
-        fireEvent.click(allCheckboxes[0])
+        it('opens archive modal for all selected projects and archives all', async function () {
+          fetchMock.post(
+            `express:/project/${project1Id}/archive`,
+            {
+              status: 200,
+            },
+            { delay: 0 }
+          )
+          fetchMock.post(
+            `express:/project/${project2Id}/archive`,
+            {
+              status: 200,
+            },
+            { delay: 0 }
+          )
+
+          const archiveButton = within(actionsToolbar).getByLabelText('Archive')
+          fireEvent.click(archiveButton)
+
+          const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
+          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')
+        })
 
-        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        let checked = allCheckboxes.filter(c => c.checked)
-        expect(checked.length).to.equal(21) // max projects viewable by default is 20, and plus one for check all
+        it('opens trash modal for all selected projects and trashes all', async function () {
+          fetchMock.post(
+            `express:/project/${project1Id}/trash`,
+            {
+              status: 200,
+            },
+            { delay: 0 }
+          )
+          fetchMock.post(
+            `express:/project/${project2Id}/trash`,
+            {
+              status: 200,
+            },
+            { delay: 0 }
+          )
+
+          const archiveButton = within(actionsToolbar).getByLabelText('Trash')
+          fireEvent.click(archiveButton)
+
+          const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
+          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')
+        })
 
-        const loadMoreButton = screen.getByLabelText('Show 17 more projects')
-        fireEvent.click(loadMoreButton)
+        it('only checks the projects that are viewable when there is a load more button', async function () {
+          // first one is the select all checkbox
+          fireEvent.click(allCheckboxes[0])
 
-        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        expect(allCheckboxes.length).to.equal(currentList.length + 1)
-        checked = allCheckboxes.filter(c => c.checked)
-        expect(checked.length).to.equal(20) // remains same even after showing more
-      })
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+          let checked = allCheckboxes.filter(c => c.checked)
+          expect(checked.length).to.equal(21) // max projects viewable by default is 20, and plus one for check all
 
-      it('maintains viewable and selected projects after loading more and then selecting all', async function () {
-        const loadMoreButton = screen.getByLabelText('Show 17 more projects')
-        fireEvent.click(loadMoreButton)
-        // verify button gone
-        screen.getByText(
-          `Showing ${currentList.length} out of ${currentList.length} projects.`
-        )
-        // first one is the select all checkbox
-        fireEvent.click(allCheckboxes[0])
-        // verify button still gone
-        screen.getByText(
-          `Showing ${currentList.length} out of ${currentList.length} projects.`
-        )
+          const loadMoreButton = screen.getByLabelText('Show 17 more projects')
+          fireEvent.click(loadMoreButton)
 
-        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        expect(allCheckboxes.length).to.equal(currentList.length + 1)
-      })
-    })
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+          expect(allCheckboxes.length).to.equal(currentList.length + 1)
+          checked = allCheckboxes.filter(c => c.checked)
+          expect(checked.length).to.equal(20) // remains same even after showing more
+        })
 
-    describe('archived projects', function () {
-      beforeEach(function () {
-        const filterButton = screen.getAllByText('Archived Projects')[0]
-        fireEvent.click(filterButton)
+        it('maintains viewable and selected projects after loading more and then selecting all', async function () {
+          const loadMoreButton = screen.getByLabelText('Show 17 more projects')
+          fireEvent.click(loadMoreButton)
+          // verify button gone
+          screen.getByText(
+            `Showing ${currentList.length} out of ${currentList.length} projects.`
+          )
+          // first one is the select all checkbox
+          fireEvent.click(allCheckboxes[0])
+          // verify button still gone
+          screen.getByText(
+            `Showing ${currentList.length} out of ${currentList.length} projects.`
+          )
+
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+          expect(allCheckboxes.length).to.equal(currentList.length + 1)
+        })
+      })
 
-        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        expect(allCheckboxes.length === 2).to.be.true
-        // first one is the select all checkbox
-        fireEvent.click(allCheckboxes[1])
-        project1Id = allCheckboxes[1].getAttribute('data-project-id')
+      describe('archived projects', function () {
+        beforeEach(function () {
+          const filterButton = screen.getAllByText('Archived Projects')[0]
+          fireEvent.click(filterButton)
 
-        actionsToolbar = screen.getAllByRole('toolbar')[0]
-      })
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+          expect(allCheckboxes.length === 2).to.be.true
+          // first one is the select all checkbox
+          fireEvent.click(allCheckboxes[1])
+          project1Id = allCheckboxes[1].getAttribute('data-project-id')
 
-      it('does not show the archive button in toolbar when archive view selected', function () {
-        expect(screen.queryByLabelText('Archive')).to.be.null
-      })
+          actionsToolbar = screen.getAllByRole('toolbar')[0]
+        })
 
-      it('restores all projects when selected', async function () {
-        fetchMock.delete(`express:/project/:id/archive`, {
-          status: 200,
+        it('does not show the archive button in toolbar when archive view selected', function () {
+          expect(screen.queryByLabelText('Archive')).to.be.null
         })
 
-        const unarchiveButton =
-          within(actionsToolbar).getByText<HTMLInputElement>('Restore')
-        fireEvent.click(unarchiveButton)
+        it('restores all projects when selected', async function () {
+          fetchMock.delete(`express:/project/:id/archive`, {
+            status: 200,
+          })
 
-        await fetchMock.flush(true)
-        expect(fetchMock.done()).to.be.true
+          const unarchiveButton =
+            within(actionsToolbar).getByText<HTMLInputElement>('Restore')
+          fireEvent.click(unarchiveButton)
 
-        screen.getByText('No projects')
-      })
+          await fetchMock.flush(true)
+          expect(fetchMock.done()).to.be.true
 
-      it('only unarchive the selected projects', async function () {
-        // beforeEach selected all, so uncheck the 1st project
-        fireEvent.click(allCheckboxes[1])
+          screen.getByText('No projects')
+        })
 
-        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
-        expect(allCheckboxesChecked.length).to.equal(
-          archivedProjects.length - 1
-        )
+        it('only unarchive the selected projects', async function () {
+          // beforeEach selected all, so uncheck the 1st project
+          fireEvent.click(allCheckboxes[1])
 
-        await fetchMock.flush(true)
-        expect(fetchMock.done()).to.be.true
+          const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+          expect(allCheckboxesChecked.length).to.equal(
+            archivedProjects.length - 1
+          )
 
-        expect(screen.queryByText('No projects')).to.be.null
-      })
-    })
+          await fetchMock.flush(true)
+          expect(fetchMock.done()).to.be.true
 
-    describe('trashed projects', function () {
-      beforeEach(function () {
-        const filterButton = screen.getAllByText('Trashed Projects')[0]
-        fireEvent.click(filterButton)
+          expect(screen.queryByText('No projects')).to.be.null
+        })
+      })
 
-        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        // + 1 because of select all
-        expect(allCheckboxes.length).to.equal(trashedList.length + 1)
+      describe('trashed projects', function () {
+        beforeEach(function () {
+          const filterButton = screen.getAllByText('Trashed Projects')[0]
+          fireEvent.click(filterButton)
 
-        // first one is the select all checkbox
-        fireEvent.click(allCheckboxes[0])
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+          // + 1 because of select all
+          expect(allCheckboxes.length).to.equal(trashedList.length + 1)
 
-        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
-        // + 1 because of select all
-        expect(allCheckboxesChecked.length).to.equal(trashedList.length + 1)
+          // first one is the select all checkbox
+          fireEvent.click(allCheckboxes[0])
 
-        actionsToolbar = screen.getAllByRole('toolbar')[0]
-      })
+          const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+          // + 1 because of select all
+          expect(allCheckboxesChecked.length).to.equal(trashedList.length + 1)
 
-      it('only shows the download, archive, and restore buttons in top toolbar', function () {
-        expect(screen.queryByLabelText('Trash')).to.be.null
-        within(actionsToolbar).queryByLabelText('Download')
-        within(actionsToolbar).queryByLabelText('Archive')
-        within(actionsToolbar).getByText('Restore') // no icon for this button
-      })
+          actionsToolbar = screen.getAllByRole('toolbar')[0]
+        })
 
-      it('clears selected projects when filter changed', function () {
-        const filterButton = screen.getAllByText('All Projects')[0]
-        fireEvent.click(filterButton)
+        it('only shows the download, archive, and restore buttons in top toolbar', function () {
+          expect(screen.queryByLabelText('Trash')).to.be.null
+          within(actionsToolbar).queryByLabelText('Download')
+          within(actionsToolbar).queryByLabelText('Archive')
+          within(actionsToolbar).getByText('Restore') // no icon for this button
+        })
 
-        const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
-        expect(allCheckboxesChecked.length).to.equal(0)
-      })
+        it('clears selected projects when filter changed', function () {
+          const filterButton = screen.getAllByText('All Projects')[0]
+          fireEvent.click(filterButton)
 
-      it('untrashes all the projects', async function () {
-        fetchMock.delete(`express:/project/:id/trash`, {
-          status: 200,
+          const allCheckboxes =
+            screen.getAllByRole<HTMLInputElement>('checkbox')
+          const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+          expect(allCheckboxesChecked.length).to.equal(0)
         })
 
-        const untrashButton =
-          within(actionsToolbar).getByText<HTMLInputElement>('Restore')
-        fireEvent.click(untrashButton)
+        it('untrashes all the projects', async function () {
+          fetchMock.delete(`express:/project/:id/trash`, {
+            status: 200,
+          })
 
-        await fetchMock.flush(true)
-        expect(fetchMock.done()).to.be.true
+          const untrashButton =
+            within(actionsToolbar).getByText<HTMLInputElement>('Restore')
+          fireEvent.click(untrashButton)
 
-        screen.getByText('No projects')
-      })
+          await fetchMock.flush(true)
+          expect(fetchMock.done()).to.be.true
 
-      it('only untrashes the selected projects', async function () {
-        // beforeEach selected all, so uncheck the 1st project
-        fireEvent.click(allCheckboxes[1])
+          screen.getByText('No projects')
+        })
 
-        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
-        expect(allCheckboxesChecked.length).to.equal(trashedList.length - 1)
+        it('only untrashes the selected projects', async function () {
+          // beforeEach selected all, so uncheck the 1st project
+          fireEvent.click(allCheckboxes[1])
 
-        await fetchMock.flush(true)
-        expect(fetchMock.done()).to.be.true
+          const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+          expect(allCheckboxesChecked.length).to.equal(trashedList.length - 1)
 
-        expect(screen.queryByText('No projects')).to.be.null
-      })
+          await fetchMock.flush(true)
+          expect(fetchMock.done()).to.be.true
 
-      it('removes project from view when archiving', async function () {
-        fetchMock.post(`express:/project/:id/archive`, {
-          status: 200,
+          expect(screen.queryByText('No projects')).to.be.null
         })
 
-        const untrashButton =
-          within(actionsToolbar).getByLabelText<HTMLInputElement>('Archive')
-        fireEvent.click(untrashButton)
-
-        const confirmButton = screen.getByText<HTMLInputElement>('Confirm')
-        fireEvent.click(confirmButton)
-        expect(confirmButton.disabled).to.be.true
+        it('removes project from view when archiving', async function () {
+          fetchMock.post(`express:/project/:id/archive`, {
+            status: 200,
+          })
 
-        await fetchMock.flush(true)
-        expect(fetchMock.done()).to.be.true
+          const untrashButton =
+            within(actionsToolbar).getByLabelText<HTMLInputElement>('Archive')
+          fireEvent.click(untrashButton)
 
-        screen.getByText('No projects')
-      })
-    })
+          const confirmButton = screen.getByText<HTMLInputElement>('Confirm')
+          fireEvent.click(confirmButton)
+          expect(confirmButton.disabled).to.be.true
 
-    describe('project tools "More" dropdown', function () {
-      beforeEach(async function () {
-        const filterButton = screen.getAllByText('All Projects')[0]
-        fireEvent.click(filterButton)
-        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        // first one is the select all checkbox
-        fireEvent.click(allCheckboxes[2])
-        actionsToolbar = screen.getAllByRole('toolbar')[0]
-      })
+          await fetchMock.flush(true)
+          expect(fetchMock.done()).to.be.true
 
-      it('does not show the dropdown when more than 1 project is selected', async function () {
-        await waitFor(() => {
-          within(actionsToolbar).getByText<HTMLElement>('More')
+          screen.getByText('No projects')
         })
-        fireEvent.click(allCheckboxes[0])
-        expect(within(actionsToolbar).queryByText<HTMLElement>('More')).to.be
-          .null
       })
 
-      it('opens the rename modal, and can rename the project, and view updated', async function () {
-        fetchMock.post(`express:/project/:id/rename`, {
-          status: 200,
+      describe('project tools "More" dropdown', function () {
+        beforeEach(async function () {
+          const filterButton = screen.getAllByText('All Projects')[0]
+          fireEvent.click(filterButton)
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+          // first one is the select all checkbox
+          fireEvent.click(allCheckboxes[2])
+          actionsToolbar = screen.getAllByRole('toolbar')[0]
         })
 
-        await waitFor(() => {
-          const moreDropdown =
+        it('does not show the dropdown when more than 1 project is selected', async function () {
+          await waitFor(() => {
             within(actionsToolbar).getByText<HTMLElement>('More')
-          fireEvent.click(moreDropdown)
+          })
+          fireEvent.click(allCheckboxes[0])
+          expect(within(actionsToolbar).queryByText<HTMLElement>('More')).to.be
+            .null
         })
 
-        const renameButton = screen.getByText<HTMLInputElement>('Rename')
-        fireEvent.click(renameButton)
-
-        const modal = screen.getAllByRole('dialog')[0]
-
-        expect(sendSpy).to.be.calledOnce
-        expect(sendSpy).calledWith('project-list-page-interaction')
-
-        // same name
-        let confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
-        expect(confirmButton.disabled).to.be.true
-        let input = screen.getByLabelText('New Name') as HTMLButtonElement
-        const oldName = input.value
-
-        // no name
-        let newProjectName = ''
-        input = screen.getByLabelText('New Name') as HTMLButtonElement
-        fireEvent.change(input, {
-          target: { value: newProjectName },
-        })
-        confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
-        expect(confirmButton.disabled).to.be.true
-
-        // a valid name
-        newProjectName = 'A new project name'
-        input = screen.getByLabelText('New Name') as HTMLButtonElement
-        fireEvent.change(input, {
-          target: { value: newProjectName },
+        it('opens the rename modal, and can rename the project, and view updated', async function () {
+          fetchMock.post(`express:/project/:id/rename`, {
+            status: 200,
+          })
+
+          await waitFor(() => {
+            const moreDropdown =
+              within(actionsToolbar).getByText<HTMLElement>('More')
+            fireEvent.click(moreDropdown)
+          })
+
+          const renameButton = screen.getByText<HTMLInputElement>('Rename')
+          fireEvent.click(renameButton)
+
+          const modal = screen.getAllByRole('dialog')[0]
+
+          expect(sendSpy).to.be.calledOnce
+          expect(sendSpy).calledWith('project-list-page-interaction')
+
+          // same name
+          let confirmButton =
+            within(modal).getByText<HTMLInputElement>('Rename')
+          expect(confirmButton.disabled).to.be.true
+          let input = screen.getByLabelText('New Name') as HTMLButtonElement
+          const oldName = input.value
+
+          // no name
+          let newProjectName = ''
+          input = screen.getByLabelText('New Name') as HTMLButtonElement
+          fireEvent.change(input, {
+            target: { value: newProjectName },
+          })
+          confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
+          expect(confirmButton.disabled).to.be.true
+
+          // a valid name
+          newProjectName = 'A new project name'
+          input = screen.getByLabelText('New Name') as HTMLButtonElement
+          fireEvent.change(input, {
+            target: { value: newProjectName },
+          })
+
+          confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
+          expect(confirmButton.disabled).to.be.false
+          fireEvent.click(confirmButton)
+
+          await fetchMock.flush(true)
+          expect(fetchMock.done()).to.be.true
+
+          screen.findByText(newProjectName)
+          expect(screen.queryByText(oldName)).to.be.null
+
+          const allCheckboxes =
+            screen.getAllByRole<HTMLInputElement>('checkbox')
+          const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+          expect(allCheckboxesChecked.length).to.equal(0)
         })
 
-        confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
-        expect(confirmButton.disabled).to.be.false
-        fireEvent.click(confirmButton)
+        it('opens the copy modal, can copy the project, and view updated', async 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
+          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,
+            },
+          })
+
+          await waitFor(() => {
+            const moreDropdown =
+              within(actionsToolbar).getByText<HTMLElement>('More')
+            fireEvent.click(moreDropdown)
+          })
+
+          const copyButton =
+            within(actionsToolbar).getByText<HTMLInputElement>('Make a copy')
+          fireEvent.click(copyButton)
+
+          // confirm in modal
+          const copyConfirmButton = document.querySelector(
+            'button[type="submit"]'
+          ) as HTMLElement
+          fireEvent.click(copyConfirmButton)
+
+          await fetchMock.flush(true)
+          expect(fetchMock.done()).to.be.true
+
+          expect(sendSpy).to.be.calledOnce
+          expect(sendSpy).calledWith('project-list-page-interaction')
+
+          screen.findByText(copiedProjectName)
+        })
+      })
+    })
 
-        await fetchMock.flush(true)
-        expect(fetchMock.done()).to.be.true
+    describe('search', function () {
+      it('shows only projects based on the input', async function () {
+        const input = screen.getAllByRole('textbox', {
+          name: /search projects/i,
+        })[0]
+        const value = currentList[0].name
 
-        screen.findByText(newProjectName)
-        expect(screen.queryByText(oldName)).to.be.null
+        fireEvent.change(input, { target: { value } })
 
-        const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
-        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
-        expect(allCheckboxesChecked.length).to.equal(0)
+        const results = screen.getAllByRole('row')
+        expect(results.length).to.equal(2) // first is header
       })
+    })
+
+    describe('copying project', function () {
+      it('correctly updates the view after copying a shared project', async function () {
+        const filterButton = screen.getAllByText('Shared with you')[0]
+        fireEvent.click(filterButton)
 
-      it('opens the copy modal, can copy the project, and view updated', async 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
-        const copiedProjectName = `${projectNameToCopy} (Copy)`
+        const projectNameToCopy = linkForProjectToCopy.textContent
+        const copiedProjectName = `${projectNameToCopy} Copy`
         fetchMock.post(`express:/project/:id/clone`, {
           status: 200,
           body: {
@@ -419,15 +521,7 @@ describe('<ProjectListRoot />', function () {
             archived: false,
           },
         })
-
-        await waitFor(() => {
-          const moreDropdown =
-            within(actionsToolbar).getByText<HTMLElement>('More')
-          fireEvent.click(moreDropdown)
-        })
-
-        const copyButton =
-          within(actionsToolbar).getByText<HTMLInputElement>('Make a copy')
+        const copyButton = within(tableRows[1]).getAllByLabelText('Copy')[0]
         fireEvent.click(copyButton)
 
         // confirm in modal
@@ -442,71 +536,20 @@ describe('<ProjectListRoot />', function () {
         expect(sendSpy).to.be.calledOnce
         expect(sendSpy).calledWith('project-list-page-interaction')
 
+        expect(screen.queryByText(copiedProjectName)).to.be.null
+
+        const yourProjectFilter = screen.getAllByText('Your Projects')[0]
+        fireEvent.click(yourProjectFilter)
         screen.findByText(copiedProjectName)
       })
     })
-  })
-
-  describe('search', function () {
-    it('shows only projects based on the input', async function () {
-      const input = screen.getAllByRole('textbox', {
-        name: /search projects/i,
-      })[0]
-      const value = currentList[0].name
-
-      fireEvent.change(input, { target: { value } })
-
-      const results = screen.getAllByRole('row')
-      expect(results.length).to.equal(2) // first is header
-    })
-  })
 
-  describe('copying project', function () {
-    it('correctly updates the view after copying a shared project', async function () {
-      const filterButton = screen.getAllByText('Shared with you')[0]
-      fireEvent.click(filterButton)
-
-      const tableRows = screen.getAllByRole('row')
-
-      const linkForProjectToCopy = within(tableRows[1]).getByRole('link')
-      const projectNameToCopy = linkForProjectToCopy.textContent
-      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,
-        },
+    describe('notifications', function () {
+      it('email confirmation alert is displayed', async function () {
+        screen.getByText(
+          'Please confirm your email test@overleaf.com by clicking on the link in the confirmation email'
+        )
       })
-      const copyButton = within(tableRows[1]).getAllByLabelText('Copy')[0]
-      fireEvent.click(copyButton)
-
-      // confirm in modal
-      const copyConfirmButton = document.querySelector(
-        'button[type="submit"]'
-      ) as HTMLElement
-      fireEvent.click(copyConfirmButton)
-
-      await fetchMock.flush(true)
-      expect(fetchMock.done()).to.be.true
-
-      expect(sendSpy).to.be.calledOnce
-      expect(sendSpy).calledWith('project-list-page-interaction')
-
-      expect(screen.queryByText(copiedProjectName)).to.be.null
-
-      const yourProjectFilter = screen.getAllByText('Your Projects')[0]
-      fireEvent.click(yourProjectFilter)
-      screen.findByText(copiedProjectName)
     })
   })
 })

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

@@ -7,6 +7,8 @@ import { renderWithProjectListContext } from '../../helpers/render-with-context'
 
 describe('<TagsList />', function () {
   beforeEach(async function () {
+    global.localStorage.clear()
+    window.metaAttributesCache = new Map()
     window.metaAttributesCache.set('ol-tags', [
       {
         _id: 'abc123def456',
@@ -30,6 +32,7 @@ describe('<TagsList />', function () {
 
     renderWithProjectListContext(<TagsList />)
 
+    await fetchMock.flush(true)
     await waitFor(() => expect(fetchMock.called('/api/project')))
   })
 
@@ -37,7 +40,7 @@ describe('<TagsList />', function () {
     fetchMock.reset()
   })
 
-  it('displays the tags list', async function () {
+  it('displays the tags list', function () {
     screen.getByRole('heading', {
       name: 'Tags/Folders',
     })