Просмотр исходного кода

Merge pull request #10802 from overleaf/ii-dashboard-leave-button

[web] Project dashboard leave button

GitOrigin-RevId: 6c472ffe9b3d07f103f32e07fec9996a6d45caef
ilkin-overleaf 3 лет назад
Родитель
Сommit
7650e06074
22 измененных файлов с 761 добавлено и 138 удалено
  1. 2 0
      services/web/frontend/extracted-translations.json
  2. 29 14
      services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx
  3. 81 0
      services/web/frontend/js/features/project-list/components/modals/delete-leave-project-modal.tsx
  4. 23 8
      services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx
  5. 23 8
      services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx
  6. 6 41
      services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx
  7. 28 0
      services/web/frontend/js/features/project-list/components/modals/projects-list.tsx
  8. 29 14
      services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx
  9. 12 12
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx
  10. 58 0
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.tsx
  11. 54 0
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/delete-projects-button.tsx
  12. 54 0
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/leave-projects-button.tsx
  13. 12 12
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx
  14. 13 0
      services/web/frontend/js/features/project-list/components/table/project-tools/project-tools.tsx
  15. 23 10
      services/web/frontend/js/features/project-list/context/project-list-context.tsx
  16. 8 0
      services/web/frontend/js/features/project-list/util/project.ts
  17. 212 18
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  18. 29 0
      services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-leave-projects-button.test.tsx
  19. 29 0
      services/web/test/frontend/features/project-list/components/table/project-tools/buttons/delete-projects-button.test.tsx
  20. 28 0
      services/web/test/frontend/features/project-list/components/table/project-tools/buttons/leave-projects-button.test.tsx
  21. 1 1
      services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx
  22. 7 0
      services/web/test/frontend/features/project-list/fixtures/projects-data.ts

+ 2 - 0
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": "",

+ 29 - 14
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<typeof ProjectsActionModal>,
@@ -13,29 +15,42 @@ function ArchiveProjectModal({
   handleCloseModal,
 }: ArchiveProjectModalProps) {
   const { t } = useTranslation()
+  const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
+    []
+  )
+
+  useEffect(() => {
+    if (showModal) {
+      setProjectsToDisplay(displayProjects => {
+        return displayProjects.length ? displayProjects : projects
+      })
+    } else {
+      setProjectsToDisplay([])
+    }
+  }, [showModal, projects])
 
   return (
     <ProjectsActionModal
       action="archive"
       actionHandler={actionHandler}
       title={t('archive_projects')}
-      bodyTop={<p>{t('about_to_archive_projects')}</p>}
-      bodyBottom={
-        <p>
-          {t('archiving_projects_wont_affect_collaborators')}{' '}
-          <a
-            href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
-            target="_blank"
-            rel="noreferrer"
-          >
-            {t('find_out_more_nt')}
-          </a>
-        </p>
-      }
       showModal={showModal}
       handleCloseModal={handleCloseModal}
       projects={projects}
-    />
+    >
+      <p>{t('about_to_archive_projects')}</p>
+      <ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
+      <p>
+        {t('archiving_projects_wont_affect_collaborators')}{' '}
+        <a
+          href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
+          target="_blank"
+          rel="noreferrer"
+        >
+          {t('find_out_more_nt')}
+        </a>
+      </p>
+    </ProjectsActionModal>
   )
 }
 

+ 81 - 0
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<typeof ProjectsActionModal>,
+  '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 (
+    <ProjectsActionModal
+      action="leaveOrDelete"
+      actionHandler={actionHandler}
+      title={t('delete_and_leave_projects')}
+      showModal={showModal}
+      handleCloseModal={handleCloseModal}
+      projects={[...projectsToDelete, ...projectsToLeave]}
+    >
+      <p>{t('about_to_delete_projects')}</p>
+      <ProjectsList
+        projects={projectsToDelete}
+        projectsToDisplay={projectsToDeleteDisplay}
+      />
+      <p>{t('about_to_leave_projects')}</p>
+      <ProjectsList
+        projects={projectsToLeave}
+        projectsToDisplay={projectsToLeaveDisplay}
+      />
+      <div className="project-action-alert alert alert-warning">
+        <Icon type="exclamation-triangle" fw />{' '}
+        {t('this_action_cannot_be_undone')}
+      </div>
+    </ProjectsActionModal>
+  )
+}
+
+export default DeleteLeaveProjectModal

