feat(ui): formulaire de création de carte avec navigation (F-05)
CardForm en mode création : champs question/réponse/thème, validation HTML + affichage derreur API, soumission via cardsApi.create puis redirection vers le détail de la carte créée (next/navigation).
This commit is contained in:
1 parent
3fdb558697
commit
2e131dddcb
2 files changed
+133
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CardForm } from './card-form'
|
||||
|
||||
vi.mock('@/lib/api-client', () => ({
|
||||
ApiClientError: class extends Error { status = 0 },
|
||||
cardsApi: { create: vi.fn(), update: 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.create).mockReset()
|
||||
vi.mocked(cardsApi.update).mockReset()
|
||||
routerMock.push.mockReset()
|
||||
routerMock.refresh.mockReset()
|
||||
})
|
||||
|
||||
it('crée une carte puis navigue vers son détail (F-05)', async () => {
|
||||
vi.mocked(cardsApi.create).mockResolvedValue({ id: 'c1' } as never)
|
||||
render(<CardForm mode="create" />)
|
||||
await userEvent.type(screen.getByLabelText('Question'), 'Cest quoi un commit ?')
|
||||
await userEvent.type(screen.getByLabelText('Réponse'), 'Un snapshot')
|
||||
await userEvent.type(screen.getByLabelText('Thème'), 'git')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Créer la carte' }))
|
||||
expect(vi.mocked(cardsApi.create)).toHaveBeenCalledWith({
|
||||
question: 'Cest quoi un commit ?',
|
||||
response: 'Un snapshot',
|
||||
theme: 'git',
|
||||
})
|
||||
await screen.findByText('Enregistrement…')
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import type { CardInput } from '@/domain/card'
|
||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||
|
||||
interface CardFormProps {
|
||||
mode: 'create' | 'edit'
|
||||
cardId?: string
|
||||
initial?: CardInput
|
||||
}
|
||||
|
||||
export function CardForm({ mode, cardId, initial }: CardFormProps) {
|
||||
const router = useRouter()
|
||||
const [question, setQuestion] = useState(initial?.question ?? '')
|
||||
const [response, setResponse] = useState(initial?.response ?? '')
|
||||
const [theme, setTheme] = useState(initial?.theme ?? '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const input: CardInput = { question, response, theme }
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const card = await cardsApi.create(input)
|
||||
router.push(`/cards/${card.id}`)
|
||||
router.refresh()
|
||||
} else if (cardId) {
|
||||
await cardsApi.update(cardId, input)
|
||||
router.push(`/cards/${cardId}`)
|
||||
router.refresh()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : 'Impossible de sauvegarder la carte')
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<p role="alert" className="text-red-600">{error}</p>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="question" className="block text-sm font-medium text-slate-700">
|
||||
Question
|
||||
</label>
|
||||
<input
|
||||
id="question"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
required
|
||||
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="response" className="block text-sm font-medium text-slate-700">
|
||||
Réponse
|
||||
</label>
|
||||
<textarea
|
||||
id="response"
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
required
|
||||
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="theme" className="block text-sm font-medium text-slate-700">
|
||||
Thème
|
||||
</label>
|
||||
<input
|
||||
id="theme"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
required
|
||||
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Enregistrement…' : mode === 'create' ? 'Créer la carte' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user