Răsfoiți Sursa

Merge pull request #9642 from overleaf/jel-project-tools-untrash

[web] Add untrash to project tools

GitOrigin-RevId: 9839f064ef1b233bec94d6c67ee5b2ff043e668e
Jessica Lawshe 3 ani în urmă
părinte
comite
1b822621a1

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

@@ -0,0 +1,24 @@
+import { memo, useCallback } from 'react'
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import { untrashProject } from '../../../../util/api'
+
+function UntrashProjectsButton() {
+  const { selectedProjects, updateProjectViewData } = useProjectListContext()
+  const { t } = useTranslation()
+
+  const handleUntrashProjects = useCallback(async () => {
+    for (const [, project] of Object.entries(selectedProjects)) {
+      await untrashProject(project.id)
+      // update view
+      project.trashed = false
+      project.selected = false
+      updateProjectViewData(project)
+    }
+  }, [selectedProjects, updateProjectViewData])
+
+  return <Button onClick={handleUntrashProjects}>{t('untrash')}</Button>
+}
+
+export default memo(UntrashProjectsButton)

+ 10 - 4
services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx

@@ -1,19 +1,25 @@
 import { memo } from 'react'
+import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
 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'
+import UntrashProjectsButton from './buttons/untrash-projects-button'
 
 function ProjectTools() {
   const { filter } = useProjectListContext()
   return (
-    <div className="btn-toolbar" role="toolbar">
-      <div className="btn-group">
+    <ButtonToolbar>
+      <ButtonGroup>
         <DownloadProjectsButton />
         {filter !== 'archived' && <ArchiveProjectsButton />}
         {filter !== 'trashed' && <TrashProjectsButton />}
-      </div>
-    </div>
+      </ButtonGroup>
+
+      <ButtonGroup>
+        {filter === 'trashed' && <UntrashProjectsButton />}
+      </ButtonGroup>
+    </ButtonToolbar>
   )
 }
 

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

@@ -8,7 +8,7 @@ import {
   uniqBy,
   without,
 } from 'lodash'
-import React, {
+import {
   createContext,
   ReactNode,
   useCallback,

+ 63 - 3
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -4,6 +4,7 @@ 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'
+import { currentProjects, trashedProjects } from '../fixtures/projects-data'
 
 const userId = '624333f147cfd8002622a1d3'
 
@@ -166,13 +167,24 @@ describe('<ProjectListRoot />', function () {
         fireEvent.click(filterButton)
 
         allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
+        // + 1 because of select all
+        expect(allCheckboxes.length).to.equal(trashedProjects.length + 1)
+
         // first one is the select all checkbox
-        fireEvent.click(allCheckboxes[1])
+        fireEvent.click(allCheckboxes[0])
 
-        project1Id = allCheckboxes[1].getAttribute('data-project-id')
+        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+        // + 1 because of select all
+        expect(allCheckboxesChecked.length).to.equal(trashedProjects.length + 1)
+
+        actionsToolbar = screen.getAllByRole('toolbar')[0]
       })
-      it('does not show the trash button in toolbar when archive view selected', function () {
+
+      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
       })
 
       it('clears selected projects when filter changed', function () {
@@ -183,6 +195,54 @@ describe('<ProjectListRoot />', function () {
         const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
         expect(allCheckboxesChecked.length).to.equal(0)
       })
+
+      it('untrashes all the projects', async function () {
+        fetchMock.delete(`express:/project/:id/trash`, {
+          status: 200,
+        })
+
+        const untrashButton =
+          within(actionsToolbar).getByText<HTMLInputElement>('Restore')
+        fireEvent.click(untrashButton)
+
+        await fetchMock.flush(true)
+        expect(fetchMock.done()).to.be.true
+
+        screen.getByText('No projects')
+      })
+
+      it('only untrashes the selected projects', async function () {
+        // beforeEach selected all, so uncheck the 1st project
+        fireEvent.click(allCheckboxes[1])
+
+        const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
+        expect(allCheckboxesChecked.length).to.equal(trashedProjects.length - 1)
+
+        await fetchMock.flush(true)
+        expect(fetchMock.done()).to.be.true
+
+        expect(screen.queryByText('No projects')).to.be.null
+      })
+    })
+  })
+
+  describe('search', function () {
+    it('shows only projects based on the input', async function () {
+      renderWithProjectListContext(<ProjectListRoot />)
+      await fetchMock.flush(true)
+      await waitFor(() => {
+        screen.findByRole('table')
+      })
+
+      const input = screen.getAllByRole('textbox', {
+        name: /search projects/i,
+      })[0]
+      const value = currentProjects[0].name
+
+      fireEvent.change(input, { target: { value } })
+
+      const results = screen.getAllByRole('row')
+      expect(results.length).to.equal(2) // first is header
     })
   })
 })