+ 23 - 8
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<typeof ProjectsActionModal>,
@@ -14,23 +16,36 @@ function DeleteProjectModal({
   handleCloseModal,
 }: DeleteProjectModalProps) {
   const { t } = useTranslation()
+  const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
+    []
+  )
+
+  useEffect(() => {
+    if (showModal) {
+      setProjectsToDisplay(displayProjects => {
+        return displayProjects.length ? displayProjects : projects
+      })
+    } else {
+      setProjectsToDisplay([])
+    }
+  }, [showModal, projects])
 
   return (
     <ProjectsActionModal
       action="delete"
       actionHandler={actionHandler}
       title={t('delete_projects')}
-      bodyTop={<p>{t('about_to_delete_projects')}</p>}
-      bodyBottom={
-        <div className="project-action-alert alert alert-warning">
-          <Icon type="exclamation-triangle" fw />{' '}
-          {t('this_action_cannot_be_undone')}
-        </div>
-      }
       showModal={showModal}
       handleCloseModal={handleCloseModal}
       projects={projects}
-    />
+    >
+      <p>{t('about_to_delete_projects')}</p>
+      <ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
+      <div className="project-action-alert alert alert-warning">
+        <Icon type="exclamation-triangle" fw />{' '}
+        {t('this_action_cannot_be_undone')}
+      </div>
+    </ProjectsActionModal>
   )
 }
 

+ 23 - 8
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<typeof ProjectsActionModal>,
@@ -14,23 +16,36 @@ function LeaveProjectModal({
   handleCloseModal,
 }: LeaveProjectModalProps) {
   const { t } = useTranslation()
+  const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
+    []
+  )
+
+  useEffect(() => {
+    if (showModal) {
+      setProjectsToDisplay(displayProjects => {
+        return displayProjects.length ? displayProjects : projects
+      })
+    } else {
+      setProjectsToDisplay([])
+    }
+  }, [showModal, projects])
 
   return (
     <ProjectsActionModal
       action="leave"
       actionHandler={actionHandler}
       title={t('leave_projects')}
-      bodyTop={<p>{t('about_to_leave_projects')}</p>}
-      bodyBottom={
-        <div className="project-action-alert alert alert-warning">
-          <Icon type="exclamation-triangle" fw />{' '}
-          {t('this_action_cannot_be_undone')}
-        </div>
-      }
       showModal={showModal}
       handleCloseModal={handleCloseModal}
       projects={projects}
-    />
+    >
+      <p>{t('about_to_leave_projects')}</p>
+      <ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
+      <div className="project-action-alert alert alert-warning">
+        <Icon type="exclamation-triangle" fw />{' '}
+        {t('this_action_cannot_be_undone')}
+      </div>
+    </ProjectsActionModal>
   )
 }
 

+ 6 - 41
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<void>
   handleCloseModal: () => void
-  bodyTop?: React.ReactNode
-  bodyBottom?: React.ReactNode
   projects: Array<Project>
   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<Array<any>>([])
   const [isProcessing, setIsProcessing] = useState(false)
