Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/adr/0003-gestion-reactive-des-overlays-et-popovers.md
Original file line number Diff line number Diff line change
@@ -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<OverlayDismissEvent>`.
- 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 `<div ngbDropdown>` 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.
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ <h5 class="modal-title m-0" style="font-weight: 600; font-size: 1.1rem; color: v
<div class="d-flex mb-2" style="gap: 0.75rem; align-items: flex-end;">
<div class="form-group mb-0" style="flex: 0 0 30%;">
<label class="mb-1" style="font-weight: 500; font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.2rem !important;">{{ 'OVERVIEW-GRAPH.MODAL.TP-GROUP' | translate }}</label>
<app-text-input id="tpGroup" formControlName="tpGroup" [floatingLabel]="false" [suggestions]="tpGroups"></app-text-input>
<app-text-input id="tpGroup" formControlName="tpGroup" [floatingLabel]="false" [suggestions]="resolvedTpGroups"></app-text-input>
</div>

<div class="form-group mb-0" style="flex: 1; min-width: 0;">
<label for="questionsChooser" class="mb-1" style="font-weight: 500; font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.2rem !important;">{{ 'QUESTIONS' | translate }}</label>
<questions-chooser #questionsChooser formControlName="questions" [questionSuggestions]="questions"
<questions-chooser #questionsChooser formControlName="questions" [questionSuggestions]="resolvedQuestions"
[mode]="'choose'" [openOnFocus]="true" [maxPillsWidth]="'65%'">
</questions-chooser>
</div>
Expand Down
13 changes: 12 additions & 1 deletion src/app/components/edit-milestone/edit-milestone.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import * as moment from "moment";
import { TypePickerOption } from "@shared/ui/type-picker/type-picker.component";

import { DataService } from "@services/data.service";

@Component({
selector: "edit-milestone",
templateUrl: "./edit-milestone.component.html",
Expand All @@ -27,6 +29,14 @@
@Input() notes: string;
milestoneForm: FormGroup;

get resolvedTpGroups(): string[] {
return this.tpGroups?.length ? this.tpGroups : (this.dataService?.tpGroups || []);

Check notice on line 33 in src/app/components/edit-milestone/edit-milestone.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-milestone/edit-milestone.component.ts#L33

Replace `·?·this.tpGroups·:·(this.dataService?.tpGroups·||·[])` with `⏎······?·this.tpGroups⏎······:·this.dataService?.tpGroups·||·[]`
}

get resolvedQuestions(): string[] {
return this.questions?.length ? this.questions : (this.dataService?.questions || []);

Check notice on line 37 in src/app/components/edit-milestone/edit-milestone.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-milestone/edit-milestone.component.ts#L37

Replace `·?·this.questions·:·(this.dataService?.questions·||·[])` with `⏎······?·this.questions⏎······:·this.dataService?.questions·||·[]`
}

milestoneTypes: TypePickerOption[] = [
{ value: 'reviews', label: 'REVIEW', color: 'var(--color-primary)' },
{ value: 'corrections', label: 'CORRECTION', color: 'var(--color-danger)' },
Expand All @@ -35,7 +45,8 @@

constructor(
public activeModalService: CustomModalRef,
public fb: FormBuilder
public fb: FormBuilder,
private dataService: DataService

Check notice on line 49 in src/app/components/edit-milestone/edit-milestone.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-milestone/edit-milestone.component.ts#L49

Insert `,`
) {}

ngOnInit(): void {
Expand Down
7 changes: 6 additions & 1 deletion src/app/components/edit-session/edit-session.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ <h5 class="modal-title m-0" style="font-weight: 600; font-size: 1.1rem; color: v
</div>
</div>

<div class="form-group mb-2">
<label class="mb-1" style="font-weight: 500; font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.2rem !important;">{{ 'EDIT-SESSION.LABEL-LABEL' | translate }}</label>
<app-text-input id="label" formControlName="label" [floatingLabel]="false" [placeholder]="defaultLabel"></app-text-input>
</div>

<div class="form-group mb-2">
<label class="mb-1" style="font-weight: 500; font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.2rem !important;">{{ 'EDIT-SESSION.TP-GROUP-LABEL' | translate }}</label>
<app-text-input id="tpGroup" formControlName="tpGroup" [floatingLabel]="false" [suggestions]="tpGroups"></app-text-input>
<app-text-input id="tpGroup" formControlName="tpGroup" [floatingLabel]="false" [suggestions]="resolvedTpGroups"></app-text-input>
</div>

<div class="form-group mb-0">
Expand Down
68 changes: 63 additions & 5 deletions src/app/components/edit-session/edit-session.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnDestroy,
OnInit,
} from "@angular/core";
import {
Expand All @@ -13,29 +15,41 @@
} 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",
templateUrl: "./edit-session.component.html",
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 = "";

Check notice on line 38 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L38

Type string trivially inferred from a string literal, remove type annotation.
private destroy$ = new Subject<void>();

get resolvedTpGroups(): string[] {
return this.tpGroups?.length ? this.tpGroups : (this.dataService?.tpGroups || []);

Check notice on line 42 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L42

Replace `·?·this.tpGroups·:·(this.dataService?.tpGroups·||·[])` with `⏎······?·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

