From 4c02db777b415a1da7d282a00610d2c7b5572b9f Mon Sep 17 00:00:00 2001
From: Jessica Lawshe
Date: Fri, 16 Sep 2022 08:35:36 -0500
Subject: [PATCH] Merge pull request #9565 from overleaf/jel-dash-btn-delete
[web] Add delete project modal
GitOrigin-RevId: 4ff408aeeac5c96dc8982ded699f3ef355999a9d
---
.../web/frontend/extracted-translations.json | 6 ++
.../action-buttons/delete-project-button.tsx | 69 ++++++++++++++++---
.../table/projects-action-modal.tsx | 2 +-
.../js/features/project-list/util/api.ts | 8 +++
.../delete-project-button.test.tsx | 53 ++++++++++++--
5 files changed, 122 insertions(+), 16 deletions(-)
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 608b246923..d00dd309d0 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/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": "",
diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx
index df4e3a4d2f..5293815d75 100644
--- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx
+++ b/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 (
-
-
-
+ <>
+
+
+
+
+ {t('about_to_delete_projects')}
}
+ bodyBottom={
+
+ {' '}
+ {t('this_action_cannot_be_undone')}
+
+ }
+ showModal={showModal}
+ handleCloseModal={handleCloseModal}
+ projects={[project]}
+ />
+ >
)
}
diff --git a/services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx b/services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx
index ec1f927b81..8425887bfb 100644
--- a/services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx
+++ b/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
handleCloseModal: () => void
bodyTop: React.ReactNode
diff --git a/services/web/frontend/js/features/project-list/util/api.ts b/services/web/frontend/js/features/project-list/util/api.ts
index db5b35212a..f7816cbd20 100644
--- a/services/web/frontend/js/features/project-list/util/api.ts
+++ b/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: {
diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx
index a1f2c2da68..d29cebe6ae 100644
--- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx
+++ b/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('', function () {
+ afterEach(function () {
+ resetProjectListContextFetch()
+ })
+
it('renders tooltip for button', function () {
window.user_id = trashedProject?.owner?.id
- render()
+ renderWithProjectListContext(
+
+ )
const btn = screen.getByLabelText('Delete')
fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Delete' })
@@ -18,13 +29,47 @@ describe('', function () {
it('does not render button when trashed and not owner', function () {
window.user_id = '123abc'
- render()
+ renderWithProjectListContext(
+
+ )
const btn = screen.queryByLabelText('Delete')
expect(btn).to.be.null
})
it('does not render the button when project is current', function () {
- render()
+ renderWithProjectListContext(
+
+ )
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()
+ 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()
+ })
})