Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88278bd1dd | |||
| d24d6459a0 | |||
| 4b866e5212 | |||
| 6c6bd20f0d | |||
| 2764228abf | |||
| f95d69c915 |
95
.gitea/workflows/e2e.yml
Normal file
95
.gitea/workflows/e2e.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Create .env for stack
|
||||||
|
run: |
|
||||||
|
cat > .env <<'EOF'
|
||||||
|
POSTGRES_PASSWORD=ci-postgres-pass
|
||||||
|
BRAIN_INTERNAL_SECRET=ci-brain-secret
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=ci-admin-pass
|
||||||
|
WEB_PORT=8081
|
||||||
|
LLM_PROVIDER=ollama
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build & start stack
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
|
||||||
|
|
||||||
|
- name: Attach runner to compose network
|
||||||
|
run: |
|
||||||
|
NET=$(docker network ls --format '{{.Name}}' | grep -E '(^|_)loremind(_|$)' | grep -i default | head -1)
|
||||||
|
if [ -z "$NET" ]; then
|
||||||
|
echo "Compose network not found" >&2
|
||||||
|
docker network ls
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Connecting $(hostname) to network $NET"
|
||||||
|
docker network connect "$NET" "$(hostname)"
|
||||||
|
|
||||||
|
- name: Wait for web to be ready
|
||||||
|
run: |
|
||||||
|
timeout 180 bash -c 'until curl -sf http://web/ > /dev/null; do echo "waiting..."; sleep 3; done'
|
||||||
|
|
||||||
|
- name: Install web deps
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Work around runner clock skew for apt
|
||||||
|
run: |
|
||||||
|
sudo tee /etc/apt/apt.conf.d/99no-check-valid-until >/dev/null <<'EOF'
|
||||||
|
Acquire::Check-Valid-Until "false";
|
||||||
|
Acquire::Check-Date "false";
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
working-directory: web
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
working-directory: web
|
||||||
|
env:
|
||||||
|
E2E_BASE_URL: http://web
|
||||||
|
CI: 'true'
|
||||||
|
run: npm run e2e
|
||||||
|
|
||||||
|
- name: Dump container logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: web/playwright-report/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Stop stack
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -53,6 +53,12 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
web/test-results/
|
||||||
|
web/playwright-report/
|
||||||
|
web/blob-report/
|
||||||
|
web/playwright/.cache/
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# IDE / Editeurs
|
# IDE / Editeurs
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.6.1",
|
version="0.6.2",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.6.1</version>
|
<version>0.6.2</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -40,17 +40,11 @@ public class ArcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<ArcDTO>> getAllArcs() {
|
public ResponseEntity<List<ArcDTO>> getAllArcs(
|
||||||
List<Arc> arcs = arcService.getAllArcs();
|
@RequestParam(value = "campaignId", required = false) String campaignId) {
|
||||||
List<ArcDTO> arcDTOs = arcs.stream()
|
List<Arc> arcs = (campaignId != null && !campaignId.isBlank())
|
||||||
.map(arcMapper::toDTO)
|
? arcService.getArcsByCampaignId(campaignId)
|
||||||
.collect(Collectors.toList());
|
: arcService.getAllArcs();
|
||||||
return ResponseEntity.ok(arcDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/campaign/{campaignId}")
|
|
||||||
public ResponseEntity<List<ArcDTO>> getArcsByCampaignId(@PathVariable String campaignId) {
|
|
||||||
List<Arc> arcs = arcService.getArcsByCampaignId(campaignId);
|
|
||||||
List<ArcDTO> arcDTOs = arcs.stream()
|
List<ArcDTO> arcDTOs = arcs.stream()
|
||||||
.map(arcMapper::toDTO)
|
.map(arcMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -40,17 +40,11 @@ public class ChapterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<ChapterDTO>> getAllChapters() {
|
public ResponseEntity<List<ChapterDTO>> getAllChapters(
|
||||||
List<Chapter> chapters = chapterService.getAllChapters();
|
@RequestParam(value = "arcId", required = false) String arcId) {
|
||||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
List<Chapter> chapters = (arcId != null && !arcId.isBlank())
|
||||||
.map(chapterMapper::toDTO)
|
? chapterService.getChaptersByArcId(arcId)
|
||||||
.collect(Collectors.toList());
|
: chapterService.getAllChapters();
|
||||||
return ResponseEntity.ok(chapterDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/arc/{arcId}")
|
|
||||||
public ResponseEntity<List<ChapterDTO>> getChaptersByArcId(@PathVariable String arcId) {
|
|
||||||
List<Chapter> chapters = chapterService.getChaptersByArcId(arcId);
|
|
||||||
List<ChapterDTO> chapterDTOs = chapters.stream()
|
List<ChapterDTO> chapterDTOs = chapters.stream()
|
||||||
.map(chapterMapper::toDTO)
|
.map(chapterMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -40,17 +40,11 @@ public class SceneController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<SceneDTO>> getAllScenes() {
|
public ResponseEntity<List<SceneDTO>> getAllScenes(
|
||||||
List<Scene> scenes = sceneService.getAllScenes();
|
@RequestParam(value = "chapterId", required = false) String chapterId) {
|
||||||
List<SceneDTO> sceneDTOs = scenes.stream()
|
List<Scene> scenes = (chapterId != null && !chapterId.isBlank())
|
||||||
.map(sceneMapper::toDTO)
|
? sceneService.getScenesByChapterId(chapterId)
|
||||||
.collect(Collectors.toList());
|
: sceneService.getAllScenes();
|
||||||
return ResponseEntity.ok(sceneDTOs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/chapter/{chapterId}")
|
|
||||||
public ResponseEntity<List<SceneDTO>> getScenesByChapterId(@PathVariable String chapterId) {
|
|
||||||
List<Scene> scenes = sceneService.getScenesByChapterId(chapterId);
|
|
||||||
List<SceneDTO> sceneDTOs = scenes.stream()
|
List<SceneDTO> sceneDTOs = scenes.stream()
|
||||||
.map(sceneMapper::toDTO)
|
.map(sceneMapper::toDTO)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class ArcControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByCampaign_pathVariant() throws Exception {
|
void getByCampaign_pathVariant() throws Exception {
|
||||||
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
|
arcRepository.save(Arc.builder().campaignId(campaignId).name("A").order(0).build());
|
||||||
mockMvc.perform(get("/api/arcs/campaign/{id}", campaignId))
|
mockMvc.perform(get("/api/arcs").param("campaignId", campaignId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ChapterControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByArc_pathVariant() throws Exception {
|
void getByArc_pathVariant() throws Exception {
|
||||||
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
|
chapterRepository.save(Chapter.builder().arcId(arcId).name("A").order(0).build());
|
||||||
mockMvc.perform(get("/api/chapters/arc/{id}", arcId))
|
mockMvc.perform(get("/api/chapters").param("arcId", arcId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class SceneControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void getByChapter_pathVariant() throws Exception {
|
void getByChapter_pathVariant() throws Exception {
|
||||||
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
|
sceneRepository.save(Scene.builder().chapterId(chapterId).name("A").order(0).build());
|
||||||
mockMvc.perform(get("/api/scenes/chapter/{id}", chapterId))
|
mockMvc.perform(get("/api/scenes").param("chapterId", chapterId))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ services:
|
|||||||
BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me}
|
BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me}
|
||||||
# Rate limit : 1 creation par IP par fenetre (en secondes).
|
# Rate limit : 1 creation par IP par fenetre (en secondes).
|
||||||
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60}
|
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60}
|
||||||
|
# Domaine public : propage aux cores de session pour configurer CORS.
|
||||||
|
DEMO_HOST: ${DEMO_HOST:-loremind-demo.igmlcreation.fr}
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
- sessions
|
- sessions
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Config struct {
|
|||||||
PreparingPage string
|
PreparingPage string
|
||||||
RateLimitWindow time.Duration
|
RateLimitWindow time.Duration
|
||||||
MaxBodyBytes int64
|
MaxBodyBytes int64
|
||||||
|
DemoHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() *Config {
|
func loadConfig() *Config {
|
||||||
@@ -40,6 +41,9 @@ func loadConfig() *Config {
|
|||||||
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
|
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
|
||||||
// 10 Mo : aligne avec la limite d'upload d'image cote core.
|
// 10 Mo : aligne avec la limite d'upload d'image cote core.
|
||||||
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
|
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
|
||||||
|
// Utilise pour injecter APP_CORS_ALLOWED_ORIGINS dans les cores spawnes :
|
||||||
|
// sans ca, Spring bloque les POST avec 403 (origine rejetee).
|
||||||
|
DemoHost: envStr("DEMO_HOST", "loremind-demo.igmlcreation.fr"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,11 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con
|
|||||||
"ADMIN_USERNAME=admin",
|
"ADMIN_USERNAME=admin",
|
||||||
"ADMIN_PASSWORD=" + adminPassword,
|
"ADMIN_PASSWORD=" + adminPassword,
|
||||||
"DEMO_MODE=true",
|
"DEMO_MODE=true",
|
||||||
"CORS_ALLOWED_ORIGINS=*",
|
// CorsConfig.java lit app.cors.allowed-origins (= APP_CORS_ALLOWED_ORIGINS
|
||||||
|
// via le relaxed binding Spring). Necessaire meme en same-origin car
|
||||||
|
// le browser envoie Origin sur les POST et le CorsFilter 403 les
|
||||||
|
// origines inconnues.
|
||||||
|
"APP_CORS_ALLOWED_ORIGINS=https://" + cfg.DemoHost,
|
||||||
},
|
},
|
||||||
Labels: copyLabels(labels, "core"),
|
Labels: copyLabels(labels, "core"),
|
||||||
Memory: cfg.CoreMemoryBytes,
|
Memory: cfg.CoreMemoryBytes,
|
||||||
|
|||||||
@@ -56,11 +56,18 @@ func (rl *rateLimiter) cleanupLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIP extrait l'IP reelle en prenant la derniere entree de X-Forwarded-For.
|
// clientIP extrait l'IP reelle du visiteur en tenant compte du setup reverse-proxy.
|
||||||
// Justification : Traefik APPEND l'IP du peer au header existant, donc la
|
// Ordre de priorite :
|
||||||
// derniere valeur est celle que Traefik a observe directement (le vrai client).
|
// 1. CF-Connecting-IP : defini par Cloudflare sur la base de SA propre vue du
|
||||||
// Prendre la premiere serait une faille : un attaquant peut preremplir le header.
|
// peer TCP, non-forgeable par le client, ecrase toute valeur entrante.
|
||||||
|
// 2. X-Forwarded-For, derniere entree : quand seul Traefik est en front (pas
|
||||||
|
// de Cloudflare), Traefik append l'IP qu'il observe. Prendre la premiere
|
||||||
|
// serait une faille (header forgeable).
|
||||||
|
// 3. RemoteAddr : fallback si aucun header de proxy n'est present.
|
||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
|
if cfIP := r.Header.Get("CF-Connecting-IP"); cfIP != "" {
|
||||||
|
return strings.TrimSpace(cfIP)
|
||||||
|
}
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
parts := strings.Split(xff, ",")
|
parts := strings.Split(xff, ",")
|
||||||
return strings.TrimSpace(parts[len(parts)-1])
|
return strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
|||||||
17
docker-compose.e2e.yml
Normal file
17
docker-compose.e2e.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Override pour la CI E2E : build les images depuis les sources au lieu de les puller.
|
||||||
|
# Usage : docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build
|
||||||
|
services:
|
||||||
|
core:
|
||||||
|
build:
|
||||||
|
context: ./core
|
||||||
|
image: loremind-core:e2e
|
||||||
|
|
||||||
|
brain:
|
||||||
|
build:
|
||||||
|
context: ./brain
|
||||||
|
image: loremind-brain:e2e
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./web
|
||||||
|
image: loremind-web:e2e
|
||||||
317
web/e2e/fixtures/api.ts
Normal file
317
web/e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import { APIRequestContext, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export interface SeededLore {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rootFolderId: string;
|
||||||
|
rootFolderName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed un Lore + un dossier racine via l'API backend.
|
||||||
|
* Les noms sont uniques (timestamp + random) pour éviter les collisions en parallèle.
|
||||||
|
*/
|
||||||
|
export async function seedLoreWithFolder(request: APIRequestContext): Promise<SeededLore> {
|
||||||
|
const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const loreName = `E2E Lore ${suffix}`;
|
||||||
|
const folderName = `E2E Folder ${suffix}`;
|
||||||
|
|
||||||
|
const loreRes = await request.post('/api/lores', {
|
||||||
|
data: { name: loreName, description: 'Seeded by Playwright' },
|
||||||
|
});
|
||||||
|
expect(loreRes.ok(), `POST /api/lores -> ${loreRes.status()}`).toBeTruthy();
|
||||||
|
const lore = await loreRes.json();
|
||||||
|
|
||||||
|
const folderRes = await request.post('/api/lore-nodes', {
|
||||||
|
data: { loreId: lore.id, name: folderName, icon: 'folder', description: '' },
|
||||||
|
});
|
||||||
|
expect(folderRes.ok(), `POST /api/lore-nodes -> ${folderRes.status()}`).toBeTruthy();
|
||||||
|
const folder = await folderRes.json();
|
||||||
|
|
||||||
|
return { id: lore.id, name: loreName, rootFolderId: folder.id, rootFolderName: folderName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cleanup best-effort — n'échoue pas si déjà supprimé. */
|
||||||
|
export async function deleteLore(request: APIRequestContext, loreId: string): Promise<void> {
|
||||||
|
await request.delete(`/api/lores/${loreId}`).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLoreById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
loreId: string,
|
||||||
|
): Promise<{ id: string; name: string; description: string }> {
|
||||||
|
const res = await request.get(`/api/lores/${loreId}`);
|
||||||
|
expect(res.ok(), `GET /api/lores/${loreId} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArcsForCampaign(
|
||||||
|
request: APIRequestContext,
|
||||||
|
campaignId: string,
|
||||||
|
): Promise<Array<{ id: string; name: string; campaignId: string }>> {
|
||||||
|
const res = await request.get(`/api/arcs?campaignId=${campaignId}`);
|
||||||
|
expect(res.ok(), `GET /api/arcs -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChaptersForArc(
|
||||||
|
request: APIRequestContext,
|
||||||
|
arcId: string,
|
||||||
|
): Promise<Array<{ id: string; name: string; arcId: string }>> {
|
||||||
|
const res = await request.get(`/api/chapters?arcId=${arcId}`);
|
||||||
|
expect(res.ok(), `GET /api/chapters -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScenesForChapter(
|
||||||
|
request: APIRequestContext,
|
||||||
|
chapterId: string,
|
||||||
|
): Promise<Array<{ id: string; name: string; chapterId: string }>> {
|
||||||
|
const res = await request.get(`/api/scenes?chapterId=${chapterId}`);
|
||||||
|
expect(res.ok(), `GET /api/scenes -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplatesForLore(
|
||||||
|
request: APIRequestContext,
|
||||||
|
loreId: string,
|
||||||
|
): Promise<Array<{ id: string; name: string }>> {
|
||||||
|
const res = await request.get(`/api/templates?loreId=${loreId}`);
|
||||||
|
expect(res.ok(), `GET /api/templates -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeededTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedTemplate(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { loreId: string; defaultNodeId: string; name?: string; fieldNames?: string[] },
|
||||||
|
): Promise<SeededTemplate> {
|
||||||
|
const templateName = opts.name ?? `E2E Template ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const fields = (opts.fieldNames ?? ['Nom', 'Description']).map((name) => ({ name, type: 'TEXT' }));
|
||||||
|
|
||||||
|
const res = await request.post('/api/templates', {
|
||||||
|
data: {
|
||||||
|
loreId: opts.loreId,
|
||||||
|
name: templateName,
|
||||||
|
description: 'Seeded by Playwright',
|
||||||
|
defaultNodeId: opts.defaultNodeId,
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/templates -> ${res.status()}`).toBeTruthy();
|
||||||
|
const tpl = await res.json();
|
||||||
|
return { id: tpl.id, name: templateName };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCampaign(request: APIRequestContext, campaignId: string): Promise<void> {
|
||||||
|
await request.delete(`/api/campaigns/${campaignId}`).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeededCampaign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedCampaign(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { name?: string; loreId?: string | null; playerCount?: number } = {},
|
||||||
|
): Promise<SeededCampaign> {
|
||||||
|
const name = opts.name ?? `E2E Campaign ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const res = await request.post('/api/campaigns', {
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: 'Seeded by Playwright',
|
||||||
|
playerCount: opts.playerCount ?? 4,
|
||||||
|
loreId: opts.loreId ?? null,
|
||||||
|
gameSystemId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/campaigns -> ${res.status()}`).toBeTruthy();
|
||||||
|
const c = await res.json();
|
||||||
|
return { id: c.id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeededArc {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedArc(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { campaignId: string; name?: string; order?: number },
|
||||||
|
): Promise<SeededArc> {
|
||||||
|
const name = opts.name ?? `E2E Arc ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const res = await request.post('/api/arcs', {
|
||||||
|
data: {
|
||||||
|
campaignId: opts.campaignId,
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
order: opts.order ?? 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/arcs -> ${res.status()}`).toBeTruthy();
|
||||||
|
const a = await res.json();
|
||||||
|
return { id: a.id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeededChapter {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedChapter(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { arcId: string; name?: string; order?: number },
|
||||||
|
): Promise<SeededChapter> {
|
||||||
|
const name = opts.name ?? `E2E Chapter ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const res = await request.post('/api/chapters', {
|
||||||
|
data: { arcId: opts.arcId, name, description: '', order: opts.order ?? 1 },
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/chapters -> ${res.status()}`).toBeTruthy();
|
||||||
|
const c = await res.json();
|
||||||
|
return { id: c.id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChapterById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
chapterId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
gmNotes?: string | null;
|
||||||
|
playerObjectives?: string | null;
|
||||||
|
narrativeStakes?: string | null;
|
||||||
|
}> {
|
||||||
|
const res = await request.get(`/api/chapters/${chapterId}`);
|
||||||
|
expect(res.ok(), `GET /api/chapters/${chapterId} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeededScene {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedScene(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { chapterId: string; name?: string; order?: number },
|
||||||
|
): Promise<SeededScene> {
|
||||||
|
const name = opts.name ?? `E2E Scene ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const res = await request.post('/api/scenes', {
|
||||||
|
data: { chapterId: opts.chapterId, name, description: '', order: opts.order ?? 1 },
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/scenes -> ${res.status()}`).toBeTruthy();
|
||||||
|
const s = await res.json();
|
||||||
|
return { id: s.id, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSceneById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
sceneId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string | null;
|
||||||
|
timing?: string | null;
|
||||||
|
atmosphere?: string | null;
|
||||||
|
playerNarration?: string | null;
|
||||||
|
gmSecretNotes?: string | null;
|
||||||
|
choicesConsequences?: string | null;
|
||||||
|
combatDifficulty?: string | null;
|
||||||
|
enemies?: string | null;
|
||||||
|
branches?: Array<{ label: string; targetSceneId: string; condition?: string }>;
|
||||||
|
}> {
|
||||||
|
const res = await request.get(`/api/scenes/${sceneId}`);
|
||||||
|
expect(res.ok(), `GET /api/scenes/${sceneId} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArcById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
arcId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
themes?: string | null;
|
||||||
|
stakes?: string | null;
|
||||||
|
gmNotes?: string | null;
|
||||||
|
rewards?: string | null;
|
||||||
|
resolution?: string | null;
|
||||||
|
}> {
|
||||||
|
const res = await request.get(`/api/arcs/${arcId}`);
|
||||||
|
expect(res.ok(), `GET /api/arcs/${arcId} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCampaigns(
|
||||||
|
request: APIRequestContext,
|
||||||
|
): Promise<Array<{ id: string; name: string; loreId: string | null }>> {
|
||||||
|
const res = await request.get('/api/campaigns');
|
||||||
|
expect(res.ok(), `GET /api/campaigns -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPagesForLore(
|
||||||
|
request: APIRequestContext,
|
||||||
|
loreId: string,
|
||||||
|
): Promise<Array<{ id: string; title: string; nodeId: string; templateId: string }>> {
|
||||||
|
const res = await request.get(`/api/pages?loreId=${loreId}`);
|
||||||
|
expect(res.ok(), `GET /api/pages -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeededPage {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedPage(
|
||||||
|
request: APIRequestContext,
|
||||||
|
opts: { loreId: string; nodeId: string; templateId: string; title?: string },
|
||||||
|
): Promise<SeededPage> {
|
||||||
|
const title = opts.title ?? `E2E Page ${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||||
|
const res = await request.post('/api/pages', {
|
||||||
|
data: { loreId: opts.loreId, nodeId: opts.nodeId, templateId: opts.templateId, title },
|
||||||
|
});
|
||||||
|
expect(res.ok(), `POST /api/pages -> ${res.status()}`).toBeTruthy();
|
||||||
|
const page = await res.json();
|
||||||
|
return { id: page.id, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPageById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
pageId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
nodeId: string;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
tags?: string[];
|
||||||
|
notes?: string;
|
||||||
|
}> {
|
||||||
|
const res = await request.get(`/api/pages/${pageId}`);
|
||||||
|
expect(res.ok(), `GET /api/pages/${pageId} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateById(
|
||||||
|
request: APIRequestContext,
|
||||||
|
templateId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
defaultNodeId?: string | null;
|
||||||
|
fields: Array<{ name: string; type: string }>;
|
||||||
|
}> {
|
||||||
|
const res = await request.get(`/api/templates/${templateId}`);
|
||||||
|
expect(res.ok(), `GET /api/templates/${templateId} -> ${res.status()}`).toBeTruthy();
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
50
web/e2e/tests/campaign/arc-create.spec.ts
Normal file
50
web/e2e/tests/campaign/arc-create.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedCampaign,
|
||||||
|
deleteCampaign,
|
||||||
|
getArcById,
|
||||||
|
type SeededCampaign,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Arc creation', () => {
|
||||||
|
let campaign: SeededCampaign;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
campaign = await seedCampaign(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates an arc and redirects to its view', async ({ page, request }) => {
|
||||||
|
const arcName = `Arc ${Date.now()}`;
|
||||||
|
const description = 'Synopsis test';
|
||||||
|
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
|
||||||
|
await expect(page.getByRole('heading', { name: /Créer un nouvel arc/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom de l'arc/i).fill(arcName);
|
||||||
|
await page.getByLabel(/Description/i).fill(description);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer l'arc$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/\\d+$`));
|
||||||
|
|
||||||
|
const createdId = page.url().match(/\/arcs\/(\d+)$/)?.[1];
|
||||||
|
expect(createdId).toBeTruthy();
|
||||||
|
const persisted = await getArcById(request, createdId!);
|
||||||
|
expect(persisted.name).toBe(arcName);
|
||||||
|
expect(persisted.description).toBe(description);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/create`);
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer l'arc$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom de l'arc/i).fill('Quelque chose');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
49
web/e2e/tests/campaign/arc-delete.spec.ts
Normal file
49
web/e2e/tests/campaign/arc-delete.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedCampaign,
|
||||||
|
seedArc,
|
||||||
|
deleteCampaign,
|
||||||
|
type SeededCampaign,
|
||||||
|
type SeededArc,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Arc delete', () => {
|
||||||
|
let campaign: SeededCampaign;
|
||||||
|
let arc: SeededArc;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
campaign = await seedCampaign(request);
|
||||||
|
arc = await seedArc(request, { campaignId: campaign.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes an arc after accepting confirm and redirects to the campaign', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
|
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||||
|
|
||||||
|
const res = await request.get(`/api/arcs/${arc.id}`);
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the arc when confirm is dismissed', async ({ page, request }) => {
|
||||||
|
page.on('dialog', (dialog) => dialog.dismiss());
|
||||||
|
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
|
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/edit$`));
|
||||||
|
|
||||||
|
const res = await request.get(`/api/arcs/${arc.id}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
76
web/e2e/tests/campaign/arc-edit.spec.ts
Normal file
76
web/e2e/tests/campaign/arc-edit.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedCampaign,
|
||||||
|
seedArc,
|
||||||
|
deleteCampaign,
|
||||||
|
getArcById,
|
||||||
|
type SeededCampaign,
|
||||||
|
type SeededArc,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Arc edit', () => {
|
||||||
|
let campaign: SeededCampaign;
|
||||||
|
let arc: SeededArc;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
campaign = await seedCampaign(request);
|
||||||
|
arc = await seedArc(request, { campaignId: campaign.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form is prefilled with the arc name', async ({ page }) => {
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
|
await expect(page.getByLabel(/Titre de l'arc/i)).toHaveValue(arc.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits all narrative fields and persists them to API', async ({ page, request }) => {
|
||||||
|
const newName = `${arc.name} renamed`;
|
||||||
|
const values = {
|
||||||
|
description: "Un arc sombre où la trahison s'installe.",
|
||||||
|
themes: 'Trahison, rédemption, dette de sang.',
|
||||||
|
stakes: 'La survie du royaume est en jeu.',
|
||||||
|
gmNotes: 'Révéler le traître en scène 3.',
|
||||||
|
rewards: 'Relique ancienne + alliance avec le clan nordique.',
|
||||||
|
resolution: 'Le héros pardonne au traître ou le tue.',
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByLabel(/Titre de l'arc/i).fill(newName);
|
||||||
|
await page.getByLabel(/Synopsis de l'arc/i).fill(values.description);
|
||||||
|
await page.getByLabel(/Thèmes principaux/i).fill(values.themes);
|
||||||
|
await page.getByLabel(/Enjeux globaux/i).fill(values.stakes);
|
||||||
|
await page.getByLabel(/Notes et planification du MJ/i).fill(values.gmNotes);
|
||||||
|
await page.getByLabel(/Récompenses et progression/i).fill(values.rewards);
|
||||||
|
await page.getByLabel(/Dénouement prévu/i).fill(values.resolution);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}$`));
|
||||||
|
|
||||||
|
const persisted = await getArcById(request, arc.id);
|
||||||
|
expect(persisted.name).toBe(newName);
|
||||||
|
expect(persisted.description).toBe(values.description);
|
||||||
|
expect(persisted.themes).toBe(values.themes);
|
||||||
|
expect(persisted.stakes).toBe(values.stakes);
|
||||||
|
expect(persisted.gmNotes).toBe(values.gmNotes);
|
||||||
|
expect(persisted.rewards).toBe(values.rewards);
|
||||||
|
expect(persisted.resolution).toBe(values.resolution);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save button is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/edit`);
|
||||||
|
|
||||||
|
const nameField = page.getByLabel(/Titre de l'arc/i);
|
||||||
|
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||||
|
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
await nameField.fill('');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
await nameField.fill('Valid');
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
web/e2e/tests/campaign/campaign-create.spec.ts
Normal file
96
web/e2e/tests/campaign/campaign-create.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedLoreWithFolder,
|
||||||
|
deleteLore,
|
||||||
|
deleteCampaign,
|
||||||
|
getCampaigns,
|
||||||
|
type SeededLore,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Campaign creation', () => {
|
||||||
|
const createdCampaignIds: string[] = [];
|
||||||
|
let linkedLore: SeededLore;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
linkedLore = await seedLoreWithFolder(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
while (createdCampaignIds.length) {
|
||||||
|
await deleteCampaign(request, createdCampaignIds.pop()!);
|
||||||
|
}
|
||||||
|
if (linkedLore?.id) await deleteLore(request, linkedLore.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a standalone campaign (no lore, no system) and shows it in the grid', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const campaignName = `Campagne E2E ${Date.now()}`;
|
||||||
|
const description = 'Une campagne créée par les tests automatisés.';
|
||||||
|
|
||||||
|
await page.goto('/campaigns');
|
||||||
|
await expect(page.getByRole('heading', { name: /Vos Campagnes|Campagnes/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.campaign-card.card-new').click();
|
||||||
|
|
||||||
|
const modal = page.locator('.modal');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
|
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
|
||||||
|
await modal.getByLabel(/Description/i).fill(description);
|
||||||
|
await modal.getByLabel(/Nombre de joueurs/i).fill('5');
|
||||||
|
|
||||||
|
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
|
||||||
|
|
||||||
|
await expect(modal).not.toBeVisible();
|
||||||
|
|
||||||
|
const newCard = page.locator('.campaign-card', { hasText: campaignName });
|
||||||
|
await expect(newCard).toBeVisible();
|
||||||
|
|
||||||
|
const campaigns = await getCampaigns(request);
|
||||||
|
const created = campaigns.find((c) => c.name === campaignName);
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
expect(created!.loreId).toBeNull();
|
||||||
|
createdCampaignIds.push(created!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a campaign linked to an existing lore', async ({ page, request }) => {
|
||||||
|
const campaignName = `Campagne liée ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto('/campaigns');
|
||||||
|
await page.locator('.campaign-card.card-new').click();
|
||||||
|
|
||||||
|
const modal = page.locator('.modal');
|
||||||
|
await modal.getByLabel(/Nom de la campagne/i).fill(campaignName);
|
||||||
|
await modal.getByLabel(/Univers associé/i).selectOption({ label: linkedLore.name });
|
||||||
|
|
||||||
|
await modal.getByRole('button', { name: /^Créer la campagne$/i }).click();
|
||||||
|
await expect(modal).not.toBeVisible();
|
||||||
|
|
||||||
|
const campaigns = await getCampaigns(request);
|
||||||
|
const created = campaigns.find((c) => c.name === campaignName);
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
expect(created!.loreId).toBe(linkedLore.id);
|
||||||
|
createdCampaignIds.push(created!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit is disabled without a name and when player count is invalid', async ({ page }) => {
|
||||||
|
await page.goto('/campaigns');
|
||||||
|
await page.locator('.campaign-card.card-new').click();
|
||||||
|
|
||||||
|
const modal = page.locator('.modal');
|
||||||
|
const submit = modal.getByRole('button', { name: /^Créer la campagne$/i });
|
||||||
|
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await modal.getByLabel(/Nom de la campagne/i).fill('Valid name');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
|
||||||
|
await modal.getByLabel(/Nombre de joueurs/i).fill('0');
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await modal.getByLabel(/Nombre de joueurs/i).fill('3');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
41
web/e2e/tests/campaign/campaign-delete.spec.ts
Normal file
41
web/e2e/tests/campaign/campaign-delete.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedCampaign, deleteCampaign, type SeededCampaign } from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Campaign delete', () => {
|
||||||
|
let campaign: SeededCampaign;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
campaign = await seedCampaign(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a campaign after accepting confirm and redirects to the list', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
|
await page.goto(`/campaigns/${campaign.id}`);
|
||||||
|
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/campaigns$/);
|
||||||
|
|
||||||
|
const res = await request.get(`/api/campaigns/${campaign.id}`);
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the campaign when confirm is dismissed', async ({ page, request }) => {
|
||||||
|
page.on('dialog', (dialog) => dialog.dismiss());
|
||||||
|
|
||||||
|
await page.goto(`/campaigns/${campaign.id}`);
|
||||||
|
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
|
||||||
|
|
||||||
|
const res = await request.get(`/api/campaigns/${campaign.id}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
54
web/e2e/tests/campaign/chapter-create.spec.ts
Normal file
54
web/e2e/tests/campaign/chapter-create.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedCampaign,
|
||||||
|
seedArc,
|
||||||
|
deleteCampaign,
|
||||||
|
getChapterById,
|
||||||
|
type SeededCampaign,
|
||||||
|
type SeededArc,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Chapter creation', () => {
|
||||||
|
let campaign: SeededCampaign;
|
||||||
|
let arc: SeededArc;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
campaign = await seedCampaign(request);
|
||||||
|
arc = await seedArc(request, { campaignId: campaign.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a chapter and redirects to its view', async ({ page, request }) => {
|
||||||
|
const chapterName = `Chapitre ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
|
||||||
|
await expect(page.getByRole('heading', { name: /Créer un nouveau chapitre/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom du chapitre/i).fill(chapterName);
|
||||||
|
await page.getByLabel(/Description/i).fill('Synopsis du chapitre');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer le chapitre$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/\\d+$`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdId = page.url().match(/\/chapters\/(\d+)$/)?.[1];
|
||||||
|
expect(createdId).toBeTruthy();
|
||||||
|
const persisted = await getChapterById(request, createdId!);
|
||||||
|
expect(persisted.name).toBe(chapterName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/create`);
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer le chapitre$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom du chapitre/i).fill('OK');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
72
web/e2e/tests/campaign/chapter-edit.spec.ts
Normal file
72
web/e2e/tests/campaign/chapter-edit.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedCampaign,
|
||||||
|
seedArc,
|
||||||
|
seedChapter,
|
||||||
|
deleteCampaign,
|
||||||
|
getChapterById,
|
||||||
|
type SeededCampaign,
|
||||||
|
type SeededArc,
|
||||||
|
type SeededChapter,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Chapter edit', () => {
|
||||||
|
let campaign: SeededCampaign;
|
||||||
|
let arc: SeededArc;
|
||||||
|
let chapter: SeededChapter;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
campaign = await seedCampaign(request);
|
||||||
|
arc = await seedArc(request, { campaignId: campaign.id });
|
||||||
|
chapter = await seedChapter(request, { arcId: arc.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits all chapter fields and persists them to API', async ({ page, request }) => {
|
||||||
|
const newName = `${chapter.name} renamed`;
|
||||||
|
const values = {
|
||||||
|
description: 'Le chapitre ouvre sur un village en proie à la peur.',
|
||||||
|
gmNotes: 'Le maire cache un pacte avec les gobelins.',
|
||||||
|
playerObjectives: "Découvrir la source des disparitions.",
|
||||||
|
narrativeStakes: "La confiance du village est en jeu.",
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
|
||||||
|
|
||||||
|
await expect(page.getByLabel(/Titre du chapitre/i)).toHaveValue(chapter.name);
|
||||||
|
|
||||||
|
await page.getByLabel(/Titre du chapitre/i).fill(newName);
|
||||||
|
await page.getByLabel(/Synopsis du chapitre/i).fill(values.description);
|
||||||
|
await page.getByLabel(/Notes du Maître de Jeu/i).fill(values.gmNotes);
|
||||||
|
await page.getByLabel(/Objectifs des joueurs/i).fill(values.playerObjectives);
|
||||||
|
await page.getByLabel(/Enjeux narratifs/i).fill(values.narrativeStakes);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}$`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const persisted = await getChapterById(request, chapter.id);
|
||||||
|
expect(persisted.name).toBe(newName);
|
||||||
|
expect(persisted.description).toBe(values.description);
|
||||||
|
expect(persisted.gmNotes).toBe(values.gmNotes);
|
||||||
|
expect(persisted.playerObjectives).toBe(values.playerObjectives);
|
||||||
|
expect(persisted.narrativeStakes).toBe(values.narrativeStakes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save button is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto(`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/edit`);
|
||||||
|
const nameField = page.getByLabel(/Titre du chapitre/i);
|
||||||
|
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||||
|
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
await nameField.fill('');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
await nameField.fill('OK');
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
64
web/e2e/tests/campaign/scene-create.spec.ts
Normal file
64
web/e2e/tests/campaign/scene-create.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedCampaign,
|
||||||
|
seedArc,
|
||||||
|
seedChapter,
|
||||||
|
deleteCampaign,
|
||||||
|
getSceneById,
|
||||||
|
type SeededCampaign,
|
||||||
|
type SeededArc,
|
||||||
|
type SeededChapter,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Scene creation', () => {
|
||||||
|
let campaign: SeededCampaign;
|
||||||
|
let arc: SeededArc;
|
||||||
|
let chapter: SeededChapter;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
campaign = await seedCampaign(request);
|
||||||
|
arc = await seedArc(request, { campaignId: campaign.id });
|
||||||
|
chapter = await seedChapter(request, { arcId: arc.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a scene and redirects to its view', async ({ page, request }) => {
|
||||||
|
const sceneName = `Scène ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('heading', { name: /Créer une nouvelle scène/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom de la scène/i).fill(sceneName);
|
||||||
|
await page.getByLabel(/Description/i).fill('Résumé rapide de la scène.');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer la scène$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(
|
||||||
|
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/\\d+$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdId = page.url().match(/\/scenes\/(\d+)$/)?.[1];
|
||||||
|
expect(createdId).toBeTruthy();
|
||||||
|
const persisted = await getSceneById(request, createdId!);
|
||||||
|
expect(persisted.name).toBe(sceneName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto(
|
||||||
|
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/create`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer la scène$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom de la scène/i).fill('OK');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
147
web/e2e/tests/campaign/scene-edit.spec.ts
Normal file
147
web/e2e/tests/campaign/scene-edit.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedCampaign,
|
||||||
|
seedArc,
|
||||||
|
seedChapter,
|
||||||
|
seedScene,
|
||||||
|
deleteCampaign,
|
||||||
|
getSceneById,
|
||||||
|
type SeededCampaign,
|
||||||
|
type SeededArc,
|
||||||
|
type SeededChapter,
|
||||||
|
type SeededScene,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Scene edit', () => {
|
||||||
|
let campaign: SeededCampaign;
|
||||||
|
let arc: SeededArc;
|
||||||
|
let chapter: SeededChapter;
|
||||||
|
let scene: SeededScene;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
campaign = await seedCampaign(request);
|
||||||
|
arc = await seedArc(request, { campaignId: campaign.id });
|
||||||
|
chapter = await seedChapter(request, { arcId: arc.id });
|
||||||
|
scene = await seedScene(request, { chapterId: chapter.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (campaign?.id) await deleteCampaign(request, campaign.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits all text fields across sections and persists them to API', async ({ page, request }) => {
|
||||||
|
const newName = `${scene.name} renamed`;
|
||||||
|
const values = {
|
||||||
|
description: "Les PJ arrivent au village à la nuit tombée.",
|
||||||
|
location: "Taverne du Dragon d'Or",
|
||||||
|
timing: 'Soir, pleine lune',
|
||||||
|
atmosphere: 'Silence pesant, regards fuyants des villageois.',
|
||||||
|
playerNarration: 'Vous poussez la porte de la taverne…',
|
||||||
|
gmSecretNotes: 'Le tavernier est complice des bandits.',
|
||||||
|
choicesConsequences: 'Accepter = piégés à l\'étage. Refuser = filature.',
|
||||||
|
combatDifficulty: 'Moyenne, 4 bandits niveau 3',
|
||||||
|
enemies: 'Bandit chef (feuille jointe) + 3 sbires.',
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page.getByLabel(/Titre de la scène/i)).toHaveValue(scene.name);
|
||||||
|
|
||||||
|
await page.getByLabel(/Titre de la scène/i).fill(newName);
|
||||||
|
await page.getByLabel(/Description courte/i).fill(values.description);
|
||||||
|
await page.getByLabel(/^Lieu$/i).fill(values.location);
|
||||||
|
await page.getByLabel(/^Moment$/i).fill(values.timing);
|
||||||
|
await page.getByLabel(/Ambiance et atmosphère/i).fill(values.atmosphere);
|
||||||
|
|
||||||
|
// Les sections suivantes sont fermées par défaut : on les ouvre avant de taper.
|
||||||
|
// Un clic sur le header de la section toggle son état.
|
||||||
|
await page.locator('app-expandable-section', { hasText: 'Narration pour les joueurs' }).click();
|
||||||
|
await page.getByPlaceholder(/Le texte que vous lirez aux joueurs/i).fill(values.playerNarration);
|
||||||
|
|
||||||
|
await page.locator('app-expandable-section', { hasText: 'Notes et secrets du MJ' }).click();
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/Informations cachées, indices, éléments secrets/i)
|
||||||
|
.fill(values.gmSecretNotes);
|
||||||
|
|
||||||
|
await page.locator('app-expandable-section', { hasText: 'Choix et conséquences' }).click();
|
||||||
|
await page
|
||||||
|
.getByPlaceholder(/Décrivez les différentes options/i)
|
||||||
|
.fill(values.choicesConsequences);
|
||||||
|
|
||||||
|
await page.locator('app-expandable-section', { hasText: 'Combat ou rencontre' }).click();
|
||||||
|
await page.getByLabel(/Difficulté estimée/i).fill(values.combatDifficulty);
|
||||||
|
await page.getByLabel(/Ennemis et créatures/i).fill(values.enemies);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(
|
||||||
|
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const persisted = await getSceneById(request, scene.id);
|
||||||
|
expect(persisted.name).toBe(newName);
|
||||||
|
expect(persisted.description).toBe(values.description);
|
||||||
|
expect(persisted.location).toBe(values.location);
|
||||||
|
expect(persisted.timing).toBe(values.timing);
|
||||||
|
expect(persisted.atmosphere).toBe(values.atmosphere);
|
||||||
|
expect(persisted.playerNarration).toBe(values.playerNarration);
|
||||||
|
expect(persisted.gmSecretNotes).toBe(values.gmSecretNotes);
|
||||||
|
expect(persisted.choicesConsequences).toBe(values.choicesConsequences);
|
||||||
|
expect(persisted.combatDifficulty).toBe(values.combatDifficulty);
|
||||||
|
expect(persisted.enemies).toBe(values.enemies);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a narrative branch to a sibling scene and persists it', async ({ page, request }) => {
|
||||||
|
const sibling = await seedScene(request, {
|
||||||
|
chapterId: chapter.id,
|
||||||
|
name: 'Scène alternative',
|
||||||
|
order: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const branchesSection = page.locator('app-expandable-section', { hasText: 'Branches narratives' });
|
||||||
|
await branchesSection.click();
|
||||||
|
|
||||||
|
await branchesSection.getByRole('button', { name: /Ajouter une branche/i }).click();
|
||||||
|
|
||||||
|
const branchItem = branchesSection.locator('.branch-item').first();
|
||||||
|
await branchItem.getByPlaceholder(/Ex: Si les joueurs attaquent/i).fill('Si les PJ fuient');
|
||||||
|
await branchItem.locator('select').selectOption({ label: sibling.name });
|
||||||
|
await branchItem.getByPlaceholder(/Jet de Persuasion/i).fill('Sur échec initiative');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(
|
||||||
|
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const persisted = await getSceneById(request, scene.id);
|
||||||
|
expect(persisted.branches).toHaveLength(1);
|
||||||
|
expect(persisted.branches![0].label).toBe('Si les PJ fuient');
|
||||||
|
expect(persisted.branches![0].targetSceneId).toBe(sibling.id);
|
||||||
|
expect(persisted.branches![0].condition).toBe('Sur échec initiative');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save button is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto(
|
||||||
|
`/campaigns/${campaign.id}/arcs/${arc.id}/chapters/${chapter.id}/scenes/${scene.id}/edit`,
|
||||||
|
);
|
||||||
|
const nameField = page.getByLabel(/Titre de la scène/i);
|
||||||
|
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||||
|
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
await nameField.fill('');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
await nameField.fill('OK');
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
web/e2e/tests/lore/lore-create.spec.ts
Normal file
77
web/e2e/tests/lore/lore-create.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { deleteLore } from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Lore creation', () => {
|
||||||
|
const createdLoreIds: string[] = [];
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
while (createdLoreIds.length) {
|
||||||
|
const id = createdLoreIds.pop()!;
|
||||||
|
await deleteLore(request, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens the modal, creates a lore, and shows it in the grid', async ({ page, request }) => {
|
||||||
|
const loreName = `Univers E2E ${Date.now()}`;
|
||||||
|
const description = "Un univers créé par les tests automatisés.";
|
||||||
|
|
||||||
|
await page.goto('/lore');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Vos Univers/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.lore-card.card-new').click();
|
||||||
|
|
||||||
|
const modal = page.locator('.modal');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await expect(modal.getByRole('heading', { name: /Créer un nouveau Lore/i })).toBeVisible();
|
||||||
|
|
||||||
|
await modal.getByLabel(/Nom de l'univers/i).fill(loreName);
|
||||||
|
await modal.getByLabel('Description').fill(description);
|
||||||
|
|
||||||
|
await modal.getByRole('button', { name: /^Créer le lore$/i }).click();
|
||||||
|
|
||||||
|
await expect(modal).not.toBeVisible();
|
||||||
|
|
||||||
|
const newCard = page.locator('.lore-card', { hasText: loreName });
|
||||||
|
await expect(newCard).toBeVisible();
|
||||||
|
await expect(newCard).toContainText(description);
|
||||||
|
|
||||||
|
const allLores = await request.get('/api/lores').then((r) => r.json());
|
||||||
|
const created = allLores.find((l: { name: string; id: string }) => l.name === loreName);
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
createdLoreIds.push(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit button is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto('/lore');
|
||||||
|
await page.locator('.lore-card.card-new').click();
|
||||||
|
|
||||||
|
const modal = page.locator('.modal');
|
||||||
|
const submit = modal.getByRole('button', { name: /^Créer le lore$/i });
|
||||||
|
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await modal.getByLabel(/Nom de l'univers/i).fill('Quelque chose');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
|
||||||
|
await modal.getByLabel(/Nom de l'univers/i).fill('');
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the backdrop closes the modal without creating', async ({ page }) => {
|
||||||
|
const typedButAbandoned = `Univers abandonné ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto('/lore');
|
||||||
|
await page.locator('.lore-card.card-new').click();
|
||||||
|
|
||||||
|
const modal = page.locator('.modal');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
|
await modal.getByLabel(/Nom de l'univers/i).fill(typedButAbandoned);
|
||||||
|
|
||||||
|
await page.locator('.modal-backdrop').click({ position: { x: 5, y: 5 } });
|
||||||
|
await expect(modal).not.toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('.lore-card', { hasText: typedButAbandoned })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
web/e2e/tests/lore/lore-delete.spec.ts
Normal file
53
web/e2e/tests/lore/lore-delete.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedLoreWithFolder, deleteLore, type SeededLore } from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Lore delete', () => {
|
||||||
|
let seeded: SeededLore;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seeded = await seedLoreWithFolder(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
// Best-effort cleanup — ne fait rien si déjà supprimé par le test.
|
||||||
|
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a lore after accepting the confirm and redirects to the list', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
let confirmMessage = '';
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
confirmMessage = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
|
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
// Attente du dialog et du retour sur la liste des lores.
|
||||||
|
await expect(page).toHaveURL(/\/lore$/);
|
||||||
|
expect(confirmMessage).toContain(seeded.name);
|
||||||
|
// Lore contient un dossier seedé : le récapitulatif doit l'indiquer.
|
||||||
|
expect(confirmMessage).toMatch(/1 dossier/i);
|
||||||
|
|
||||||
|
const res = await request.get(`/api/lores/${seeded.id}`);
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the lore when the confirm is dismissed', async ({ page, request }) => {
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
|
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
// On reste sur le détail, le titre du lore est toujours visible.
|
||||||
|
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||||
|
|
||||||
|
const res = await request.get(`/api/lores/${seeded.id}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
web/e2e/tests/lore/lore-detail-edit.spec.ts
Normal file
70
web/e2e/tests/lore/lore-detail-edit.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedLoreWithFolder, deleteLore, getLoreById, type SeededLore } from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Lore inline edit (on detail page)', () => {
|
||||||
|
let seeded: SeededLore;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seeded = await seedLoreWithFolder(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renames the lore inline and persists the change', async ({ page, request }) => {
|
||||||
|
const newName = `${seeded.name} renamed`;
|
||||||
|
const newDescription = 'Nouvelle description éditée via UI';
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
|
|
||||||
|
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Modifier$/i }).click();
|
||||||
|
|
||||||
|
const nameInput = page.getByLabel(/^Nom$/);
|
||||||
|
const descInput = page.getByLabel(/^Description$/);
|
||||||
|
await expect(nameInput).toHaveValue(seeded.name);
|
||||||
|
|
||||||
|
await nameInput.fill(newName);
|
||||||
|
await descInput.fill(newDescription);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
|
||||||
|
// Sortie du mode édition : le header bascule en mode lecture avec la nouvelle valeur.
|
||||||
|
await expect(page.locator('.detail-header h1')).toHaveText(newName);
|
||||||
|
await expect(page.locator('.detail-header .description')).toHaveText(newDescription);
|
||||||
|
|
||||||
|
const persisted = await getLoreById(request, seeded.id);
|
||||||
|
expect(persisted.name).toBe(newName);
|
||||||
|
expect(persisted.description).toBe(newDescription);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save button is disabled when name is emptied during edit', async ({ page }) => {
|
||||||
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
|
await page.getByRole('button', { name: /^Modifier$/i }).click();
|
||||||
|
|
||||||
|
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom$/).fill('');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom$/).fill(' ');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel discards the in-flight edits', async ({ page, request }) => {
|
||||||
|
await page.goto(`/lore/${seeded.id}`);
|
||||||
|
await page.getByRole('button', { name: /^Modifier$/i }).click();
|
||||||
|
|
||||||
|
await page.getByLabel(/^Nom$/).fill('Name jamais sauvegardé');
|
||||||
|
await page.getByRole('button', { name: /^Annuler$/i }).click();
|
||||||
|
|
||||||
|
// Retour en mode lecture avec le nom d'origine.
|
||||||
|
await expect(page.locator('.detail-header h1')).toHaveText(seeded.name);
|
||||||
|
|
||||||
|
const persisted = await getLoreById(request, seeded.id);
|
||||||
|
expect(persisted.name).toBe(seeded.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
web/e2e/tests/lore/page-create.spec.ts
Normal file
90
web/e2e/tests/lore/page-create.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedLoreWithFolder,
|
||||||
|
seedTemplate,
|
||||||
|
deleteLore,
|
||||||
|
getPagesForLore,
|
||||||
|
type SeededLore,
|
||||||
|
type SeededTemplate,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Page creation', () => {
|
||||||
|
let seeded: SeededLore;
|
||||||
|
let template: SeededTemplate;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seeded = await seedLoreWithFolder(request);
|
||||||
|
template = await seedTemplate(request, {
|
||||||
|
loreId: seeded.id,
|
||||||
|
defaultNodeId: seeded.rootFolderId,
|
||||||
|
fieldNames: ['Apparence', 'Motivation'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates an empty page from a template and redirects to edit', async ({ page, request }) => {
|
||||||
|
const pageTitle = `Maître Eldrin ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/pages/create`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Créer une nouvelle Page/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/Titre de la page/i).fill(pageTitle);
|
||||||
|
|
||||||
|
await page.locator('.template-card', { hasText: template.name }).click();
|
||||||
|
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer la page$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`));
|
||||||
|
|
||||||
|
const pages = await getPagesForLore(request, seeded.id);
|
||||||
|
const created = pages.find((p) => p.title === pageTitle);
|
||||||
|
expect(created).toBeDefined();
|
||||||
|
expect(created?.templateId).toBe(template.id);
|
||||||
|
expect(created?.nodeId).toBe(seeded.rootFolderId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit is disabled until title, template and folder are set', async ({ page }) => {
|
||||||
|
await page.goto(`/lore/${seeded.id}/pages/create`);
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer la page$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/Titre de la page/i).fill('Un titre');
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.locator('.template-card', { hasText: template.name }).click();
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => {
|
||||||
|
const pageTitle = `Page scoped ${Date.now()}`;
|
||||||
|
|
||||||
|
const secondFolderRes = await request.post('/api/lore-nodes', {
|
||||||
|
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
|
||||||
|
});
|
||||||
|
expect(secondFolderRes.ok()).toBeTruthy();
|
||||||
|
const secondFolderId = (await secondFolderRes.json()).id;
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/nodes/${secondFolderId}/pages/create`);
|
||||||
|
|
||||||
|
const nodeSelect = page.locator('#page-node');
|
||||||
|
await expect(nodeSelect).toHaveValue(secondFolderId);
|
||||||
|
await expect(nodeSelect).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/Titre de la page/i).fill(pageTitle);
|
||||||
|
await page.locator('.template-card', { hasText: template.name }).click();
|
||||||
|
await page.getByRole('button', { name: /^Créer la page$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/[^/]+/edit$`));
|
||||||
|
|
||||||
|
const pages = await getPagesForLore(request, seeded.id);
|
||||||
|
expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
web/e2e/tests/lore/page-delete.spec.ts
Normal file
58
web/e2e/tests/lore/page-delete.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedLoreWithFolder,
|
||||||
|
seedTemplate,
|
||||||
|
seedPage,
|
||||||
|
deleteLore,
|
||||||
|
type SeededLore,
|
||||||
|
type SeededTemplate,
|
||||||
|
type SeededPage,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Page delete', () => {
|
||||||
|
let seeded: SeededLore;
|
||||||
|
let template: SeededTemplate;
|
||||||
|
let pageEntity: SeededPage;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seeded = await seedLoreWithFolder(request);
|
||||||
|
template = await seedTemplate(request, {
|
||||||
|
loreId: seeded.id,
|
||||||
|
defaultNodeId: seeded.rootFolderId,
|
||||||
|
});
|
||||||
|
pageEntity = await seedPage(request, {
|
||||||
|
loreId: seeded.id,
|
||||||
|
nodeId: seeded.rootFolderId,
|
||||||
|
templateId: template.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes the page after accepting confirm', async ({ page, request }) => {
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
|
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
// Le composant redirige vers la racine du Lore après suppression.
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
|
|
||||||
|
const res = await request.get(`/api/pages/${pageEntity.id}`);
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the page when confirm is dismissed', async ({ page, request }) => {
|
||||||
|
page.on('dialog', (dialog) => dialog.dismiss());
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
|
await page.getByRole('button', { name: /^Supprimer$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}/edit$`));
|
||||||
|
|
||||||
|
const res = await request.get(`/api/pages/${pageEntity.id}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
web/e2e/tests/lore/page-edit.spec.ts
Normal file
95
web/e2e/tests/lore/page-edit.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedLoreWithFolder,
|
||||||
|
seedTemplate,
|
||||||
|
seedPage,
|
||||||
|
deleteLore,
|
||||||
|
getPageById,
|
||||||
|
type SeededLore,
|
||||||
|
type SeededTemplate,
|
||||||
|
type SeededPage,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Page edit', () => {
|
||||||
|
let seeded: SeededLore;
|
||||||
|
let template: SeededTemplate;
|
||||||
|
let pageEntity: SeededPage;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seeded = await seedLoreWithFolder(request);
|
||||||
|
template = await seedTemplate(request, {
|
||||||
|
loreId: seeded.id,
|
||||||
|
defaultNodeId: seeded.rootFolderId,
|
||||||
|
fieldNames: ['Apparence', 'Motivation'],
|
||||||
|
});
|
||||||
|
pageEntity = await seedPage(request, {
|
||||||
|
loreId: seeded.id,
|
||||||
|
nodeId: seeded.rootFolderId,
|
||||||
|
templateId: template.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edits title, dynamic fields, notes and persists to API', async ({ page, request }) => {
|
||||||
|
const newTitle = `Maître Eldrin ${Date.now()}`;
|
||||||
|
const apparence = 'Vieil homme au regard perçant.';
|
||||||
|
const motivation = 'Protéger la cité à tout prix.';
|
||||||
|
const notes = 'MJ : il connaît le secret du roi.';
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
|
|
||||||
|
await expect(page.locator('input[name="title"]')).toHaveValue(pageEntity.title);
|
||||||
|
|
||||||
|
await page.locator('input[name="title"]').fill(newTitle);
|
||||||
|
await page.getByPlaceholder('Valeur pour Apparence...').fill(apparence);
|
||||||
|
await page.getByPlaceholder('Valeur pour Motivation...').fill(motivation);
|
||||||
|
await page.locator('textarea[name="notes"]').fill(notes);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`));
|
||||||
|
|
||||||
|
const persisted = await getPageById(request, pageEntity.id);
|
||||||
|
expect(persisted.title).toBe(newTitle);
|
||||||
|
expect(persisted.values?.['Apparence']).toBe(apparence);
|
||||||
|
expect(persisted.values?.['Motivation']).toBe(motivation);
|
||||||
|
expect(persisted.notes).toBe(notes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a tag via chips input and persists it', async ({ page, request }) => {
|
||||||
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
|
|
||||||
|
const chipsInput = page.locator('app-chips-input input');
|
||||||
|
await chipsInput.fill('dangereux');
|
||||||
|
await chipsInput.press('Enter');
|
||||||
|
await chipsInput.fill('ancien');
|
||||||
|
await chipsInput.press('Enter');
|
||||||
|
|
||||||
|
await expect(page.locator('app-chips-input').getByText('dangereux')).toBeVisible();
|
||||||
|
await expect(page.locator('app-chips-input').getByText('ancien')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/pages/${pageEntity.id}$`));
|
||||||
|
|
||||||
|
const persisted = await getPageById(request, pageEntity.id);
|
||||||
|
expect(persisted.tags).toEqual(expect.arrayContaining(['dangereux', 'ancien']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save button is disabled when title is empty', async ({ page }) => {
|
||||||
|
await page.goto(`/lore/${seeded.id}/pages/${pageEntity.id}/edit`);
|
||||||
|
|
||||||
|
const titleInput = page.locator('input[name="title"]');
|
||||||
|
const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
|
||||||
|
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
await titleInput.fill('');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
await titleInput.fill(' ');
|
||||||
|
await expect(saveBtn).toBeDisabled();
|
||||||
|
await titleInput.fill('OK');
|
||||||
|
await expect(saveBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
72
web/e2e/tests/lore/template-create.spec.ts
Normal file
72
web/e2e/tests/lore/template-create.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { seedLoreWithFolder, deleteLore, getTemplatesForLore, type SeededLore } from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Template creation', () => {
|
||||||
|
let seeded: SeededLore;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seeded = await seedLoreWithFolder(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a template with default fields', async ({ page, request }) => {
|
||||||
|
const templateName = `Auberge ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/templates/create`);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Créer un nouveau Template/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom du template/i).fill(templateName);
|
||||||
|
await page.getByLabel('Description').fill('Template créé via E2E');
|
||||||
|
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer le template$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
|
|
||||||
|
const templates = await getTemplatesForLore(request, seeded.id);
|
||||||
|
expect(templates.map((t) => t.name)).toContain(templateName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit is disabled when name is empty', async ({ page }) => {
|
||||||
|
await page.goto(`/lore/${seeded.id}/templates/create`);
|
||||||
|
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer le template$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom du template/i).fill('Valid name');
|
||||||
|
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can add and remove a custom field before creating', async ({ page, request }) => {
|
||||||
|
const templateName = `Artefact ${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/templates/create`);
|
||||||
|
|
||||||
|
await page.getByLabel(/Nom du template/i).fill(templateName);
|
||||||
|
await page.getByLabel(/Dossier par défaut/i).selectOption({ label: seeded.rootFolderName });
|
||||||
|
|
||||||
|
const addFieldInput = page.getByPlaceholder('+ Ajouter un champ');
|
||||||
|
await addFieldInput.fill('Pouvoir');
|
||||||
|
await addFieldInput.press('Enter');
|
||||||
|
await expect(page.locator('.fields-list .field-chip', { hasText: 'Pouvoir' })).toBeVisible();
|
||||||
|
|
||||||
|
await addFieldInput.fill('Origine');
|
||||||
|
await addFieldInput.press('Enter');
|
||||||
|
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toBeVisible();
|
||||||
|
|
||||||
|
const origineRow = page.locator('.fields-list .field-row', { hasText: 'Origine' });
|
||||||
|
await origineRow.getByRole('button', { name: 'Supprimer' }).click();
|
||||||
|
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Créer le template$/i }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
|
|
||||||
|
const templates = await getTemplatesForLore(request, seeded.id);
|
||||||
|
expect(templates.find((t) => t.name === templateName)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
web/e2e/tests/lore/template-delete.spec.ts
Normal file
51
web/e2e/tests/lore/template-delete.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedLoreWithFolder,
|
||||||
|
seedTemplate,
|
||||||
|
deleteLore,
|
||||||
|
getTemplatesForLore,
|
||||||
|
type SeededLore,
|
||||||
|
type SeededTemplate,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Template delete', () => {
|
||||||
|
let seeded: SeededLore;
|
||||||
|
let template: SeededTemplate;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seeded = await seedLoreWithFolder(request);
|
||||||
|
template = await seedTemplate(request, {
|
||||||
|
loreId: seeded.id,
|
||||||
|
defaultNodeId: seeded.rootFolderId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes the template after accepting confirm', async ({ page, request }) => {
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
|
await page.locator('.page-header .btn-danger').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
|
|
||||||
|
const templates = await getTemplatesForLore(request, seeded.id);
|
||||||
|
expect(templates.find((t) => t.id === template.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the template when confirm is dismissed', async ({ page, request }) => {
|
||||||
|
page.on('dialog', (dialog) => dialog.dismiss());
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
|
await page.locator('.page-header .btn-danger').click();
|
||||||
|
|
||||||
|
// On reste sur l'écran d'édition (l'URL ne change pas).
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}/templates/${template.id}$`));
|
||||||
|
|
||||||
|
const templates = await getTemplatesForLore(request, seeded.id);
|
||||||
|
expect(templates.find((t) => t.id === template.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
74
web/e2e/tests/lore/template-edit.spec.ts
Normal file
74
web/e2e/tests/lore/template-edit.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
seedLoreWithFolder,
|
||||||
|
seedTemplate,
|
||||||
|
deleteLore,
|
||||||
|
getTemplateById,
|
||||||
|
type SeededLore,
|
||||||
|
type SeededTemplate,
|
||||||
|
} from '../../fixtures/api';
|
||||||
|
|
||||||
|
test.describe('Template edit', () => {
|
||||||
|
let seeded: SeededLore;
|
||||||
|
let template: SeededTemplate;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ request }) => {
|
||||||
|
seeded = await seedLoreWithFolder(request);
|
||||||
|
template = await seedTemplate(request, {
|
||||||
|
loreId: seeded.id,
|
||||||
|
defaultNodeId: seeded.rootFolderId,
|
||||||
|
fieldNames: ['Nom', 'Description'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
if (seeded?.id) await deleteLore(request, seeded.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('form is prefilled with the current template data', async ({ page }) => {
|
||||||
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
|
|
||||||
|
await expect(page.getByLabel(/^Nom$/)).toHaveValue(template.name);
|
||||||
|
await expect(page.getByLabel(/Dossier par défaut/i)).toHaveValue(seeded.rootFolderId);
|
||||||
|
await expect(page.locator('.fields-list .field-chip', { hasText: 'Nom' })).toBeVisible();
|
||||||
|
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renames the template and persists to API', async ({ page, request }) => {
|
||||||
|
const newName = `${template.name} renamed`;
|
||||||
|
|
||||||
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
|
|
||||||
|
const nameInput = page.getByLabel(/^Nom$/);
|
||||||
|
await nameInput.fill(newName);
|
||||||
|
await page.getByLabel(/Description/i).fill('Description mise à jour');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
|
|
||||||
|
const persisted = await getTemplateById(request, template.id);
|
||||||
|
expect(persisted.name).toBe(newName);
|
||||||
|
expect(persisted.description).toBe('Description mise à jour');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds a new field, removes an existing one, and persists the order', async ({ page, request }) => {
|
||||||
|
await page.goto(`/lore/${seeded.id}/templates/${template.id}`);
|
||||||
|
|
||||||
|
const addInput = page.getByPlaceholder('+ Ajouter un champ');
|
||||||
|
await addInput.fill('Stats');
|
||||||
|
await addInput.press('Enter');
|
||||||
|
await expect(page.locator('.fields-list .field-chip', { hasText: 'Stats' })).toBeVisible();
|
||||||
|
|
||||||
|
const descriptionRow = page.locator('.fields-list .field-row', { hasText: 'Description' });
|
||||||
|
await descriptionRow.getByRole('button', { name: 'Supprimer' }).click();
|
||||||
|
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||||
|
|
||||||
|
const persisted = await getTemplateById(request, template.id);
|
||||||
|
const names = persisted.fields.map((f) => f.name);
|
||||||
|
expect(names).toEqual(['Nom', 'Stats']);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
web/e2e/tests/smoke.spec.ts
Normal file
15
web/e2e/tests/smoke.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Smoke', () => {
|
||||||
|
test('app loads without uncaught errors', async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('pageerror', (err) => errors.push(err.message));
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page.locator('app-sidebar')).toBeVisible();
|
||||||
|
await expect(page.locator('main.main-content')).toBeAttached();
|
||||||
|
|
||||||
|
expect(errors, `Page errors:\n${errors.join('\n')}`).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
web/package-lock.json
generated
68
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.4.0",
|
"version": "0.6.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.4.0",
|
"version": "0.6.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@angular-devkit/build-angular": "^17.0.0",
|
"@angular-devkit/build-angular": "^17.0.0",
|
||||||
"@angular/cli": "^17.0.0",
|
"@angular/cli": "^17.0.0",
|
||||||
"@angular/compiler-cli": "^17.0.0",
|
"@angular/compiler-cli": "^17.0.0",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"typescript": "~5.2.2"
|
"typescript": "~5.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3137,6 +3138,22 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||||
@@ -9063,6 +9080,53 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.35",
|
"version": "8.4.35",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.1",
|
"version": "0.6.2",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test"
|
"test": "ng test",
|
||||||
|
"e2e": "playwright test",
|
||||||
|
"e2e:ui": "playwright test --ui",
|
||||||
|
"e2e:headed": "playwright test --headed",
|
||||||
|
"e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -30,6 +34,7 @@
|
|||||||
"@angular-devkit/build-angular": "^17.0.0",
|
"@angular-devkit/build-angular": "^17.0.0",
|
||||||
"@angular/cli": "^17.0.0",
|
"@angular/cli": "^17.0.0",
|
||||||
"@angular/compiler-cli": "^17.0.0",
|
"@angular/compiler-cli": "^17.0.0",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"typescript": "~5.2.2"
|
"typescript": "~5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
web/playwright.config.ts
Normal file
24
web/playwright.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseURL = process.env['E2E_BASE_URL'] || 'http://localhost:8081';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e/tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env['CI'],
|
||||||
|
retries: process.env['CI'] ? 2 : 0,
|
||||||
|
workers: process.env['CI'] ? 1 : undefined,
|
||||||
|
reporter: process.env['CI'] ? [['html', { open: 'never' }], ['list']] : 'html',
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -7,8 +7,9 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="arc-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="arc-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom de l'arc *</label>
|
<label for="arc-create-name">Nom de l'arc *</label>
|
||||||
<input
|
<input
|
||||||
|
id="arc-create-name"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Ex: L'Ombre du Nord"
|
placeholder="Ex: L'Ombre du Nord"
|
||||||
@@ -17,8 +18,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description</label>
|
<label for="arc-create-description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="arc-create-description"
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Décrivez l'arc narratif principal..."
|
placeholder="Décrivez l'arc narratif principal..."
|
||||||
rows="5">
|
rows="5">
|
||||||
|
|||||||
@@ -43,8 +43,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Titre de l'arc *</label>
|
<label for="arc-edit-name">Titre de l'arc *</label>
|
||||||
<input
|
<input
|
||||||
|
id="arc-edit-name"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Ex: L'Ombre du Nord"
|
placeholder="Ex: L'Ombre du Nord"
|
||||||
@@ -53,8 +54,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Synopsis de l'arc</label>
|
<label for="arc-edit-description">Synopsis de l'arc</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="arc-edit-description"
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Décrivez l'histoire principale de cet arc narratif..."
|
placeholder="Décrivez l'histoire principale de cet arc narratif..."
|
||||||
rows="5">
|
rows="5">
|
||||||
@@ -63,16 +65,18 @@
|
|||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Thèmes principaux</label>
|
<label for="arc-edit-themes">Thèmes principaux</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="arc-edit-themes"
|
||||||
formControlName="themes"
|
formControlName="themes"
|
||||||
placeholder="Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)"
|
placeholder="Quels sont les thèmes explorés dans cet arc ? (trahison, rédemption...)"
|
||||||
rows="4">
|
rows="4">
|
||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Enjeux globaux</label>
|
<label for="arc-edit-stakes">Enjeux globaux</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="arc-edit-stakes"
|
||||||
formControlName="stakes"
|
formControlName="stakes"
|
||||||
placeholder="Quels sont les enjeux majeurs de cet arc pour les personnages ?"
|
placeholder="Quels sont les enjeux majeurs de cet arc pour les personnages ?"
|
||||||
rows="4">
|
rows="4">
|
||||||
@@ -81,8 +85,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Notes et planification du MJ</label>
|
<label for="arc-edit-gm-notes">Notes et planification du MJ</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="arc-edit-gm-notes"
|
||||||
formControlName="gmNotes"
|
formControlName="gmNotes"
|
||||||
placeholder="Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes..."
|
placeholder="Vos notes sur la direction de l'arc, les twists prévus, les révélations importantes..."
|
||||||
rows="5">
|
rows="5">
|
||||||
@@ -91,8 +96,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Récompenses et progression</label>
|
<label for="arc-edit-rewards">Récompenses et progression</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="arc-edit-rewards"
|
||||||
formControlName="rewards"
|
formControlName="rewards"
|
||||||
placeholder="Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts..."
|
placeholder="Quelles récompenses les joueurs obtiendront-ils ? Objets, niveaux, connaissances, contacts..."
|
||||||
rows="4">
|
rows="4">
|
||||||
@@ -100,8 +106,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Dénouement prévu</label>
|
<label for="arc-edit-resolution">Dénouement prévu</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="arc-edit-resolution"
|
||||||
formControlName="resolution"
|
formControlName="resolution"
|
||||||
placeholder="Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?"
|
placeholder="Comment cet arc devrait-il se terminer ? Quelles sont les issues possibles ?"
|
||||||
rows="4">
|
rows="4">
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom de la campagne *</label>
|
<label for="campaign-name">Nom de la campagne *</label>
|
||||||
<input
|
<input
|
||||||
|
id="campaign-name"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
|
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
|
||||||
@@ -21,8 +22,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description / Pitch</label>
|
<label for="campaign-description">Description / Pitch</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="campaign-description"
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Résumez l'intrigue principale de votre campagne..."
|
placeholder="Résumez l'intrigue principale de votre campagne..."
|
||||||
rows="5"
|
rows="5"
|
||||||
@@ -30,13 +32,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nombre de joueurs</label>
|
<label for="campaign-player-count">Nombre de joueurs</label>
|
||||||
<input type="number" formControlName="playerCount" min="1" />
|
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Univers associé</label>
|
<label for="campaign-lore">Univers associé</label>
|
||||||
<select formControlName="loreId">
|
<select id="campaign-lore" formControlName="loreId">
|
||||||
<option value="">— Aucun univers (campagne libre) —</option>
|
<option value="">— Aucun univers (campagne libre) —</option>
|
||||||
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
|
<option *ngFor="let lore of availableLores" [value]="lore.id">{{ lore.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -47,8 +49,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Système de JDR</label>
|
<label for="campaign-game-system">Système de JDR</label>
|
||||||
<select formControlName="gameSystemId">
|
<select id="campaign-game-system" formControlName="gameSystemId">
|
||||||
<option value="">— Aucun (campagne générique) —</option>
|
<option value="">— Aucun (campagne générique) —</option>
|
||||||
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
<option *ngFor="let gs of availableGameSystems" [value]="gs.id">{{ gs.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom du chapitre *</label>
|
<label for="chapter-create-name">Nom du chapitre *</label>
|
||||||
<input
|
<input
|
||||||
|
id="chapter-create-name"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Ex: Chapitre 1: Les Disparitions"
|
placeholder="Ex: Chapitre 1: Les Disparitions"
|
||||||
@@ -18,8 +19,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description</label>
|
<label for="chapter-create-description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="chapter-create-description"
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Décrivez ce chapitre..."
|
placeholder="Décrivez ce chapitre..."
|
||||||
rows="5">
|
rows="5">
|
||||||
|
|||||||
@@ -43,8 +43,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Titre du chapitre *</label>
|
<label for="chapter-edit-name">Titre du chapitre *</label>
|
||||||
<input
|
<input
|
||||||
|
id="chapter-edit-name"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Ex: Chapitre 1: Les Disparitions"
|
placeholder="Ex: Chapitre 1: Les Disparitions"
|
||||||
@@ -53,8 +54,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Synopsis du chapitre</label>
|
<label for="chapter-edit-description">Synopsis du chapitre</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="chapter-edit-description"
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Décrivez brièvement ce qui se passe dans ce chapitre..."
|
placeholder="Décrivez brièvement ce qui se passe dans ce chapitre..."
|
||||||
rows="5">
|
rows="5">
|
||||||
@@ -62,8 +64,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Notes du Maître de Jeu</label>
|
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="chapter-edit-gm-notes"
|
||||||
formControlName="gmNotes"
|
formControlName="gmNotes"
|
||||||
placeholder="Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements..."
|
placeholder="Vos notes privées sur le déroulement du chapitre, les événements clés, les rebondissements..."
|
||||||
rows="6">
|
rows="6">
|
||||||
@@ -73,16 +76,18 @@
|
|||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Objectifs des joueurs</label>
|
<label for="chapter-edit-player-objectives">Objectifs des joueurs</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="chapter-edit-player-objectives"
|
||||||
formControlName="playerObjectives"
|
formControlName="playerObjectives"
|
||||||
placeholder="Que doivent accomplir les joueurs dans ce chapitre ?"
|
placeholder="Que doivent accomplir les joueurs dans ce chapitre ?"
|
||||||
rows="4">
|
rows="4">
|
||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Enjeux narratifs</label>
|
<label for="chapter-edit-narrative-stakes">Enjeux narratifs</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="chapter-edit-narrative-stakes"
|
||||||
formControlName="narrativeStakes"
|
formControlName="narrativeStakes"
|
||||||
placeholder="Quels sont les enjeux dramatiques ?"
|
placeholder="Quels sont les enjeux dramatiques ?"
|
||||||
rows="4">
|
rows="4">
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="scene-form">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom de la scène *</label>
|
<label for="scene-create-name">Nom de la scène *</label>
|
||||||
<input
|
<input
|
||||||
|
id="scene-create-name"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Ex: Arrivée au village"
|
placeholder="Ex: Arrivée au village"
|
||||||
@@ -18,8 +19,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description</label>
|
<label for="scene-create-description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="scene-create-description"
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Décrivez la scène, les événements clés, les PNJ présents..."
|
placeholder="Décrivez la scène, les événements clés, les PNJ présents..."
|
||||||
rows="6">
|
rows="6">
|
||||||
|
|||||||
@@ -43,8 +43,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Titre de la scène *</label>
|
<label for="scene-edit-name">Titre de la scène *</label>
|
||||||
<input
|
<input
|
||||||
|
id="scene-edit-name"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Ex: Arrivée au village"
|
placeholder="Ex: Arrivée au village"
|
||||||
@@ -53,8 +54,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description courte *</label>
|
<label for="scene-edit-description">Description courte *</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="scene-edit-description"
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Résumé en une ou deux phrases de ce qui se passe..."
|
placeholder="Résumé en une ou deux phrases de ce qui se passe..."
|
||||||
rows="3">
|
rows="3">
|
||||||
@@ -65,17 +67,18 @@
|
|||||||
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
|
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Lieu</label>
|
<label for="scene-edit-location">Lieu</label>
|
||||||
<input type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
|
<input id="scene-edit-location" type="text" formControlName="location" placeholder="Ex: Taverne du Dragon d'Or" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Moment</label>
|
<label for="scene-edit-timing">Moment</label>
|
||||||
<input type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
|
<input id="scene-edit-timing" type="text" formControlName="timing" placeholder="Ex: Soir, à la tombée de la nuit" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Ambiance et atmosphère</label>
|
<label for="scene-edit-atmosphere">Ambiance et atmosphère</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="scene-edit-atmosphere"
|
||||||
formControlName="atmosphere"
|
formControlName="atmosphere"
|
||||||
placeholder="Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)"
|
placeholder="Décrivez l'ambiance générale de la scène (sons, odeurs, lumière, émotions...)"
|
||||||
rows="4">
|
rows="4">
|
||||||
@@ -179,12 +182,13 @@
|
|||||||
<!-- Section : Combat ou rencontre -->
|
<!-- Section : Combat ou rencontre -->
|
||||||
<app-expandable-section title="Combat ou rencontre" icon="⚔️">
|
<app-expandable-section title="Combat ou rencontre" icon="⚔️">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Difficulté estimée</label>
|
<label for="scene-edit-combat-difficulty">Difficulté estimée</label>
|
||||||
<input type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
|
<input id="scene-edit-combat-difficulty" type="text" formControlName="combatDifficulty" placeholder="Ex: Moyenne, 3 gobelins niveau 2" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Ennemis et créatures</label>
|
<label for="scene-edit-enemies">Ennemis et créatures</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="scene-edit-enemies"
|
||||||
formControlName="enemies"
|
formControlName="enemies"
|
||||||
placeholder="Liste des ennemis présents dans cette scène..."
|
placeholder="Liste des ennemis présents dans cette scène..."
|
||||||
rows="4">
|
rows="4">
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom de l'univers *</label>
|
<label for="lore-name">Nom de l'univers *</label>
|
||||||
<input
|
<input
|
||||||
|
id="lore-name"
|
||||||
type="text"
|
type="text"
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
placeholder="Ex: Royaume des Ombres, Cyberpunk 2157..."
|
placeholder="Ex: Royaume des Ombres, Cyberpunk 2157..."
|
||||||
@@ -21,8 +22,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description</label>
|
<label for="lore-description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="lore-description"
|
||||||
formControlName="description"
|
formControlName="description"
|
||||||
placeholder="Décrivez brièvement votre univers, son ambiance, son genre..."
|
placeholder="Décrivez brièvement votre univers, son ambiance, son genre..."
|
||||||
rows="5"
|
rows="5"
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
<!-- ============ Header : mode édition inline ============ -->
|
<!-- ============ Header : mode édition inline ============ -->
|
||||||
<div class="detail-header edit-mode" *ngIf="editing">
|
<div class="detail-header edit-mode" *ngIf="editing">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom</label>
|
<label for="lore-detail-edit-name">Nom</label>
|
||||||
<input type="text" [(ngModel)]="editName" name="editName" required />
|
<input id="lore-detail-edit-name" type="text" [(ngModel)]="editName" name="editName" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description</label>
|
<label for="lore-detail-edit-description">Description</label>
|
||||||
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
<textarea id="lore-detail-edit-description" [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
|
|
||||||
<!-- Titre -->
|
<!-- Titre -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Titre de la page *</label>
|
<label for="page-title">Titre de la page *</label>
|
||||||
<input type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
|
<input id="page-title" type="text" formControlName="title" placeholder="Ex: Maître Eldrin, La Cité d'Argent..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Template -->
|
<!-- Template -->
|
||||||
@@ -42,10 +42,10 @@
|
|||||||
|
|
||||||
<!-- Dossier de destination -->
|
<!-- Dossier de destination -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Dossier de destination *</label>
|
<label for="page-node">Dossier de destination *</label>
|
||||||
|
|
||||||
<ng-container *ngIf="nodes.length; else emptyFolders">
|
<ng-container *ngIf="nodes.length; else emptyFolders">
|
||||||
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
|
<select id="page-node" formControlName="nodeId">
|
||||||
<option value="" disabled>Sélectionnez un dossier</option>
|
<option value="" disabled>Sélectionnez un dossier</option>
|
||||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -87,7 +87,8 @@
|
|||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
border-color: #6c63ff;
|
border-color: #6c63ff;
|
||||||
background: #1e1c3a;
|
background: #2a2558;
|
||||||
|
box-shadow: 0 0 0 1px #6c63ff, 0 0 12px rgba(108, 99, 255, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-card-head {
|
.template-card-head {
|
||||||
|
|||||||
@@ -88,11 +88,34 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.templates = data.templates;
|
this.templates = data.templates;
|
||||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||||
|
|
||||||
// Si nodeId fourni par l'URL, on verrouille la valeur du formulaire.
|
// Si nodeId fourni par l'URL, on fige la valeur ET on désactive le
|
||||||
|
// contrôle de formulaire (FormControl.disable, pas attr.disabled qui
|
||||||
|
// serait cosmétique). La valeur reste incluse dans les submits.
|
||||||
if (this.preselectedNodeId) {
|
if (this.preselectedNodeId) {
|
||||||
this.form.patchValue({ nodeId: this.preselectedNodeId });
|
this.form.patchValue({ nodeId: this.preselectedNodeId });
|
||||||
|
this.form.get('nodeId')?.disable();
|
||||||
|
this.autoSelectTemplateForNode(this.preselectedNodeId);
|
||||||
|
} else {
|
||||||
|
// Pas de nodeId dans l'URL : le <select> affiche visuellement la
|
||||||
|
// première option mais la valeur du FormControl reste ''. On tente
|
||||||
|
// l'auto-sélection inverse : si un seul template a un defaultNodeId
|
||||||
|
// qui pointe sur un dossier existant, on le sélectionne et on
|
||||||
|
// pré-remplit le dossier — sinon on laisse l'utilisateur choisir.
|
||||||
|
const validNodeIds = new Set(this.nodes.map(n => n.id));
|
||||||
|
const candidates = this.templates.filter(
|
||||||
|
t => t.defaultNodeId && validNodeIds.has(t.defaultNodeId)
|
||||||
|
);
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
const tpl = candidates[0];
|
||||||
|
this.selectedTemplateId = tpl.id!;
|
||||||
|
this.form.patchValue({ nodeId: tpl.defaultNodeId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.form.get('nodeId')?.valueChanges.subscribe(nodeId => {
|
||||||
|
this.autoSelectTemplateForNode(nodeId);
|
||||||
|
});
|
||||||
|
|
||||||
this.restoreDraft();
|
this.restoreDraft();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -134,6 +157,18 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
} catch { /* JSON corrompu : on ignore */ }
|
} catch { /* JSON corrompu : on ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-sélection du template dont defaultNodeId === nodeId courant.
|
||||||
|
* Ne fait rien si l'utilisateur a déjà choisi un template manuellement
|
||||||
|
* (on ne veut pas écraser un choix explicite).
|
||||||
|
*/
|
||||||
|
private autoSelectTemplateForNode(nodeId: string | null | undefined): void {
|
||||||
|
if (!nodeId) return;
|
||||||
|
if (this.selectedTemplateId) return;
|
||||||
|
const matching = this.templates.find(t => t.defaultNodeId === nodeId);
|
||||||
|
if (matching) this.selectedTemplateId = matching.id!;
|
||||||
|
}
|
||||||
|
|
||||||
selectTemplate(template: Template): void {
|
selectTemplate(template: Template): void {
|
||||||
this.selectedTemplateId = template.id!;
|
this.selectedTemplateId = template.id!;
|
||||||
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
||||||
@@ -152,7 +187,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.canSubmit) return;
|
if (!this.canSubmit) return;
|
||||||
const raw = this.form.value;
|
const raw = this.form.getRawValue();
|
||||||
this.pageService.create({
|
this.pageService.create({
|
||||||
loreId: this.loreId,
|
loreId: this.loreId,
|
||||||
nodeId: raw.nodeId,
|
nodeId: raw.nodeId,
|
||||||
@@ -206,7 +241,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.wizardError = null;
|
this.wizardError = null;
|
||||||
const raw = this.form.value;
|
const raw = this.form.getRawValue();
|
||||||
// Le backend POST /api/pages ne prend pas `values` — on crée d'abord la
|
// Le backend POST /api/pages ne prend pas `values` — on crée d'abord la
|
||||||
// coquille, puis on PUT immédiatement avec les valeurs extraites.
|
// coquille, puis on PUT immédiatement avec les valeurs extraites.
|
||||||
// 2 roundtrips, mais zéro modification backend nécessaire.
|
// 2 roundtrips, mais zéro modification backend nécessaire.
|
||||||
|
|||||||
@@ -11,20 +11,20 @@
|
|||||||
<div class="col-left">
|
<div class="col-left">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom du template *</label>
|
<label for="template-name">Nom du template *</label>
|
||||||
<input type="text" formControlName="name" placeholder="Ex: Auberge, Artefact, Monstre..." />
|
<input id="template-name" type="text" formControlName="name" placeholder="Ex: Auberge, Artefact, Monstre..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description</label>
|
<label for="template-description">Description</label>
|
||||||
<textarea formControlName="description" rows="4" placeholder="À quoi sert ce template ?"></textarea>
|
<textarea id="template-description" formControlName="description" rows="4" placeholder="À quoi sert ce template ?"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Dossier par défaut *</label>
|
<label for="template-default-node">Dossier par défaut *</label>
|
||||||
|
|
||||||
<ng-container *ngIf="nodes.length; else emptyFolders">
|
<ng-container *ngIf="nodes.length; else emptyFolders">
|
||||||
<select formControlName="defaultNodeId">
|
<select id="template-default-node" formControlName="defaultNodeId">
|
||||||
<option value="" disabled>Sélectionnez un dossier</option>
|
<option value="" disabled>Sélectionnez un dossier</option>
|
||||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
<div class="col-left">
|
<div class="col-left">
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nom</label>
|
<label for="template-edit-name">Nom</label>
|
||||||
<input type="text" formControlName="name" />
|
<input id="template-edit-name" type="text" formControlName="name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Dossier par défaut</label>
|
<label for="template-edit-default-node">Dossier par défaut</label>
|
||||||
<select formControlName="defaultNodeId">
|
<select id="template-edit-default-node" formControlName="defaultNodeId">
|
||||||
<option value="">-- Aucun --</option>
|
<option value="">-- Aucun --</option>
|
||||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Description</label>
|
<label for="template-edit-description">Description</label>
|
||||||
<textarea formControlName="description" rows="6"></textarea>
|
<textarea id="template-edit-description" formControlName="description" rows="6"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export class CampaignService {
|
|||||||
|
|
||||||
// ========== ARC ==========
|
// ========== ARC ==========
|
||||||
getArcs(campaignId: string): Observable<Arc[]> {
|
getArcs(campaignId: string): Observable<Arc[]> {
|
||||||
return this.http.get<Arc[]>(`/api/arcs/campaign/${campaignId}`);
|
const params = new HttpParams().set('campaignId', campaignId);
|
||||||
|
return this.http.get<Arc[]>('/api/arcs', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getArcById(id: string): Observable<Arc> {
|
getArcById(id: string): Observable<Arc> {
|
||||||
@@ -85,7 +86,8 @@ export class CampaignService {
|
|||||||
|
|
||||||
// ========== CHAPTER ==========
|
// ========== CHAPTER ==========
|
||||||
getChapters(arcId: string): Observable<Chapter[]> {
|
getChapters(arcId: string): Observable<Chapter[]> {
|
||||||
return this.http.get<Chapter[]>(`/api/chapters/arc/${arcId}`);
|
const params = new HttpParams().set('arcId', arcId);
|
||||||
|
return this.http.get<Chapter[]>('/api/chapters', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getChapterById(id: string): Observable<Chapter> {
|
getChapterById(id: string): Observable<Chapter> {
|
||||||
@@ -110,7 +112,8 @@ export class CampaignService {
|
|||||||
|
|
||||||
// ========== SCENE ==========
|
// ========== SCENE ==========
|
||||||
getScenes(chapterId: string): Observable<Scene[]> {
|
getScenes(chapterId: string): Observable<Scene[]> {
|
||||||
return this.http.get<Scene[]>(`/api/scenes/chapter/${chapterId}`);
|
const params = new HttpParams().set('chapterId', chapterId);
|
||||||
|
return this.http.get<Scene[]>('/api/scenes', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getSceneById(id: string): Observable<Scene> {
|
getSceneById(id: string): Observable<Scene> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { shareReplay, tap } from 'rxjs/operators';
|
||||||
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
|
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
|
||||||
|
|
||||||
/** Compte des entités qui seront supprimées en cascade avec un dossier. */
|
/** Compte des entités qui seront supprimées en cascade avec un dossier. */
|
||||||
@@ -23,6 +24,11 @@ export interface LoreDeletionImpact {
|
|||||||
/**
|
/**
|
||||||
* Service HTTP pour la gestion des Lores.
|
* Service HTTP pour la gestion des Lores.
|
||||||
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
||||||
|
*
|
||||||
|
* Les lectures agrégées par la sidebar (getAllLores, getLoreById, getLoreNodes)
|
||||||
|
* sont mises en cache via `shareReplay(1)` pour éviter 5 fetchs redondants à
|
||||||
|
* chaque navigation interne. Toute mutation (create/update/delete) invalide
|
||||||
|
* l'ensemble du cache du service.
|
||||||
*/
|
*/
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -31,26 +37,51 @@ export class LoreService {
|
|||||||
private apiUrl = '/api/lores';
|
private apiUrl = '/api/lores';
|
||||||
private nodesUrl = '/api/lore-nodes';
|
private nodesUrl = '/api/lore-nodes';
|
||||||
|
|
||||||
|
private allLoresCache: Observable<Lore[]> | null = null;
|
||||||
|
private loreByIdCache = new Map<string, Observable<Lore>>();
|
||||||
|
private nodesByLoreIdCache = new Map<string, Observable<LoreNode[]>>();
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
/** Vide tous les caches de lecture — appelé après toute mutation. */
|
||||||
|
private invalidate(): void {
|
||||||
|
this.allLoresCache = null;
|
||||||
|
this.loreByIdCache.clear();
|
||||||
|
this.nodesByLoreIdCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
getAllLores(): Observable<Lore[]> {
|
getAllLores(): Observable<Lore[]> {
|
||||||
return this.http.get<Lore[]>(this.apiUrl);
|
if (!this.allLoresCache) {
|
||||||
|
this.allLoresCache = this.http.get<Lore[]>(this.apiUrl).pipe(
|
||||||
|
tap({ error: () => (this.allLoresCache = null) }),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.allLoresCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLoreById(id: string): Observable<Lore> {
|
getLoreById(id: string): Observable<Lore> {
|
||||||
return this.http.get<Lore>(`${this.apiUrl}/${id}`);
|
let obs = this.loreByIdCache.get(id);
|
||||||
|
if (!obs) {
|
||||||
|
obs = this.http.get<Lore>(`${this.apiUrl}/${id}`).pipe(
|
||||||
|
tap({ error: () => this.loreByIdCache.delete(id) }),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
this.loreByIdCache.set(id, obs);
|
||||||
|
}
|
||||||
|
return obs;
|
||||||
}
|
}
|
||||||
|
|
||||||
createLore(lore: LoreCreate): Observable<Lore> {
|
createLore(lore: LoreCreate): Observable<Lore> {
|
||||||
return this.http.post<Lore>(this.apiUrl, lore);
|
return this.http.post<Lore>(this.apiUrl, lore).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLore(id: string, lore: LoreCreate): Observable<Lore> {
|
updateLore(id: string, lore: LoreCreate): Observable<Lore> {
|
||||||
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore);
|
return this.http.put<Lore>(`${this.apiUrl}/${id}`, lore).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteLore(id: string): Observable<void> {
|
deleteLore(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
getLoreDeletionImpact(id: string): Observable<LoreDeletionImpact> {
|
getLoreDeletionImpact(id: string): Observable<LoreDeletionImpact> {
|
||||||
@@ -58,7 +89,15 @@ export class LoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getLoreNodes(loreId: string): Observable<LoreNode[]> {
|
getLoreNodes(loreId: string): Observable<LoreNode[]> {
|
||||||
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
|
let obs = this.nodesByLoreIdCache.get(loreId);
|
||||||
|
if (!obs) {
|
||||||
|
obs = this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`).pipe(
|
||||||
|
tap({ error: () => this.nodesByLoreIdCache.delete(loreId) }),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
this.nodesByLoreIdCache.set(loreId, obs);
|
||||||
|
}
|
||||||
|
return obs;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLoreNodeById(id: string): Observable<LoreNode> {
|
getLoreNodeById(id: string): Observable<LoreNode> {
|
||||||
@@ -66,16 +105,16 @@ export class LoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createLoreNode(node: LoreNodeCreate): Observable<LoreNode> {
|
createLoreNode(node: LoreNodeCreate): Observable<LoreNode> {
|
||||||
return this.http.post<LoreNode>(this.nodesUrl, node);
|
return this.http.post<LoreNode>(this.nodesUrl, node).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PUT complet — envoie l'objet entier au backend (qui attend un LoreNodeDTO). */
|
/** PUT complet — envoie l'objet entier au backend (qui attend un LoreNodeDTO). */
|
||||||
updateLoreNode(id: string, node: LoreNode): Observable<LoreNode> {
|
updateLoreNode(id: string, node: LoreNode): Observable<LoreNode> {
|
||||||
return this.http.put<LoreNode>(`${this.nodesUrl}/${id}`, node);
|
return this.http.put<LoreNode>(`${this.nodesUrl}/${id}`, node).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteLoreNode(id: string): Observable<void> {
|
deleteLoreNode(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
|
return this.http.delete<void>(`${this.nodesUrl}/${id}`).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {
|
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map, shareReplay, tap } from 'rxjs/operators';
|
||||||
import { Page, PageCreate } from './page.model';
|
import { Page, PageCreate } from './page.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service HTTP pour la gestion des Pages.
|
* Service HTTP pour la gestion des Pages.
|
||||||
* Port de sortie du Frontend vers le Backend Java (/api/pages).
|
* Port de sortie du Frontend vers le Backend Java (/api/pages).
|
||||||
|
*
|
||||||
|
* `getByLoreId` est cache via shareReplay(1) — toute mutation
|
||||||
|
* (create/update/delete) invalide l'ensemble du cache.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PageService {
|
export class PageService {
|
||||||
private apiUrl = '/api/pages';
|
private apiUrl = '/api/pages';
|
||||||
|
|
||||||
|
private byLoreIdCache = new Map<string, Observable<Page[]>>();
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
private invalidate(): void {
|
||||||
|
this.byLoreIdCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/** Toutes les pages d'un Lore (flat, pour répartir ensuite par nodeId). */
|
/** Toutes les pages d'un Lore (flat, pour répartir ensuite par nodeId). */
|
||||||
getByLoreId(loreId: string): Observable<Page[]> {
|
getByLoreId(loreId: string): Observable<Page[]> {
|
||||||
const params = new HttpParams().set('loreId', loreId);
|
let obs = this.byLoreIdCache.get(loreId);
|
||||||
return this.http.get<Page[]>(this.apiUrl, { params });
|
if (!obs) {
|
||||||
|
const params = new HttpParams().set('loreId', loreId);
|
||||||
|
obs = this.http.get<Page[]>(this.apiUrl, { params }).pipe(
|
||||||
|
tap({ error: () => this.byLoreIdCache.delete(loreId) }),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
this.byLoreIdCache.set(loreId, obs);
|
||||||
|
}
|
||||||
|
return obs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toutes les pages d'un noeud donné. */
|
/** Toutes les pages d'un noeud donné. */
|
||||||
@@ -31,15 +48,15 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(payload: PageCreate): Observable<Page> {
|
create(payload: PageCreate): Observable<Page> {
|
||||||
return this.http.post<Page>(this.apiUrl, payload);
|
return this.http.post<Page>(this.apiUrl, payload).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, page: Page): Observable<Page> {
|
update(id: string, page: Page): Observable<Page> {
|
||||||
return this.http.put<Page>(`${this.apiUrl}/${id}`, page);
|
return this.http.put<Page>(`${this.apiUrl}/${id}`, page).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(id: string): Observable<void> {
|
delete(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
search(q: string): Observable<Page[]> {
|
search(q: string): Observable<Page[]> {
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { shareReplay, tap } from 'rxjs/operators';
|
||||||
import { Template, TemplateCreate } from './template.model';
|
import { Template, TemplateCreate } from './template.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service HTTP pour la gestion des Templates.
|
* Service HTTP pour la gestion des Templates.
|
||||||
* Port de sortie du Frontend vers le Backend Java (/api/templates).
|
* Port de sortie du Frontend vers le Backend Java (/api/templates).
|
||||||
|
*
|
||||||
|
* `getByLoreId` est cache via shareReplay(1) — toute mutation
|
||||||
|
* (create/update/delete) invalide l'ensemble du cache.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TemplateService {
|
export class TemplateService {
|
||||||
private apiUrl = '/api/templates';
|
private apiUrl = '/api/templates';
|
||||||
|
|
||||||
|
private byLoreIdCache = new Map<string, Observable<Template[]>>();
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
private invalidate(): void {
|
||||||
|
this.byLoreIdCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/** Tous les templates d'un Lore (alimente le panneau sidebar). */
|
/** Tous les templates d'un Lore (alimente le panneau sidebar). */
|
||||||
getByLoreId(loreId: string): Observable<Template[]> {
|
getByLoreId(loreId: string): Observable<Template[]> {
|
||||||
const params = new HttpParams().set('loreId', loreId);
|
let obs = this.byLoreIdCache.get(loreId);
|
||||||
return this.http.get<Template[]>(this.apiUrl, { params });
|
if (!obs) {
|
||||||
|
const params = new HttpParams().set('loreId', loreId);
|
||||||
|
obs = this.http.get<Template[]>(this.apiUrl, { params }).pipe(
|
||||||
|
tap({ error: () => this.byLoreIdCache.delete(loreId) }),
|
||||||
|
shareReplay(1)
|
||||||
|
);
|
||||||
|
this.byLoreIdCache.set(loreId, obs);
|
||||||
|
}
|
||||||
|
return obs;
|
||||||
}
|
}
|
||||||
|
|
||||||
getById(id: string): Observable<Template> {
|
getById(id: string): Observable<Template> {
|
||||||
@@ -24,15 +42,15 @@ export class TemplateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(payload: TemplateCreate): Observable<Template> {
|
create(payload: TemplateCreate): Observable<Template> {
|
||||||
return this.http.post<Template>(this.apiUrl, payload);
|
return this.http.post<Template>(this.apiUrl, payload).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: string, template: Template): Observable<Template> {
|
update(id: string, template: Template): Observable<Template> {
|
||||||
return this.http.put<Template>(`${this.apiUrl}/${id}`, template);
|
return this.http.put<Template>(`${this.apiUrl}/${id}`, template).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(id: string): Observable<void> {
|
delete(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
|
||||||
}
|
}
|
||||||
|
|
||||||
search(q: string): Observable<Template[]> {
|
search(q: string): Observable<Template[]> {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 1.25rem 0.75rem;
|
padding: 1.25rem 0.75rem;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
// Pas de transition sur la largeur : sinon le drag de resize "traîne" derrière la souris.
|
// Pas de transition sur la largeur : sinon le drag de resize "traîne" derrière la souris.
|
||||||
// L'animation d'expand/collapse est gérée uniquement par la classe .collapsed ci-dessous.
|
// L'animation d'expand/collapse est gérée uniquement par la classe .collapsed ci-dessous.
|
||||||
|
|||||||
Reference in New Issue
Block a user