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