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:
1 parent
31283ada26
commit
67569e2d16
18 files changed
+334
-8
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ vi.mock('@/lib/api-client', () => {
|
||||
}
|
||||
return {
|
||||
ApiClientError: ApiClientErrorMock,
|
||||
cardsApi: { get: vi.fn() },
|
||||
cardsApi: { get: vi.fn(), tags: vi.fn().mockResolvedValue([]) },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ vi.mock('@/lib/api-client', () => ({
|
||||
ApiClientError: class extends Error {
|
||||
status = 0
|
||||
},
|
||||
cardsApi: { create: vi.fn() },
|
||||
cardsApi: { create: vi.fn(), tags: vi.fn().mockResolvedValue([]) },
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
|
||||
+1
-2
@@ -39,8 +39,7 @@ const runtimeCaching: RuntimeCaching[] = [
|
||||
},
|
||||
// Payloads RSC des navigations côté client : réseau d'abord
|
||||
{
|
||||
matcher: ({ request, sameOrigin }) =>
|
||||
sameOrigin && request.headers.get('rsc') === '1',
|
||||
matcher: ({ request, sameOrigin }) => sameOrigin && request.headers.get('rsc') === '1',
|
||||
handler: new NetworkFirst({
|
||||
networkTimeoutSeconds: 3,
|
||||
cacheName: 'rsc-network-first',
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock('@/lib/api-client', () => ({
|
||||
ApiClientError: class extends Error {
|
||||
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() }
|
||||
@@ -20,6 +20,8 @@ import { cardsApi } from '@/lib/api-client'
|
||||
beforeEach(() => {
|
||||
vi.mocked(cardsApi.create).mockReset()
|
||||
vi.mocked(cardsApi.update).mockReset()
|
||||
vi.mocked(cardsApi.tags).mockReset()
|
||||
vi.mocked(cardsApi.tags).mockResolvedValue([])
|
||||
routerMock.push.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 ?',
|
||||
response: 'Un snapshot',
|
||||
theme: 'git',
|
||||
tags: [],
|
||||
})
|
||||
await screen.findByText('Enregistrement…')
|
||||
})
|
||||
@@ -57,6 +60,73 @@ it('pré-remplit le formulaire et enregistre une modification (F-06 édition)',
|
||||
question: 'Nouvelle question',
|
||||
response: 'Ancienne réponse',
|
||||
theme: 'git',
|
||||
tags: [],
|
||||
})
|
||||
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'],
|
||||
})
|
||||
})
|
||||
@@ -1,29 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CardInput } from '@/domain/card'
|
||||
import { ApiClientError, cardsApi } from '@/lib/api-client'
|
||||
|
||||
const MAX_TAGS = 3
|
||||
|
||||
interface CardFormProps {
|
||||
mode: 'create' | 'edit'
|
||||
cardId?: string
|
||||
initial?: CardInput
|
||||
}
|
||||
|
||||
function normalizeTag(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function CardForm({ mode, cardId, initial }: CardFormProps) {
|
||||
const router = useRouter()
|
||||
const [question, setQuestion] = useState(initial?.question ?? '')
|
||||
const [response, setResponse] = useState(initial?.response ?? '')
|
||||
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 [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) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const input: CardInput = { question, response, theme }
|
||||
const input: CardInput = { question, response, theme, tags }
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -9,4 +9,5 @@ export interface CardRepository {
|
||||
findRandom(): Promise<Card | null>
|
||||
findRandomByTheme(theme: string): Promise<Card | null>
|
||||
findThemes(): Promise<string[]>
|
||||
findTags(): Promise<string[]>
|
||||
}
|
||||
@@ -27,6 +27,41 @@ describe('createCardSchema', () => {
|
||||
it('rejette un thème manquant', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
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({
|
||||
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'),
|
||||
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()
|
||||
@@ -74,6 +74,12 @@ describe('cardsApi', () => {
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ error: 'NotFoundError', message: 'Carte inconnue' }, 404),
|
||||
|
||||
@@ -55,4 +55,5 @@ export const cardsApi = {
|
||||
random: (theme?: string) =>
|
||||
request<Card>(`/api/cards/random${theme ? `?theme=${encodeURIComponent(theme)}` : ''}`),
|
||||
themes: () => request<string[]>('/api/themes'),
|
||||
tags: () => request<string[]>('/api/tags'),
|
||||
}
|
||||
@@ -141,4 +141,11 @@ describe('MongoCardRepository', () => {
|
||||
collection.distinct.mockResolvedValueOnce(['react', 'docker'])
|
||||
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')
|
||||
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' }]])
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -147,4 +147,15 @@ export class MysqlCardRepository implements CardRepository {
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ function setup() {
|
||||
findRandom: vi.fn(),
|
||||
findRandomByTheme: vi.fn(),
|
||||
findThemes: vi.fn(),
|
||||
findTags: vi.fn(),
|
||||
}
|
||||
const service = new CardService(repository as unknown as CardRepository)
|
||||
return { service, repository }
|
||||
@@ -112,4 +113,10 @@ describe('CardService', () => {
|
||||
repository.findThemes.mockResolvedValueOnce(['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'])
|
||||
})
|
||||
})
|
||||
@@ -51,4 +51,8 @@ export class CardService {
|
||||
async themes(): Promise<string[]> {
|
||||
return this.repository.findThemes()
|
||||
}
|
||||
|
||||
async tags(): Promise<string[]> {
|
||||
return this.repository.findTags()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user