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

[web] Add delete project modal

GitOrigin-RevId: 4ff408aeeac5c96dc8982ded699f3ef355999a9d
This commit is contained in:
Jessica Lawshe 2022-09-16 08:35:36 -05:00 committed by Copybot
parent 8438de1167
commit 4c02db777b
5 changed files with 122 additions and 16 deletions

View file

@ -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": "",

View file

@ -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]}
/>
</>
)
}

View file

@ -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

View file

@ -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: {

View file

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