-  const [projectsToDisplay, setProjectsToDisplay] = useState<Project[]>([])
-  const projectsRef = useRef<Project[]>([])
   const isMounted = useIsMounted()
 
   async function handleActionForProjects(projects: Array<Project>) {
@@ -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 (
     <AccessibleModal
@@ -93,25 +76,7 @@ function ProjectsActionModal({
       <Modal.Header closeButton>
         <Modal.Title>{title}</Modal.Title>
       </Modal.Header>
-      <Modal.Body>
-        {bodyTop}
-        <ul>
-          {projectsToDisplay.map(project => (
-            <li
-              key={`projects-action-list-${project.id}`}
-              className={
-                projectIdsRemainingToProcess.includes(project.id)
-                  ? ''
-                  : 'list-style-check-green'
-              }
-            >
-              <b>{project.name}</b>
-            </li>
-          ))}
-        </ul>
-
-        {bodyBottom}
-      </Modal.Body>
+      <Modal.Body>{children}</Modal.Body>
       <Modal.Footer>
         {!isProcessing &&
           errors.length > 0 &&

+ 28 - 0
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 (
+    <ul className="projects-action-list">
+      {projectsToDisplay.map(project => (
+        <li
+          key={`projects-action-list-${project.id}`}
+          className={classnames({
+            'list-style-check-green': !projects.some(
+              ({ id }) => id === project.id
+            ),
+          })}
+        >
+          <b>{project.name}</b>
+        </li>
+      ))}
+    </ul>
+  )
+}
+
+export default ProjectsList

+ 29 - 14
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<typeof ProjectsActionModal>,
@@ -13,29 +15,42 @@ function TrashProjectModal({
   handleCloseModal,
 }: TrashProjectPropsModalProps) {
   const { t } = useTranslation()
+  const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
+    []
+  )
+
+  useEffect(() => {
+    if (showModal) {
+      setProjectsToDisplay(displayProjects => {
+        return displayProjects.length ? displayProjects : projects
+      })
+    } else {
+      setProjectsToDisplay([])
+    }
+  }, [showModal, projects])
 
   return (
     <ProjectsActionModal
       action="trash"
       actionHandler={actionHandler}
       title={t('trash_projects')}
-      bodyTop={<p>{t('about_to_trash_projects')}</p>}
-      bodyBottom={
-        <p>
-          {t('trashing_projects_wont_affect_collaborators')}{' '}
-          <a
-            href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
-            target="_blank"
-            rel="noreferrer"
-          >
-            {t('find_out_more_nt')}
-          </a>
-        </p>
-      }
       showModal={showModal}
       handleCloseModal={handleCloseModal}
       projects={projects}
-    />
+    >
+      <p>{t('about_to_trash_projects')}</p>
+      <ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
+      <p>
+        {t('trashing_projects_wont_affect_collaborators')}{' '}
+        <a
+          href="https://www.overleaf.com/blog/new-feature-using-archive-and-trash-to-keep-your-projects-organized"
+          target="_blank"
+          rel="noreferrer"
+        >
+          {t('find_out_more_nt')}
+        </a>
+      </p>
+    </ProjectsActionModal>
   )
 }
 

+ 12 - 12
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() {
       </Tooltip>
       <ArchiveProjectModal
         projects={selectedProjects}
-        actionHandler={handleArchiveProjects}
+        actionHandler={handleArchiveProject}
         showModal={showModal}
         handleCloseModal={handleCloseModal}
       />

+ 58 - 0
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 && (
+        <Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
+          {t('delete_and_leave')}
+        </Button>
+      )}
+      <DeleteLeaveProjectModal
+        projects={selectedProjects}
+        actionHandler={handleDeleteAndLeaveProject}
+        showModal={showModal}
+        handleCloseModal={handleCloseModal}
+      />
+    </>
+  )
+}
+
+export default DeleteLeaveProjectsButton

+ 54 - 0
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 && (
+        <Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
+          {t('delete')}
+        </Button>
+      )}
+      <DeleteProjectModal
+        projects={selectedProjects}
+        actionHandler={handleDeleteProject}
+        showModal={showModal}
+        handleCloseModal={handleCloseModal}
+      />
+    </>
+  )
+}
+
+export default DeleteProjectsButton

+ 54 - 0
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 && (
+        <Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
+          {t('leave')}
+        </Button>
+      )}
+      <LeaveProjectModal
+        projects={selectedProjects}
+        actionHandler={handleLeaveProject}
+        showModal={showModal}
+        handleCloseModal={handleCloseModal}
+      />
+    </>
+  )
+}
+
+export default LeaveProjectsButton

