Compare commits
10
Commits
087945472a
...
bdf119eb0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf119eb0d | ||
|
|
5c04defe31 | ||
|
|
f6b4f7f79b | ||
|
|
8a576f6884 | ||
|
|
bf2c9ae4fb | ||
|
|
67569e2d16 | ||
|
|
31283ada26 | ||
|
|
ba5bd8288d | ||
|
|
0ca2846f2b | ||
|
|
8ff530bf99 |
No files matched your search
@@ -1,6 +1,9 @@
|
||||
# Variables exposées au client (préfixées NEXT_PUBLIC_)
|
||||
# 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
|
||||
DB_IMPL=mysql
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ cp .env.example .env
|
||||
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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
+21
-57
@@ -1,70 +1,34 @@
|
||||
@startuml c4-container
|
||||
' C4 modèle — Niveau 2 : Conteneurs (syntaxe native PlantUML, compatible anciennes versions)
|
||||
' Application web Flashcards (SPA/PWA)
|
||||
|
||||
hide stereotype
|
||||
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
|
||||
}
|
||||
' C4 modèle — Niveau 2 : Conteneurs
|
||||
' Bibliothèque standard : C4_Container.puml inclut lui-même C4_Context.puml,
|
||||
' et définit les macros Container / ContainerDb utilisées ici.
|
||||
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
|
||||
|
||||
title Conteneurs — Flashcards (Niveau 2)
|
||||
|
||||
' ===== Personne =====
|
||||
rectangle "Utilisateur\nNavigateur / mobile\n(accès au jeu et au CRUD)" <<person>> as User
|
||||
Person(user, "Utilisateur", "Navigateur / mobile (accès au jeu et au CRUD)")
|
||||
|
||||
' ===== Système Flashcards et ses conteneurs =====
|
||||
rectangle "Application Flashcards [Système]\nSPA/PWA Next.js — double persistance simultanée" <<system>> as Flashcards {
|
||||
System_Boundary(flashcards, "Application 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
|
||||
rectangle "Implémentation MongoDB\n[Conteneur : librairie]\nImplémentation Mongo du repository" <<container>> as RepoMongo
|
||||
Container(repoMysql, "Implémentation MySQL", "Library (mysql2)", "Implémentation SQL du repository")
|
||||
Container(repoMongo, "Implémentation MongoDB", "Library (mongodb)", "Implémentation Mongo du repository")
|
||||
}
|
||||
|
||||
' ===== Systèmes externes =====
|
||||
rectangle "Base MySQL\n[Conteneur externe : SGBD]\nPersistance des cartes (table cards)\n+ stockage Docker avec volume" <<external_system>> as MySql
|
||||
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
|
||||
ContainerDb(mysql, "Base MySQL", "SGBD relationnel", "Persistance des cartes (table cards) + volume Docker")
|
||||
ContainerDb(mongo, "Base MongoDB", "SGBD documentaire", "Persistance des cartes (collection cards) + volume 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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
+11
-47
@@ -1,55 +1,19 @@
|
||||
@startuml c4-context
|
||||
' C4 modèle — Niveau 1 : Contexte système (syntaxe native PlantUML, compatible anciennes versions)
|
||||
' Application web Flashcards (SPA/PWA)
|
||||
|
||||
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
|
||||
}
|
||||
' C4 modèle — Niveau 1 : Contexte système
|
||||
' Bibliothèque standard : !include C4_Context.puml
|
||||
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml
|
||||
|
||||
title Contexte système — Flashcards (Niveau 1)
|
||||
|
||||
' ===== Personnes =====
|
||||
rectangle "Utilisateur\nJoueur et contributeur de cartes\n(CRUD) : code, dev, culture tech" <<person>> as User
|
||||
Person(user, "Utilisateur", "Joueur et contributeur de cartes (CRUD) : code, dev, culture tech")
|
||||
|
||||
' ===== Système à modéliser =====
|
||||
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
|
||||
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.")
|
||||
|
||||
' ===== Systèmes externes =====
|
||||
rectangle "MySQL\nBase relationnelle\n(conteneur Docker)" <<external_system>> as MySql
|
||||
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
|
||||
System_Ext(mysql, "MySQL", "Base relationnelle (conteneur Docker) — table cards")
|
||||
System_Ext(mongo, "MongoDB", "Base documentaire (conteneur Docker) — collection cards")
|
||||
|
||||
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
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@startuml
|
||||
@startuml classes
|
||||
' Diagramme de classes — Application web Flashcards (Architecture : couche d'accès générique)
|
||||
skinparam classAttributeIconSize 0
|
||||
skinparam classFontStyle bold
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -1,4 +1,4 @@
|
||||
@startuml
|
||||
@startuml sequence
|
||||
' 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)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
File renamed without changes.
File renamed without changes.
@@ -191,6 +191,7 @@ Traceabilité F-01 à F-10 (tests unitaires) :
|
||||
- [x] DoD finale pleinement vérifiée
|
||||
|
||||
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).
|
||||
- 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).
|
||||
@@ -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([]) },
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { CardDeleteButton } from '@/components/card-delete'
|
||||
import { CardForm } from '@/components/card-form'
|
||||
import type { Card, CardInput } from '@/domain/card'
|
||||
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
|
||||
</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>
|
||||
<CardForm mode="edit" cardId={card.id} initial={initial} />
|
||||
</main>
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
+31
-3
@@ -1,7 +1,7 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { defaultCache } from '@serwist/turbopack/worker'
|
||||
import type { PrecacheEntry, RuntimeCaching, SerwistGlobalConfig } from 'serwist'
|
||||
import { NetworkOnly, Serwist } from 'serwist'
|
||||
import { ExpirationPlugin, NetworkFirst, Serwist } from 'serwist'
|
||||
|
||||
declare global {
|
||||
interface WorkerGlobalScope extends SerwistGlobalConfig {
|
||||
@@ -11,11 +11,39 @@ declare global {
|
||||
|
||||
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[] = [
|
||||
{
|
||||
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,
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -27,7 +27,7 @@ const CARDS = [
|
||||
question: 'Cest quoi un commit ?',
|
||||
response: 'Un snapshot du projet',
|
||||
theme: 'git',
|
||||
tags: [],
|
||||
tags: ['commit', 'repo'],
|
||||
createdAt: 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()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
vi.mocked(cardsApi.list).mockResolvedValue([])
|
||||
render(<CardList />)
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ListedCard {
|
||||
id: string
|
||||
question: string
|
||||
theme: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export function CardList() {
|
||||
@@ -25,6 +26,7 @@ export function CardList() {
|
||||
id: card.id,
|
||||
question: card.question,
|
||||
theme: card.theme,
|
||||
tags: card.tags,
|
||||
})),
|
||||
)
|
||||
}
|
||||
@@ -62,7 +64,21 @@ export function CardList() {
|
||||
href={`/cards/${card.id}`}
|
||||
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">
|
||||
{card.theme}
|
||||
</span>
|
||||
|
||||
@@ -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()
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -14,10 +14,16 @@ interface ApiErrorPayload {
|
||||
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> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: init?.method ?? 'GET',
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
@@ -49,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