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:
devcodetools committed 2026-09-21 19:13:27 +02:00
1 parent 03a6b7b455
commit 208fe98f1b
2 files changed
+153

No files matched your search

+59
View File
@@ -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()
})
+94
View File
@@ -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>
)}
</>
)
}