Check notice on line 52 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L52

Insert `,`
) {}

endTimeValidator(): ValidatorFn {
Expand All @@ -60,7 +74,43 @@
}

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());

Check notice on line 98 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L98

Replace `(a,·b)·=>·new·Date(a.startDate).getTime()·-·new·Date(b.startDate).getTime());` with `⏎········(a,·b)·=>⏎··········new·Date(a.startDate).getTime()·-·new·Date(b.startDate).getTime(),`

const curTime = this.session?.startDate ? new Date(this.session.startDate).getTime() : 0;

Check notice on line 100 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L100

Replace `·?·new·Date(this.session.startDate).getTime()` with `⏎······?·new·Date(this.session.startDate).getTime()⏎·····`
let idx = sameGroup.findIndex((s) => new Date(s.startDate).getTime() > curTime);

Check notice on line 101 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L101

Replace `(s)·=>·new·Date(s.startDate).getTime()·>·curTime` with `⏎······(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";

Check notice on line 110 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L110

Insert `⏎·······`
this.defaultLabel = `${sessionPrefix} ${sessionNumber}`;
}
this.cdr.markForCheck();
}

private initForm() {
Expand All @@ -71,6 +121,7 @@
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],
Expand All @@ -79,6 +130,13 @@
});
this.sessionForm.setValidators(this.endTimeValidator());

// Recompute default placeholder if TP group changes
this.sessionForm.get('tpGroup')?.valueChanges

Check notice on line 134 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L134

Replace `.get('tpGroup')?.valueChanges⏎······` with `⏎······.get("tpGroup")⏎······?.valueChanges`

Check notice on line 134 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L134

Strings must use doublequote.
.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;
Expand All @@ -93,7 +151,6 @@
this.sessionForm.markAsDirty();
}


deleteSession() {
this.activeModalService.close(null);
}
Expand All @@ -112,8 +169,9 @@
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() : ""

Check notice on line 174 in src/app/components/edit-session/edit-session.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/edit-session/edit-session.component.ts#L174

Insert `,`
);

this.activeModalService.close(session);
Expand Down
7 changes: 4 additions & 3 deletions src/app/components/graphs/base-graph.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,17 @@
repositories: Repository[],
reviews: Milestone[],
corrections: Milestone[],
questions
questions,
conserveZoom: boolean = false

Check notice on line 40 in src/app/components/graphs/base-graph.component.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/app/components/graphs/base-graph.component.ts#L40

Insert `,`
) {
this.loaderService.loadCommitsMetadata(
repositories,
reviews,
corrections,
questions
);
this.loadGraphDataAndRefresh();
this.loadGraphDataAndRefresh(conserveZoom);
}

abstract loadGraphDataAndRefresh();
abstract loadGraphDataAndRefresh(conserveZoom?: boolean);
}
Loading