feat(tags): gestion des tags dans le formulaire carte (max 3, suggestions, normalisation)

- schéma zod : tags normalisés (trim + minuscules + dédupe), 3 max par carte
- couche générique : findTags (MySQL + Mongo), service tags()
- API GET /api/tags + cardsApi.tags() pour les suggestions
- formulaire : saisie + ajout (Entrée/bouton), suggestions des tags existants,
  chips supprimables, compteur n/3, blocage au-delà de 3
- tests : schémas, repositories, service, route, api-client, formulaire
This commit is contained in:
devcodetools committed 2026-09-22 10:18:13 +02:00
1 parent 31283ada26
commit 67569e2d16
18 files changed
+334 -8

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCardService } from '@/services/card-service-factory'
import { GET } from './route'
vi.mock('@/services/card-service-factory', () => ({
getCardService: vi.fn(),
}))
describe('GET /api/tags', () => {
const service = vi.mocked(getCardService)
beforeEach(() => {
service.mockReset()
})
it('renvoie la liste des tags', async () => {
const serviceMock = { tags: vi.fn().mockResolvedValue(['docker', 'scope']) }
service.mockReturnValue(serviceMock as never)
const res = await GET()
expect(res.status).toBe(200)
expect(await res.json()).toEqual(['docker', 'scope'])
})
it('propage les erreurs du service', async () => {
const serviceMock = { tags: vi.fn().mockRejectedValue(new Error('boom')) }
service.mockReturnValue(serviceMock as never)
const res = await GET()
expect(res.status).toBe(500)
})
})
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server'
import { handleError } from '@/lib/http'
import { getCardService } from '@/services/card-service-factory'
export async function GET() {
try {
const tags = await getCardService().tags()
return NextResponse.json(tags)
} catch (error) {
return handleError(error)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ vi.mock('@/lib/api-client', () => {
} }
return { return {
ApiClientError: ApiClientErrorMock, ApiClientError: ApiClientErrorMock,
cardsApi: { get: vi.fn() }, cardsApi: { get: vi.fn(), tags: vi.fn().mockResolvedValue([]) },
} }
}) })
+1 -1
View File
@@ -6,7 +6,7 @@ vi.mock('@/lib/api-client', () => ({
ApiClientError: class extends Error { ApiClientError: class extends Error {
status = 0 status = 0
}, },
cardsApi: { create: vi.fn() }, cardsApi: { create: vi.fn(), tags: vi.fn().mockResolvedValue([]) },
})) }))
vi.mock('next/navigation', () => ({ vi.mock('next/navigation', () => ({
+1 -2
View File
@@ -39,8 +39,7 @@ const runtimeCaching: RuntimeCaching[] = [
}, },
// Payloads RSC des navigations côté client : réseau d'abord // Payloads RSC des navigations côté client : réseau d'abord
{ {
matcher: ({ request, sameOrigin }) => matcher: ({ request, sameOrigin }) => sameOrigin && request.headers.get('rsc') === '1',
sameOrigin && request.headers.get('rsc') === '1',
handler: new NetworkFirst({ handler: new NetworkFirst({
networkTimeoutSeconds: 3, networkTimeoutSeconds: 3,
cacheName: 'rsc-network-first', cacheName: 'rsc-network-first',
+71 -1
View File
@@ -7,7 +7,7 @@ vi.mock('@/lib/api-client', () => ({
ApiClientError: class extends Error { ApiClientError: class extends Error {
status = 0 status = 0
}, },
cardsApi: { create: vi.fn(), update: vi.fn() }, cardsApi: { create: vi.fn(), update: vi.fn(), tags: vi.fn() },
})) }))
const routerMock = { push: vi.fn(), refresh: vi.fn() } const routerMock = { push: vi.fn(), refresh: vi.fn() }
@@ -20,6 +20,8 @@ import { cardsApi } from '@/lib/api-client'
beforeEach(() => { beforeEach(() => {
vi.mocked(cardsApi.create).mockReset() vi.mocked(cardsApi.create).mockReset()
vi.mocked(cardsApi.update).mockReset() vi.mocked(cardsApi.update).mockReset()
vi.mocked(cardsApi.tags).mockReset()
vi.mocked(cardsApi.tags).mockResolvedValue([])
routerMock.push.mockReset() routerMock.push.mockReset()
routerMock.refresh.mockReset() routerMock.refresh.mockReset()
}) })
@@ -35,6 +37,7 @@ it('crée une carte puis navigue vers son détail (F-05)', async () => {
question: 'Cest quoi un commit ?', question: 'Cest quoi un commit ?',
response: 'Un snapshot', response: 'Un snapshot',
theme: 'git', theme: 'git',
tags: [],
}) })
await screen.findByText('Enregistrement…') await screen.findByText('Enregistrement…')
}) })
@@ -57,6 +60,73 @@ it('pré-remplit le formulaire et enregistre une modification (F-06 édition)',
question: 'Nouvelle question', question: 'Nouvelle question',
response: 'Ancienne réponse', response: 'Ancienne réponse',
theme: 'git', theme: 'git',
tags: [],
}) })
expect(routerMock.push).toHaveBeenCalledWith('/cards/c1') expect(routerMock.push).toHaveBeenCalledWith('/cards/c1')
}) })
it('ajoute des tags (saisie + suggestion), applique le max de 3 et les envoie', async () => {
vi.mocked(cardsApi.tags).mockResolvedValue(['git', 'docker'])
vi.mocked(cardsApi.create).mockResolvedValue({ id: 'c1' } as never)
render(<CardForm mode="create" />)
await userEvent.type(screen.getByLabelText('Question'), 'Q ?')
await userEvent.type(screen.getByLabelText('Réponse'), 'R')
await userEvent.type(screen.getByLabelText('Thème'), 'git')
const tagInput = screen.getByLabelText('Ajouter un tag')
await userEvent.type(tagInput, 'gi')
await userEvent.click(await screen.findByRole('button', { name: '+ git' }))
await userEvent.type(tagInput, 'DOCKER')
await userEvent.keyboard('{Enter}')
await userEvent.type(tagInput, 'hook')
await userEvent.keyboard('{Enter}')
expect(screen.getByText('Tags (3/3)')).toBeInTheDocument()
expect(tagInput).toBeDisabled()
await userEvent.type(tagInput, 'extra')
await userEvent.keyboard('{Enter}')
expect(screen.queryByText('#extra')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Créer la carte' }))
expect(vi.mocked(cardsApi.create)).toHaveBeenCalledWith({
question: 'Q ?',
response: 'R',
theme: 'git',
tags: ['git', 'docker', 'hook'],
})
})
it('refuse un tag en doublon et signale une erreur', async () => {
vi.mocked(cardsApi.tags).mockResolvedValue(['git'])
render(<CardForm mode="create" />)
const tagInput = screen.getByLabelText('Ajouter un tag')
await userEvent.type(tagInput, 'git')
await userEvent.keyboard('{Enter}')
await userEvent.type(tagInput, 'GIT')
await userEvent.keyboard('{Enter}')
expect(screen.getByRole('alert')).toHaveTextContent('déjà présent')
expect(screen.getAllByText('#git')).toHaveLength(1)
})
it('pré-remplit les tags en édition et permet d en retirer', async () => {
vi.mocked(cardsApi.update).mockResolvedValue({ id: 'c1' } as never)
render(
<CardForm
mode="edit"
cardId="c1"
initial={{ question: 'A', response: 'R', theme: 'git', tags: ['git', 'scope'] }}
/>,
)
expect(screen.getByText('Tags (2/3)')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Retirer le tag git' }))
expect(screen.getByText('Tags (1/3)')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Enregistrer' }))
expect(vi.mocked(cardsApi.update)).toHaveBeenCalledWith('c1', {
question: 'A',
response: 'R',
theme: 'git',
tags: ['scope'],
})
})
+120 -2
View File
@@ -1,29 +1,89 @@
'use client' 'use client'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useState } from 'react' import { useEffect, useState } from 'react'
import type { CardInput } from '@/domain/card' import type { CardInput } from '@/domain/card'
import { ApiClientError, cardsApi } from '@/lib/api-client' import { ApiClientError, cardsApi } from '@/lib/api-client'
const MAX_TAGS = 3
interface CardFormProps { interface CardFormProps {
mode: 'create' | 'edit' mode: 'create' | 'edit'
cardId?: string cardId?: string
initial?: CardInput initial?: CardInput
} }
function normalizeTag(value: string): string {
return value.trim().toLowerCase()
}
export function CardForm({ mode, cardId, initial }: CardFormProps) { export function CardForm({ mode, cardId, initial }: CardFormProps) {
const router = useRouter() const router = useRouter()
const [question, setQuestion] = useState(initial?.question ?? '') const [question, setQuestion] = useState(initial?.question ?? '')
const [response, setResponse] = useState(initial?.response ?? '') const [response, setResponse] = useState(initial?.response ?? '')
const [theme, setTheme] = useState(initial?.theme ?? '') const [theme, setTheme] = useState(initial?.theme ?? '')
const [tags, setTags] = useState<string[]>(initial?.tags ?? [])
const [tagInput, setTagInput] = useState('')
const [availableTags, setAvailableTags] = useState<string[]>([])
const [tagError, setTagError] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
useEffect(() => {
let cancelled = false
cardsApi
.tags()
.then((data) => {
if (!cancelled) setAvailableTags(data)
})
.catch(() => {
if (!cancelled) setAvailableTags([])
})
return () => {
cancelled = true
}
}, [])
const suggestions = availableTags.filter(
(tag) => tag.includes(normalizeTag(tagInput)) && !tags.includes(tag),
)
function addTag(raw: string) {
const tag = normalizeTag(raw)
if (!tag) return
if (tags.includes(tag)) {
setTagError(`Le tag « ${tag} » est déjà présent`)
return
}
if (tags.length >= MAX_TAGS) {
setTagError(`Maximum ${MAX_TAGS} tags par carte`)
return
}
setTags((current) => [...current, tag])
setTagInput('')
setTagError(null)
}
function removeTag(tag: string) {
setTags((current) => current.filter((t) => t !== tag))
setTagError(null)
}
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault()
addTag(tagInput)
} else if (e.key === 'Escape') {
setTagInput('')
setTagError(null)
}
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setSaving(true) setSaving(true)
setError(null) setError(null)
const input: CardInput = { question, response, theme } const input: CardInput = { question, response, theme, tags }
try { try {
if (mode === 'create') { if (mode === 'create') {
const card = await cardsApi.create(input) const card = await cardsApi.create(input)
@@ -83,6 +143,64 @@ export function CardForm({ mode, cardId, initial }: CardFormProps) {
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2" className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
/> />
</div> </div>
<div>
<span className="block text-sm font-medium text-slate-700">
Tags ({tags.length}/{MAX_TAGS})
</span>
{tags.length > 0 && (
<ul className="mt-1 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<li
key={tag}
className="flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
>
#{tag}
<button
type="button"
aria-label={`Retirer le tag ${tag}`}
onClick={() => removeTag(tag)}
className="text-slate-400 hover:text-slate-700"
>
×
</button>
</li>
))}
</ul>
)}
<input
id="tags"
value={tagInput}
onChange={(e) => {
setTagInput(e.target.value)
setTagError(null)
}}
onKeyDown={handleTagKeyDown}
placeholder="Saisis un tag puis Entrée (ex. git)"
aria-label="Ajouter un tag"
disabled={tags.length >= MAX_TAGS}
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 disabled:opacity-50"
/>
{tagError && (
<p role="alert" className="mt-1 text-sm text-red-600">
{tagError}
</p>
)}
{suggestions.length > 0 && (
<ul className="mt-2 flex flex-wrap gap-1.5">
{suggestions.slice(0, 6).map((tag) => (
<li key={tag}>
<button
type="button"
onClick={() => addTag(tag)}
className="rounded-full border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50"
>
+ {tag}
</button>
</li>
))}
</ul>
)}
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="submit" type="submit"
+1
View File
@@ -9,4 +9,5 @@ export interface CardRepository {
findRandom(): Promise<Card | null> findRandom(): Promise<Card | null>
findRandomByTheme(theme: string): Promise<Card | null> findRandomByTheme(theme: string): Promise<Card | null>
findThemes(): Promise<string[]> findThemes(): Promise<string[]>
findTags(): Promise<string[]>
} }
+35
View File
@@ -27,6 +27,41 @@ describe('createCardSchema', () => {
it('rejette un thème manquant', () => { it('rejette un thème manquant', () => {
expect(() => createCardSchema.parse({ question: 'Q', response: 'R' })).toThrow() expect(() => createCardSchema.parse({ question: 'Q', response: 'R' })).toThrow()
}) })
it('normalise les tags (trim + minuscules) et supprime les doublons', () => {
const result = createCardSchema.parse({
question: 'Q',
response: 'R',
theme: 'git',
tags: [' Git ', 'git', 'HOOK', 'hook'],
})
expect(result.tags).toEqual(['git', 'hook'])
})
it('rejette plus de 3 tags', () => {
expect(() =>
createCardSchema.parse({
question: 'Q',
response: 'R',
theme: 'git',
tags: ['a', 'b', 'c', 'd'],
}),
).toThrow()
})
it('rejette un tag vide ou trop long', () => {
expect(() =>
createCardSchema.parse({ question: 'Q', response: 'R', theme: 'git', tags: [' '] }),
).toThrow()
expect(() =>
createCardSchema.parse({
question: 'Q',
response: 'R',
theme: 'git',
tags: ['x'.repeat(31)],
}),
).toThrow()
})
}) })
describe('updateCardSchema', () => { describe('updateCardSchema', () => {
+12 -1
View File
@@ -1,10 +1,21 @@
import { z } from 'zod' import { z } from 'zod'
const tagSchema = z
.string()
.trim()
.toLowerCase()
.min(1, 'Tag vide')
.max(30, 'Tag trop long (30 caractères max)')
export const createCardSchema = z.object({ export const createCardSchema = z.object({
question: z.string().trim().min(1, 'La question est requise').max(1000, 'Question trop longue'), question: z.string().trim().min(1, 'La question est requise').max(1000, 'Question trop longue'),
response: z.string().trim().min(1, 'La réponse est requise').max(2000, 'Réponse trop longue'), response: z.string().trim().min(1, 'La réponse est requise').max(2000, 'Réponse trop longue'),
theme: z.string().trim().min(1, 'Le thème est requis').max(50, 'Thème trop long'), theme: z.string().trim().min(1, 'Le thème est requis').max(50, 'Thème trop long'),
tags: z.array(z.string().trim().min(1, 'Tag vide')).max(50, 'Trop de tags').optional(), tags: z
.array(tagSchema)
.refine((tags) => new Set(tags).size <= 3, 'Maximum 3 tags par carte')
.transform((tags) => [...new Set(tags)])
.optional(),
}) })
export const updateCardSchema = createCardSchema.partial() export const updateCardSchema = createCardSchema.partial()
+6
View File
@@ -74,6 +74,12 @@ describe('cardsApi', () => {
expectRequest('GET', '/api/themes') expectRequest('GET', '/api/themes')
}) })
it('tags() appelle GET /api/tags', async () => {
fetchMock.mockResolvedValue(jsonResponse(['scope', 'fonction']))
await expect(cardsApi.tags()).resolves.toEqual(['scope', 'fonction'])
expectRequest('GET', '/api/tags')
})
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( fetchMock.mockResolvedValue(
jsonResponse({ error: 'NotFoundError', message: 'Carte inconnue' }, 404), jsonResponse({ error: 'NotFoundError', message: 'Carte inconnue' }, 404),
+1
View File
@@ -55,4 +55,5 @@ export const cardsApi = {
random: (theme?: string) => random: (theme?: string) =>
request<Card>(`/api/cards/random${theme ? `?theme=${encodeURIComponent(theme)}` : ''}`), request<Card>(`/api/cards/random${theme ? `?theme=${encodeURIComponent(theme)}` : ''}`),
themes: () => request<string[]>('/api/themes'), themes: () => request<string[]>('/api/themes'),
tags: () => request<string[]>('/api/tags'),
} }
@@ -141,4 +141,11 @@ describe('MongoCardRepository', () => {
collection.distinct.mockResolvedValueOnce(['react', 'docker']) collection.distinct.mockResolvedValueOnce(['react', 'docker'])
await expect(repo.findThemes()).resolves.toEqual(['docker', 'react']) await expect(repo.findThemes()).resolves.toEqual(['docker', 'react'])
}) })
it('findTags renvoie les tags distincts triés', async () => {
const { repo, collection } = setup()
collection.distinct.mockResolvedValueOnce(['scope', 'docker'])
await expect(repo.findTags()).resolves.toEqual(['docker', 'scope'])
expect(collection.distinct).toHaveBeenCalledWith('tags')
})
}) })
@@ -124,4 +124,9 @@ export class MongoCardRepository implements CardRepository {
const themes: string[] = await this.collection.distinct('theme') const themes: string[] = await this.collection.distinct('theme')
return themes.sort() return themes.sort()
} }
async findTags(): Promise<string[]> {
const tags = await this.collection.distinct('tags')
return tags.map((tag) => String(tag)).sort()
}
} }
@@ -154,4 +154,12 @@ describe('MysqlCardRepository', () => {
query.mockResolvedValueOnce([[{ theme: 'docker' }, { theme: 'javascript' }]]) query.mockResolvedValueOnce([[{ theme: 'docker' }, { theme: 'javascript' }]])
await expect(repo.findThemes()).resolves.toEqual(['docker', 'javascript']) await expect(repo.findThemes()).resolves.toEqual(['docker', 'javascript'])
}) })
it('findTags agrège les tags distincts et les trie', async () => {
const { repo, query } = setup()
query.mockResolvedValueOnce([
[{ tags: '["scope","git"]' }, { tags: null }, { tags: ['docker', 'git'] }],
])
await expect(repo.findTags()).resolves.toEqual(['docker', 'git', 'scope'])
})
}) })
+11
View File
@@ -147,4 +147,15 @@ export class MysqlCardRepository implements CardRepository {
) )
return rows.map((row) => row.theme as string) return rows.map((row) => row.theme as string)
} }
async findTags(): Promise<string[]> {
const [rows] = await this.pool.query<RowDataPacket[]>('SELECT tags FROM cards')
const tags = new Set<string>()
for (const row of rows) {
for (const tag of this.parseTags(row.tags)) {
tags.add(tag)
}
}
return [...tags].sort()
}
} }
+7
View File
@@ -27,6 +27,7 @@ function setup() {
findRandom: vi.fn(), findRandom: vi.fn(),
findRandomByTheme: vi.fn(), findRandomByTheme: vi.fn(),
findThemes: vi.fn(), findThemes: vi.fn(),
findTags: vi.fn(),
} }
const service = new CardService(repository as unknown as CardRepository) const service = new CardService(repository as unknown as CardRepository)
return { service, repository } return { service, repository }
@@ -112,4 +113,10 @@ describe('CardService', () => {
repository.findThemes.mockResolvedValueOnce(['docker', 'git']) repository.findThemes.mockResolvedValueOnce(['docker', 'git'])
await expect(service.themes()).resolves.toEqual(['docker', 'git']) await expect(service.themes()).resolves.toEqual(['docker', 'git'])
}) })
it('tags renvoie la liste des tags', async () => {
const { service, repository } = setup()
repository.findTags.mockResolvedValueOnce(['fonction', 'scope'])
await expect(service.tags()).resolves.toEqual(['fonction', 'scope'])
})
}) })
+4
View File
@@ -51,4 +51,8 @@ export class CardService {
async themes(): Promise<string[]> { async themes(): Promise<string[]> {
return this.repository.findThemes() return this.repository.findThemes()
} }
async tags(): Promise<string[]> {
return this.repository.findTags()
}
} }