feat(ui): suppression d'une carte avec confirmation (P3-6, F-07)
CardDeleteButton ouvre un dialog modal (aria-modal, aria-labelledby) qui résume la carte à supprimer. Confirmation : cardsApi.remove puis retour à la liste /cards. Annuler referme sans REMOVE. Test F-07 couvre l'ouverture, l'annulation (remove non appelé) et la confirmation (remove + navigation).
This commit is contained in:
1 parent
03a6b7b455
commit
208fe98f1b
2 files changed
+153
No files matched your search
@@ -0,0 +1,59 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CardDeleteButton } from './card-delete'
|
||||
|
||||
vi.mock('@/lib/api-client', () => {
|
||||
class ApiClientErrorMock extends Error {
|
||||
status: number
|
||||
constructor(status: number, name: string, message: string) {
|
||||
super(message)
|
||||
this.name = name
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
return {
|
||||
ApiClientError: ApiClientErrorMock,
|
||||
cardsApi: { remove: vi.fn() },
|
||||
}
|
||||
})
|
||||
|
||||
const routerMock = { push: vi.fn(), refresh: vi.fn() }
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => routerMock,
|
||||
}))
|
||||
|
||||
import { cardsApi } from '@/lib/api-client'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(cardsApi.remove).mockReset()
|
||||
routerMock.push.mockReset()
|
||||
routerMock.refresh.mockReset()
|
||||
})
|
||||
|
||||
it('ouvre une confirmation avant de supprimer (F-07)', async () => {
|
||||
render(<CardDeleteButton cardId="c1" question="Cest quoi un commit ?" />)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Supprimer' }))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Cest quoi un commit \?/)).toBeInTheDocument()
|
||||
expect(cardsApi.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('annule sans supprimer ni naviguer', async () => {
|
||||
render(<CardDeleteButton cardId="c1" question="question-x" />)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Supprimer' }))
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Annuler' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(cardsApi.remove).not.toHaveBeenCalled()
|
||||
expect(routerMock.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supprime après confirmation puis retourne à la liste (F-07)', async () => {
|
||||
vi.mocked(cardsApi.remove).mockResolvedValue(undefined as never)
|
||||
render(<CardDeleteButton cardId="c1" question="question-x" />)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Supprimer' }))
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Supprimer' }))
|
||||
expect(cardsApi.remove).toHaveBeenCalledWith('c1')
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/cards')
|
||||
expect(routerMock.refresh).toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||
|
||||
export function CardDeleteButton({ cardId, question }: { cardId: string; question: string }) {
|
||||
const router = useRouter()
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
function open() {
|
||||
setConfirming(true)
|
||||
setError(null)
|
||||
}
|
||||
function close() {
|
||||
if (!deleting) {
|
||||
setConfirming(false)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await cardsApi.remove(cardId)
|
||||
router.push('/cards')
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) setError(e.message)
|
||||
else setError('Impossible de supprimer la carte')
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
className="rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
|
||||
{confirming && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4"
|
||||
onClick={close}
|
||||
>
|
||||
<div
|
||||
role="document"
|
||||
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="delete-title" className="text-lg font-semibold text-slate-900">
|
||||
Supprimer cette carte ?
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
« {question} » sera définitivement supprimée. Cette action est irréversible.
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="rounded-lg bg-slate-100 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDelete}
|
||||
disabled={deleting}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? 'Suppression…' : 'Supprimer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user