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:
1 parent
b030896e06
commit
9224fe003d
3 files changed
+158
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user