ソースを参照

Merge pull request #9603 from overleaf/jel-project-tools

[web] Begin project tools for React dash

GitOrigin-RevId: a735864153f836ca01135001c661aa31ec52cfa8
Jessica Lawshe 3 年 前
コミット
475201d42f
22 ファイル変更680 行追加117 行削除
  1. 13 4
      services/web/frontend/js/features/project-list/components/project-list-root.tsx
  2. 0 14
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx
  3. 0 8
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx
  4. 1 9
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx
  5. 0 14
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx
  6. 27 1
      services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx
  7. 29 2
      services/web/frontend/js/features/project-list/components/table/project-list-table.tsx
  8. 65 0
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx
  9. 46 0
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button.tsx
  10. 66 0
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx
  11. 20 0
      services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx
  12. 53 7
      services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx
  13. 9 0
      services/web/frontend/js/features/project-list/context/project-list-context.tsx
  14. 188 0
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  15. 64 40
      services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx
  16. 26 0
      services/web/test/frontend/features/project-list/components/table/project-tools/buttons/archive-projects.button.test.tsx
  17. 19 0
      services/web/test/frontend/features/project-list/components/table/project-tools/buttons/download-projects-button.test.tsx
  18. 26 0
      services/web/test/frontend/features/project-list/components/table/project-tools/buttons/trash-projects.button.test.tsx
  19. 21 0
      services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx
  20. 0 15
      services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx
  21. 4 0
      services/web/test/frontend/features/project-list/fixtures/projects-data.ts
  22. 3 3
      services/web/test/frontend/features/project-list/helpers/render-with-context.js

+ 13 - 4
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -14,6 +14,7 @@ import WelcomeMessage from './welcome-message'
 import LoadingBranded from '../../../shared/components/loading-branded'
 import UserNotifications from './notifications/user-notifications'
 import SearchForm from './search-form'
