Parcourir la source

Merge pull request #9565 from overleaf/jel-dash-btn-delete

[web] Add delete project modal

GitOrigin-RevId: 4ff408aeeac5c96dc8982ded699f3ef355999a9d
Jessica Lawshe il y a 3 ans
Parent
commit
4c02db777b

+ 6 - 0
services/web/frontend/extracted-translations.json

@@ -1,6 +1,8 @@
 {
   "about_to_archive_projects": "",
   "about_to_delete_folder": "",
+  "about_to_delete_projects": "",
+  "about_to_delete_the_following": "",
   "about_to_leave_projects": "",
   "about_to_trash_projects": "",
   "access_denied": "",
@@ -60,6 +62,7 @@
   "change_or_cancel-cancel": "",
   "change_or_cancel-change": "",
   "change_or_cancel-or": "",
+  "change_owner": "",
   "change_password": "",
   "change_primary_email_address_instructions": "<0></0><2></2>",
   "change_project_owner": "",
@@ -114,6 +117,7 @@
   "delete_account_confirmation_label": "",
   "delete_account_warning_message_3": "",
   "delete_acct_no_existing_pw": "",
+  "delete_folder": "",
   "delete_projects": "",
   "delete_your_account": "",
   "deleting": "",
@@ -439,6 +443,8 @@
   "project_layout_sharing_submission": "",
   "project_name": "",
   "project_not_linked_to_github": "",
+  "project_ownership_transfer_confirmation_1": "",
+  "project_ownership_transfer_confirmation_2": "",
   "project_synced_with_git_repo_at": "",
   "project_synchronisation": "",
   "project_timed_out_enable_stop_on_first_error": "",

+ 58 - 11
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx

@@ -1,33 +1,80 @@
 import { useTranslation } from 'react-i18next'
-import { memo, useMemo } from 'react'
+import { memo, useCallback, useMemo, useState } from 'react'
 import { Project } from '../../../../../../../../types/project/dashboard/api'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
+import ProjectsActionModal from '../../projects-action-modal'
+import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
+import { deleteProject } from '../../../../util/api'
+import { useProjectListContext } from '../../../../context/project-list-context'
 
 type DeleteProjectButtonProps = {
   project: Project
 }
 
 function DeleteProjectButton({ project }: DeleteProjectButtonProps) {
+  const { removeProjectFromView } = useProjectListContext()
   const { t } = useTranslation()
   const text = t('delete')
+  const [showModal, setShowModal] = useState(false)
+  const isMounted = useIsMounted()
+
+  const handleOpenModal = useCallback(() => {
+    setShowModal(true)
+  }, [])
+
+  const handleCloseModal = useCallback(() => {
+    if (isMounted.current) {
+      setShowModal(false)
+    }
+  }, [isMounted])
+
   const isOwner = useMemo(() => {
     return project.owner && window.user_id === project.owner.id
   }, [project])
 
+  const handleDeleteProject = useCallback(async () => {
+    await deleteProject(project.id)
+
+    // update view
+    removeProjectFromView(project)
+  }, [project, removeProjectFromView])
+
   if (!project.trashed || !isOwner) return null
 
   return (
-    <Tooltip
-      key={`tooltip-delete-project-${project.id}`}
-      id={`tooltip-delete-project-${project.id}`}
-      description={text}
-      overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
-    >
-      <button className="btn btn-link action-btn" aria-label={text}>
-        <Icon type="ban" />
-      </button>
-    </Tooltip>
+    <>
+      <Tooltip
+        key={`tooltip-delete-project-${project.id}`}
+        id={`tooltip-delete-project-${project.id}`}
+        description={text}
+        overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
+      >
+        <button
+          className="btn btn-link action-btn"
+          aria-label={text}
+          onClick={handleOpenModal}
+        >
+          <Icon type="ban" />
+        </button>
+      </Tooltip>
+
+      <ProjectsActionModal
+        title={t('delete_projects')}
+        action="delete"
+        actionHandler={handleDeleteProject}
+        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={[project]}
+      />
+    </>
   )
 }
 

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

@@ -9,7 +9,7 @@ import * as eventTracking from '../../../../infrastructure/event-tracking'
 
 type ProjectsActionModalProps = {
   title: string
-  action: 'archive' | 'trash'
+  action: 'archive' | 'trash' | 'delete'
   actionHandler: (project: Project) => Promise<void>
   handleCloseModal: () => void
   bodyTop: React.ReactNode

+ 8 - 0
services/web/frontend/js/features/project-list/util/api.ts

@@ -33,6 +33,14 @@ export function archiveProject(projectId: string) {
   })
 }
 
