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

[web] Add untrash to project tools

GitOrigin-RevId: 9839f064ef1b233bec94d6c67ee5b2ff043e668e
This commit is contained in:
Jessica Lawshe 2022-09-26 12:39:43 -05:00 committed by Copybot
parent ab852d1955
commit 1b822621a1
9 changed files with 115 additions and 61 deletions

View file

@ -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)

View file

@ -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>
)
}

View file

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

View file

@ -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')
// 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('<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
})
})
})

View file

@ -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)
})
})
})

View file

@ -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')

View file

@ -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
)

View file

@ -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

View file

@ -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>