+ 12 - 12
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() {
       </Tooltip>
       <TrashProjectModal
         projects={selectedProjects}
-        actionHandler={handleTrashProjects}
+        actionHandler={handleTrashProject}
         showModal={showModal}
         handleCloseModal={handleCloseModal}
       />

+ 13 - 0
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' && <UnarchiveProjectsButton />}
       </ButtonGroup>
 
+      <ButtonGroup>
+        {filter === 'trashed' && (
+          <>
+            <LeaveProjectsButton />
+            <DeleteProjectsButton />
+            <DeleteLeaveProjectsButton />
+          </>
+        )}
+      </ButtonGroup>
+
       {!['archived', 'trashed'].includes(filter) && <TagsDropdown />}
 
       {selectedProjects.length === 1 &&

+ 23 - 10
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<React.SetStateAction<boolean>>
   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<ProjectListContextValue>(
@@ -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,

+ 8 - 0
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
+}

+ 212 - 18
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('<ProjectListRoot />', function () {
 
   describe('project table', function () {
     beforeEach(async function () {
-      renderWithProjectListContext(<ProjectListRoot />, {
+      const { unmount } = renderWithProjectListContext(<ProjectListRoot />, {
         projects: fullList,
       })
+      this.unmount = unmount
       await fetchMock.flush(true)
       await screen.findByRole('table')
     })
@@ -240,7 +243,7 @@ describe('<ProjectListRoot />', function () {
           })
 
           const unarchiveButton =
-            within(actionsToolbar).getByText<HTMLInputElement>('Restore')
+            within(actionsToolbar).getByText<HTMLButtonElement>('Restore')
           fireEvent.click(unarchiveButton)
 
           await fetchMock.flush(true)
@@ -284,7 +287,7 @@ describe('<ProjectListRoot />', 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('<ProjectListRoot />', function () {
           })
 
           const untrashButton =
-            within(actionsToolbar).getByText<HTMLInputElement>('Restore')
+            within(actionsToolbar).getByText<HTMLButtonElement>('Restore')
           fireEvent.click(untrashButton)
 
           await fetchMock.flush(true)
@@ -330,22 +333,213 @@ describe('<ProjectListRoot />', 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 archiveButton =
+            within(actionsToolbar).getByLabelText<HTMLButtonElement>('Archive')
+          fireEvent.click(archiveButton)
+
+          const confirmButton = screen.getByText<HTMLButtonElement>('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)
+
+          trashedList.forEach(project => {
+            expect(calls).to.contain(`/project/${project.id}/archive`)
           })
+        })
 
-          const untrashButton =
-            within(actionsToolbar).getByLabelText<HTMLInputElement>('Archive')
-          fireEvent.click(untrashButton)
+        it('removes only selected projects from view when leaving', async function () {
+          // rerender content with different projects
+          this.unmount()
+          fetchMock.restore()
+
+          renderWithProjectListContext(<ProjectListRoot />, {
+            projects: leavableList,
+          })
+
+          await fetchMock.flush(true)
+          await screen.findByRole('table')
+
+          expect(leavableList.length).to.be.greaterThan(0)
 
-          const confirmButton = screen.getByText<HTMLInputElement>('Confirm')
+          fetchMock.post(
+            `express:/project/:id/leave`,
+            {
+              status: 200,
+            },
+            { repeat: leavableList.length }
+          )
+
+          allCheckboxes = screen.getAllByRole<HTMLInputElement>('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<HTMLButtonElement>('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)
+          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(<ProjectListRoot />, {
+            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<HTMLInputElement>('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<HTMLButtonElement>('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(<ProjectListRoot />, {
+            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<HTMLInputElement>('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<HTMLButtonElement>('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('<ProjectListRoot />', function () {
           fireEvent.click(moreDropdown)
 
           const renameButton =
-            screen.getAllByText<HTMLInputElement>('Rename')[1] // first one is for the tag in the sidebar
+            screen.getAllByText<HTMLButtonElement>('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('<ProjectListRoot />', function () {
 
           // same name
           let confirmButton =
-            within(modal).getByText<HTMLInputElement>('Rename')
+            within(modal).getByText<HTMLButtonElement>('Rename')
           expect(confirmButton.disabled).to.be.true
           let input = screen.getByLabelText('New Name') as HTMLButtonElement
 
@@ -517,7 +711,7 @@ describe('<ProjectListRoot />', function () {
           fireEvent.change(input, {
             target: { value: '' },
           })
-          confirmButton = within(modal).getByText<HTMLInputElement>('Rename')
+          confirmButton = within(modal).getByText<HTMLButtonElement>('Rename')
           expect(confirmButton.disabled).to.be.true
         })
 
@@ -534,7 +728,7 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(moreDropdown)
 
           const renameButton =
-            within(actionsToolbar).getByText<HTMLInputElement>('Rename') // first one is for the tag in the sidebar
+            within(actionsToolbar).getByText<HTMLButtonElement>('Rename') // first one is for the tag in the sidebar
           fireEvent.click(renameButton)
 
           const modals = await screen.findAllByRole('dialog')
@@ -551,7 +745,7 @@ describe('<ProjectListRoot />', function () {
           })
 
           const confirmButton =
-            within(modal).getByText<HTMLInputElement>('Rename')
+            within(modal).getByText<HTMLButtonElement>('Rename')
           expect(confirmButton.disabled).to.be.false
           fireEvent.click(confirmButton)
 
@@ -605,7 +799,7 @@ describe('<ProjectListRoot />', function () {
           })
 
           const copyButton =
-            within(actionsToolbar).getByText<HTMLInputElement>('Make a copy')
+            within(actionsToolbar).getByText<HTMLButtonElement>('Make a copy')
           fireEvent.click(copyButton)
 
           // confirm in modal

+ 29 - 0
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('<DeleteLeaveProjectsButton />', function () {
+  it('opens the modal when clicked', function () {
+    const value = {
+      selectedProjects: [...deletableList, ...leavableList],
+      hasDeletableProjectsSelected: true,
+      hasLeavableProjectsSelected: true,
+    } as ProjectListContextValue
+
+    render(
+      <ProjectListContext.Provider value={value}>
+        <DeleteLeaveProjectsButton />
+      </ProjectListContext.Provider>
+    )
+
+    const btn = screen.getByRole('button', { name: /delete \/ leave/i })
+    fireEvent.click(btn)
+    screen.getByRole('heading', { name: /delete and leave projects/i })
+  })
+})

+ 29 - 0
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('<DeleteProjectsButton />', function () {
+  it('opens the modal when clicked', function () {
+    const value = {
+      selectedProjects: deletableList,
+      hasDeletableProjectsSelected: true,
+      hasLeavableProjectsSelected: false,
+    } as ProjectListContextValue
+
+    render(
+      <ProjectListContext.Provider value={value}>
+        <DeleteProjectsButton />
+      </ProjectListContext.Provider>
+    )
+
+    const btn = screen.getByRole('button', { name: /delete/i })
+    fireEvent.click(btn)
+    screen.getByRole('heading', { name: /delete projects/i })
+  })
+})

+ 28 - 0
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('<LeaveProjectsButton />', function () {
+  it('opens the modal when clicked', function () {
+    const value = {
+      selectedProjects: leavableList,
+      hasDeletableProjectsSelected: false,
+      hasLeavableProjectsSelected: true,
+    } as ProjectListContextValue
+
+    render(
+      <ProjectListContext.Provider value={value}>
+        <LeaveProjectsButton />
+      </ProjectListContext.Provider>
+    )
+    const btn = screen.getByRole('button', { name: /leave/i })
+    fireEvent.click(btn)
+    screen.getByRole('heading', { name: /leave projects/i })
+  })
+})

+ 1 - 1
services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx

@@ -76,7 +76,7 @@ describe('<ProjectsActionModal />', function () {
     })
   })
 
-  it('should send an analytics even when opened', function () {
+  it('should send an analytics event when opened', function () {
     renderWithProjectListContext(
       <ProjectsActionModal
         action="archive"

+ 7 - 0
services/web/test/frontend/features/project-list/fixtures/projects-data.ts

@@ -1,4 +1,9 @@
 import { Project } from '../../../../../types/project/dashboard/api'
+import {
+  isDeletableProject,
+  isLeavableProject,
+} from '../../../../../frontend/js/features/project-list/util/project'
+
 const moment = require('moment')
 
 export const owner = {
@@ -157,5 +162,7 @@ export const makeLongProjectList = (listLength: number) => {
       ({ archived, trashed }) => !archived && !trashed
     ),
     trashedList: longList.filter(({ trashed }) => trashed),
+    leavableList: longList.filter(isLeavableProject),
+    deletableList: longList.filter(isDeletableProject),
   }
 }