+export function deleteProject(projectId: string) {
+  return deleteJSON(`/project/${projectId}`, {
+    body: {
+      _csrf: window.csrfToken,
+    },
+  })
+}
+
 export function leaveProject(projectId: string) {
   return postJSON(`/project/${projectId}/leave`, {
     body: {

+ 49 - 4
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx

@@ -1,16 +1,27 @@
 import { expect } from 'chai'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
 import DeleteProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button'
 import {
   archiveableProject,
   trashedAndNotOwnedProject,
   trashedProject,
 } from '../../../../fixtures/projects-data'
+import fetchMock from 'fetch-mock'
+import {
+  renderWithProjectListContext,
+  resetProjectListContextFetch,
+} from '../../../../helpers/render-with-context'
 
 describe('<DeleteProjectButton />', function () {
+  afterEach(function () {
+    resetProjectListContextFetch()
+  })
+
   it('renders tooltip for button', function () {
     window.user_id = trashedProject?.owner?.id
-    render(<DeleteProjectButton project={trashedProject} />)
+    renderWithProjectListContext(
+      <DeleteProjectButton project={trashedProject} />
+    )
     const btn = screen.getByLabelText('Delete')
     fireEvent.mouseOver(btn)
     screen.getByRole('tooltip', { name: 'Delete' })
@@ -18,13 +29,47 @@ describe('<DeleteProjectButton />', function () {
 
   it('does not render button when trashed and not owner', function () {
     window.user_id = '123abc'
-    render(<DeleteProjectButton project={trashedAndNotOwnedProject} />)
+    renderWithProjectListContext(
+      <DeleteProjectButton project={trashedAndNotOwnedProject} />
+    )
     const btn = screen.queryByLabelText('Delete')
     expect(btn).to.be.null
   })
 
   it('does not render the button when project is current', function () {
-    render(<DeleteProjectButton project={archiveableProject} />)
+    renderWithProjectListContext(
+      <DeleteProjectButton project={archiveableProject} />
+    )
     expect(screen.queryByLabelText('Delete')).to.be.null
   })
+
+  it('opens the modal and deletes the project', async function () {
+    window.user_id = trashedProject?.owner?.id
+    const project = Object.assign({}, trashedProject)
+    fetchMock.delete(
+      `express:/project/${project.id}`,
+      {
+        status: 200,
+      },
+      { delay: 0 }
+    )
+    renderWithProjectListContext(<DeleteProjectButton project={project} />)
+    const btn = screen.getByLabelText('Delete')
+    fireEvent.click(btn)
+    screen.getByText('Delete Projects')
+    screen.getByText('You are about to delete the following projects:')
+    screen.getByText('This action cannot be undone.')
+    const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement
+    fireEvent.click(confirmBtn)
+    expect(confirmBtn.disabled).to.be.true
+    // verify trashed
+    await fetchMock.flush(true)
+    expect(fetchMock.done()).to.be.true
+    const requests = fetchMock.calls()
+    // first request is project list api in projectlistcontext
+    const [requestUrl, requestHeaders] = requests[1]
+    expect(requestUrl).to.equal(`/project/${project.id}`)
+    expect(requestHeaders?.method).to.equal('DELETE')
+    fetchMock.reset()
+  })
 })