mirror of
https://github.com/overleaf/overleaf.git
synced 2025-03-03 11:43:26 +00:00
Merge pull request #9642 from overleaf/jel-project-tools-untrash
[web] Add untrash to project tools GitOrigin-RevId: 9839f064ef1b233bec94d6c67ee5b2ff043e668e
This commit is contained in:
parent
ab852d1955
commit
1b822621a1
9 changed files with 115 additions and 61 deletions
|
@ -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)
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
uniqBy,
|
||||
without,
|
||||
} from 'lodash'
|
||||
import React, {
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
12
services/web/types/project/dashboard/api.d.ts
vendored
12
services/web/types/project/dashboard/api.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue