feat(ui): liste des cartes cliquable avec thème (F-08)
Composant CardList : charge les cartes via cardsApi.list(), affiche question + thème, chaque entrée est un lien vers le détail. Gère les états chargement, vide et erreur (ApiClientError).
This commit is contained in:
1 parent
405ce6e603
commit
d82a82baf3
2 files changed
+133
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { CardList } from './card-list'
|
||||
|
||||
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: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { cardsApi, ApiClientError } from '@/lib/api-client'
|
||||
|
||||
const CARDS = [
|
||||
{ id: '1', question: 'Cest quoi un commit ?', response: 'Un snapshot du projet', theme: 'git', tags: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
{ id: '2', question: 'Cest quoi une closure ?', response: 'Une fonction + son scope', theme: 'javascript', tags: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(cardsApi.list).mockReset()
|
||||
})
|
||||
|
||||
it('affiche le nom de chaque carte', async () => {
|
||||
vi.mocked(cardsApi.list).mockResolvedValue(CARDS)
|
||||
render(<CardList />)
|
||||
const question = await screen.findByText('Cest quoi un commit ?')
|
||||
expect(question).toBeInTheDocument()
|
||||
expect(screen.getByText('Cest quoi une closure ?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('affiche un lien vers le détail de chaque carte', async () => {
|
||||
vi.mocked(cardsApi.list).mockResolvedValue(CARDS)
|
||||
render(<CardList />)
|
||||
const link = await screen.findByRole('link', { name: /Cest quoi un commit \?/ })
|
||||
expect(link).toHaveAttribute('href', '/cards/1')
|
||||
})
|
||||
|
||||
it("affiche le thème de chaque carte", async () => {
|
||||
vi.mocked(cardsApi.list).mockResolvedValue(CARDS)
|
||||
render(<CardList />)
|
||||
const item = (await screen.findByText('Cest quoi un commit ?')).closest('li')
|
||||
expect(within(item as HTMLElement).getByText('git')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('affiche un message si aucune carte', async () => {
|
||||
vi.mocked(cardsApi.list).mockResolvedValue([])
|
||||
render(<CardList />)
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Aucune carte')
|
||||
})
|
||||
|
||||
it('affiche un message derreur si le chargement échoue', async () => {
|
||||
vi.mocked(cardsApi.list).mockRejectedValue(new ApiClientError(500, 'InternalServerError', 'Boom'))
|
||||
render(<CardList />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Boom')
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||
|
||||
interface ListedCard {
|
||||
id: string
|
||||
question: string
|
||||
theme: string
|
||||
}
|
||||
|
||||
export function CardList() {
|
||||
const [cards, setCards] = useState<ListedCard[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
cardsApi
|
||||
.list()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setCards(
|
||||
data.map((card) => ({
|
||||
id: card.id,
|
||||
question: card.question,
|
||||
theme: card.theme,
|
||||
})),
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof ApiClientError ? e.message : 'Impossible de charger les cartes')
|
||||
setCards([])
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return <p role="alert" className="text-red-600">{error}</p>
|
||||
}
|
||||
if (cards === null) {
|
||||
return <p aria-busy="true">Chargement…</p>
|
||||
}
|
||||
if (cards.length === 0) {
|
||||
return <p role="status">Aucune carte pour le moment.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-slate-200">
|
||||
{cards.map((card) => (
|
||||
<li key={card.id}>
|
||||
<Link
|
||||
href={`/cards/${card.id}`}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-slate-50"
|
||||
>
|
||||
<span className="font-medium">{card.question}</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
|
||||
{card.theme}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user