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><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() + }) })