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