From 2a3b33467b61f9629b6e874e1137b4434de9fac8 Mon Sep 17 00:00:00 2001 From: gilles Date: Sun, 13 Sep 2026 15:46:03 +0200 Subject: [PATCH] feat(notes): require a title to create a note --- src/App.test.tsx | 58 ++++++++- src/App.tsx | 42 ++++--- src/components/NoteEditor.test.tsx | 122 +++++++++++++++++-- src/components/NoteEditor.tsx | 188 +++++++++++++++++++---------- src/hooks/useNotes.test.ts | 8 +- src/hooks/useNotes.ts | 4 +- 6 files changed, 323 insertions(+), 99 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index d7408c6..7a00161 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -36,7 +36,7 @@ describe('App', () => { ]) }) - it('creates a note with the + button and opens the editor in edit mode', () => { + it('opens the editor when + is clicked without persisting any note', () => { vi.spyOn(Date, 'now').mockReturnValue(1234) renderApp() @@ -45,12 +45,11 @@ describe('App', () => { expect(screen.getByLabelText('Titre')).toHaveValue('') expect(screen.getByLabelText('Contenu')).toHaveValue('') expect(screen.getByRole('button', { name: 'Enregistrer' })).toBeInTheDocument() - expect(JSON.parse(localStorage.getItem('notes')!)).toEqual([ - { id: 1234, title: '', content: '' }, - ]) + expect(JSON.parse(localStorage.getItem('notes')!)).toEqual([]) }) - it('saves a new note and displays it in read mode', () => { + it('creates a note only when saved with a title', () => { + vi.spyOn(Date, 'now').mockReturnValue(1234) renderApp() fireEvent.click(screen.getByRole('button', { name: 'Créer une note' })) @@ -61,6 +60,40 @@ describe('App', () => { expect(screen.getByText('Courses')).toBeInTheDocument() expect(screen.getByText('Lait')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Éditer' })).toBeInTheDocument() + expect(JSON.parse(localStorage.getItem('notes')!)).toEqual([ + { id: 1234, title: 'Courses', content: 'Lait' }, + ]) + }) + + it('does not create a note when saving with an empty title', () => { + renderApp() + + fireEvent.click(screen.getByRole('button', { name: 'Créer une note' })) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + + expect(screen.getByText('Le titre est obligatoire.')).toBeInTheDocument() + expect(JSON.parse(localStorage.getItem('notes')!)).toEqual([]) + }) + + it('discards the draft when creation is cancelled', () => { + renderApp() + + fireEvent.click(screen.getByRole('button', { name: 'Créer une note' })) + fireEvent.change(screen.getByLabelText('Titre'), { target: { value: 'Brouillon' } }) + fireEvent.click(screen.getByRole('button', { name: 'Annuler' })) + + expect(screen.getByText(/aucune note/i)).toBeInTheDocument() + expect(JSON.parse(localStorage.getItem('notes')!)).toEqual([]) + }) + + it('discards the draft when going back from the header', () => { + renderApp() + + fireEvent.click(screen.getByRole('button', { name: 'Créer une note' })) + fireEvent.click(screen.getByRole('button', { name: 'Retour' })) + + expect(screen.getByText(/aucune note/i)).toBeInTheDocument() + expect(JSON.parse(localStorage.getItem('notes')!)).toEqual([]) }) it('opens a note from the list and navigates back', () => { @@ -81,6 +114,21 @@ describe('App', () => { expect(screen.getByRole('button', { name: 'Créer une note' })).toBeInTheDocument() }) + it('blocks saving an existing note with an empty title', () => { + localStorage.setItem('notes', JSON.stringify([{ id: 1, title: 'Avant', content: 'x' }])) + renderApp() + + fireEvent.click(screen.getByText('Avant')) + fireEvent.click(screen.getByRole('button', { name: 'Éditer' })) + fireEvent.change(screen.getByLabelText('Titre'), { target: { value: ' ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + + expect(screen.getByText('Le titre est obligatoire.')).toBeInTheDocument() + expect(JSON.parse(localStorage.getItem('notes')!)).toEqual([ + { id: 1, title: 'Avant', content: 'x' }, + ]) + }) + it('deletes a note from the editor with confirmation', () => { localStorage.setItem('notes', JSON.stringify([{ id: 1, title: 'À supprimer', content: 'x' }])) renderApp() diff --git a/src/App.tsx b/src/App.tsx index 4c89e3b..6b431a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,19 +7,18 @@ import FloatingButton from './components/FloatingButton' function App() { const { notes, addNote, updateNote, deleteNote } = useNotes() const [selectedId, setSelectedId] = useState(null) - const [createdId, setCreatedId] = useState(null) + const [creating, setCreating] = useState(false) - const selectedNote = notes.find((note) => note.id === selectedId) ?? null + const selectedNote = + selectedId === null ? null : (notes.find((note) => note.id === selectedId) ?? null) - const createNote = () => { - const note = addNote() + const createNote = () => setCreating(true) + const cancelCreate = () => setCreating(false) + + const handleCreate = (title: string, content: string) => { + const note = addNote(title, content) + setCreating(false) setSelectedId(note.id) - setCreatedId(note.id) - } - - const handleUpdate = (id: number, patch: Parameters[1]) => { - updateNote(id, patch) - if (id === createdId) setCreatedId(null) } const handleDelete = (id: number) => { @@ -27,14 +26,19 @@ function App() { setSelectedId(null) } + const goBack = () => { + if (creating) cancelCreate() + else setSelectedId(null) + } + return (
- {selectedNote ? ( + {creating || selectedNote ? (
- {selectedNote ? ( + {creating ? ( + ) : selectedNote ? ( + ) : ( diff --git a/src/components/NoteEditor.test.tsx b/src/components/NoteEditor.test.tsx index 22edaca..798e97d 100644 --- a/src/components/NoteEditor.test.tsx +++ b/src/components/NoteEditor.test.tsx @@ -5,16 +5,30 @@ import type { Note } from '../types' const note: Note = { id: 42, title: 'Courses', content: 'Lait\nOeufs' } -const renderEditor = (overrides: Partial = {}) => { +const renderEdit = (overrides: Partial = {}) => { const onUpdate = vi.fn() const onDelete = vi.fn() - render() + render( + , + ) return { onUpdate, onDelete } } -describe('NoteEditor', () => { +const renderCreate = () => { + const onCreate = vi.fn() + const onCancelCreate = vi.fn() + render() + return { onCreate, onCancelCreate } +} + +describe('NoteEditor in edit mode', () => { it('displays the note title and content in read mode', () => { - renderEditor() + renderEdit() expect(screen.getByText('Courses')).toBeInTheDocument() expect(screen.getByText('Lait Oeufs')).toBeInTheDocument() @@ -22,14 +36,14 @@ describe('NoteEditor', () => { }) it('shows placeholders for a note without title or content', () => { - renderEditor({ title: '', content: '' }) + renderEdit({ title: '', content: '' }) expect(screen.getByText('Sans titre')).toBeInTheDocument() expect(screen.getByText('Note vide')).toBeInTheDocument() }) it('switches to edit mode with prefilled fields', () => { - renderEditor() + renderEdit() fireEvent.click(screen.getByRole('button', { name: 'Éditer' })) @@ -40,7 +54,7 @@ describe('NoteEditor', () => { }) it('saves edits and calls onUpdate with the updated values', () => { - const { onUpdate } = renderEditor() + const { onUpdate } = renderEdit() fireEvent.click(screen.getByRole('button', { name: 'Éditer' })) fireEvent.change(screen.getByLabelText('Titre'), { target: { value: 'Liste de courses' } }) @@ -52,7 +66,7 @@ describe('NoteEditor', () => { }) it('saves when the form is submitted', () => { - const { onUpdate } = renderEditor() + const { onUpdate } = renderEdit() fireEvent.click(screen.getByRole('button', { name: 'Éditer' })) const form = screen.getByLabelText('Titre').closest('form')! @@ -61,8 +75,43 @@ describe('NoteEditor', () => { expect(onUpdate).toHaveBeenCalledWith(42, { title: 'Courses', content: 'Lait\nOeufs' }) }) + it('trims the title on save', () => { + const { onUpdate } = renderEdit() + + fireEvent.click(screen.getByRole('button', { name: 'Éditer' })) + fireEvent.change(screen.getByLabelText('Titre'), { target: { value: ' Courses ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + + expect(onUpdate).toHaveBeenCalledWith(42, { title: 'Courses', content: 'Lait\nOeufs' }) + }) + + it('refuses saving when the title is empty', () => { + const { onUpdate } = renderEdit() + + fireEvent.click(screen.getByRole('button', { name: 'Éditer' })) + fireEvent.change(screen.getByLabelText('Titre'), { target: { value: '' } }) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + + expect(onUpdate).not.toHaveBeenCalled() + expect(screen.getByText('Le titre est obligatoire.')).toBeInTheDocument() + expect(screen.getByLabelText('Titre')).toHaveAttribute('aria-invalid', 'true') + }) + + it('clears the title error once a title is typed', () => { + renderEdit() + + fireEvent.click(screen.getByRole('button', { name: 'Éditer' })) + fireEvent.change(screen.getByLabelText('Titre'), { target: { value: '' } }) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + expect(screen.getByText('Le titre est obligatoire.')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Titre'), { target: { value: 'Rempli' } }) + + expect(screen.queryByText('Le titre est obligatoire.')).not.toBeInTheDocument() + }) + it('cancels editing without calling onUpdate', () => { - const { onUpdate } = renderEditor() + const { onUpdate } = renderEdit() fireEvent.click(screen.getByRole('button', { name: 'Éditer' })) fireEvent.change(screen.getByLabelText('Titre'), { target: { value: 'Non sauvegardé' } }) @@ -73,7 +122,7 @@ describe('NoteEditor', () => { }) it('opens the delete confirmation when the × button is clicked', () => { - renderEditor() + renderEdit() expect(screen.queryByRole('dialog')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' })) @@ -82,7 +131,7 @@ describe('NoteEditor', () => { }) it('deletes the note after confirmation', () => { - const { onDelete } = renderEditor() + const { onDelete } = renderEdit() fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' })) fireEvent.click(screen.getByRole('button', { name: 'Supprimer' })) @@ -92,7 +141,7 @@ describe('NoteEditor', () => { }) it('does not delete when the confirmation is cancelled', () => { - const { onDelete } = renderEditor() + const { onDelete } = renderEdit() fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' })) fireEvent.click(screen.getByRole('button', { name: 'Annuler' })) @@ -101,3 +150,52 @@ describe('NoteEditor', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) }) + +describe('NoteEditor in create mode', () => { + it('renders an empty form without read view or delete button', () => { + renderCreate() + + expect(screen.getByLabelText('Titre')).toHaveValue('') + expect(screen.getByLabelText('Contenu')).toHaveValue('') + expect(screen.getByRole('button', { name: 'Enregistrer' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Éditer' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Supprimer la note' })).not.toBeInTheDocument() + }) + + it('creates the note with title and content on save', () => { + const { onCreate } = renderCreate() + + fireEvent.change(screen.getByLabelText('Titre'), { target: { value: 'Courses' } }) + fireEvent.change(screen.getByLabelText('Contenu'), { target: { value: 'Lait' } }) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + + expect(onCreate).toHaveBeenCalledWith('Courses', 'Lait') + }) + + it('trims the title on create', () => { + const { onCreate } = renderCreate() + + fireEvent.change(screen.getByLabelText('Titre'), { target: { value: ' Courses ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + + expect(onCreate).toHaveBeenCalledWith('Courses', '') + }) + + it('refuses creation when the title is empty', () => { + const { onCreate } = renderCreate() + + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + + expect(onCreate).not.toHaveBeenCalled() + expect(screen.getByText('Le titre est obligatoire.')).toBeInTheDocument() + }) + + it('cancels creation without creating the note', () => { + const { onCreate, onCancelCreate } = renderCreate() + + fireEvent.click(screen.getByRole('button', { name: 'Annuler' })) + + expect(onCreate).not.toHaveBeenCalled() + expect(onCancelCreate).toHaveBeenCalled() + }) +}) diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index f83e843..5b0b2c7 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -2,80 +2,142 @@ import { useState } from 'react' import type { Note } from '../types' import DeleteModal from './DeleteModal' -interface NoteEditorProps { +interface EditEditorProps { + mode: 'edit' note: Note onUpdate: (id: number, patch: Partial>) => void onDelete: (id: number) => void - autoEdit?: boolean } -function NoteEditor({ note, onUpdate, onDelete, autoEdit = false }: NoteEditorProps) { - const [editing, setEditing] = useState(autoEdit) +interface CreateEditorProps { + mode: 'create' + onCreate: (title: string, content: string) => void + onCancelCreate: () => void +} + +type NoteEditorProps = EditEditorProps | CreateEditorProps + +interface NoteFormProps { + title: string + content: string + titleMissing: boolean + cancelLabel: string + onTitleChange: (value: string) => void + onContentChange: (value: string) => void + onCancel: () => void + onSubmit: () => void +} + +const TITLE_REQUIRED_MESSAGE = 'Le titre est obligatoire.' + +function NoteForm({ + title, + content, + titleMissing, + cancelLabel, + onTitleChange, + onContentChange, + onCancel, + onSubmit, +}: NoteFormProps) { + return ( +
{ + event.preventDefault() + onSubmit() + }} + > + + {titleMissing && ( + + {TITLE_REQUIRED_MESSAGE} + + )} +