diff --git a/src/app/api/tags/route.test.ts b/src/app/api/tags/route.test.ts new file mode 100644 index 0000000..c02f83e --- /dev/null +++ b/src/app/api/tags/route.test.ts @@ -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) + }) +}) diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts new file mode 100644 index 0000000..69eef69 --- /dev/null +++ b/src/app/api/tags/route.ts @@ -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) + } +} diff --git a/src/app/cards/[id]/edit/page.test.tsx b/src/app/cards/[id]/edit/page.test.tsx index 0ccdcac..5401fb4 100644 --- a/src/app/cards/[id]/edit/page.test.tsx +++ b/src/app/cards/[id]/edit/page.test.tsx @@ -13,7 +13,7 @@ vi.mock('@/lib/api-client', () => { } return { ApiClientError: ApiClientErrorMock, - cardsApi: { get: vi.fn() }, + cardsApi: { get: vi.fn(), tags: vi.fn().mockResolvedValue([]) }, } }) diff --git a/src/app/cards/new/page.test.tsx b/src/app/cards/new/page.test.tsx index 2aee1d5..719e763 100644 --- a/src/app/cards/new/page.test.tsx +++ b/src/app/cards/new/page.test.tsx @@ -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', () => ({ diff --git a/src/app/sw.ts b/src/app/sw.ts index c0bb34a..62896f2 100644 --- a/src/app/sw.ts +++ b/src/app/sw.ts @@ -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', diff --git a/src/components/card-form.test.tsx b/src/components/card-form.test.tsx index 70ba592..e96132f 100644 --- a/src/components/card-form.test.tsx +++ b/src/components/card-form.test.tsx @@ -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() + 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() + 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( + , + ) + 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'], + }) +}) diff --git a/src/components/card-form.tsx b/src/components/card-form.tsx index ddae030..a207228 100644 --- a/src/components/card-form.tsx +++ b/src/components/card-form.tsx @@ -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(initial?.tags ?? []) + const [tagInput, setTagInput] = useState('') + const [availableTags, setAvailableTags] = useState([]) + const [tagError, setTagError] = useState(null) const [error, setError] = useState(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) { + 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" /> +
+ + Tags ({tags.length}/{MAX_TAGS}) + + {tags.length > 0 && ( +
    + {tags.map((tag) => ( +
  • + #{tag} + +
  • + ))} +
+ )} + { + 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 && ( +

+ {tagError} +

+ )} + {suggestions.length > 0 && ( +
    + {suggestions.slice(0, 6).map((tag) => ( +
  • + +
  • + ))} +
+ )} +