+ 0 - 47
services/web/test/frontend/features/project-list/components/project-search.test.tsx

@@ -1,15 +1,9 @@
 import sinon from 'sinon'
 import { render, screen, fireEvent } from '@testing-library/react'
-import { renderHook } from '@testing-library/react-hooks'
 import { expect } from 'chai'
 import SearchForm from '../../../../../frontend/js/features/project-list/components/search-form'
-import {
-  ProjectListProvider,
-  useProjectListContext,
-} from '../../../../../frontend/js/features/project-list/context/project-list-context'
 import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
 import fetchMock from 'fetch-mock'
-import { projectsData } from '../fixtures/projects-data'
 
 describe('Project list search form', function () {
   beforeEach(function () {
@@ -59,45 +53,4 @@ describe('Project list search form', function () {
     expect(setInputValueMock).to.be.calledWith(value)
     sendSpy.restore()
   })
-
-  describe('integration with projects table', function () {
-    it('shows only data based on the input', async function () {
-      const filteredProjects = projectsData.filter(
-        ({ archived, trashed }) => !archived && !trashed
-      )
-
-      fetchMock.post('/api/project', {
-        status: 200,
-        body: {
-          projects: filteredProjects,
-          totalSize: filteredProjects.length,
-        },
-      })
-
-      const { result, waitForNextUpdate } = renderHook(
-        () => useProjectListContext(),
-        {
-          wrapper: ({ children }) => (
-            <ProjectListProvider>{children}</ProjectListProvider>
-          ),
-        }
-      )
-
-      await waitForNextUpdate()
-
-      expect(result.current.visibleProjects.length).to.equal(
-        filteredProjects.length
-      )
-
-      const handleChange = result.current.setSearchText
-      render(<SearchForm inputValue="" setInputValue={handleChange} />)
-
-      const input = screen.getByRole('textbox', { name: /search projects/i })
-      const value = projectsData[0].name
-
-      fireEvent.change(input, { target: { value } })
-
-      expect(result.current.visibleProjects.length).to.equal(1)
-    })
-  })
 })

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

@@ -11,7 +11,7 @@ describe('<ProjectListTable />', function () {
     resetProjectListContextFetch()
   })
 
-  it('renders the project tools', function () {
+  it('renders the project tools for the all projects filter', function () {
     renderWithProjectListContext(<ProjectTools />)
     expect(screen.getAllByRole('button').length).to.equal(3)
     screen.getByLabelText('Download')

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

@@ -127,8 +127,13 @@ export const projectsData: Array<Project> = [
   },
   archivedProject,
   trashedProject,
+  trashedAndNotOwnedProject,
 ]
 
 export const currentProjects: Array<Project> = projectsData.filter(
   ({ archived, trashed }) => !archived && !trashed
 )
+
+export const trashedProjects: Array<Project> = projectsData.filter(
+  ({ trashed }) => trashed
+)

+ 8 - 4
services/web/types/project/dashboard/api.d.ts

@@ -1,4 +1,5 @@
 import { SortingOrder } from '../../sorting-order'
+import { MergeAndOverride } from '../../utils'
 
 export type Page = {
   size: number
@@ -44,10 +45,13 @@ export type ProjectApi = {
   source: 'owner' | 'invite' | 'token'
 }
 
-export type Project = ProjectApi & {
-  lastUpdated: string
-  selected?: boolean
-}
+export type Project = MergeAndOverride<
+  ProjectApi,
+  {
+    lastUpdated: string
+    selected?: boolean
+  }
+>
 
 export type GetProjectsResponseBody = {
   totalSize: number

+ 2 - 0
services/web/types/utils.ts

@@ -16,3 +16,5 @@ export type DeepReadonly<T> = T extends (infer R)[]
   : T
 
 export type DeepPartial<T> = Partial<{ [P in keyof T]: DeepPartial<T[P]> }>
+
+export type MergeAndOverride<Parent, Own> = Own & Omit<Parent, keyof Own>