feat(notes): add note deletion with confirmation
This commit is contained in:
1 parent
0976c51cc1
commit
0af0cdc405
4 files changed
+141
-3
No files matched your search
@@ -0,0 +1,42 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import DeleteModal from './DeleteModal'
|
||||||
|
|
||||||
|
describe('DeleteModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
render(<DeleteModal open={false} title="Courses" onConfirm={vi.fn()} onCancel={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the dialog with the note title when open', () => {
|
||||||
|
render(<DeleteModal open title="Courses" onConfirm={vi.fn()} onCancel={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Supprimer la note ?' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Courses/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a placeholder when the note has no title', () => {
|
||||||
|
render(<DeleteModal open title="" onConfirm={vi.fn()} onCancel={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/Sans titre/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm when the confirm button is clicked', () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
render(<DeleteModal open title="Courses" onConfirm={onConfirm} onCancel={vi.fn()} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer' }))
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onCancel when the cancel button is clicked', () => {
|
||||||
|
const onCancel = vi.fn()
|
||||||
|
render(<DeleteModal open title="Courses" onConfirm={vi.fn()} onCancel={onCancel} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Annuler' }))
|
||||||
|
|
||||||
|
expect(onCancel).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
interface DeleteModalProps {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteModal({ open, title, onConfirm, onCancel }: DeleteModalProps) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-confirm-title"
|
||||||
|
className="w-full max-w-sm bg-white rounded-lg p-5 flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<h2 id="delete-confirm-title" className="text-lg font-semibold">
|
||||||
|
Supprimer la note ?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">{title || 'Sans titre'} sera définitivement supprimée.</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 rounded border border-gray-300 text-gray-600"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 rounded bg-red-600 text-white"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteModal
|
||||||
@@ -7,8 +7,9 @@ const note: Note = { id: 42, title: 'Courses', content: 'Lait\nOeufs' }
|
|||||||
|
|
||||||
const renderEditor = (overrides: Partial<Note> = {}) => {
|
const renderEditor = (overrides: Partial<Note> = {}) => {
|
||||||
const onUpdate = vi.fn()
|
const onUpdate = vi.fn()
|
||||||
render(<NoteEditor note={{ ...note, ...overrides }} onUpdate={onUpdate} />)
|
const onDelete = vi.fn()
|
||||||
return { onUpdate }
|
render(<NoteEditor note={{ ...note, ...overrides }} onUpdate={onUpdate} onDelete={onDelete} />)
|
||||||
|
return { onUpdate, onDelete }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('NoteEditor', () => {
|
describe('NoteEditor', () => {
|
||||||
@@ -70,4 +71,33 @@ describe('NoteEditor', () => {
|
|||||||
expect(onUpdate).not.toHaveBeenCalled()
|
expect(onUpdate).not.toHaveBeenCalled()
|
||||||
expect(screen.getByText('Courses')).toBeInTheDocument()
|
expect(screen.getByText('Courses')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens the delete confirmation when the × button is clicked', () => {
|
||||||
|
renderEditor()
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' }))
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Supprimer la note ?' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes the note after confirmation', () => {
|
||||||
|
const { onDelete } = renderEditor()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer' }))
|
||||||
|
|
||||||
|
expect(onDelete).toHaveBeenCalledWith(42)
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not delete when the confirmation is cancelled', () => {
|
||||||
|
const { onDelete } = renderEditor()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Annuler' }))
|
||||||
|
|
||||||
|
expect(onDelete).not.toHaveBeenCalled()
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { Note } from '../types'
|
import type { Note } from '../types'
|
||||||
|
import DeleteModal from './DeleteModal'
|
||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
note: Note
|
note: Note
|
||||||
onUpdate: (id: number, patch: Partial<Pick<Note, 'title' | 'content'>>) => void
|
onUpdate: (id: number, patch: Partial<Pick<Note, 'title' | 'content'>>) => void
|
||||||
|
onDelete: (id: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteEditor({ note, onUpdate }: NoteEditorProps) {
|
function NoteEditor({ note, onUpdate, onDelete }: NoteEditorProps) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
const [title, setTitle] = useState(note.title)
|
const [title, setTitle] = useState(note.title)
|
||||||
const [content, setContent] = useState(note.content)
|
const [content, setContent] = useState(note.content)
|
||||||
|
|
||||||
@@ -71,7 +74,17 @@ function NoteEditor({ note, onUpdate }: NoteEditorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="flex flex-col gap-4">
|
<article className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h2 className="text-2xl font-semibold">{note.title || 'Sans titre'}</h2>
|
<h2 className="text-2xl font-semibold">{note.title || 'Sans titre'}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
aria-label="Supprimer la note"
|
||||||
|
className="px-2 py-1 rounded text-gray-600 hover:text-red-600"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p className="whitespace-pre-wrap">
|
<p className="whitespace-pre-wrap">
|
||||||
{note.content || <span className="text-gray-400 italic">Note vide</span>}
|
{note.content || <span className="text-gray-400 italic">Note vide</span>}
|
||||||
</p>
|
</p>
|
||||||
@@ -84,6 +97,15 @@ function NoteEditor({ note, onUpdate }: NoteEditorProps) {
|
|||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<DeleteModal
|
||||||
|
open={confirmDelete}
|
||||||
|
title={note.title}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmDelete(false)
|
||||||
|
onDelete(note.id)
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user