Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
All checks were successful
Corrections fonctionnelles - order initialisé à la création des pages et dossiers de lore (nextOrderFor) : un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0 - storePortrait : type MIME canonique dérivé de l'extension du fichier (un data URL "image/jpg" n'est plus rejeté silencieusement) - takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view, folder-view et campaign-detail (fuites mémoire) Factorisation (suppression de duplication) - shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar (folderChildren). Le tri des dossiers est désormais cohérent entre la sidebar et les vues cartes (insensible casse/accents partout) - DataSyncService.onChange()/persist() remplacent le câblage changed$ + reorder recopié dans 5 vues - campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent le forkJoin dupliqué entre ngOnInit et reload - ReorderSupport (domain/shared) : squelette générique remplaçant les 8 boucles de réordonnancement copiées dans les services - FoundryExportService : GameSystem résolu une seule fois dans buildBundle - module Foundry : walkScalars mutualise la récursion de flattenStats et flattenStructure (comportements préservés) ; esc() de l'importer aligné sur foundry.utils.escapeHTML Suppression de code mort - characterService/characters retirés de loadCampaignTreeData et de ses 16 appelants (arguments, injections, imports et littéraux CampaignTreeData) - CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back - BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant - commentaire trompeur du PDF corrigé Tests - mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui vérifiaient encore l'ancien comportement (tri alphabétique, non-classés en enfants directs) → alignés sur le modèle actuel (tri par order + pseudo-dossier "Sans dossier")
This commit is contained in:
@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { forkJoin, Subscription } from 'rxjs';
|
||||
import { CampaignService } from './campaign.service';
|
||||
import { CharacterService } from './character.service';
|
||||
import { NpcService } from './npc.service';
|
||||
import { RandomTableService } from './random-table.service';
|
||||
import { EnemyService } from './enemy.service';
|
||||
@@ -27,7 +26,6 @@ import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/c
|
||||
export class CampaignSidebarService {
|
||||
constructor(
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private enemyService: EnemyService,
|
||||
@@ -47,7 +45,6 @@ export class CampaignSidebarService {
|
||||
treeData: loadCampaignTreeData(
|
||||
this.campaignService,
|
||||
campaignId,
|
||||
this.characterService,
|
||||
this.npcService,
|
||||
this.randomTableService,
|
||||
this.enemyService
|
||||
|
||||
@@ -47,6 +47,11 @@ export class CampaignService {
|
||||
return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
/** Génère et télécharge le livret PDF de la campagne. */
|
||||
exportPdf(id: string): Observable<Blob> {
|
||||
return this.http.get(`${this.apiUrl}/${id}/pdf-export`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
createCampaign(campaign: CampaignCreate): Observable<Campaign> {
|
||||
return this.http.post<Campaign>(this.apiUrl, campaign);
|
||||
}
|
||||
@@ -89,6 +94,11 @@ export class CampaignService {
|
||||
return this.http.get<ArcDeletionImpact>(`/api/arcs/${id}/deletion-impact`);
|
||||
}
|
||||
|
||||
/** Réordonne les arcs d'une campagne (glisser-déposer) : order = position. */
|
||||
reorderArcs(orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>('/api/arcs/reorder', { orderedIds });
|
||||
}
|
||||
|
||||
// ========== CHAPTER ==========
|
||||
/**
|
||||
* Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend
|
||||
@@ -122,6 +132,11 @@ export class CampaignService {
|
||||
return this.http.get<ChapterDeletionImpact>(`/api/chapters/${id}/deletion-impact`);
|
||||
}
|
||||
|
||||
/** Réordonne (et déplace) les chapitres d'un arc : order = position, arcId = arc cible. */
|
||||
reorderChapters(arcId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>('/api/chapters/reorder', { arcId, orderedIds });
|
||||
}
|
||||
|
||||
// ========== SCENE ==========
|
||||
getScenes(chapterId: string): Observable<Scene[]> {
|
||||
const params = new HttpParams().set('chapterId', chapterId);
|
||||
@@ -144,6 +159,11 @@ export class CampaignService {
|
||||
return this.http.delete<void>(`/api/scenes/${id}`);
|
||||
}
|
||||
|
||||
/** Réordonne (et déplace) les scènes d'un chapitre : order = position, chapterId = cible. */
|
||||
reorderScenes(chapterId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>('/api/scenes/reorder', { chapterId, orderedIds });
|
||||
}
|
||||
|
||||
search(q: string): Observable<Campaign[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params });
|
||||
|
||||
38
web/src/app/services/data-sync.service.ts
Normal file
38
web/src/app/services/data-sync.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Bus d'événement minimal pour synchroniser les vues qui affichent les MÊMES données
|
||||
* (arbre de la sidebar ↔ vues en cartes). Après un réordonnancement/déplacement
|
||||
* (depuis n'importe quelle vue), on émet `changed$` ; chaque vue concernée se recharge
|
||||
* → tout reste cohérent SANS rafraîchir la page (F5).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DataSyncService {
|
||||
private readonly _changed = new Subject<void>();
|
||||
/** Émis quand une donnée ordonnable a changé (reorder/move). */
|
||||
readonly changed$ = this._changed.asObservable();
|
||||
|
||||
notify(): void {
|
||||
this._changed.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonne `handler` aux changements de données, avec auto-désabonnement lié au
|
||||
* cycle de vie du composant (`destroyRef`). Évite de recopier le couple
|
||||
* `changed$.pipe(takeUntilDestroyed(...)).subscribe(...)` dans chaque vue.
|
||||
*/
|
||||
onChange(destroyRef: DestroyRef, handler: () => void): void {
|
||||
this.changed$.pipe(takeUntilDestroyed(destroyRef)).subscribe(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste un réordonnancement : au succès, notifie les autres vues
|
||||
* (`notify()`) ; en cas d'échec, exécute `rollback` (typiquement recharger pour
|
||||
* annuler le déplacement optimiste à l'écran).
|
||||
*/
|
||||
persist<T>(reorder$: Observable<T>, rollback: () => void): void {
|
||||
reorder$.subscribe({ next: () => this.notify(), error: () => rollback() });
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,11 @@ export class EnemyService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Réordonne (et reclasse) les ennemis d'un dossier : order = position, folder = cible. */
|
||||
reorder(folder: string | null, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/reorder`, { folder, orderedIds });
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<Enemy[]> {
|
||||
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
/** Type d'entité déplaçable (réordonnancement par glisser-déposer dans l'arbre). */
|
||||
export type ReorderKind = 'arc' | 'chapter' | 'scene' | 'npc' | 'enemy' | 'table' | 'folder' | 'page';
|
||||
|
||||
/** Contexte servant à recharger la bonne sidebar après un réordonnancement. */
|
||||
export interface SidebarReorderContext {
|
||||
scope: 'campaign' | 'lore';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TreeItem {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -9,6 +18,14 @@ export interface TreeItem {
|
||||
isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre")
|
||||
/** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */
|
||||
iconKey?: string;
|
||||
/** Type d'entité déplaçable (active le drag sur ce nœud). Absent = non déplaçable. */
|
||||
dragKind?: ReorderKind;
|
||||
/** Id BRUT de l'entité (sans préfixe) — utilisé pour l'ordre persisté. */
|
||||
dragId?: string;
|
||||
/** Types acceptés en dépôt dans la liste des enfants de ce nœud (drop list). */
|
||||
dropKinds?: ReorderKind[];
|
||||
/** Parent transmis au réordonnancement (arcId, chapterId, nodeId, dossier…). */
|
||||
dropParentId?: string | null;
|
||||
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
|
||||
meta?: string;
|
||||
/**
|
||||
@@ -66,7 +83,6 @@ export interface BottomPanel {
|
||||
id: string; // identifiant pour mémoriser l'état ouvert/fermé
|
||||
title: string;
|
||||
items: BottomPanelItem[];
|
||||
initiallyOpen?: boolean;
|
||||
/** Action "+" inline dans le header — créer un item sans déplier le panneau. */
|
||||
headerAction?: { label: string; route: string };
|
||||
}
|
||||
@@ -87,6 +103,12 @@ export interface SecondarySidebarConfig {
|
||||
bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates)
|
||||
/** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */
|
||||
footerLabel?: string;
|
||||
/** Types déplaçables acceptés à la RACINE de l'arbre (ex: arcs côté campagne). */
|
||||
rootDropKinds?: ReorderKind[];
|
||||
/** Parent transmis au reorder pour les éléments racine (campaignId, ou null = racine lore). */
|
||||
rootDropParentId?: string | null;
|
||||
/** Contexte de rechargement après un réordonnancement (présent = DnD activé). */
|
||||
reorderContext?: SidebarReorderContext;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface LoreNode {
|
||||
/** ID du dossier parent (null = racine). */
|
||||
parentId?: string | null;
|
||||
loreId: string;
|
||||
/** Position parmi les dossiers frères (glisser-déposer). */
|
||||
order?: number;
|
||||
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
|
||||
type?: string;
|
||||
description?: string;
|
||||
|
||||
@@ -117,6 +117,15 @@ export class LoreService {
|
||||
return this.http.delete<void>(`${this.nodesUrl}/${id}`).pipe(tap(() => this.invalidate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Réordonne (et déplace) des dossiers : `order` = position dans `orderedIds`, et
|
||||
* chaque dossier reçoit `parentId` (null = racine). Anti-cycle géré côté backend.
|
||||
*/
|
||||
reorderNodes(parentId: string | null, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.nodesUrl}/reorder`, { parentId, orderedIds })
|
||||
.pipe(tap(() => this.invalidate()));
|
||||
}
|
||||
|
||||
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {
|
||||
return this.http.get<LoreNodeDeletionImpact>(`${this.nodesUrl}/${id}/deletion-impact`);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ export class NpcService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Réordonne (et reclasse) les PNJ d'un dossier : order = position, folder = dossier cible. */
|
||||
reorder(folder: string | null, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/reorder`, { folder, orderedIds });
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<Npc[]> {
|
||||
return this.http.get<Npc[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface Page {
|
||||
nodeId: string;
|
||||
templateId?: string | null;
|
||||
title: string;
|
||||
/** Position de la page dans son dossier (glisser-déposer). */
|
||||
order?: number;
|
||||
values?: Record<string, string>;
|
||||
/**
|
||||
* Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images
|
||||
|
||||
@@ -59,6 +59,15 @@ export class PageService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Réordonne (et déplace) les pages d'un dossier : `order` = position dans
|
||||
* `orderedIds`, et chaque page reçoit `nodeId` (déplacement par glisser-déposer).
|
||||
*/
|
||||
reorder(nodeId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/reorder`, { nodeId, orderedIds })
|
||||
.pipe(tap(() => this.invalidate()));
|
||||
}
|
||||
|
||||
search(q: string): Observable<Page[]> {
|
||||
const params = new HttpParams().set('q', q);
|
||||
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });
|
||||
|
||||
@@ -32,6 +32,11 @@ export class RandomTableService {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Réordonne les tables aléatoires d'une campagne : order = position. */
|
||||
reorder(orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/reorder`, { orderedIds });
|
||||
}
|
||||
|
||||
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
|
||||
search(q: string): Observable<RandomTable[]> {
|
||||
return this.http.get<RandomTable[]>(`${this.apiUrl}/search`, { params: { q } });
|
||||
|
||||
67
web/src/app/services/sidebar-reorder.service.ts
Normal file
67
web/src/app/services/sidebar-reorder.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { CampaignService } from './campaign.service';
|
||||
import { NpcService } from './npc.service';
|
||||
import { EnemyService } from './enemy.service';
|
||||
import { RandomTableService } from './random-table.service';
|
||||
import { PageService } from './page.service';
|
||||
import { LoreService } from './lore.service';
|
||||
import { TemplateService } from './template.service';
|
||||
import { LayoutService, ReorderKind, SidebarReorderContext } from './layout.service';
|
||||
import { CampaignSidebarService } from './campaign-sidebar.service';
|
||||
import { DataSyncService } from './data-sync.service';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore/lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
* Persiste un réordonnancement déclenché par glisser-déposer DANS L'ARBRE de la
|
||||
* sidebar, puis recharge la sidebar depuis le backend (source de vérité).
|
||||
*
|
||||
* Le `kind` détermine l'endpoint (et le sens du `parentId` : arcId / chapterId /
|
||||
* nodeId / dossier…). On recharge systématiquement après la réponse — succès comme
|
||||
* erreur — pour resynchroniser l'affichage sur l'état persisté (zéro dérive).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SidebarReorderService {
|
||||
constructor(
|
||||
private campaignService: CampaignService,
|
||||
private npcService: NpcService,
|
||||
private enemyService: EnemyService,
|
||||
private randomTableService: RandomTableService,
|
||||
private pageService: PageService,
|
||||
private loreService: LoreService,
|
||||
private templateService: TemplateService,
|
||||
private layoutService: LayoutService,
|
||||
private campaignSidebar: CampaignSidebarService,
|
||||
private dataSync: DataSyncService
|
||||
) {}
|
||||
|
||||
reorder(context: SidebarReorderContext, kind: ReorderKind, parentId: string | null, orderedIds: string[]): void {
|
||||
this.persist(kind, parentId, orderedIds).subscribe({
|
||||
next: () => { this.reload(context); this.dataSync.notify(); },
|
||||
error: () => { this.reload(context); this.dataSync.notify(); }
|
||||
});
|
||||
}
|
||||
|
||||
private persist(kind: ReorderKind, parentId: string | null, orderedIds: string[]): Observable<void> {
|
||||
switch (kind) {
|
||||
case 'arc': return this.campaignService.reorderArcs(orderedIds);
|
||||
case 'chapter': return this.campaignService.reorderChapters(parentId ?? '', orderedIds);
|
||||
case 'scene': return this.campaignService.reorderScenes(parentId ?? '', orderedIds);
|
||||
case 'npc': return this.npcService.reorder(parentId || null, orderedIds);
|
||||
case 'enemy': return this.enemyService.reorder(parentId || null, orderedIds);
|
||||
case 'table': return this.randomTableService.reorder(orderedIds);
|
||||
case 'folder': return this.loreService.reorderNodes(parentId || null, orderedIds);
|
||||
case 'page': return this.pageService.reorder(parentId ?? '', orderedIds);
|
||||
default: return of(void 0);
|
||||
}
|
||||
}
|
||||
|
||||
private reload(context: SidebarReorderContext): void {
|
||||
if (context.scope === 'campaign') {
|
||||
this.campaignSidebar.show(context.id);
|
||||
} else {
|
||||
loadLoreSidebarData(context.id, this.loreService, this.templateService, this.pageService)
|
||||
.subscribe(data => this.layoutService.show(buildLoreSidebarConfig(data)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user