feat(ui): page détail d'une carte avec édition (F-09)

CardDetail affiche question, réponse révélée, thème et tags, avec liens
Modifier et retour à la liste. Page /cards/[id] qui la monte.
This commit is contained in:
devcodetools committed 2026-09-21 18:45:40 +02:00
1 parent b030896e06
commit 9224fe003d
3 files changed
+158

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import { CardDetail } from '@/components/card-detail'
export const metadata: Metadata = {
title: 'Carte',
}
export default async function CardDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return (
<main className="mx-auto max-w-3xl px-4 py-8">
<CardDetail cardId={id} />
</main>
)
}
+70
View File
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CardDetail } from './card-detail'
vi.mock('@/lib/api-client', () => {
class ApiClientErrorMock extends Error {
status: number
constructor(status: number, name: string, message: string) {
super(message)
this.name = name
this.status = status
}
}
return {
ApiClientError: ApiClientErrorMock,
cardsApi: {
get: vi.fn(),
},
}
})
import { cardsApi, ApiClientError } from '@/lib/api-client'
const CARD = {
id: '1',
question: 'Cest quoi un commit ?',
response: 'Un snapshot du projet',
theme: 'git',
tags: ['snapshot'],
}
beforeEach(() => {
vi.mocked(cardsApi.get).mockReset()
})
it('affiche la question, la réponse et le thème', async () => {
vi.mocked(cardsApi.get).mockResolvedValue(CARD)
render(<CardDetail cardId="1" />)
expect(await screen.findByRole('heading', { name: 'Cest quoi un commit ?' })).toBeInTheDocument()
expect(screen.getByText('Un snapshot du projet')).toBeInTheDocument()
expect(screen.getByText('git')).toBeInTheDocument()
})
it('affiche les tags de la carte', async () => {
vi.mocked(cardsApi.get).mockResolvedValue(CARD)
render(<CardDetail cardId="1" />)
expect(await screen.findByText('#snapshot')).toBeInTheDocument()
})
it('affiche un lien modifier vers la page dedition', async () => {
vi.mocked(cardsApi.get).mockResolvedValue(CARD)
render(<CardDetail cardId="1" />)
const link = await screen.findByRole('link', { name: 'Modifier' })
expect(link).toHaveAttribute('href', '/cards/1/edit')
})
it('affiche un lien retour vers la liste', async () => {
vi.mocked(cardsApi.get).mockResolvedValue(CARD)
render(<CardDetail cardId="1" />)
const link = await screen.findByRole('link', { name: 'Retour à la liste' })
expect(link).toHaveAttribute('href', '/cards')
})
it('affiche un message derreur si la carte est introuvable', async () => {
vi.mocked(cardsApi.get).mockRejectedValue(
new ApiClientError(404, 'NotFoundError', 'Carte introuvable'),
)
render(<CardDetail cardId="inconnu" />)
expect(await screen.findByRole('alert')).toHaveTextContent('Carte introuvable')
})
+69
View File
@@ -0,0 +1,69 @@
import Link from 'next/link'
import { useEffect, useState } from 'react'
import type { Card } from '@/domain/card'
import { ApiClientError, cardsApi } from '@/lib/api-client'
export function CardDetail({ cardId }: { cardId: string }) {
const [card, setCard] = useState<Card | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
cardsApi
.get(cardId)
.then((data) => {
if (!cancelled) setCard(data)
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof ApiClientError ? e.message : 'Impossible de charger la carte')
}
})
return () => {
cancelled = true
}
}, [cardId])
if (error) {
return <p role="alert" className="text-red-600">{error}</p>
}
if (card === null) {
return <p aria-busy="true">Chargement…</p>
}
return (
<article className="space-y-6">
<div>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{card.theme}
</span>
<h1 className="mt-2 text-2xl font-bold text-slate-900">{card.question}</h1>
</div>
<div className="rounded-lg border border-slate-200 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Réponse</h2>
<p className="mt-1 text-slate-800">{card.response}</p>
</div>
{card.tags.length > 0 && (
<ul className="flex flex-wrap gap-1.5">
{card.tags.map((tag) => (
<li
key={tag}
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
>
#{tag}
</li>
))}
</ul>
)}
<div className="flex items-center gap-3">
<Link href={`/cards/${card.id}/edit`} className="text-sm font-medium text-slate-700 underline-offset-4 hover:underline">
Modifier
</Link>
<span className="text-slate-300">·</span>
<Link href="/cards" className="text-sm font-medium text-slate-700 underline-offset-4 hover:underline">
Retour à la liste
</Link>
</div>
</article>
)
}