diff --git a/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md b/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md new file mode 100644 index 0000000..05c57f4 --- /dev/null +++ b/docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md @@ -0,0 +1,32 @@ +# 0003 - Gestion réactive et découplée des overlays et popovers (Pub/Sub) + +## Contexte +Dans la vue d'ensemble (`OverviewComponent`), de multiples éléments d'interface flottants et éphémères cohabitent (infobulles D3, menus contextuels de jalon/séance, suggestions de recherche typeahead, popover d'aide rapide, menu déroulant des groupes de TP, légende interactive). +L'architecture initiale présentait plusieurs fragilités et dettes techniques : +1. **Couplage fort et rôle omniscient du composant parent** : `OverviewComponent` agissait comme un contrôleur impératif manipulant directement ses composants enfants via `@ViewChild` (`closePopovers`, `close`, etc.). +2. **Hacks directs sur le DOM** : Destruction manuelle du DOM via `document.querySelector("ngb-typeahead-window")?.remove()` pour forcer la fermeture du typeahead Bootstrap sans notification Angular. +3. **Couplage du pipeline de données avec l'UI** : Le rechargement des données (`loadGraphDataAndRefresh`, modification des filtres ou de la légende) déclenchait des fermetures intempestives d'overlays et des pertes de focus sur l'input de recherche (`blur`). +4. **Désynchronisation entre D3 et Angular (`NgZone`)** : L'interception d'événements de souris dans D3 (notamment sur `mousedown`) hors de la zone Angular provoquait des glitches visuels lors de la fermeture des dropdowns (suppression immédiate du style CSS `transform` par ng-bootstrap avant le retrait effectif de la classe `.show` par la détection de changement, faisant sauter le menu en haut à gauche sous la barre de navigation pendant l'enfoncement du clic). +5. **Calcul de positionnement dynamique inadapté sur le menu contextuel** : Le menu contextuel utilisait `ngbDropdown` en mode dynamique (Popper.js) sans ancre (`_anchor`), alors qu'il est positionné de manière fixe via les coordonnées de la souris (`position: fixed; [style.left]="left"; [style.top]="top"`). + +## Décision +1. **Mise en place d'un service réactif d'orchestration (`OverlayManagerService`)** : + - Service singleton Angular (`providedIn: 'root'`) implémentant le patron **Publish-Subscribe (Pub/Sub)** via un `Subject`. + - Typage granulaire des overlays (`OverlayType` : `TOOLTIP`, `CONTEXT_MENU`, `TYPEAHEAD`, `QUICK_HELP`, `DROPDOWN`, `ALL`). + - Méthodes sémantiques : `dismiss(type, options)`, `dismissAll(options)`, `dismissTransient(options)`. +2. **Synchronisation garantie avec Angular (`NgZone`)** : + - Encapsulation systématique des émissions d'événements dans `this.ngZone.run(...)` au sein du service pour garantir que tout ordre de fermeture issu de D3 déclenche immédiatement un cycle de détection de changement synchrone sans étape intermédiaire visible. +3. **Autonomie et auto-gestion des composants récepteurs** : + - `QuestionsChooserComponent`, `OverviewGraphContextualMenuComponent`, `TooltipService` et `OverviewComponent` (pour ses menus locaux de barre d'outils) s'abonnent à `overlayManagerService.dismiss$` et gèrent leur propre fermeture via les API officielles (`dismissPopup()`, `close()`, `hide()`). + - Désabonnement automatique via l'opérateur RxJS `takeUntil(this.destroy$)` pour prévenir toute fuite mémoire. +4. **Découplage strict entre le rendu D3 et l'état de l'UI** : + - Le pipeline de chargement des données (`loadGraphData`, `loadGraphDataAndRefresh`) est hermétique et ne déclenche aucune fermeture d'overlay ni de perte de focus. + - Suppression du listener prématuré `mousedown` sur le conteneur D3 `.chart-container` : la fermeture des overlays s'effectue uniquement lors des gestes physiques de navigation réels (`zoom.on("start")` et `zoom.on("zoom")` avec `event.sourceEvent != null`, `wheel`, `scroll`, glisser-déposer de jalons). +5. **Désactivation du calcul dynamique Popper sur le menu contextuel** : + - Ajout de l'attribut `display="static"` sur le `
` du menu contextuel pour désactiver le calcul dynamique Popper et laisser le contrôle du positionnement au CSS inline (`position: fixed`). + +## Conséquences +- **Élimination complète des hacks DOM** : Plus aucun appel à `querySelector` pour manipuler ou supprimer des éléments de composants tiers. +- **Robustesse et extensibilité** : Tout nouvel overlay ou panneau ajouté dans l'application peut s'abonner à `OverlayManagerService` sans modifier `OverviewComponent`. +- **Fidélité visuelle et fin des sauts d'affichage** : Élimination définitive des scintillements et des sauts de dropdowns sous la barre de navigation. +- **Règle d'architecture formalisée** : Mise à jour de `GEMINI.md` imposant l'usage exclusif de `OverlayManagerService` pour coordonner la fermeture des overlays. diff --git a/src/app/components/edit-milestone/edit-milestone.component.html b/src/app/components/edit-milestone/edit-milestone.component.html index 48a93da..1b47396 100644 --- a/src/app/components/edit-milestone/edit-milestone.component.html +++ b/src/app/components/edit-milestone/edit-milestone.component.html @@ -45,12 +45,12 @@
+
- +
diff --git a/src/app/components/edit-session/edit-session.component.ts b/src/app/components/edit-session/edit-session.component.ts index e3dd096..ddb7399 100644 --- a/src/app/components/edit-session/edit-session.component.ts +++ b/src/app/components/edit-session/edit-session.component.ts @@ -1,7 +1,9 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, Input, + OnDestroy, OnInit, } from "@angular/core"; import { @@ -13,9 +15,12 @@ import { } from "@angular/forms"; import { Session } from "@models/Session.model"; import { CustomModalRef } from "@shared/ui/custom-modal/custom-modal-ref"; +import { DataService } from "@services/data.service"; import { Utils } from "@services/utils"; +import { TranslateService } from "@ngx-translate/core"; import * as moment from "moment"; import { Observable, Subject, merge } from "rxjs"; +import { takeUntil } from "rxjs/operators"; @Component({ selector: "app-edit-session", @@ -23,19 +28,28 @@ import { Observable, Subject, merge } from "rxjs"; styleUrls: ["./edit-session.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class EditSessionComponent implements OnInit { +export class EditSessionComponent implements OnInit, OnDestroy { @Input() session: Session; @Input() addMode: boolean; @Input() tpGroups: string[]; @Input() defaultSessionDuration; @Input() notes: string; sessionForm: FormGroup; + defaultLabel: string = ""; + private destroy$ = new Subject(); + + get resolvedTpGroups(): string[] { + return this.tpGroups?.length ? this.tpGroups : (this.dataService?.tpGroups || []); + } notesOpen: boolean = false; constructor( public activeModalService: CustomModalRef, - public fb: FormBuilder + public fb: FormBuilder, + private dataService: DataService, + private translateService: TranslateService, + private cdr: ChangeDetectorRef ) {} endTimeValidator(): ValidatorFn { @@ -60,7 +74,43 @@ export class EditSessionComponent implements OnInit { } ngOnInit(): void { + this.computeDefaultLabel(); this.initForm(); + + this.translateService.onLangChange + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.computeDefaultLabel(); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + computeDefaultLabel() { + const group = this.session?.tpGroup || ""; + const sessions = this.dataService?.sessions || []; + const sameGroup = sessions + .filter((s) => (s.tpGroup || "") === group && s !== this.session) + .slice() + .sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); + + const curTime = this.session?.startDate ? new Date(this.session.startDate).getTime() : 0; + let idx = sameGroup.findIndex((s) => new Date(s.startDate).getTime() > curTime); + let sessionNumber = idx === -1 ? sameGroup.length + 1 : idx + 1; + + const defaultName = this.translateService.instant("DEFAULT-SESSION-NAME", { + number: sessionNumber, + }); + if (defaultName && defaultName !== "DEFAULT-SESSION-NAME") { + this.defaultLabel = defaultName; + } else { + const sessionPrefix = this.translateService.instant("SESSION") || "Séance"; + this.defaultLabel = `${sessionPrefix} ${sessionNumber}`; + } + this.cdr.markForCheck(); } private initForm() { @@ -71,6 +121,7 @@ export class EditSessionComponent implements OnInit { const endStr = tEnd ? `${tEnd.hour.toString().padStart(2, '0')}:${tEnd.minute.toString().padStart(2, '0')}` : '14:00'; this.sessionForm = this.fb.group({ + label: [this.session.label || ""], date: [this.session.startDate, Validators.required], startTime: [startStr, Validators.required], endTime: [endStr, Validators.required], @@ -79,6 +130,13 @@ export class EditSessionComponent implements OnInit { }); this.sessionForm.setValidators(this.endTimeValidator()); + // Recompute default placeholder if TP group changes + this.sessionForm.get('tpGroup')?.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.computeDefaultLabel(); + }); + // Open the notes section if there's already text in it if (this.session.notes && this.session.notes.trim().length > 0) { this.notesOpen = true; @@ -93,7 +151,6 @@ export class EditSessionComponent implements OnInit { this.sessionForm.markAsDirty(); } - deleteSession() { this.activeModalService.close(null); } @@ -112,8 +169,9 @@ export class EditSessionComponent implements OnInit { const session = new Session( startDate, endDate, - form.value.tpGroup.trim() || "", - form.value.notes.trim() || "" + form.value.tpGroup ? form.value.tpGroup.trim() : "", + form.value.notes ? form.value.notes.trim() : "", + form.value.label ? form.value.label.trim() : "" ); this.activeModalService.close(session); diff --git a/src/app/components/graphs/base-graph.component.ts b/src/app/components/graphs/base-graph.component.ts index daef71f..10105c8 100644 --- a/src/app/components/graphs/base-graph.component.ts +++ b/src/app/components/graphs/base-graph.component.ts @@ -36,7 +36,8 @@ export abstract class BaseGraphComponent implements OnInit { repositories: Repository[], reviews: Milestone[], corrections: Milestone[], - questions + questions, + conserveZoom: boolean = false ) { this.loaderService.loadCommitsMetadata( repositories, @@ -44,8 +45,8 @@ export abstract class BaseGraphComponent implements OnInit { corrections, questions ); - this.loadGraphDataAndRefresh(); + this.loadGraphDataAndRefresh(conserveZoom); } - abstract loadGraphDataAndRefresh(); + abstract loadGraphDataAndRefresh(conserveZoom?: boolean); } diff --git a/src/app/components/graphs/overview/chart.scss b/src/app/components/graphs/overview/chart.scss index 41f2265..5cc22c2 100644 --- a/src/app/components/graphs/overview/chart.scss +++ b/src/app/components/graphs/overview/chart.scss @@ -79,17 +79,131 @@ body.dark-theme .commit { opacity: 0.5; } +.session-edge { + stroke: var(--color-info); + stroke-width: 1px; + stroke-dasharray: 4 3; + stroke-opacity: 0.3; + pointer-events: none; + transition: stroke-opacity var(--transition-normal); +} + +.session-container { + cursor: default; + + .session-body { + cursor: default; + fill: var(--color-info); + fill-opacity: 0.08; + stroke: none; + transition: fill-opacity var(--transition-normal); + } + + &:hover { + .session-body { + fill-opacity: 0.14; + } + .session-edge { + stroke-opacity: 0.55; + } + } +} + +.session-header-container { + cursor: default; + pointer-events: auto; + + .session-header-top-bar { + fill: var(--color-primary); + opacity: 0.95; + pointer-events: none; + transition: opacity var(--transition-normal); + } + + .session-header-bg { + fill: var(--color-info); + fill-opacity: 0.08; + stroke: none; + pointer-events: auto; + cursor: default; + transition: fill-opacity var(--transition-normal); + } + + .session-header-fo { + pointer-events: auto; + overflow: hidden; + cursor: default; + } + + .session-pill { + cursor: default; + transition: box-shadow var(--transition-fast), border-color var(--transition-fast); + min-width: 0; + max-width: 100%; + overflow: hidden; + + .session-pill-text { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + } + } + + .session-note-btn { + transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); + cursor: pointer; + pointer-events: auto; + + &:hover { + background-color: rgba(56, 189, 248, 0.12) !important; + border-color: var(--color-primary) !important; + color: var(--color-primary) !important; + } + } + + .session-more-btn { + color: var(--color-text-muted); + background: transparent; + border: none; + cursor: pointer; + pointer-events: auto; + transition: color var(--transition-fast), transform var(--transition-fast); + + &:hover { + color: var(--color-primary) !important; + transform: scale(1.1); + } + } + + &:hover { + .session-header-top-bar { + opacity: 1; + filter: brightness(1.15); + } + .session-header-bg { + fill-opacity: 0.14; + } + .session-edge { + stroke-opacity: 0.55; + } + } +} + +// Fallback legacy .session class .session { stroke-width: 1px; stroke: var(--color-info); fill: var(--color-info); - fill-opacity: 0.1; + fill-opacity: 0.08; rx: 8px; ry: 8px; transition: fill-opacity var(--transition-normal); &:hover { - fill-opacity: 0.25; + fill-opacity: 0.2; } } @@ -134,14 +248,17 @@ body.dark-theme .commit { .review { > rect:first-child, > rect:nth-child(2) { fill: var(--color-primary); stroke: var(--color-primary); } + .milestone-drag-badge .badge-progress { stroke: var(--color-primary); } } .correction { > rect:first-child, > rect:nth-child(2) { fill: var(--color-danger); stroke: var(--color-danger); } + .milestone-drag-badge .badge-progress { stroke: var(--color-danger); } } .other { > rect:first-child, > rect:nth-child(2) { fill: var(--color-secondary); stroke: var(--color-secondary); } + .milestone-drag-badge .badge-progress { stroke: var(--color-secondary); } } .hitbox { diff --git a/src/app/components/graphs/overview/overview.component.html b/src/app/components/graphs/overview/overview.component.html index 46cbe71..f8eebed 100644 --- a/src/app/components/graphs/overview/overview.component.html +++ b/src/app/components/graphs/overview/overview.component.html @@ -7,11 +7,11 @@ [mode]="'search'" [openOnFocus]="true" [ngModel]="searchFilter" - (ngModelChange)="searchFilter = $event; loadGraphDataAndRefresh()" + (ngModelChange)="searchFilter = $event; loadGraphDataAndRefresh(false)" [maxPillsWidth]="'70%'" [commitMessages]="commitMessagesFilter" - (commitMessagesChange)="commitMessagesFilter = $event; loadGraphDataAndRefresh()" - (filterGroupsChange)="filterGroups = $event; loadGraphDataAndRefresh()" + (commitMessagesChange)="commitMessagesFilter = $event; loadGraphDataAndRefresh(false)" + (filterGroupsChange)="filterGroups = $event; loadGraphDataAndRefresh(false)" [noQuestionMessage]="false" > @@ -19,7 +19,7 @@
-