refactor(notes): split NoteEditor into NoteForm and NoteReadView
This commit is contained in:
1 parent
7325485ee3
commit
e13652a753
5 files changed
+195
-110
No files matched your search
+13
-110
@@ -1,82 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { NoteEditorProps, NoteFormProps } from '../interfaces/note-editor'
|
import type { NoteEditorProps } from '../interfaces/note-editor'
|
||||||
import { isTitleValid, normalizeTitle } from '../lib/noteValidation'
|
import { isTitleValid, normalizeTitle } from '../lib/noteValidation'
|
||||||
import DeleteModal from './DeleteModal'
|
import NoteForm from './NoteForm'
|
||||||
|
import NoteReadView from './NoteReadView'
|
||||||
const TITLE_REQUIRED_MESSAGE = 'Le titre est obligatoire.'
|
|
||||||
|
|
||||||
function NoteForm({
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
titleMissing,
|
|
||||||
cancelLabel,
|
|
||||||
onTitleChange,
|
|
||||||
onContentChange,
|
|
||||||
onCancel,
|
|
||||||
onSubmit,
|
|
||||||
}: NoteFormProps) {
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
onSubmit()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-sm font-medium text-gray-500">Titre</span>
|
|
||||||
<input
|
|
||||||
value={title}
|
|
||||||
onChange={(event) => onTitleChange(event.target.value)}
|
|
||||||
autoFocus
|
|
||||||
aria-invalid={titleMissing}
|
|
||||||
className={`w-full rounded-lg border px-3 py-3 text-base focus:outline-none focus:ring-2 ${
|
|
||||||
titleMissing
|
|
||||||
? 'border-red-500 focus:ring-red-500/30'
|
|
||||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/30'
|
|
||||||
}`}
|
|
||||||
placeholder="Sans titre"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{titleMissing && (
|
|
||||||
<span role="alert" className="text-sm text-red-600">
|
|
||||||
{TITLE_REQUIRED_MESSAGE}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<label className="flex flex-col gap-1">
|
|
||||||
<span className="text-sm font-medium text-gray-500">Contenu</span>
|
|
||||||
<textarea
|
|
||||||
value={content}
|
|
||||||
onChange={(event) => onContentChange(event.target.value)}
|
|
||||||
className="w-full min-h-48 rounded-lg border border-gray-300 px-3 py-3 text-base focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30"
|
|
||||||
placeholder="Écrire ici…"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-lg border border-gray-300 px-5 py-3 text-gray-600 hover:bg-gray-50 active:bg-gray-100"
|
|
||||||
>
|
|
||||||
{cancelLabel}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-lg bg-blue-600 px-5 py-3 text-white hover:bg-blue-700 active:bg-blue-800"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoteEditor(props: NoteEditorProps) {
|
function NoteEditor(props: NoteEditorProps) {
|
||||||
const [editing, setEditing] = useState(props.mode === 'create')
|
const [editing, setEditing] = useState(props.mode === 'create')
|
||||||
const [title, setTitle] = useState(props.mode === 'edit' ? props.note.title : '')
|
const [title, setTitle] = useState(props.mode === 'edit' ? props.note.title : '')
|
||||||
const [content, setContent] = useState(props.mode === 'edit' ? props.note.content : '')
|
const [content, setContent] = useState(props.mode === 'edit' ? props.note.content : '')
|
||||||
const [titleMissing, setTitleMissing] = useState(false)
|
const [titleMissing, setTitleMissing] = useState(false)
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
||||||
|
|
||||||
const handleTitleChange = (value: string) => {
|
const handleTitleChange = (value: string) => {
|
||||||
setTitle(value)
|
setTitle(value)
|
||||||
@@ -114,45 +46,16 @@ function NoteEditor(props: NoteEditorProps) {
|
|||||||
|
|
||||||
const { note } = props
|
const { note } = props
|
||||||
return (
|
return (
|
||||||
<article className="flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
|
<NoteReadView
|
||||||
<div className="flex items-start justify-between gap-4">
|
note={note}
|
||||||
<h2 className="text-xl font-semibold leading-snug">{note.title || 'Sans titre'}</h2>
|
onEdit={() => {
|
||||||
<button
|
setTitle(note.title)
|
||||||
type="button"
|
setContent(note.content)
|
||||||
onClick={() => setConfirmDelete(true)}
|
setEditing(true)
|
||||||
aria-label="Supprimer la note"
|
}}
|
||||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-2xl leading-none text-red-600 hover:bg-red-50 hover:text-red-700 active:bg-red-100"
|
onDelete={props.onDelete}
|
||||||
>
|
/>
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="whitespace-pre-wrap text-base leading-relaxed">
|
|
||||||
{note.content || <span className="text-gray-400 italic">Note vide</span>}
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setTitle(props.note.title)
|
|
||||||
setContent(props.note.content)
|
|
||||||
setEditing(true)
|
|
||||||
}}
|
|
||||||
className="rounded-lg bg-blue-600 px-5 py-3 text-white hover:bg-blue-700 active:bg-blue-800"
|
|
||||||
>
|
|
||||||
Éditer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<DeleteModal
|
|
||||||
open={confirmDelete}
|
|
||||||
title={note.title}
|
|
||||||
onConfirm={() => {
|
|
||||||
setConfirmDelete(false)
|
|
||||||
props.onDelete(note.id)
|
|
||||||
}}
|
|
||||||
onCancel={() => setConfirmDelete(false)}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NoteEditor
|
export default NoteEditor
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { NoteFormProps } from '../interfaces/note-editor'
|
||||||
|
|
||||||
|
const TITLE_REQUIRED_MESSAGE = 'Le titre est obligatoire.'
|
||||||
|
|
||||||
|
function NoteForm({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
titleMissing,
|
||||||
|
cancelLabel,
|
||||||
|
onTitleChange,
|
||||||
|
onContentChange,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}: NoteFormProps) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-gray-500">Titre</span>
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => onTitleChange(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
aria-invalid={titleMissing}
|
||||||
|
className={`w-full rounded-lg border px-3 py-3 text-base focus:outline-none focus:ring-2 ${
|
||||||
|
titleMissing
|
||||||
|
? 'border-red-500 focus:ring-red-500/30'
|
||||||
|
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/30'
|
||||||
|
}`}
|
||||||
|
placeholder="Sans titre"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{titleMissing && (
|
||||||
|
<span role="alert" className="text-sm text-red-600">
|
||||||
|
{TITLE_REQUIRED_MESSAGE}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-gray-500">Contenu</span>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => onContentChange(event.target.value)}
|
||||||
|
className="w-full min-h-48 rounded-lg border border-gray-300 px-3 py-3 text-base focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||||
|
placeholder="Écrire ici…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-lg border border-gray-300 px-5 py-3 text-gray-600 hover:bg-gray-50 active:bg-gray-100"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-blue-600 px-5 py-3 text-white hover:bg-blue-700 active:bg-blue-800"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoteForm
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import NoteReadView from './NoteReadView'
|
||||||
|
import type { Note } from '../types/note'
|
||||||
|
|
||||||
|
const note: Note = { id: 42, title: 'Courses', content: 'Lait\nOeufs' }
|
||||||
|
|
||||||
|
const renderView = (overrides: Partial<Note> = {}) => {
|
||||||
|
const onEdit = vi.fn()
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
render(
|
||||||
|
<NoteReadView note={{ ...note, ...overrides }} onEdit={onEdit} onDelete={onDelete} />,
|
||||||
|
)
|
||||||
|
return { onEdit, onDelete }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NoteReadView', () => {
|
||||||
|
it('displays the note title and content', () => {
|
||||||
|
renderView()
|
||||||
|
|
||||||
|
expect(screen.getByText('Courses')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Lait Oeufs')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Éditer' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows placeholders for a note without title or content', () => {
|
||||||
|
renderView({ title: '', content: '' })
|
||||||
|
|
||||||
|
expect(screen.getByText('Sans titre')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Note vide')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onEdit when Éditer is clicked', () => {
|
||||||
|
const { onEdit } = renderView()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Éditer' }))
|
||||||
|
|
||||||
|
expect(onEdit).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes the note after confirmation', () => {
|
||||||
|
const { onDelete } = renderView()
|
||||||
|
|
||||||
|
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 } = renderView()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Annuler' }))
|
||||||
|
|
||||||
|
expect(onDelete).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { NoteReadViewProps } from '../interfaces/note-editor'
|
||||||
|
import DeleteModal from './DeleteModal'
|
||||||
|
|
||||||
|
function NoteReadView({ note, onEdit, onDelete }: NoteReadViewProps) {
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<h2 className="text-xl font-semibold leading-snug">{note.title || 'Sans titre'}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
aria-label="Supprimer la note"
|
||||||
|
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-2xl leading-none text-red-600 hover:bg-red-50 hover:text-red-700 active:bg-red-100"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-base leading-relaxed">
|
||||||
|
{note.content || <span className="text-gray-400 italic">Note vide</span>}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="rounded-lg bg-blue-600 px-5 py-3 text-white hover:bg-blue-700 active:bg-blue-800"
|
||||||
|
>
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DeleteModal
|
||||||
|
open={confirmDelete}
|
||||||
|
title={note.title}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmDelete(false)
|
||||||
|
onDelete(note.id)
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoteReadView
|
||||||
@@ -17,6 +17,12 @@ export interface CreateNoteEditorProps {
|
|||||||
|
|
||||||
export type NoteEditorProps = EditNoteEditorProps | CreateNoteEditorProps
|
export type NoteEditorProps = EditNoteEditorProps | CreateNoteEditorProps
|
||||||
|
|
||||||
|
export interface NoteReadViewProps {
|
||||||
|
note: Note
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: (id: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface NoteFormProps {
|
export interface NoteFormProps {
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
|
|||||||
Reference in new issue
Block a user