Compare commits

..
10 Commits
Author SHA1 Message Date
devcodetools bdf119eb0d docs(diagrams): export PNG des diagrammes (contexte, conteneurs, classes, séquence, cas d'utilisation) 2026-09-22 11:14:06 +02:00
devcodetools 5c04defe31 docs(diagrams): nommage du diagramme de séquence (startuml) 2026-09-22 11:02:03 +02:00
devcodetools f6b4f7f79b docs(diagrams): nommage du diagramme de classes (startuml) 2026-09-22 11:00:50 +02:00
devcodetools 8a576f6884 docs(diagrams): diagrammes C4 avec C4-PlantUML (stdlib, sans légende) 2026-09-22 10:56:16 +02:00
devcodetools bf2c9ae4fb feat(ui): afficher les tags dans la liste des cartes 2026-09-22 10:22:10 +02:00
devcodetools 67569e2d16 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
2026-09-22 10:18:13 +02:00
devcodetools 31283ada26 feat(pwa): hors-ligne (app shell + lecture API) et suppression depuis la page d'édition
- sw.ts : API GET, navigation et payloads RSC en network-first (cache en
  secours hors-ligne) ; écritures non interceptées ; fallback ~offline
- edit page : bouton Supprimer avec confirmation (CardDeleteButton)
2026-09-22 10:04:47 +02:00
devcodetools ba5bd8288d style(docs): ligne vide ajoutée par prettier (TODO) 2026-09-22 09:46:29 +02:00
devcodetools 0ca2846f2b fix(ssr): résoudre une base absolue pour les fetch API côté serveur (page édition) 2026-09-22 09:46:24 +02:00
devcodetools 8ff530bf99 docs: regrouper la documentation dans docs/ (hors README) 2026-09-22 09:21:18 +02:00
36 changed files with 489 additions and 120 deletions

No files matched your search

+3
View File
@@ -1,6 +1,9 @@
# Variables exposées au client (préfixées NEXT_PUBLIC_) # Variables exposées au client (préfixées NEXT_PUBLIC_)
# NEXT_PUBLIC_API_URL=http://localhost:3000 # NEXT_PUBLIC_API_URL=http://localhost:3000
# Base interne pour les fetch SSR (port interne du serveur Next.js, défaut 3000)
# API_BASE_URL=http://localhost:3000
# Implémentation repository active : mysql | mongo # Implémentation repository active : mysql | mongo
DB_IMPL=mysql DB_IMPL=mysql
+1 -1
View File
@@ -69,7 +69,7 @@ cp .env.example .env
docker compose up -d --build docker compose up -d --build
``` ```
Le service web est exposé sur **http://localhost:4000** avec les bases MySQL (port 13306) et MongoDB (port 27018). Le mode opératoire complet (démarrage, vérifications, dev local, arrêt, dépannage) est décrit dans [MOD_OP.md](MOD_OP.md). Le service web est exposé sur **http://localhost:4000** avec les bases MySQL (port 13306) et MongoDB (port 27018). Le mode opératoire complet (démarrage, vérifications, dev local, arrêt, dépannage) est décrit dans [docs/MOD_OP.md](docs/MOD_OP.md).
## Structure du projet ## Structure du projet
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+21 -57
View File
@@ -1,70 +1,34 @@
@startuml c4-container @startuml c4-container
' C4 modèle — Niveau 2 : Conteneurs (syntaxe native PlantUML, compatible anciennes versions) ' C4 modèle — Niveau 2 : Conteneurs
' Application web Flashcards (SPA/PWA) ' Bibliothèque standard : C4_Container.puml inclut lui-même C4_Context.puml,
' et définit les macros Container / ContainerDb utilisées ici.
hide stereotype !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
skinparam backgroundColor #FFFFFF
' ===== Styles par type =====
skinparam rectangle<<person>> {
BackgroundColor #08427B
BorderColor #073B6F
FontColor #FFFFFF
}
skinparam rectangle<<system>> {
'BackgroundColor #1168BD
BorderColor #3C7FC0
FontColor #FFFFFF
}
skinparam rectangle<<container>> {
BackgroundColor #438DD5
BorderColor #135F97
FontColor #FFFFFF
}
skinparam rectangle<<external_system>> {
BackgroundColor #999999
BorderColor #8A8A8A
FontColor #FFFFFF
}
skinparam arrow {
Color #707070
}
title Conteneurs — Flashcards (Niveau 2) title Conteneurs — Flashcards (Niveau 2)
' ===== Personne ===== Person(user, "Utilisateur", "Navigateur / mobile (accès au jeu et au CRUD)")
rectangle "Utilisateur\nNavigateur / mobile\n(accès au jeu et au CRUD)" <<person>> as User
' ===== Système Flashcards et ses conteneurs ===== System_Boundary(flashcards, "Application Flashcards") {
rectangle "Application Flashcards [Système]\nSPA/PWA Next.js — double persistance simultanée" <<system>> as Flashcards {
rectangle "SPA React (Next.js)\n[Conteneur : Web Browser]\nRendu de l'UI, état local, navigation" <<container>> as Spa Container(spa, "SPA React (Next.js)", "Web Browser", "Rendu de l'UI, état local, navigation, mode hors-ligne (service worker)")
rectangle "API Next.js / App Router\n[Conteneur : Node.js]\nCardController > CardService :\nliste, CRUD, tirage aléatoire, thèmes\n(seul conteneur qui accède aux données)" <<container>> as Api Container(api, "API Next.js / App Router", "Node.js", "CardController > CardService : liste, CRUD, tirage aléatoire, thèmes, suggestions de tags. Seul conteneur qui accède aux données.")
rectangle "CardRepository (générique)\n[Abstraction de données]\nContracts : findAll, findById,\nfindRandom, findRandomByTheme,\nfindThemes, create, update, delete" <<container>> as Repo Container(repo, "CardRepository (générique)", "Abstraction de données", "Contracts : findAll, findById, findRandom, findRandomByTheme, findThemes, tags, create, update, delete")
rectangle "Implémentation MySQL\n[Conteneur : librairie]\nImplémentation SQL du repository" <<container>> as RepoMysql Container(repoMysql, "Implémentation MySQL", "Library (mysql2)", "Implémentation SQL du repository")
rectangle "Implémentation MongoDB\n[Conteneur : librairie]\nImplémentation Mongo du repository" <<container>> as RepoMongo Container(repoMongo, "Implémentation MongoDB", "Library (mongodb)", "Implémentation Mongo du repository")
} }
' ===== Systèmes externes ===== ContainerDb(mysql, "Base MySQL", "SGBD relationnel", "Persistance des cartes (table cards) + volume Docker")
rectangle "Base MySQL\n[Conteneur externe : SGBD]\nPersistance des cartes (table cards)\n+ stockage Docker avec volume" <<external_system>> as MySql ContainerDb(mongo, "Base MongoDB", "SGBD documentaire", "Persistance des cartes (collection cards) + volume Docker")
rectangle "Base MongoDB\n[Conteneur externe : SGBD]\nPersistance des cartes (collection cards)\n+ stockage Docker avec volume" <<external_system>> as Mongo
'rectangle "Docker / docker-compose\n[infrastructure]\nOrchestration des conteneurs\nweb + bases + volumes" <<external_system>> as Docker Rel(user, spa, "Navigue et interagit (HTTP/HTTPS)")
Rel(spa, api, "Appels API JSON (jeu, CRUD, révélation)")
Rel(api, repo, "Utilise uniquement l'abstraction")
Rel(repo, repoMysql, "implémente")
Rel(repo, repoMongo, "implémente")
Rel(repoMysql, mysql, "Requêtes SQL (table cards)")
Rel(repoMongo, mongo, "Requêtes Mongo (collection cards)")
' ===== Relations =====
User -[#707070]-> Spa : "Navigue et interagit (HTTP/HTTPS)"
Spa -[#707070]-> Api : "Appels API JSON (dialogues, CRUD,\ntirage, révélation de réponse)"
Api -[#707070]down-> Repo : "Utilise uniquement l'abstraction"
Repo -[#707070]-> RepoMysql : "implémente"
Repo -[#707070]-> RepoMongo : "implémente"
Api -[#707070]down-> MySql : "Tables SQL via repository\n(selectionné par config/driver)"
Api -[#707070]down-> Mongo : "Requêtes Mongo via repository\n(selectionné par config/driver)"
'Docker -[#707070]-> MySql : "Démarre / arrête (docker-compose)"
'Docker -[#707070]-> Mongo : "Démarre / arrête (docker-compose)"
@enduml @enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+11 -47
View File
@@ -1,55 +1,19 @@
@startuml c4-context @startuml c4-context
' C4 modèle — Niveau 1 : Contexte système (syntaxe native PlantUML, compatible anciennes versions) ' C4 modèle — Niveau 1 : Contexte système
' Application web Flashcards (SPA/PWA) ' Bibliothèque standard : !include C4_Context.puml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml
hide stereotype
skinparam backgroundColor #FFFFFF
' ===== Styles par type =====
skinparam rectangle<<person>> {
BackgroundColor #08427B
BorderColor #073B6F
FontColor #FFFFFF
}
skinparam rectangle<<external_person>> {
BackgroundColor #686868
BorderColor #8A8A8A
FontColor #FFFFFF
}
skinparam rectangle<<system>> {
BackgroundColor #1168BD
BorderColor #3C7FC0
FontColor #FFFFFF
}
skinparam rectangle<<external_system>> {
BackgroundColor #999999
BorderColor #8A8A8A
FontColor #FFFFFF
}
skinparam arrow {
Color #707070
}
title Contexte système — Flashcards (Niveau 1) title Contexte système — Flashcards (Niveau 1)
' ===== Personnes ===== Person(user, "Utilisateur", "Joueur et contributeur de cartes (CRUD) : code, dev, culture tech")
rectangle "Utilisateur\nJoueur et contributeur de cartes\n(CRUD) : code, dev, culture tech" <<person>> as User
' ===== Système à modéliser ===== System(flashcards, "App Flashcards", "SPA/PWA Next.js — tirage de cartes au hasard, tirage par thème, CRUD des cartes. Double persistance simultanée MySQL + MongoDB via une couche générique.")
rectangle "App Flashcards\nSPA/PWA Next.js — tirage de cartes au\nhasard, tirage par thème, CRUD des cartes.\nDouble persistance simultanée\nMySQL + MongoDB via couche générique." <<system>> as Flashcards
' ===== Systèmes externes ===== System_Ext(mysql, "MySQL", "Base relationnelle (conteneur Docker) — table cards")
rectangle "MySQL\nBase relationnelle\n(conteneur Docker)" <<external_system>> as MySql System_Ext(mongo, "MongoDB", "Base documentaire (conteneur Docker) — collection cards")
rectangle "MongoDB\nBase documentaire\n(conteneur Docker)" <<external_system>> as Mongo
'rectangle "Docker / docker-compose\nOrchestration des conteneurs\n(web, bases) + volumes persistants" <<external_system>> as Docker Rel(user, flashcards, "Joue, crée, modifie, supprime,\nconsulte et révèle des cartes", "HTTPS")
Rel(flashcards, mysql, "Lit / écrit via CardRepository", "SQL")
Rel(flashcards, mongo, "Lit / écrit via CardRepository", "MongoDB")
User -[#707070]-> Flashcards : Joue, crée, modifie, supprime,\nconsulte et révèle des cartes
Flashcards -[#707070]-> MySql : Lit / écrit (CardRepository)
Flashcards -[#707070]-> Mongo : Lit / écrit (CardRepository)
'Docker -[#707070]-> MySql : Démarre et gère
'Docker -[#707070]-> Mongo : Démarre et gère
@enduml @enduml
+1 -1
View File
@@ -1,4 +1,4 @@
@startuml @startuml classes
' Diagramme de classes — Application web Flashcards (Architecture : couche d'accès générique) ' Diagramme de classes — Application web Flashcards (Architecture : couche d'accès générique)
skinparam classAttributeIconSize 0 skinparam classAttributeIconSize 0
skinparam classFontStyle bold skinparam classFontStyle bold
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
@startuml @startuml sequence
' Diagramme de séquence — "Afficher une carte" puis "Révéler la réponse" ' Diagramme de séquence — "Afficher une carte" puis "Révéler la réponse"
' Application web Flashcards (SPA/PWA, couche d'accès générique MySQL/Mongo) ' Application web Flashcards (SPA/PWA, couche d'accès générique MySQL/Mongo)
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File
File renamed without changes.
View File
File renamed without changes.
+1
View File
@@ -191,6 +191,7 @@ Traceabilité F-01 à F-10 (tests unitaires) :
- [x] DoD finale pleinement vérifiée - [x] DoD finale pleinement vérifiée
Notes Phase 5 : Notes Phase 5 :
- Image web multi-étapes (node:22-alpine, pnpm, `output: 'standalone'`), port hôte **4000** (3000/3001 occupés par l'hôte). - Image web multi-étapes (node:22-alpine, pnpm, `output: 'standalone'`), port hôte **4000** (3000/3001 occupés par l'hôte).
- Correction UTF-8 du seed MySQL : `02-seed.sh` avec `mysql --default-character-set=utf8mb4` (le `.sql` brut importait en double-encodage) ; lignes seed réparées en base via `CONVERT(BINARY(CONVERT(... USING latin1)) USING utf8mb4)`. - Correction UTF-8 du seed MySQL : `02-seed.sh` avec `mysql --default-character-set=utf8mb4` (le `.sql` brut importait en double-encodage) ; lignes seed réparées en base via `CONVERT(BINARY(CONVERT(... USING latin1)) USING utf8mb4)`.
- Correction `parseTags()` dans le repository MySQL (`mysql2` auto-parse les colonnes JSON en tableaux). - Correction `parseTags()` dans le repository MySQL (`mysql2` auto-parse les colonnes JSON en tableaux).
+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([]) },
} }
}) })
+5 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { CardDeleteButton } from '@/components/card-delete'
import { CardForm } from '@/components/card-form' import { CardForm } from '@/components/card-form'
import type { Card, CardInput } from '@/domain/card' import type { Card, CardInput } from '@/domain/card'
import { ApiClientError, cardsApi } from '@/lib/api-client' import { ApiClientError, cardsApi } from '@/lib/api-client'
@@ -33,7 +34,10 @@ export default async function EditCardPage({ params }: { params: Promise<{ id: s
> >
← Retour au détail ← Retour au détail
</Link> </Link>
<h1 className="mt-2 text-2xl font-bold text-slate-900">{card.question}</h1> <div className="mt-2 flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold text-slate-900">{card.question}</h1>
<CardDeleteButton cardId={card.id} question={card.question} />
</div>
</div> </div>
<CardForm mode="edit" cardId={card.id} initial={initial} /> <CardForm mode="edit" cardId={card.id} initial={initial} />
</main> </main>
+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', () => ({
+31 -3
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { defaultCache } from '@serwist/turbopack/worker' import { defaultCache } from '@serwist/turbopack/worker'
import type { PrecacheEntry, RuntimeCaching, SerwistGlobalConfig } from 'serwist' import type { PrecacheEntry, RuntimeCaching, SerwistGlobalConfig } from 'serwist'
import { NetworkOnly, Serwist } from 'serwist' import { ExpirationPlugin, NetworkFirst, Serwist } from 'serwist'
declare global { declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig { interface WorkerGlobalScope extends SerwistGlobalConfig {
@@ -11,11 +11,39 @@ declare global {
declare const self: ServiceWorkerGlobalScope declare const self: ServiceWorkerGlobalScope
// Ne jamais mettre en cache les appels API // API : lectures (GET) en réseau d'abord, servies depuis le cache en secours
// hors-ligne. Les écritures (POST/PUT/DELETE) ne sont pas interceptées par le
// service worker et passent toujours par le réseau.
const runtimeCaching: RuntimeCaching[] = [ const runtimeCaching: RuntimeCaching[] = [
{ {
matcher: ({ sameOrigin, url }) => sameOrigin && url.pathname.startsWith('/api/'), matcher: ({ sameOrigin, url }) => sameOrigin && url.pathname.startsWith('/api/'),
handler: new NetworkOnly(), handler: new NetworkFirst({
networkTimeoutSeconds: 3,
cacheName: 'api-network-first',
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 7 * 24 * 60 * 60,
}),
],
}),
},
// Navigation : réseau d'abord (pages toujours fraîches après déploiement),
// cache en secours uniquement hors-ligne
{
matcher: ({ request }) => request.mode === 'navigate',
handler: new NetworkFirst({
networkTimeoutSeconds: 3,
cacheName: 'pages-network-first',
}),
},
// Payloads RSC des navigations côté client : réseau d'abord
{
matcher: ({ request, sameOrigin }) => sameOrigin && request.headers.get('rsc') === '1',
handler: new NetworkFirst({
networkTimeoutSeconds: 3,
cacheName: 'rsc-network-first',
}),
}, },
...defaultCache, ...defaultCache,
] ]
+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"
+9 -1
View File
@@ -27,7 +27,7 @@ const CARDS = [
question: 'Cest quoi un commit ?', question: 'Cest quoi un commit ?',
response: 'Un snapshot du projet', response: 'Un snapshot du projet',
theme: 'git', theme: 'git',
tags: [], tags: ['commit', 'repo'],
createdAt: new Date('2026-01-01T08:00:00Z'), createdAt: new Date('2026-01-01T08:00:00Z'),
updatedAt: new Date('2026-01-01T08:00:00Z'), updatedAt: new Date('2026-01-01T08:00:00Z'),
}, },
@@ -68,6 +68,14 @@ it('affiche le thème de chaque carte', async () => {
expect(within(item as HTMLElement).getByText('git')).toBeInTheDocument() expect(within(item as HTMLElement).getByText('git')).toBeInTheDocument()
}) })
it('affiche les tags 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('#commit')).toBeInTheDocument()
expect(within(item as HTMLElement).getByText('#repo')).toBeInTheDocument()
})
it('affiche un message si aucune carte', async () => { it('affiche un message si aucune carte', async () => {
vi.mocked(cardsApi.list).mockResolvedValue([]) vi.mocked(cardsApi.list).mockResolvedValue([])
render(<CardList />) render(<CardList />)
+17 -1
View File
@@ -8,6 +8,7 @@ interface ListedCard {
id: string id: string
question: string question: string
theme: string theme: string
tags: string[]
} }
export function CardList() { export function CardList() {
@@ -25,6 +26,7 @@ export function CardList() {
id: card.id, id: card.id,
question: card.question, question: card.question,
theme: card.theme, theme: card.theme,
tags: card.tags,
})), })),
) )
} }
@@ -62,7 +64,21 @@ export function CardList() {
href={`/cards/${card.id}`} href={`/cards/${card.id}`}
className="flex items-center justify-between px-4 py-3 hover:bg-slate-50" className="flex items-center justify-between px-4 py-3 hover:bg-slate-50"
> >
<span className="font-medium">{card.question}</span> <span className="flex flex-col gap-1">
<span className="font-medium">{card.question}</span>
{card.tags.length > 0 && (
<span className="flex flex-wrap gap-1.5">
{card.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
>
#{tag}
</span>
))}
</span>
)}
</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"> <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{card.theme} {card.theme}
</span> </span>
+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()
+48
View File
@@ -0,0 +1,48 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fetchMock = vi.fn()
async function loadModule(env?: Record<string, string>) {
vi.resetModules()
delete process.env.API_BASE_URL
if (env) Object.assign(process.env, env)
return (await import('./api-client')) as typeof import('./api-client')
}
beforeEach(() => {
global.fetch = fetchMock as unknown as typeof fetch
fetchMock.mockReset()
})
describe('cardsApi (SSR / Node)', () => {
it("résout une URL absolue sur localhost:3000 en l'absence de window", async () => {
const { cardsApi: api } = await loadModule()
fetchMock.mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
await api.list()
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3000/api/cards',
expect.objectContaining({ method: 'GET' }),
)
})
it('respecte API_BASE_URL si elle est définie', async () => {
const { cardsApi: api } = await loadModule({ API_BASE_URL: 'http://api:9999' })
fetchMock.mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
await api.list()
expect(fetchMock).toHaveBeenCalledWith(
'http://api:9999/api/cards',
expect.objectContaining({ method: 'GET' }),
)
})
})
+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),
+8 -1
View File
@@ -14,10 +14,16 @@ interface ApiErrorPayload {
message?: string message?: string
} }
// Côté serveur (SSR/standalone), une URL relative n'est pas résolvable :
// on cible la propre API de l'application (port interne 3000). Côté client,
// l'URL relative est résolue par le navigateur.
const API_BASE_URL =
typeof window === 'undefined' ? (process.env.API_BASE_URL ?? 'http://localhost:3000') : ''
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response let res: Response
try { try {
res = await fetch(path, { res = await fetch(`${API_BASE_URL}${path}`, {
method: init?.method ?? 'GET', method: init?.method ?? 'GET',
...init, ...init,
headers: { 'Content-Type': 'application/json', ...init?.headers }, headers: { 'Content-Type': 'application/json', ...init?.headers },
@@ -49,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()
}
} }