From 1b822621a1f73dc44889f7f5b977763ad7d88cdd Mon Sep 17 00:00:00 2001 From: Jessica Lawshe Date: Mon, 26 Sep 2022 12:39:43 -0500 Subject: [PATCH] Merge pull request #9642 from overleaf/jel-project-tools-untrash [web] Add untrash to project tools GitOrigin-RevId: 9839f064ef1b233bec94d6c67ee5b2ff043e668e --- .../buttons/untrash-projects-button.tsx | 24 +++++++ .../table/project-tools/project-tools.tsx | 14 ++-- .../context/project-list-context.tsx | 2 +- .../components/project-list-root.test.tsx | 68 +++++++++++++++++-- .../components/project-search.test.tsx | 47 ------------- .../project-tools/project-tools.test.tsx | 2 +- .../project-list/fixtures/projects-data.ts | 5 ++ services/web/types/project/dashboard/api.d.ts | 12 ++-- services/web/types/utils.ts | 2 + 9 files changed, 115 insertions(+), 61 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx new file mode 100644 index 0000000000..f53ffd727a --- /dev/null +++ b/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 +} + +export default memo(UntrashProjectsButton) diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx index f17fb94737..838722a13b 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx +++ b/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 ( -
-
+ + {filter !== 'archived' && } {filter !== 'trashed' && } -
-
+ + + + {filter === 'trashed' && } + + ) } diff --git a/services/web/frontend/js/features/project-list/context/project-list-context.tsx b/services/web/frontend/js/features/project-list/context/project-list-context.tsx index 96dbd6e186..6c1186c2b9 100644 --- a/services/web/frontend/js/features/project-list/context/project-list-context.tsx +++ b/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, diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx index 2b5037591e..a9ca3c2742 100644 --- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx +++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx @@ -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('', function () { fireEvent.click(filterButton) allCheckboxes = screen.getAllByRole('checkbox') - // first one is the select all checkbox - fireEvent.click(allCheckboxes[1]) + // + 1 because of select all + expect(allCheckboxes.length).to.equal(trashedProjects.length + 1) - project1Id = allCheckboxes[1].getAttribute('data-project-id') + // first one is the select all checkbox + fireEvent.click(allCheckboxes[0]) + + 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('', 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('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() + 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 }) }) }) diff --git a/services/web/test/frontend/features/project-list/components/project-search.test.tsx b/services/web/test/frontend/features/project-list/components/project-search.test.tsx index b2b9228a0c..adc930522f 100644 --- a/services/web/test/frontend/features/project-list/components/project-search.test.tsx +++ b/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 }) => ( - {children} - ), - } - ) - - await waitForNextUpdate() - - expect(result.current.visibleProjects.length).to.equal( - filteredProjects.length - ) - - const handleChange = result.current.setSearchText - render() - - 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) - }) - }) }) diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx index 7ad09d8a38..b2ce94fff3 100644 --- a/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/project-tools/project-tools.test.tsx @@ -11,7 +11,7 @@ describe('', function () { resetProjectListContextFetch() }) - it('renders the project tools', function () { + it('renders the project tools for the all projects filter', function () { renderWithProjectListContext() expect(screen.getAllByRole('button').length).to.equal(3) screen.getByLabelText('Download') diff --git a/services/web/test/frontend/features/project-list/fixtures/projects-data.ts b/services/web/test/frontend/features/project-list/fixtures/projects-data.ts index c62dd82659..8a32b6bd9a 100644 --- a/services/web/test/frontend/features/project-list/fixtures/projects-data.ts +++ b/services/web/test/frontend/features/project-list/fixtures/projects-data.ts @@ -127,8 +127,13 @@ export const projectsData: Array = [ }, archivedProject, trashedProject, + trashedAndNotOwnedProject, ] export const currentProjects: Array = projectsData.filter( ({ archived, trashed }) => !archived && !trashed ) + +export const trashedProjects: Array = projectsData.filter( + ({ trashed }) => trashed +) diff --git a/services/web/types/project/dashboard/api.d.ts b/services/web/types/project/dashboard/api.d.ts index 03d9339373..536ba90f3f 100644 --- a/services/web/types/project/dashboard/api.d.ts +++ b/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 diff --git a/services/web/types/utils.ts b/services/web/types/utils.ts index ced038150c..4cb4394f30 100644 --- a/services/web/types/utils.ts +++ b/services/web/types/utils.ts @@ -16,3 +16,5 @@ export type DeepReadonly = T extends (infer R)[] : T export type DeepPartial = Partial<{ [P in keyof T]: DeepPartial }> + +export type MergeAndOverride = Own & Omit