From 7650e0607408fecdbd3a12b5d8f91ff49921cbec Mon Sep 17 00:00:00 2001
From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com>
Date: Thu, 8 Dec 2022 12:46:34 +0200
Subject: [PATCH] Merge pull request #10802 from
overleaf/ii-dashboard-leave-button
[web] Project dashboard leave button
GitOrigin-RevId: 6c472ffe9b3d07f103f32e07fec9996a6d45caef
---
.../web/frontend/extracted-translations.json | 2 +
.../modals/archive-project-modal.tsx | 43 ++--
.../modals/delete-leave-project-modal.tsx | 81 ++++++
.../modals/delete-project-modal.tsx | 31 ++-
.../components/modals/leave-project-modal.tsx | 31 ++-
.../modals/projects-action-modal.tsx | 47 +---
.../components/modals/projects-list.tsx | 28 +++
.../components/modals/trash-project-modal.tsx | 43 ++--
.../buttons/archive-projects-button.tsx | 24 +-
.../buttons/delete-leave-projects-button.tsx | 58 +++++
.../buttons/delete-projects-button.tsx | 54 ++++
.../buttons/leave-projects-button.tsx | 54 ++++
.../buttons/trash-projects-button.tsx | 24 +-
.../table/project-tools/project-tools.tsx | 13 +
.../context/project-list-context.tsx | 33 ++-
.../js/features/project-list/util/project.ts | 8 +
.../components/project-list-root.test.tsx | 232 ++++++++++++++++--
.../delete-leave-projects-button.test.tsx | 29 +++
.../buttons/delete-projects-button.test.tsx | 29 +++
.../buttons/leave-projects-button.test.tsx | 28 +++
.../table/projects-action-modal.test.tsx | 2 +-
.../project-list/fixtures/projects-data.ts | 7 +
22 files changed, 762 insertions(+), 139 deletions(-)
create mode 100644 services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx
create mode 100644 services/web/frontend/js/features/project-list/components/modals/projects-list.tsx
create mode 100644 services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx
create mode 100644 services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx
create mode 100644 services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx
create mode 100644 services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx
create mode 100644 services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx
create mode 100644 services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index cc30096efd..499b51298e 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -128,6 +128,8 @@
"delete_account_confirmation_label": "",
"delete_account_warning_message_3": "",
"delete_acct_no_existing_pw": "",
+ "delete_and_leave": "",
+ "delete_and_leave_projects": "",
"delete_folder": "",
"delete_projects": "",
"delete_your_account": "",
diff --git a/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx
index da03aafdcb..8ad54b8781 100644
--- a/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx
+++ b/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx
@@ -1,5 +1,7 @@
+import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
+import ProjectsList from './projects-list'
type ArchiveProjectModalProps = Pick<
React.ComponentProps,
@@ -13,29 +15,42 @@ function ArchiveProjectModal({
handleCloseModal,
}: ArchiveProjectModalProps) {
const { t } = useTranslation()
+ const [projectsToDisplay, setProjectsToDisplay] = useState(
+ []
+ )
+
+ useEffect(() => {
+ if (showModal) {
+ setProjectsToDisplay(displayProjects => {
+ return displayProjects.length ? displayProjects : projects
+ })
+ } else {
+ setProjectsToDisplay([])
+ }
+ }, [showModal, projects])
return (
{t('about_to_archive_projects')}
}
- bodyBottom={
-
- {t('archiving_projects_wont_affect_collaborators')}{' '}
-
- {t('find_out_more_nt')}
-
-
- }
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
- />
+ >
+ {t('about_to_archive_projects')}
+
+
+ {t('archiving_projects_wont_affect_collaborators')}{' '}
+
+ {t('find_out_more_nt')}
+
+
+
)
}
diff --git a/services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx
new file mode 100644
index 0000000000..4d870055f7
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useState, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import ProjectsActionModal from './projects-action-modal'
+import Icon from '../../../../shared/components/icon'
+import ProjectsList from './projects-list'
+import { isLeavableProject, isDeletableProject } from '../../util/project'
+
+type DeleteLeaveProjectModalProps = Pick<
+ React.ComponentProps,
+ 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
+>
+
+function DeleteLeaveProjectModal({
+ projects,
+ actionHandler,
+ showModal,
+ handleCloseModal,
+}: DeleteLeaveProjectModalProps) {
+ const { t } = useTranslation()
+ const [projectsToDeleteDisplay, setProjectsToDeleteDisplay] = useState<
+ typeof projects
+ >([])
+ const [projectsToLeaveDisplay, setProjectsToLeaveDisplay] = useState<
+ typeof projects
+ >([])
+
+ const projectsToDelete = useMemo(() => {
+ return projects.filter(isDeletableProject)
+ }, [projects])
+ const projectsToLeave = useMemo(() => {
+ return projects.filter(isLeavableProject)
+ }, [projects])
+
+ useEffect(() => {
+ if (showModal) {
+ setProjectsToDeleteDisplay(displayProjects => {
+ return displayProjects.length ? displayProjects : projectsToDelete
+ })
+ } else {
+ setProjectsToDeleteDisplay([])
+ }
+ }, [showModal, projectsToDelete])
+
+ useEffect(() => {
+ if (showModal) {
+ setProjectsToLeaveDisplay(displayProjects => {
+ return displayProjects.length ? displayProjects : projectsToLeave
+ })
+ } else {
+ setProjectsToLeaveDisplay([])
+ }
+ }, [showModal, projectsToLeave])
+
+ return (
+
+ {t('about_to_delete_projects')}
+
+ {t('about_to_leave_projects')}
+
+
+ {' '}
+ {t('this_action_cannot_be_undone')}
+
+
+ )
+}
+
+export default DeleteLeaveProjectModal
diff --git a/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx
index 4fb6260f4c..c2fa587b81 100644
--- a/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx
+++ b/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx
@@ -1,6 +1,8 @@
+import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
import Icon from '../../../../shared/components/icon'
+import ProjectsList from './projects-list'
type DeleteProjectModalProps = Pick<
React.ComponentProps,
@@ -14,23 +16,36 @@ function DeleteProjectModal({
handleCloseModal,
}: DeleteProjectModalProps) {
const { t } = useTranslation()
+ const [projectsToDisplay, setProjectsToDisplay] = useState(
+ []
+ )
+
+ useEffect(() => {
+ if (showModal) {
+ setProjectsToDisplay(displayProjects => {
+ return displayProjects.length ? displayProjects : projects
+ })
+ } else {
+ setProjectsToDisplay([])
+ }
+ }, [showModal, projects])
return (
{t('about_to_delete_projects')}}
- bodyBottom={
-
- {' '}
- {t('this_action_cannot_be_undone')}
-
- }
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
- />
+ >
+ {t('about_to_delete_projects')}
+
+
+ {' '}
+ {t('this_action_cannot_be_undone')}
+
+
)
}
diff --git a/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx
index f708caa01d..6a26bd32cd 100644
--- a/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx
+++ b/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx
@@ -1,6 +1,8 @@
+import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
import Icon from '../../../../shared/components/icon'
+import ProjectsList from './projects-list'
type LeaveProjectModalProps = Pick<
React.ComponentProps,
@@ -14,23 +16,36 @@ function LeaveProjectModal({
handleCloseModal,
}: LeaveProjectModalProps) {
const { t } = useTranslation()
+ const [projectsToDisplay, setProjectsToDisplay] = useState(
+ []
+ )
+
+ useEffect(() => {
+ if (showModal) {
+ setProjectsToDisplay(displayProjects => {
+ return displayProjects.length ? displayProjects : projects
+ })
+ } else {
+ setProjectsToDisplay([])
+ }
+ }, [showModal, projects])
return (
{t('about_to_leave_projects')}}
- bodyBottom={
-
- {' '}
- {t('this_action_cannot_be_undone')}
-
- }
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
- />
+ >
+ {t('about_to_leave_projects')}
+
+
+ {' '}
+ {t('this_action_cannot_be_undone')}
+
+
)
}
diff --git a/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx
index 60aeb1198c..03f3289281 100644
--- a/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx
+++ b/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useRef, useState } from 'react'
+import { memo, useEffect, useState } from 'react'
import { Alert, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../types/project/dashboard/api'
@@ -9,13 +9,12 @@ import * as eventTracking from '../../../../infrastructure/event-tracking'
type ProjectsActionModalProps = {
title?: string
- action: 'archive' | 'trash' | 'delete' | 'leave'
+ action: 'archive' | 'trash' | 'delete' | 'leave' | 'leaveOrDelete'
actionHandler: (project: Project) => Promise
handleCloseModal: () => void
- bodyTop?: React.ReactNode
- bodyBottom?: React.ReactNode
projects: Array
showModal: boolean
+ children?: React.ReactNode
}
function ProjectsActionModal({
@@ -23,16 +22,13 @@ function ProjectsActionModal({
action,
actionHandler,
handleCloseModal,
- bodyTop,
- bodyBottom,
showModal,
projects,
+ children,
}: ProjectsActionModalProps) {
const { t } = useTranslation()
const [errors, setErrors] = useState>([])
const [isProcessing, setIsProcessing] = useState(false)
- const [projectsToDisplay, setProjectsToDisplay] = useState([])
- const projectsRef = useRef([])
const isMounted = useIsMounted()
async function handleActionForProjects(projects: Array) {
@@ -66,21 +62,8 @@ function ProjectsActionModal({
'project action',
action
)
-
- if (projectsRef.current.length > 0) {
- // maintain the original list in the display of the modal,
- // even after some project actions have completed
- setProjectsToDisplay(projectsRef.current)
- } else {
- projectsRef.current = projects
- setProjectsToDisplay(projects)
- }
- } else {
- projectsRef.current = []
}
- }, [action, projects, projectsRef, showModal])
-
- const projectIdsRemainingToProcess = projects.map(p => p.id)
+ }, [action, showModal])
return (
{title}
-
- {bodyTop}
-
- {projectsToDisplay.map(project => (
- -
- {project.name}
-
- ))}
-
-
- {bodyBottom}
-
+ {children}
{!isProcessing &&
errors.length > 0 &&
diff --git a/services/web/frontend/js/features/project-list/components/modals/projects-list.tsx b/services/web/frontend/js/features/project-list/components/modals/projects-list.tsx
new file mode 100644
index 0000000000..77592d7057
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/modals/projects-list.tsx
@@ -0,0 +1,28 @@
+import classnames from 'classnames'
+import { Project } from '../../../../../../types/project/dashboard/api'
+
+type ProjectsToDisplayProps = {
+ projects: Project[]
+ projectsToDisplay: Project[]
+}
+
+function ProjectsList({ projects, projectsToDisplay }: ProjectsToDisplayProps) {
+ return (
+
+ {projectsToDisplay.map(project => (
+ - id === project.id
+ ),
+ })}
+ >
+ {project.name}
+
+ ))}
+
+ )
+}
+
+export default ProjectsList
diff --git a/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx
index 7a3962e937..5c882343dc 100644
--- a/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx
+++ b/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx
@@ -1,5 +1,7 @@
+import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
+import ProjectsList from './projects-list'
type TrashProjectPropsModalProps = Pick<
React.ComponentProps,
@@ -13,29 +15,42 @@ function TrashProjectModal({
handleCloseModal,
}: TrashProjectPropsModalProps) {
const { t } = useTranslation()
+ const [projectsToDisplay, setProjectsToDisplay] = useState(
+ []
+ )
+
+ useEffect(() => {
+ if (showModal) {
+ setProjectsToDisplay(displayProjects => {
+ return displayProjects.length ? displayProjects : projects
+ })
+ } else {
+ setProjectsToDisplay([])
+ }
+ }, [showModal, projects])
return (
{t('about_to_trash_projects')}}
- bodyBottom={
-
- {t('trashing_projects_wont_affect_collaborators')}{' '}
-
- {t('find_out_more_nt')}
-
-
- }
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
- />
+ >
+ {t('about_to_trash_projects')}
+
+
+ {t('trashing_projects_wont_affect_collaborators')}{' '}
+
+ {t('find_out_more_nt')}
+
+
+
)
}
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx
index bd6e714131..14897b2a8b 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx
@@ -6,6 +6,7 @@ import ArchiveProjectModal from '../../../modals/archive-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { archiveProject } from '../../../../util/api'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
function ArchiveProjectsButton() {
const { selectedProjects, updateProjectViewData } = useProjectListContext()
@@ -25,17 +26,16 @@ function ArchiveProjectsButton() {
}
}, [isMounted])
- const handleArchiveProjects = useCallback(async () => {
- for (const project of selectedProjects) {
- await archiveProject(project.id)
- updateProjectViewData({
- ...project,
- archived: true,
- selected: false,
- trashed: false,
- })
- }
- }, [selectedProjects, updateProjectViewData])
+ const handleArchiveProject = async (project: Project) => {
+ await archiveProject(project.id)
+
+ updateProjectViewData({
+ ...project,
+ archived: true,
+ selected: false,
+ trashed: false,
+ })
+ }
return (
<>
@@ -54,7 +54,7 @@ function ArchiveProjectsButton() {
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx
new file mode 100644
index 0000000000..705579a123
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx
@@ -0,0 +1,58 @@
+import { useState } from 'react'
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import DeleteLeaveProjectModal from '../../../modals/delete-leave-project-modal'
+import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import { deleteProject, leaveProject } from '../../../../util/api'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
+
+function DeleteLeaveProjectsButton() {
+ const { t } = useTranslation()
+ const {
+ selectedProjects,
+ removeProjectFromView,
+ hasLeavableProjectsSelected,
+ hasDeletableProjectsSelected,
+ } = useProjectListContext()
+ const [showModal, setShowModal] = useState(false)
+ const isMounted = useIsMounted()
+
+ const handleOpenModal = () => {
+ setShowModal(true)
+ }
+
+ const handleCloseModal = () => {
+ if (isMounted.current) {
+ setShowModal(false)
+ }
+ }
+
+ const handleDeleteAndLeaveProject = async (project: Project) => {
+ if (project.accessLevel === 'owner') {
+ await deleteProject(project.id)
+ } else {
+ await leaveProject(project.id)
+ }
+
+ removeProjectFromView(project)
+ }
+
+ return (
+ <>
+ {hasDeletableProjectsSelected && hasLeavableProjectsSelected && (
+
+ )}
+
+ >
+ )
+}
+
+export default DeleteLeaveProjectsButton
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx
new file mode 100644
index 0000000000..3ff3fb1da5
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx
@@ -0,0 +1,54 @@
+import { useState } from 'react'
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import DeleteProjectModal from '../../../modals/delete-project-modal'
+import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import { deleteProject } from '../../../../util/api'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
+
+function DeleteProjectsButton() {
+ const { t } = useTranslation()
+ const {
+ selectedProjects,
+ removeProjectFromView,
+ hasLeavableProjectsSelected,
+ hasDeletableProjectsSelected,
+ } = useProjectListContext()
+ const [showModal, setShowModal] = useState(false)
+ const isMounted = useIsMounted()
+
+ const handleOpenModal = () => {
+ setShowModal(true)
+ }
+
+ const handleCloseModal = () => {
+ if (isMounted.current) {
+ setShowModal(false)
+ }
+ }
+
+ const handleDeleteProject = async (project: Project) => {
+ await deleteProject(project.id)
+
+ removeProjectFromView(project)
+ }
+
+ return (
+ <>
+ {hasDeletableProjectsSelected && !hasLeavableProjectsSelected && (
+
+ )}
+
+ >
+ )
+}
+
+export default DeleteProjectsButton
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx
new file mode 100644
index 0000000000..a8e65186ff
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx
@@ -0,0 +1,54 @@
+import { useState } from 'react'
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import LeaveProjectModal from '../../../modals/leave-project-modal'
+import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
+import { useProjectListContext } from '../../../../context/project-list-context'
+import { leaveProject } from '../../../../util/api'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
+
+function LeaveProjectsButton() {
+ const { t } = useTranslation()
+ const {
+ selectedProjects,
+ removeProjectFromView,
+ hasLeavableProjectsSelected,
+ hasDeletableProjectsSelected,
+ } = useProjectListContext()
+ const [showModal, setShowModal] = useState(false)
+ const isMounted = useIsMounted()
+
+ const handleOpenModal = () => {
+ setShowModal(true)
+ }
+
+ const handleCloseModal = () => {
+ if (isMounted.current) {
+ setShowModal(false)
+ }
+ }
+
+ const handleLeaveProject = async (project: Project) => {
+ await leaveProject(project.id)
+
+ removeProjectFromView(project)
+ }
+
+ return (
+ <>
+ {!hasDeletableProjectsSelected && hasLeavableProjectsSelected && (
+
+ )}
+
+ >
+ )
+}
+
+export default LeaveProjectsButton
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx
index c190f86521..b57d5cb120 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx
@@ -6,6 +6,7 @@ import TrashProjectModal from '../../../modals/trash-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { trashProject } from '../../../../util/api'
+import { Project } from '../../../../../../../../types/project/dashboard/api'
function TrashProjectsButton() {
const { selectedProjects, updateProjectViewData } = useProjectListContext()
@@ -25,17 +26,16 @@ function TrashProjectsButton() {
}
}, [isMounted])
- const handleTrashProjects = useCallback(async () => {
- for (const project of selectedProjects) {
- await trashProject(project.id)
- updateProjectViewData({
- ...project,
- trashed: true,
- archived: false,
- selected: false,
- })
- }
- }, [selectedProjects, updateProjectViewData])
+ const handleTrashProject = async (project: Project) => {
+ await trashProject(project.id)
+
+ updateProjectViewData({
+ ...project,
+ trashed: true,
+ archived: false,
+ selected: false,
+ })
+ }
return (
<>
@@ -54,7 +54,7 @@ function TrashProjectsButton() {
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 68dee26ac8..db7d459f81 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
@@ -8,6 +8,9 @@ import TagsDropdown from './buttons/tags-dropdown'
import TrashProjectsButton from './buttons/trash-projects-button'
import UnarchiveProjectsButton from './buttons/unarchive-projects-button'
import UntrashProjectsButton from './buttons/untrash-projects-button'
+import DeleteLeaveProjectsButton from './buttons/delete-leave-projects-button'
+import LeaveProjectsButton from './buttons/leave-projects-button'
+import DeleteProjectsButton from './buttons/delete-projects-button'
function ProjectTools() {
const { filter, selectedProjects } = useProjectListContext()
@@ -24,6 +27,16 @@ function ProjectTools() {
{filter === 'archived' && }
+
+ {filter === 'trashed' && (
+ <>
+
+
+
+ >
+ )}
+
+
{!['archived', 'trashed'].includes(filter) && }
{selectedProjects.length === 1 &&
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 e43afeffc1..9079c71579 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
@@ -29,6 +29,7 @@ import getMeta from '../../../utils/meta'
import useAsync from '../../../shared/hooks/use-async'
import { getProjects } from '../util/api'
import sortProjects from '../util/sort-projects'
+import { isDeletableProject, isLeavableProject } from '../util/project'
const MAX_PROJECT_PER_PAGE = 20
@@ -62,7 +63,7 @@ const filters: FilterMap = {
export const UNCATEGORIZED_KEY = 'uncategorized'
-type ProjectListContextValue = {
+export type ProjectListContextValue = {
addClonedProjectToViewData: (project: Project) => void
selectOrUnselectAllProjects: React.Dispatch>
visibleProjects: Project[]
@@ -92,6 +93,8 @@ type ProjectListContextValue = {
loadMoreCount: number
showAllProjects: () => void
loadMoreProjects: () => void
+ hasLeavableProjectsSelected: boolean
+ hasDeletableProjectsSelected: boolean
}
export const ProjectListContext = createContext<
@@ -375,20 +378,26 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
const updateProjectViewData = useCallback((newProjectData: Project) => {
setLoadedProjects(loadedProjects => {
- return loadedProjects.map((p: Project) =>
+ return loadedProjects.map(p =>
p.id === newProjectData.id ? { ...newProjectData } : p
)
})
}, [])
- const removeProjectFromView = useCallback(
- (project: Project) => {
- const projects = loadedProjects.filter(
- (p: Project) => p.id !== project.id
- )
- setLoadedProjects(projects)
- },
- [loadedProjects]
+ const removeProjectFromView = useCallback((project: Project) => {
+ setLoadedProjects(loadedProjects => {
+ return loadedProjects.filter(p => p.id !== project.id)
+ })
+ }, [])
+
+ const hasLeavableProjectsSelected = useMemo(
+ () => selectedProjects.some(isLeavableProject),
+ [selectedProjects]
+ )
+
+ const hasDeletableProjectsSelected = useMemo(
+ () => selectedProjects.some(isDeletableProject),
+ [selectedProjects]
)
const value = useMemo(
@@ -399,6 +408,8 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
deleteTag,
error,
filter,
+ hasLeavableProjectsSelected,
+ hasDeletableProjectsSelected,
hiddenProjectsCount,
isLoading,
loadMoreCount,
@@ -430,6 +441,8 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
deleteTag,
error,
filter,
+ hasLeavableProjectsSelected,
+ hasDeletableProjectsSelected,
hiddenProjectsCount,
isLoading,
loadMoreCount,
diff --git a/services/web/frontend/js/features/project-list/util/project.ts b/services/web/frontend/js/features/project-list/util/project.ts
index f2ecd967b0..d005ff4be3 100644
--- a/services/web/frontend/js/features/project-list/util/project.ts
+++ b/services/web/frontend/js/features/project-list/util/project.ts
@@ -12,3 +12,11 @@ export function getOwnerName(project: Project) {
return ''
}
+
+export function isDeletableProject(project: Project) {
+ return project.accessLevel === 'owner' && project.trashed
+}
+
+export function isLeavableProject(project: Project) {
+ return project.accessLevel !== 'owner' && project.trashed
+}
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 f6533a84fc..78b0305c7b 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
@@ -11,7 +11,9 @@ import {
archivedProjects,
makeLongProjectList,
} from '../fixtures/projects-data'
-const { fullList, currentList, trashedList } = makeLongProjectList(40)
+
+const { fullList, currentList, trashedList, leavableList, deletableList } =
+ makeLongProjectList(40)
const userId = owner.id
@@ -78,9 +80,10 @@ describe('', function () {
describe('project table', function () {
beforeEach(async function () {
- renderWithProjectListContext(, {
+ const { unmount } = renderWithProjectListContext(, {
projects: fullList,
})
+ this.unmount = unmount
await fetchMock.flush(true)
await screen.findByRole('table')
})
@@ -240,7 +243,7 @@ describe('', function () {
})
const unarchiveButton =
- within(actionsToolbar).getByText('Restore')
+ within(actionsToolbar).getByText('Restore')
fireEvent.click(unarchiveButton)
await fetchMock.flush(true)
@@ -284,7 +287,7 @@ describe('', function () {
actionsToolbar = screen.getAllByRole('toolbar')[0]
})
- it('only shows the download, archive, and restore buttons in top toolbar', function () {
+ it('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')
@@ -307,7 +310,7 @@ describe('', function () {
})
const untrashButton =
- within(actionsToolbar).getByText('Restore')
+ within(actionsToolbar).getByText('Restore')
fireEvent.click(untrashButton)
await fetchMock.flush(true)
@@ -330,22 +333,213 @@ describe('', function () {
})
it('removes project from view when archiving', async function () {
- fetchMock.post(`express:/project/:id/archive`, {
- status: 200,
- })
+ fetchMock.post(
+ `express:/project/:id/archive`,
+ {
+ status: 200,
+ },
+ { repeat: trashedList.length }
+ )
- const untrashButton =
- within(actionsToolbar).getByLabelText('Archive')
- fireEvent.click(untrashButton)
+ const archiveButton =
+ within(actionsToolbar).getByLabelText('Archive')
+ fireEvent.click(archiveButton)
- const confirmButton = screen.getByText('Confirm')
+ const confirmButton = screen.getByText('Confirm')
fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
await fetchMock.flush(true)
expect(fetchMock.done()).to.be.true
- screen.getByText('No projects')
+ const calls = fetchMock.calls().map(([url]) => url)
+
+ trashedList.forEach(project => {
+ expect(calls).to.contain(`/project/${project.id}/archive`)
+ })
+ })
+
+ it('removes only selected projects from view when leaving', async function () {
+ // rerender content with different projects
+ this.unmount()
+ fetchMock.restore()
+
+ renderWithProjectListContext(, {
+ projects: leavableList,
+ })
+
+ await fetchMock.flush(true)
+ await screen.findByRole('table')
+
+ expect(leavableList.length).to.be.greaterThan(0)
+
+ fetchMock.post(
+ `express:/project/:id/leave`,
+ {
+ status: 200,
+ },
+ { repeat: leavableList.length }
+ )
+
+ allCheckboxes = screen.getAllByRole('checkbox')
+ // + 1 because of select all
+ expect(allCheckboxes.length).to.equal(leavableList.length + 1)
+
+ // first one is the select all checkbox
+ fireEvent.click(allCheckboxes[0])
+
+ actionsToolbar = screen.getAllByRole('toolbar')[0]
+
+ const toolbar = within(actionsToolbar)
+ expect(toolbar.queryByRole('button', { name: /delete/i })).to.be.null
+ expect(
+ toolbar.queryByRole('button', {
+ name: /delete \/ leave/i,
+ })
+ ).to.be.null
+
+ const leaveButton = toolbar.getByRole('button', {
+ name: /leave/i,
+ })
+ fireEvent.click(leaveButton)
+
+ const confirmButton = screen.getByText('Confirm')
+ fireEvent.click(confirmButton)
+ expect(confirmButton.disabled).to.be.true
+
+ await fetchMock.flush(true)
+ expect(fetchMock.done()).to.be.true
+
+ const calls = fetchMock.calls().map(([url]) => url)
+ leavableList.forEach(project => {
+ expect(calls).to.contain(`/project/${project.id}/leave`)
+ })
+ })
+
+ it('removes only selected projects from view when deleting', async function () {
+ // rerender content with different projects
+ this.unmount()
+ fetchMock.restore()
+
+ renderWithProjectListContext(, {
+ projects: deletableList,
+ })
+
+ await fetchMock.flush(true)
+ await screen.findByRole('table')
+
+ expect(deletableList.length).to.be.greaterThan(0)
+
+ fetchMock.delete(
+ `express:/project/:id`,
+ {
+ status: 200,
+ },
+ { repeat: deletableList.length }
+ )
+
+ allCheckboxes = screen.getAllByRole('checkbox')
+ // + 1 because of select all
+ expect(allCheckboxes.length).to.equal(deletableList.length + 1)
+
+ // first one is the select all checkbox
+ fireEvent.click(allCheckboxes[0])
+
+ actionsToolbar = screen.getAllByRole('toolbar')[0]
+
+ const toolbar = within(actionsToolbar)
+ expect(toolbar.queryByRole('button', { name: /leave/i })).to.be.null
+ expect(
+ toolbar.queryByRole('button', {
+ name: /delete \/ leave/i,
+ })
+ ).to.be.null
+
+ const deleteButton = toolbar.getByRole('button', {
+ name: /delete/i,
+ })
+ fireEvent.click(deleteButton)
+
+ const confirmButton = screen.getByText('Confirm')
+ fireEvent.click(confirmButton)
+ expect(confirmButton.disabled).to.be.true
+
+ await fetchMock.flush(true)
+ expect(fetchMock.done()).to.be.true
+
+ const calls = fetchMock.calls().map(([url]) => url)
+ deletableList.forEach(project => {
+ expect(calls).to.contain(`/project/${project.id}`)
+ })
+ })
+
+ it('removes only selected projects from view when deleting and leaving', async function () {
+ // rerender content with different projects
+ this.unmount()
+ fetchMock.restore()
+
+ const deletableAndLeavableList = [...deletableList, ...leavableList]
+
+ renderWithProjectListContext(, {
+ projects: deletableAndLeavableList,
+ })
+
+ await fetchMock.flush(true)
+ await screen.findByRole('table')
+
+ expect(deletableList.length).to.be.greaterThan(0)
+ expect(leavableList.length).to.be.greaterThan(0)
+
+ fetchMock
+ .delete(
+ `express:/project/:id`,
+ {
+ status: 200,
+ },
+ { repeat: deletableList.length }
+ )
+ .post(
+ `express:/project/:id/leave`,
+ {
+ status: 200,
+ },
+ { repeat: leavableList.length }
+ )
+
+ allCheckboxes = screen.getAllByRole('checkbox')
+ // + 1 because of select all
+ expect(allCheckboxes.length).to.equal(
+ deletableAndLeavableList.length + 1
+ )
+
+ // first one is the select all checkbox
+ fireEvent.click(allCheckboxes[0])
+
+ actionsToolbar = screen.getAllByRole('toolbar')[0]
+
+ const toolbar = within(actionsToolbar)
+ expect(toolbar.queryByRole('button', { name: 'Leave' })).to.be.null
+ expect(toolbar.queryByRole('button', { name: 'Delete' })).to.be.null
+
+ const deleteLeaveButton = toolbar.getByRole('button', {
+ name: /delete \/ leave/i,
+ })
+ fireEvent.click(deleteLeaveButton)
+
+ const confirmButton = screen.getByText('Confirm')
+ fireEvent.click(confirmButton)
+ expect(confirmButton.disabled).to.be.true
+
+ await fetchMock.flush(true)
+ expect(fetchMock.done()).to.be.true
+
+ const calls = fetchMock.calls().map(([url]) => url)
+ deletableAndLeavableList.forEach(project => {
+ expect(calls).to.contain.oneOf([
+ `/project/${project.id}`,
+ `/project/${project.id}/leave`,
+ ])
+ })
})
})
@@ -497,7 +691,7 @@ describe('', function () {
fireEvent.click(moreDropdown)
const renameButton =
- screen.getAllByText('Rename')[1] // first one is for the tag in the sidebar
+ screen.getAllByText('Rename')[1] // first one is for the tag in the sidebar
fireEvent.click(renameButton)
const modals = await screen.findAllByRole('dialog')
@@ -508,7 +702,7 @@ describe('', function () {
// same name
let confirmButton =
- within(modal).getByText('Rename')
+ within(modal).getByText('Rename')
expect(confirmButton.disabled).to.be.true
let input = screen.getByLabelText('New Name') as HTMLButtonElement
@@ -517,7 +711,7 @@ describe('', function () {
fireEvent.change(input, {
target: { value: '' },
})
- confirmButton = within(modal).getByText('Rename')
+ confirmButton = within(modal).getByText('Rename')
expect(confirmButton.disabled).to.be.true
})
@@ -534,7 +728,7 @@ describe('', function () {
fireEvent.click(moreDropdown)
const renameButton =
- within(actionsToolbar).getByText('Rename') // first one is for the tag in the sidebar
+ within(actionsToolbar).getByText('Rename') // first one is for the tag in the sidebar
fireEvent.click(renameButton)
const modals = await screen.findAllByRole('dialog')
@@ -551,7 +745,7 @@ describe('', function () {
})
const confirmButton =
- within(modal).getByText('Rename')
+ within(modal).getByText('Rename')
expect(confirmButton.disabled).to.be.false
fireEvent.click(confirmButton)
@@ -605,7 +799,7 @@ describe('', function () {
})
const copyButton =
- within(actionsToolbar).getByText('Make a copy')
+ within(actionsToolbar).getByText('Make a copy')
fireEvent.click(copyButton)
// confirm in modal
diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx
new file mode 100644
index 0000000000..928265cbad
--- /dev/null
+++ b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx
@@ -0,0 +1,29 @@
+import { fireEvent, screen, render } from '@testing-library/react'
+import DeleteLeaveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button'
+import { makeLongProjectList } from '../../../../fixtures/projects-data'
+import {
+ ProjectListContext,
+ ProjectListContextValue,
+} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
+
+const { deletableList, leavableList } = makeLongProjectList(40)
+
+describe('', function () {
+ it('opens the modal when clicked', function () {
+ const value = {
+ selectedProjects: [...deletableList, ...leavableList],
+ hasDeletableProjectsSelected: true,
+ hasLeavableProjectsSelected: true,
+ } as ProjectListContextValue
+
+ render(
+
+
+
+ )
+
+ const btn = screen.getByRole('button', { name: /delete \/ leave/i })
+ fireEvent.click(btn)
+ screen.getByRole('heading', { name: /delete and leave projects/i })
+ })
+})
diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx
new file mode 100644
index 0000000000..ffcaa51217
--- /dev/null
+++ b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx
@@ -0,0 +1,29 @@
+import { fireEvent, screen, render } from '@testing-library/react'
+import DeleteProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button'
+import { makeLongProjectList } from '../../../../fixtures/projects-data'
+import {
+ ProjectListContext,
+ ProjectListContextValue,
+} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
+
+const { deletableList } = makeLongProjectList(40)
+
+describe('', function () {
+ it('opens the modal when clicked', function () {
+ const value = {
+ selectedProjects: deletableList,
+ hasDeletableProjectsSelected: true,
+ hasLeavableProjectsSelected: false,
+ } as ProjectListContextValue
+
+ render(
+
+
+
+ )
+
+ const btn = screen.getByRole('button', { name: /delete/i })
+ fireEvent.click(btn)
+ screen.getByRole('heading', { name: /delete projects/i })
+ })
+})
diff --git a/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx
new file mode 100644
index 0000000000..71e11c91d2
--- /dev/null
+++ b/services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx
@@ -0,0 +1,28 @@
+import { fireEvent, screen, render } from '@testing-library/react'
+import LeaveProjectsButton from '../../../../../../../../frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button'
+import { makeLongProjectList } from '../../../../fixtures/projects-data'
+import {
+ ProjectListContext,
+ ProjectListContextValue,
+} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
+
+const { leavableList } = makeLongProjectList(40)
+
+describe('', function () {
+ it('opens the modal when clicked', function () {
+ const value = {
+ selectedProjects: leavableList,
+ hasDeletableProjectsSelected: false,
+ hasLeavableProjectsSelected: true,
+ } as ProjectListContextValue
+
+ render(
+
+
+
+ )
+ const btn = screen.getByRole('button', { name: /leave/i })
+ fireEvent.click(btn)
+ screen.getByRole('heading', { name: /leave projects/i })
+ })
+})
diff --git a/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx b/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx
index 7f9d32c50f..19dce5f7d5 100644
--- a/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx
@@ -76,7 +76,7 @@ describe('', function () {
})
})
- it('should send an analytics even when opened', function () {
+ it('should send an analytics event when opened', function () {
renderWithProjectListContext(
{
({ archived, trashed }) => !archived && !trashed
),
trashedList: longList.filter(({ trashed }) => trashed),
+ leavableList: longList.filter(isLeavableProject),
+ deletableList: longList.filter(isDeletableProject),
}
}