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