fix(ui): directives use client et correctifs qualité (lint, format, typecheck, tests)
This commit is contained in:
1 parent
9a62743e6f
commit
5f4596578f
11 files changed
+77
-38
No files matched your search
@@ -7,11 +7,7 @@ import { ApiClientError, cardsApi } from '@/lib/api-client'
|
|||||||
|
|
||||||
export const metadata: Metadata = { title: 'Modifier la carte' }
|
export const metadata: Metadata = { title: 'Modifier la carte' }
|
||||||
|
|
||||||
export default async function EditCardPage({
|
export default async function EditCardPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>
|
|
||||||
}) {
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
let card: Card | null = null
|
let card: Card | null = null
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ export const metadata: Metadata = {
|
|||||||
title: 'Carte',
|
title: 'Carte',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CardDetailPage({
|
export default async function CardDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>
|
|
||||||
}) {
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { render, screen, within } from '@testing-library/react'
|
import { render, screen, within } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { CardDeleteButton } from './card-delete'
|
import { CardDeleteButton } from './card-delete'
|
||||||
|
|
||||||
vi.mock('@/lib/api-client', () => {
|
vi.mock('@/lib/api-client', () => {
|
||||||
@@ -52,7 +52,9 @@ it('supprime après confirmation puis retourne à la liste (F-07)', async () =>
|
|||||||
vi.mocked(cardsApi.remove).mockResolvedValue(undefined as never)
|
vi.mocked(cardsApi.remove).mockResolvedValue(undefined as never)
|
||||||
render(<CardDeleteButton cardId="c1" question="question-x" />)
|
render(<CardDeleteButton cardId="c1" question="question-x" />)
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Supprimer' }))
|
await userEvent.click(screen.getByRole('button', { name: 'Supprimer' }))
|
||||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Supprimer' }))
|
await userEvent.click(
|
||||||
|
within(screen.getByRole('dialog')).getByRole('button', { name: 'Supprimer' }),
|
||||||
|
)
|
||||||
expect(cardsApi.remove).toHaveBeenCalledWith('c1')
|
expect(cardsApi.remove).toHaveBeenCalledWith('c1')
|
||||||
expect(routerMock.push).toHaveBeenCalledWith('/cards')
|
expect(routerMock.push).toHaveBeenCalledWith('/cards')
|
||||||
expect(routerMock.refresh).toHaveBeenCalled()
|
expect(routerMock.refresh).toHaveBeenCalled()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { CardDetail } from './card-detail'
|
import { CardDetail } from './card-detail'
|
||||||
|
|
||||||
vi.mock('@/lib/api-client', () => {
|
vi.mock('@/lib/api-client', () => {
|
||||||
@@ -27,6 +27,8 @@ const CARD = {
|
|||||||
response: 'Un snapshot du projet',
|
response: 'Un snapshot du projet',
|
||||||
theme: 'git',
|
theme: 'git',
|
||||||
tags: ['snapshot'],
|
tags: ['snapshot'],
|
||||||
|
createdAt: new Date('2026-01-01T08:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T08:00:00Z'),
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Card } from '@/domain/card'
|
import type { Card } from '@/domain/card'
|
||||||
@@ -25,7 +27,11 @@ export function CardDetail({ cardId }: { cardId: string }) {
|
|||||||
}, [cardId])
|
}, [cardId])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <p role="alert" className="text-red-600">{error}</p>
|
return (
|
||||||
|
<p role="alert" className="text-red-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (card === null) {
|
if (card === null) {
|
||||||
return <p aria-busy="true">Chargement…</p>
|
return <p aria-busy="true">Chargement…</p>
|
||||||
@@ -46,21 +52,24 @@ export function CardDetail({ cardId }: { cardId: string }) {
|
|||||||
{card.tags.length > 0 && (
|
{card.tags.length > 0 && (
|
||||||
<ul className="flex flex-wrap gap-1.5">
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
{card.tags.map((tag) => (
|
{card.tags.map((tag) => (
|
||||||
<li
|
<li key={tag} className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
|
||||||
key={tag}
|
|
||||||
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
|
||||||
>
|
|
||||||
#{tag}
|
#{tag}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<Link
|
||||||
|
href={`/cards/${card.id}/edit`}
|
||||||
|
className="text-sm font-medium text-slate-700 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
Modifier
|
Modifier
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-slate-300">·</span>
|
<span className="text-slate-300">·</span>
|
||||||
<Link href="/cards" className="text-sm font-medium text-slate-700 underline-offset-4 hover:underline">
|
<Link
|
||||||
|
href="/cards"
|
||||||
|
className="text-sm font-medium text-slate-700 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
Retour à la liste
|
Retour à la liste
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { CardForm } from './card-form'
|
import { CardForm } from './card-form'
|
||||||
|
|
||||||
vi.mock('@/lib/api-client', () => ({
|
vi.mock('@/lib/api-client', () => ({
|
||||||
ApiClientError: class extends Error { status = 0 },
|
ApiClientError: class extends Error {
|
||||||
|
status = 0
|
||||||
|
},
|
||||||
cardsApi: { create: vi.fn(), update: vi.fn() },
|
cardsApi: { create: vi.fn(), update: vi.fn() },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export function CardForm({ mode, cardId, initial }: CardFormProps) {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-red-600">{error}</p>
|
<p role="alert" className="text-red-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="question" className="block text-sm font-medium text-slate-700">
|
<label htmlFor="question" className="block text-sm font-medium text-slate-700">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render, screen, within } from '@testing-library/react'
|
import { render, screen, within } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
import { expect, it, vi, beforeEach } from 'vitest'
|
||||||
import { CardList } from './card-list'
|
import { CardList } from './card-list'
|
||||||
|
|
||||||
vi.mock('@/lib/api-client', () => {
|
vi.mock('@/lib/api-client', () => {
|
||||||
@@ -22,8 +22,24 @@ vi.mock('@/lib/api-client', () => {
|
|||||||
import { cardsApi, ApiClientError } from '@/lib/api-client'
|
import { cardsApi, ApiClientError } from '@/lib/api-client'
|
||||||
|
|
||||||
const CARDS = [
|
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() },
|
id: '1',
|
||||||
|
question: 'Cest quoi un commit ?',
|
||||||
|
response: 'Un snapshot du projet',
|
||||||
|
theme: 'git',
|
||||||
|
tags: [],
|
||||||
|
createdAt: new Date('2026-01-01T08:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T08:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
question: 'Cest quoi une closure ?',
|
||||||
|
response: 'Une fonction + son scope',
|
||||||
|
theme: 'javascript',
|
||||||
|
tags: [],
|
||||||
|
createdAt: new Date('2026-01-01T08:00:00Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T08:00:00Z'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -45,7 +61,7 @@ it('affiche un lien vers le détail de chaque carte', async () => {
|
|||||||
expect(link).toHaveAttribute('href', '/cards/1')
|
expect(link).toHaveAttribute('href', '/cards/1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it("affiche le thème de chaque carte", async () => {
|
it('affiche le thème de chaque carte', async () => {
|
||||||
vi.mocked(cardsApi.list).mockResolvedValue(CARDS)
|
vi.mocked(cardsApi.list).mockResolvedValue(CARDS)
|
||||||
render(<CardList />)
|
render(<CardList />)
|
||||||
const item = (await screen.findByText('Cest quoi un commit ?')).closest('li')
|
const item = (await screen.findByText('Cest quoi un commit ?')).closest('li')
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||||
@@ -39,7 +41,11 @@ export function CardList() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <p role="alert" className="text-red-600">{error}</p>
|
return (
|
||||||
|
<p role="alert" className="text-red-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (cards === null) {
|
if (cards === null) {
|
||||||
return <p aria-busy="true">Chargement…</p>
|
return <p aria-busy="true">Chargement…</p>
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function expectRequest(method: string, path: string) {
|
function expectRequest(method: string, path: string) {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(path, expect.objectContaining({ method }))
|
||||||
path,
|
|
||||||
expect.objectContaining({ method }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('cardsApi', () => {
|
describe('cardsApi', () => {
|
||||||
@@ -43,8 +40,10 @@ describe('cardsApi', () => {
|
|||||||
|
|
||||||
it('create() envoie POST avec un corps JSON', async () => {
|
it('create() envoie POST avec un corps JSON', async () => {
|
||||||
fetchMock.mockResolvedValue(jsonResponse(CARD, 201))
|
fetchMock.mockResolvedValue(jsonResponse(CARD, 201))
|
||||||
await expect(cardsApi.create({ question: 'Q ?', response: 'R', theme: 'git' } as never)).resolves.toEqual(CARD)
|
await expect(
|
||||||
const [_, init] = fetchMock.mock.calls[0]
|
cardsApi.create({ question: 'Q ?', response: 'R', theme: 'git' } as never),
|
||||||
|
).resolves.toEqual(CARD)
|
||||||
|
const [, init] = fetchMock.mock.calls[0]
|
||||||
expect(init.method).toBe('POST')
|
expect(init.method).toBe('POST')
|
||||||
expect(JSON.parse(init.body)).toEqual({ question: 'Q ?', response: 'R', theme: 'git' })
|
expect(JSON.parse(init.body)).toEqual({ question: 'Q ?', response: 'R', theme: 'git' })
|
||||||
})
|
})
|
||||||
@@ -76,7 +75,9 @@ describe('cardsApi', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('lève ApiClientError avec le message de l API sur une erreur 404', async () => {
|
it('lève ApiClientError avec le message de l API sur une erreur 404', async () => {
|
||||||
fetchMock.mockResolvedValue(jsonResponse({ error: 'NotFoundError', message: 'Carte inconnue' }, 404))
|
fetchMock.mockResolvedValue(
|
||||||
|
jsonResponse({ error: 'NotFoundError', message: 'Carte inconnue' }, 404),
|
||||||
|
)
|
||||||
await expect(cardsApi.get('nope')).rejects.toMatchObject({
|
await expect(cardsApi.get('nope')).rejects.toMatchObject({
|
||||||
name: 'NotFoundError',
|
name: 'NotFoundError',
|
||||||
message: 'Carte inconnue',
|
message: 'Carte inconnue',
|
||||||
@@ -85,7 +86,9 @@ describe('cardsApi', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('lève ApiClientError sur une réponse invalide', async () => {
|
it('lève ApiClientError sur une réponse invalide', async () => {
|
||||||
fetchMock.mockResolvedValue(jsonResponse({ error: 'ValidationError', message: 'Payload invalide' }, 400))
|
fetchMock.mockResolvedValue(
|
||||||
|
jsonResponse({ error: 'ValidationError', message: 'Payload invalide' }, 400),
|
||||||
|
)
|
||||||
await expect(cardsApi.create({} as never)).rejects.toBeInstanceOf(ApiClientError)
|
await expect(cardsApi.create({} as never)).rejects.toBeInstanceOf(ApiClientError)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
const body: T | ApiErrorPayload = await res.json().catch(() => ({}))
|
const body: T | ApiErrorPayload = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const payload = body as ApiErrorPayload
|
const payload = body as ApiErrorPayload
|
||||||
throw new ApiClientError(res.status, payload.error ?? 'HttpError', payload.message ?? `Erreur ${res.status}`)
|
throw new ApiClientError(
|
||||||
|
res.status,
|
||||||
|
payload.error ?? 'HttpError',
|
||||||
|
payload.message ?? `Erreur ${res.status}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return body as T
|
return body as T
|
||||||
}
|
}
|
||||||
@@ -37,7 +41,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
export const cardsApi = {
|
export const cardsApi = {
|
||||||
list: () => request<Card[]>('/api/cards'),
|
list: () => request<Card[]>('/api/cards'),
|
||||||
get: (id: string) => request<Card>(`/api/cards/${id}`),
|
get: (id: string) => request<Card>(`/api/cards/${id}`),
|
||||||
create: (input: CardInput) => request<Card>('/api/cards', { method: 'POST', body: JSON.stringify(input) }),
|
create: (input: CardInput) =>
|
||||||
|
request<Card>('/api/cards', { method: 'POST', body: JSON.stringify(input) }),
|
||||||
update: (id: string, input: CardInput) =>
|
update: (id: string, input: CardInput) =>
|
||||||
request<Card>(`/api/cards/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
|
request<Card>(`/api/cards/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
|
||||||
remove: (id: string) => request<void>(`/api/cards/${id}`, { method: 'DELETE' }),
|
remove: (id: string) => request<void>(`/api/cards/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
Reference in new issue
Block a user