+import ProjectTools from './table/project-tools/project-tools'
 
 function ProjectListRoot() {
   const { isReady } = useWaitForI18n()
@@ -26,8 +27,14 @@ function ProjectListRoot() {
 }
 
 function ProjectListPageContent() {
-  const { totalProjectsCount, error, isLoading, loadProgress, setSearchText } =
-    useProjectListContext()
+  const {
+    totalProjectsCount,
+    error,
+    isLoading,
+    loadProgress,
+    setSearchText,
+    selectedProjects,
+  } = useProjectListContext()
 
   return isLoading ? (
     <div className="loading-container">
@@ -58,9 +65,11 @@ function ProjectListPageContent() {
                 </Col>
                 <Col md={5} xs={12}>
                   <div className="project-tools">
-                    <div className="text-right pull-right">
+                    {selectedProjects.length === 0 ? (
                       <CurrentPlanWidget />
-                    </div>
+                    ) : (
+                      <ProjectTools />
+                    )}
                   </div>
                 </Col>
               </Row>

+ 0 - 14
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx

@@ -57,22 +57,8 @@ function ArchiveProjectButton({ project }: ArchiveProjectButtonProps) {
       </Tooltip>
 
       <ProjectsActionModal
-        title={t('archive_projects')}
         action="archive"
         actionHandler={handleArchiveProject}
-        bodyTop={<p>{t('about_to_archive_projects')}</p>}
-        bodyBottom={
-          <p>
-            {t('archiving_projects_wont_affect_collaborators')}{' '}
-            <a
-              href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
-              target="_blank"
-              rel="noreferrer"
-            >
-              {t('find_out_more_nt')}
-            </a>
-          </p>
-        }
         showModal={showModal}
         handleCloseModal={handleCloseModal}
         projects={[project]}

+ 0 - 8
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx

@@ -60,16 +60,8 @@ function DeleteProjectButton({ project }: DeleteProjectButtonProps) {
       </Tooltip>
 
       <ProjectsActionModal
-        title={t('delete_projects')}
         action="delete"
         actionHandler={handleDeleteProject}
-        bodyTop={<p>{t('about_to_delete_projects')}</p>}
-        bodyBottom={
-          <div className="project-action-alert alert alert-warning">
-            <Icon type="exclamation-triangle" fw />{' '}
-            {t('this_action_cannot_be_undone')}
-          </div>
-        }
         showModal={showModal}
         handleCloseModal={handleCloseModal}
         projects={[project]}

+ 1 - 9
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx

@@ -59,16 +59,8 @@ function LeaveProjectButton({ project }: LeaveProjectButtonProps) {
       </Tooltip>
 
       <ProjectsActionModal
-        title={t('leave_projects')}
-        action="trash"
+        action="leave"
         actionHandler={handleLeaveProject}
-        bodyTop={<p>{t('about_to_leave_projects')}</p>}
-        bodyBottom={
-          <div className="project-action-alert alert alert-warning">
-            <Icon type="exclamation-triangle" fw />{' '}
-            {t('this_action_cannot_be_undone')}
-          </div>
-        }
         showModal={showModal}
         handleCloseModal={handleCloseModal}
         projects={[project]}

+ 0 - 14
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx

@@ -58,22 +58,8 @@ function TrashProjectButton({ project }: TrashProjectButtonProps) {
       </Tooltip>
 
       <ProjectsActionModal
-        title={t('trash_projects')}
         action="trash"
         actionHandler={handleTrashProject}
-        bodyTop={<p>{t('about_to_trash_projects')}</p>}
-        bodyBottom={
-          <p>
-            {t('trashing_projects_wont_affect_collaborators')}{' '}
-            <a
-              href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
-              target="_blank"
-              rel="noreferrer"
-            >
-              {t('find_out_more_nt')}
-            </a>
-          </p>
-        }
         showModal={showModal}
         handleCloseModal={handleCloseModal}
         projects={[project]}

+ 27 - 1
services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx

@@ -4,6 +4,8 @@ import InlineTags from './cells/inline-tags'
 import OwnerCell from './cells/owner-cell'
 import LastUpdatedCell from './cells/last-updated-cell'
 import ActionsCell from './cells/actions-cell'
+import { useProjectListContext } from '../../context/project-list-context'
+import { useCallback } from 'react'
 
 type ProjectListTableRowProps = {
   project: Project
@@ -12,11 +14,35 @@ export default function ProjectListTableRow({
   project,
 }: ProjectListTableRowProps) {
   const { t } = useTranslation()
+  const { selectedProjects, setSelectedProjects } = useProjectListContext()
+
+  const handleCheckboxChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      const checked = event.target.checked
+      setSelectedProjects(selectedProjects => {
+        let projects = [...selectedProjects]
+        if (checked) {
+          projects.push(project)
+        } else {
+          const projectId = event.target.getAttribute('data-project-id')
+          projects = projects.filter(p => p.id !== projectId)
+        }
+        return projects
+      })
+    },
+    [project, setSelectedProjects]
+  )
 
   return (
     <tr>
       <td className="dash-cell-checkbox">
-        <input type="checkbox" id={`select-project-${project.id}`} />
+        <input
+          type="checkbox"
+          id={`select-project-${project.id}`}
+          checked={selectedProjects.includes(project)}
+          onChange={handleCheckboxChange}
+          data-project-id={project.id}
+        />
         <label
           htmlFor={`select-project-${project.id}`}
           aria-label={t('select_project', { project: project.name })}

+ 29 - 2
services/web/frontend/js/features/project-list/components/table/project-list-table.tsx

@@ -1,3 +1,4 @@
+import { useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 import Icon from '../../../../shared/components/icon'
 import ProjectListTableRow from './project-list-table-row'
@@ -39,7 +40,13 @@ const toggleSort = (order: SortingOrder): SortingOrder => {
 
 function ProjectListTable() {
   const { t } = useTranslation()
-  const { visibleProjects, sort, setSort } = useProjectListContext()
+  const {
+    visibleProjects,
+    sort,
+    setSort,
+    selectedProjects,
+    setSelectedProjects,
+  } = useProjectListContext()
 
   const handleSortClick = (by: Sort['by']) => {
     setSort(prev => ({
@@ -48,6 +55,17 @@ function ProjectListTable() {
     }))
   }
 
+  const handleAllProjectsCheckboxChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      if (event.target.checked) {
+        setSelectedProjects(visibleProjects)
+      } else {
+        setSelectedProjects([])
+      }
+    },
+    [setSelectedProjects, visibleProjects]
+  )
+
   return (
     <div className="card project-list-card">
       <table className="project-dash-table">
@@ -57,7 +75,16 @@ function ProjectListTable() {
               className="dash-cell-checkbox"
               aria-label={t('select_projects')}
             >
-              <input type="checkbox" id="project-list-table-select-all" />
+              <input
+                type="checkbox"
+                id="project-list-table-select-all"
+                onChange={handleAllProjectsCheckboxChange}
+                checked={
+                  visibleProjects.length === selectedProjects.length &&
+                  visibleProjects.length !== 0
+                }
+                disabled={visibleProjects.length === 0}
+              />
               <label
                 htmlFor="project-list-table-select-all"
                 aria-label={t('select_all_projects')}

+ 65 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx

@@ -0,0 +1,65 @@
+import { memo, useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../../../../../../shared/components/icon'
+import Tooltip from '../../../../../../shared/components/tooltip'
+import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import { archiveProject } from '../../../../util/api'
+import ProjectsActionModal from '../../projects-action-modal'
+
+function ArchiveProjectsButton() {
+  const { selectedProjects, updateProjectViewData, setSelectedProjects } =
+    useProjectListContext()
+  const { t } = useTranslation()
+  const text = t('archive')
+
+  const [showModal, setShowModal] = useState(false)
+  const isMounted = useIsMounted()
+
+  const handleOpenModal = useCallback(() => {
+    setShowModal(true)
+  }, [])
+
+  const handleCloseModal = useCallback(() => {
+    if (isMounted.current) {
+      setShowModal(false)
+    }
+  }, [isMounted])
+
+  const handleArchiveProjects = useCallback(async () => {
+    for (const project of selectedProjects) {
+      await archiveProject(project.id)
+      // update view
+      project.archived = true
+      updateProjectViewData(project)
+    }
+    setSelectedProjects([])
+  }, [selectedProjects, setSelectedProjects, updateProjectViewData])
+
+  return (
+    <>
+      <Tooltip
+        id="tooltip-download-projects"
+        description={text}
+        overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
+      >
+        <button
+          className="btn btn-default"
+          aria-label={text}
+          onClick={handleOpenModal}
+        >
+          <Icon type="inbox" />
+        </button>
+      </Tooltip>
+      <ProjectsActionModal
+        action="archive"
+        actionHandler={handleArchiveProjects}
+        showModal={showModal}
+        handleCloseModal={handleCloseModal}
+        projects={selectedProjects}
+      />
+    </>
+  )
+}
+
+export default memo(ArchiveProjectsButton)

+ 46 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button.tsx

@@ -0,0 +1,46 @@
+import { memo, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../../../../../../shared/components/icon'
+import Tooltip from '../../../../../../shared/components/tooltip'
+import * as eventTracking from '../../../../../../infrastructure/event-tracking'
+import { useProjectListContext } from '../../../../context/project-list-context'
+
+function DownloadProjectsButton() {
+  const { selectedProjects, setSelectedProjects } = useProjectListContext()
+  const { t } = useTranslation()
+  const text = t('download')
+
+  const projectIds = selectedProjects.map(p => p.id)
+
+  const handleDownloadProjects = useCallback(() => {
+    eventTracking.send(
+      'project-list-page-interaction',
+      'project action',
+      'Download Zip'
+    )
+
+    window.location.assign(
+      `/project/download/zip?project_ids=${projectIds.join(',')}`
+    )
+
+    setSelectedProjects([])
+  }, [projectIds, setSelectedProjects])
+
+  return (
+    <Tooltip
+      id="tooltip-download-projects"
+      description={text}
+      overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
+    >
+      <button
+        className="btn btn-default"
+        aria-label={text}
+        onClick={handleDownloadProjects}
+      >
+        <Icon type="cloud-download" />
+      </button>
+    </Tooltip>
+  )
+}
+
+export default memo(DownloadProjectsButton)

+ 66 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx

@@ -0,0 +1,66 @@
+import { memo, useCallback, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../../../../../../shared/components/icon'
+import Tooltip from '../../../../../../shared/components/tooltip'
+import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import { trashProject } from '../../../../util/api'
+import ProjectsActionModal from '../../projects-action-modal'
+
+function TrashProjectsButton() {
+  const { selectedProjects, setSelectedProjects, updateProjectViewData } =
+    useProjectListContext()
+  const { t } = useTranslation()
+  const text = t('trash')
+
+  const [showModal, setShowModal] = useState(false)
+  const isMounted = useIsMounted()
+
+  const handleOpenModal = useCallback(() => {
+    setShowModal(true)
+  }, [])
+
+  const handleCloseModal = useCallback(() => {
+    if (isMounted.current) {
+      setShowModal(false)
+    }
+  }, [isMounted])
+
+  const handleTrashProjects = useCallback(async () => {
+    for (const project of selectedProjects) {
+      await trashProject(project.id)
+      // update view
+      project.trashed = true
+      project.archived = false
+      updateProjectViewData(project)
+    }
+    setSelectedProjects([])
+  }, [selectedProjects, setSelectedProjects, updateProjectViewData])
+
+  return (
+    <>
+      <Tooltip
+        id="tooltip-download-projects"
+        description={text}
+        overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
+      >
+        <button
+          className="btn btn-default"
+          aria-label={text}
+          onClick={handleOpenModal}
+        >
+          <Icon type="trash" />
+        </button>
+      </Tooltip>
+      <ProjectsActionModal
+        action="trash"
+        actionHandler={handleTrashProjects}
+        showModal={showModal}
+        handleCloseModal={handleCloseModal}
+        projects={selectedProjects}
+      />
+    </>
+  )
+}
+
+export default memo(TrashProjectsButton)

+ 20 - 0
services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx

@@ -0,0 +1,20 @@
+import { memo } from 'react'
+import { useProjectListContext } from '../../../context/project-list-context'
+import ArchiveProjectsButton from './buttons/archive-projects-button'
+import DownloadProjectsButton from './buttons/download-projects-button'
+import TrashProjectsButton from './buttons/trash-projects-button'
+
+function ProjectTools() {
+  const { filter } = useProjectListContext()
+  return (
+    <div className="btn-toolbar" role="toolbar">
+      <div className="btn-group">
+        <DownloadProjectsButton />
+        {filter !== 'archived' && <ArchiveProjectsButton />}
+        {filter !== 'trashed' && <TrashProjectsButton />}
+      </div>
+    </div>
+  )
+}
+
+export default memo(ProjectTools)

+ 53 - 7
services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx

@@ -6,28 +6,24 @@ import AccessibleModal from '../../../../shared/components/accessible-modal'
 import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
 import useIsMounted from '../../../../shared/hooks/use-is-mounted'
 import * as eventTracking from '../../../../infrastructure/event-tracking'
+import Icon from '../../../../shared/components/icon'
 
 type ProjectsActionModalProps = {
-  title: string
-  action: 'archive' | 'trash' | 'delete'
+  action: 'archive' | 'trash' | 'delete' | 'leave'
   actionHandler: (project: Project) => Promise<void>
   handleCloseModal: () => void
-  bodyTop: React.ReactNode
-  bodyBottom: React.ReactNode
   projects: Array<Project>
   showModal: boolean
 }
 
 function ProjectsActionModal({
-  title,
   action,
   actionHandler,
   handleCloseModal,
-  bodyTop,
-  bodyBottom,
   showModal,
   projects,
 }: ProjectsActionModalProps) {
+  let bodyTop, bodyBottom, title
   const { t } = useTranslation()
   const [errors, setErrors] = useState<Array<any>>([])
   const [isProcessing, setIsProcessing] = useState(false)
@@ -67,6 +63,56 @@ function ProjectsActionModal({
     }
   }, [action, showModal])
 
+  if (action === 'archive') {
+    title = t('archive_projects')
+    bodyTop = <p>{t('about_to_archive_projects')}</p>
+    bodyBottom = (
+      <p>
+        {t('archiving_projects_wont_affect_collaborators')}{' '}
+        <a
+          href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
+          target="_blank"
+          rel="noreferrer"
+        >
+          {t('find_out_more_nt')}
+        </a>
+      </p>
+    )
+  } else if (action === 'leave') {
+    title = t('leave_projects')
+    bodyTop = <p>{t('about_to_leave_projects')}</p>
+    bodyBottom = (
+      <div className="project-action-alert alert alert-warning">
+        <Icon type="exclamation-triangle" fw />{' '}
+        {t('this_action_cannot_be_undone')}
+      </div>
+    )
+  } else if (action === 'trash') {
+    title = t('trash_projects')
+    bodyTop = <p>{t('about_to_trash_projects')}</p>
+    bodyBottom = (
+      <p>
+        {t('trashing_projects_wont_affect_collaborators')}{' '}
+        <a
+          href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
+          target="_blank"
+          rel="noreferrer"
+        >
+          {t('find_out_more_nt')}
+        </a>
+      </p>
+    )
+  } else if (action === 'delete') {
+    title = t('delete_projects')
+    bodyTop = <p>{t('about_to_delete_projects')}</p>
+    bodyBottom = (
+      <div className="project-action-alert alert alert-warning">
+        <Icon type="exclamation-triangle" fw />{' '}
+        {t('this_action_cannot_be_undone')}
+      </div>
+    )
+  }
+
   return (
     <AccessibleModal
       animation

+ 9 - 0
services/web/frontend/js/features/project-list/context/project-list-context.tsx

@@ -72,6 +72,8 @@ type ProjectListContextValue = {
   updateProjectViewData: (project: Project) => void
   removeProjectFromView: (project: Project) => void
   setSearchText: React.Dispatch<React.SetStateAction<string>>
+  selectedProjects: Project[]
+  setSelectedProjects: React.Dispatch<React.SetStateAction<Project[]>>
 }
 
 export const ProjectListContext = createContext<
@@ -101,6 +103,8 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
   >('project-list-selected-tag-id', undefined)
   const [tags, setTags] = useState<Tag[]>(getMeta('ol-tags', []) as Tag[])
   const [searchText, setSearchText] = useState('')
+  const [selectedProjects, setSelectedProjects] = useState<Project[]>([])
+
   const {
     isLoading: loading,
     isIdle,
@@ -193,6 +197,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     (filter: Filter) => {
       setFilter(filter)
       setSelectedTagId(undefined)
+      setSelectedProjects([])
     },
     [setFilter, setSelectedTagId]
   )
@@ -285,8 +290,10 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       renameTag,
       selectedTagId,
       selectFilter,
+      selectedProjects,
       selectTag,
       setSearchText,
+      setSelectedProjects,
       setSort,
       sort,
       tags,
@@ -307,8 +314,10 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       renameTag,
       selectedTagId,
       selectFilter,
+      selectedProjects,
       selectTag,
       setSearchText,
+      setSelectedProjects,
       setSort,
       sort,
       tags,

+ 188 - 0
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -0,0 +1,188 @@
+import { fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import sinon from 'sinon'
+import ProjectListRoot from '../../../../../frontend/js/features/project-list/components/project-list-root'
+import { renderWithProjectListContext } from '../helpers/render-with-context'
+
+const userId = '624333f147cfd8002622a1d3'
+
+describe('<ProjectListRoot />', function () {
+  const originalLocation = window.location
+  const locationStub = sinon.stub()
+
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-tags', [])
+    window.metaAttributesCache.set('ol-ExposedSettings', { templateLinks: [] })
+    window.user_id = userId
+
+    Object.defineProperty(window, 'location', {
+      value: { assign: locationStub },
+    })
+  })
+
+  afterEach(function () {
+    window.user_id = undefined
+    fetchMock.reset()
+    Object.defineProperty(window, 'location', {
+      value: originalLocation,
+    })
+  })
+
+  describe('checkboxes', function () {
+    let allCheckboxes: Array<HTMLInputElement> = []
+    let toolbar: HTMLElement
+    let project1Id: string | null, project2Id: string | null
+
+    beforeEach(async function () {
+      renderWithProjectListContext(<ProjectListRoot />)
+      await fetchMock.flush(true)
+      await waitFor(() => {
+        screen.findByRole('table')
+      })
+    })
+
+    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')
+        toolbar = screen.getByRole('toolbar')
+      })
+
+      it('downloads all selected projects and then unselects them', async function () {
+        const downloadButton = within(toolbar).getByLabelText('Download')
+        fireEvent.click(downloadButton)
+
+        await waitFor(() => {
+          expect(locationStub).to.have.been.called
+        })
+
+        sinon.assert.calledWithMatch(
+          locationStub,
+          `/project/download/zip?project_ids=${project1Id},${project2Id}`
+        )
+
+        const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+        expect(allCheckboxesChecked.length).to.equal(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(toolbar).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')
+      })
+
+      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(toolbar).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')
+      })
+    })
+
+    describe('archived projects', function () {
+      beforeEach(function () {
+        const filterButton = screen.getByText('Archived Projects')
+        fireEvent.click(filterButton)
+
+        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
+      })
+    })
+
+    describe('trashed projects', function () {
+      beforeEach(function () {
+        const filterButton = screen.getByText('Trashed Projects')
+        fireEvent.click(filterButton)
+
+        allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+        // first one is the select all checkbox
+        fireEvent.click(allCheckboxes[1])
+
+        project1Id = allCheckboxes[1].getAttribute('data-project-id')
+      })
+      it('does not show the trash button in toolbar when archive view selected', function () {
+        expect(screen.queryByLabelText('Trash')).to.be.null
+      })
+
+      it('clears selected projects when filter changed', function () {
+        const filterButton = screen.getByText('All Projects')
+        fireEvent.click(filterButton)
+
+        const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+        expect(allCheckboxesChecked.length).to.equal(0)
+      })
+    })
+  })
+})

+ 64 - 40
services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx

@@ -1,20 +1,12 @@
-import { render, screen, within, fireEvent } from '@testing-library/react'
+import { screen, within, fireEvent } from '@testing-library/react'
 import { expect } from 'chai'
 import ProjectListTable from '../../../../../../frontend/js/features/project-list/components/table/project-list-table'
-import { ProjectListProvider } from '../../../../../../frontend/js/features/project-list/context/project-list-context'
-import { projectsData } from '../../fixtures/projects-data'
+import { currentProjects } from '../../fixtures/projects-data'
 import fetchMock from 'fetch-mock'
+import { renderWithProjectListContext } from '../../helpers/render-with-context'
 
 const userId = '624333f147cfd8002622a1d3'
 
-const renderProjectListTableWithinProjectListProvider = () => {
-  render(<ProjectListTable />, {
-    wrapper: ({ children }) => (
-      <ProjectListProvider>{children}</ProjectListProvider>
-    ),
-  })
-}
-
 describe('<ProjectListTable />', function () {
   beforeEach(function () {
     window.metaAttributesCache = new Map()
@@ -29,12 +21,12 @@ describe('<ProjectListTable />', function () {
   })
 
   it('renders the table', function () {
-    renderProjectListTableWithinProjectListProvider()
+    renderWithProjectListContext(<ProjectListTable />)
     screen.getByRole('table')
   })
 
   it('sets aria-sort on column header currently sorted', function () {
-    renderProjectListTableWithinProjectListProvider()
+    renderWithProjectListContext(<ProjectListTable />)
     let foundSortedColumn = false
     const columns = screen.getAllByRole('columnheader')
     columns.forEach(col => {
@@ -49,7 +41,7 @@ describe('<ProjectListTable />', function () {
   })
 
   it('keeps the order type when selecting different column for sorting', function () {
-    renderProjectListTableWithinProjectListProvider()
+    renderWithProjectListContext(<ProjectListTable />)
     const lastModifiedBtn = screen.getByRole('button', {
       name: /last modified/i,
     })
@@ -65,64 +57,51 @@ describe('<ProjectListTable />', function () {
   })
 
   it('renders buttons for sorting all sortable columns', function () {
-    renderProjectListTableWithinProjectListProvider()
+    renderWithProjectListContext(<ProjectListTable />)
     screen.getByText('Sort by Title')
     screen.getByText('Sort by Owner')
     screen.getByText('Reverse Last Modified sort order') // currently sorted
   })
 
   it('renders project title, owner, last modified, and action buttons', async function () {
-    // archived and trashed projects are currently not shown
-    const filteredProjects = projectsData.filter(
-      ({ archived, trashed }) => !archived && !trashed
-    )
-
-    fetchMock.post('/api/project', {
-      status: 200,
-      body: {
-        projects: filteredProjects,
-        totalSize: filteredProjects.length,
-      },
-    })
-
-    renderProjectListTableWithinProjectListProvider()
+    renderWithProjectListContext(<ProjectListTable />)
     await fetchMock.flush(true)
 
     const rows = screen.getAllByRole('row')
     rows.shift() // remove first row since it's the header
-    expect(rows.length).to.equal(filteredProjects.length)
+    expect(rows.length).to.equal(currentProjects.length)
 
     // Project name cell
-    filteredProjects.forEach(project => {
+    currentProjects.forEach(project => {
       screen.getByText(project.name)
     })
 
     // Owner Column and Last Modified Column
     const row1 = screen
-      .getByRole('cell', { name: filteredProjects[0].name })
+      .getByRole('cell', { name: currentProjects[0].name })
       .closest('tr')!
     within(row1).getByText('You')
     within(row1).getByText('a day ago by Jean-Luc Picard')
     const row2 = screen
-      .getByRole('cell', { name: filteredProjects[1].name })
+      .getByRole('cell', { name: currentProjects[1].name })
       .closest('tr')!
     within(row2).getByText('Jean-Luc Picard')
     within(row2).getByText('7 days ago by Jean-Luc Picard')
     const row3 = screen
-      .getByRole('cell', { name: filteredProjects[2].name })
+      .getByRole('cell', { name: currentProjects[2].name })
       .closest('tr')!
     within(row3).getByText('worf@overleaf.com')
     within(row3).getByText('a month ago by worf@overleaf.com')
     // link sharing project
     const row4 = screen
-      .getByRole('cell', { name: filteredProjects[3].name })
+      .getByRole('cell', { name: currentProjects[3].name })
       .closest('tr')!
     within(row4).getByText('La Forge')
     within(row4).getByText('Link sharing')
     within(row4).getByText('2 months ago by La Forge')
     // link sharing read only, so it will not show an owner
     const row5 = screen
-      .getByRole('cell', { name: filteredProjects[4].name })
+      .getByRole('cell', { name: currentProjects[4].name })
       .closest('tr')!
     within(row5).getByText('Link sharing')
     within(row5).getByText('2 years ago')
@@ -131,13 +110,13 @@ describe('<ProjectListTable />', function () {
     // temporary count tests until we add filtering for archived/trashed
     const copyButtons = screen.getAllByLabelText('Copy')
     screen.debug()
-    expect(copyButtons.length).to.equal(filteredProjects.length)
+    expect(copyButtons.length).to.equal(currentProjects.length)
     const downloadButtons = screen.getAllByLabelText('Download')
-    expect(downloadButtons.length).to.equal(filteredProjects.length)
+    expect(downloadButtons.length).to.equal(currentProjects.length)
     const archiveButtons = screen.getAllByLabelText('Archive')
-    expect(archiveButtons.length).to.equal(filteredProjects.length)
+    expect(archiveButtons.length).to.equal(currentProjects.length)
     const trashButtons = screen.getAllByLabelText('Trash')
-    expect(trashButtons.length).to.equal(filteredProjects.length)
+    expect(trashButtons.length).to.equal(currentProjects.length)
 
     // TODO to be implemented when the component renders trashed & archived projects
     // const restoreButtons = screen.getAllByLabelText('Restore')
@@ -145,4 +124,49 @@ describe('<ProjectListTable />', function () {
     // const deleteButtons = screen.getAllByLabelText('Delete')
     // expect(deleteButtons.length).to.equal(1)
   })
+
+  it('selects all projects when header checkbox checked', async function () {
+    renderWithProjectListContext(<ProjectListTable />)
+    await fetchMock.flush(true)
+    const checkbox = screen.getByLabelText('Select all projects')
+    fireEvent.click(checkbox)
+    const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+    const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+    // + 1 because of select all checkbox
+    expect(allCheckboxesChecked.length).to.equal(currentProjects.length + 1)
+  })
+
+  it('unselects all projects when select all checkbox uchecked', async function () {
+    renderWithProjectListContext(<ProjectListTable />)
+    await fetchMock.flush(true)
+    const checkbox = screen.getByLabelText('Select all projects')
+    fireEvent.click(checkbox)
+    fireEvent.click(checkbox)
+    const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+    const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+    expect(allCheckboxesChecked.length).to.equal(0)
+  })
+
+  it('unselects select all projects checkbox when one project is unchecked', async function () {
+    renderWithProjectListContext(<ProjectListTable />)
+    await fetchMock.flush(true)
+    const checkbox = screen.getByLabelText('Select all projects')
+    fireEvent.click(checkbox)
+    let allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+    expect(allCheckboxes[1].getAttribute('data-project-id')).to.exist // make sure we are unchecking a project checkbox
+    fireEvent.click(allCheckboxes[1])
+    allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+    const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+    expect(allCheckboxesChecked.length).to.equal(currentProjects.length - 1)
+  })
+
+  it('only checks the checked project', async function () {
+    renderWithProjectListContext(<ProjectListTable />)
+    await fetchMock.flush(true)
+    const checkbox = screen.getByLabelText(`Select ${currentProjects[0].name}`)
+    fireEvent.click(checkbox)
+    const allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+    const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+    expect(allCheckboxesChecked.length).to.equal(1)
+  })
 })

+ 26 - 0
services/web/test/frontend/features/project-list/components/table/project-tools/buttons/archive-projects.button.test.tsx

@@ -0,0 +1,26 @@
+import { fireEvent, screen } from '@testing-library/react'
+import ArchiveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button'
+import {
+  resetProjectListContextFetch,
+  renderWithProjectListContext,
+} from '../../../../helpers/render-with-context'
+
+describe('<ArchiveProjectsButton />', function () {
+  afterEach(function () {
+    resetProjectListContextFetch()
+  })
+
+  it('renders tooltip for button', function () {
+    renderWithProjectListContext(<ArchiveProjectsButton />)
+    const btn = screen.getByLabelText('Archive')
+    fireEvent.mouseOver(btn)
+    screen.getByRole('tooltip', { name: 'Archive' })
+  })
+
+  it('opens the modal when clicked', function () {
+    renderWithProjectListContext(<ArchiveProjectsButton />)
+    const btn = screen.getByLabelText('Archive')
+    fireEvent.click(btn)
+    screen.getByText('Archive Projects')
+  })
+})

+ 19 - 0
services/web/test/frontend/features/project-list/components/table/project-tools/buttons/download-projects-button.test.tsx

@@ -0,0 +1,19 @@
+import { fireEvent, screen } from '@testing-library/react'
+import DownloadProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button'
+import {
+  resetProjectListContextFetch,
+  renderWithProjectListContext,
+} from '../../../../helpers/render-with-context'
+
+describe('<DownloadProjectsButton />', function () {
+  afterEach(function () {
+    resetProjectListContextFetch()
+  })
+
+  it('renders tooltip for button', function () {
+    renderWithProjectListContext(<DownloadProjectsButton />)
+    const btn = screen.getByLabelText('Download')
+    fireEvent.mouseOver(btn)
+    screen.getByRole('tooltip', { name: 'Download' })
+  })
+})

+ 26 - 0
services/web/test/frontend/features/project-list/components/table/project-tools/buttons/trash-projects.button.test.tsx

@@ -0,0 +1,26 @@
+import { fireEvent, screen } from '@testing-library/react'
+import TrashProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button'
+import {
+  resetProjectListContextFetch,
+  renderWithProjectListContext,
+} from '../../../../helpers/render-with-context'
+
+describe('<TrashProjectsButton />', function () {
+  afterEach(function () {
+    resetProjectListContextFetch()
+  })
+
+  it('renders tooltip for button', function () {
+    renderWithProjectListContext(<TrashProjectsButton />)
+    const btn = screen.getByLabelText('Trash')
+    fireEvent.mouseOver(btn)
+    screen.getByRole('tooltip', { name: 'Trash' })
+  })
+
+  it('opens the modal when clicked', function () {
+    renderWithProjectListContext(<TrashProjectsButton />)
+    const btn = screen.getByLabelText('Trash')
+    fireEvent.click(btn)
+    screen.getByText('Trash Projects')
+  })
+})

+ 21 - 0
services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx

@@ -0,0 +1,21 @@
+import { screen } from '@testing-library/react'
+import { expect } from 'chai'
+import ProjectTools from '../../../../../../../frontend/js/features/project-list/components/table/project-tools/project-tools'
+import {
+  renderWithProjectListContext,
+  resetProjectListContextFetch,
+} from '../../../helpers/render-with-context'
+
+describe('<ProjectListTable />', function () {
+  afterEach(function () {
+    resetProjectListContextFetch()
+  })
+
+  it('renders the project tools', function () {
+    renderWithProjectListContext(<ProjectTools />)
+    expect(screen.getAllByRole('button').length).to.equal(3)
+    screen.getByLabelText('Download')
+    screen.getByLabelText('Archive')
+    screen.getByLabelText('Trash')
+  })
+})

+ 0 - 15
services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx

@@ -13,12 +13,6 @@ describe('<ProjectsActionModal />', function () {
   const actionHandler = sinon.stub().resolves({})
   let sendSpy: sinon.SinonSpy
 
-  const modalText = {
-    title: 'Action Title',
-    top: <p>top text</p>,
-    bottom: <b>bottom text</b>,
-  }
-
   beforeEach(function () {
     sendSpy = sinon.spy(eventTracking, 'send')
   })
@@ -31,11 +25,8 @@ describe('<ProjectsActionModal />', function () {
   it('should handle the action passed', async function () {
     renderWithProjectListContext(
       <ProjectsActionModal
-        title={modalText.title}
         action="archive"
         actionHandler={actionHandler}
-        bodyTop={modalText.top}
-        bodyBottom={modalText.bottom}
         projects={[projectsData[0], projectsData[1]]}
         handleCloseModal={() => {}}
         showModal
@@ -58,11 +49,8 @@ describe('<ProjectsActionModal />', function () {
 
     renderWithProjectListContext(
       <ProjectsActionModal
-        title={modalText.title}
         action="archive"
         actionHandler={actionHandler}
-        bodyTop={modalText.top}
-        bodyBottom={modalText.bottom}
         projects={[
           projectsData[0],
           projectsData[1],
@@ -91,11 +79,8 @@ describe('<ProjectsActionModal />', function () {
   it('should send an analytics even when opened', function () {
     renderWithProjectListContext(
       <ProjectsActionModal
-        title={modalText.title}
         action="archive"
         actionHandler={actionHandler}
-        bodyTop={modalText.top}
-        bodyBottom={modalText.bottom}
         projects={[projectsData[0], projectsData[1]]}
         handleCloseModal={() => {}}
         showModal

+ 4 - 0
services/web/test/frontend/features/project-list/fixtures/projects-data.ts

@@ -128,3 +128,7 @@ export const projectsData: Array<Project> = [
   archivedProject,
   trashedProject,
 ]
+
+export const currentProjects: Array<Project> = projectsData.filter(
+  ({ archived, trashed }) => !archived && !trashed
+)

+ 3 - 3
services/web/test/frontend/features/project-list/helpers/render-with-context.js

@@ -6,13 +6,13 @@ import fetchMock from 'fetch-mock'
 import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
 import { projectsData } from '../fixtures/projects-data'
 
-export function renderWithProjectListContext(component, contextProps) {
+export function renderWithProjectListContext(component) {
   fetchMock.post('express:/api/project', {
     status: 200,
-    body: { projects: projectsData },
+    body: { projects: projectsData, totalSize: projectsData.length },
   })
   const ProjectListProviderWrapper = ({ children }) => (
-    <ProjectListProvider {...contextProps}>{children}</ProjectListProvider>
+    <ProjectListProvider>{children}</ProjectListProvider>
   )
 
   return render(component, {