Dependence milestone

This commit is contained in:
2026-05-30 08:32:08 +02:00
parent fb0e853122
commit 16a39ca5e7
12 changed files with 217 additions and 9 deletions
+8 -3
View File
@@ -4,13 +4,18 @@
Si une demande ne peut pas être implémentée avec les endpoints API existants (endpoint manquant, champ absent, comportement insuffisant), ne pas contourner le problème côté frontend. Si une demande ne peut pas être implémentée avec les endpoints API existants (endpoint manquant, champ absent, comportement insuffisant), ne pas contourner le problème côté frontend.
## Action requise ## Action requise
Créer un fichier dans le dossier `api-issues/` à la racine du projet, nommé d'après le **numéro de ticket** extrait du nom de la branche courante, suivi du slug de la branche : Créer un **nouveau fichier** dans le dossier `api-issues/` à la racine du projet pour **chaque besoin d'évolution API distinct**. Ne jamais modifier un fichier existant : toujours créer un fichier supplémentaire.
Le nom du fichier est composé du **numéro de ticket** (extrait de la branche courante) suivi d'un **slug explicite décrivant le changement demandé** :
``` ```
api-issues/<numéro>-<slug>.md api-issues/<numéro>-<description-du-changement>.md
``` ```
> **Exemple** : branche `feat/30-ordre-statut` → fichier `api-issues/30-ordre-statut.md` > **Exemple** : ticket 30, besoin d'ajouter les dates de milestone → fichier `api-issues/30-milestone-dates.md`
> **Exemple** : ticket 30, besoin d'un endpoint de statistiques → fichier `api-issues/30-statistiques-epic.md`
Si plusieurs besoins distincts émergent pour le même ticket, créer autant de fichiers séparés.
## Contenu du fichier ## Contenu du fichier
Le fichier est un **prompt** destiné à un agent ou développeur backend. Il doit être rédigé comme une instruction directe et suffisamment complète pour être exécutée sans contexte supplémentaire. Il doit décrire : Le fichier est un **prompt** destiné à un agent ou développeur backend. Il doit être rédigé comme une instruction directe et suffisamment complète pour être exécutée sans contexte supplémentaire. Il doit décrire :
+3 -1
View File
@@ -15,7 +15,9 @@
], ],
"additionalDirectories": [ "additionalDirectories": [
"/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app", "/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app",
"/var/home/Gato/IdeaProjects/Bonsai-webapp.wiki" "/var/home/Gato/IdeaProjects/Bonsai-webapp.wiki",
"/home/Gato/IdeaProjects/Bonsai-webapp/api-issues",
"/var/home/Gato/IdeaProjects/Bonsai-webapp/api-issues"
] ]
} }
} }
+2 -2
View File
@@ -239,7 +239,7 @@ describe('Dashboard', () => {
describe('activeMilestones', () => { describe('activeMilestones', () => {
it('exclut les milestones terminés à 100%', () => { it('exclut les milestones terminés à 100%', () => {
issuesStore.seed([makeIssue({ id: 1, status: 'done' })]); issuesStore.seed([makeIssue({ id: 1, status: 'done' })]);
milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1] }]); milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1], dependsOnIds: [] }]);
expect((component as any).activeMilestones().length).toBe(0); expect((component as any).activeMilestones().length).toBe(0);
}); });
@@ -248,7 +248,7 @@ describe('Dashboard', () => {
makeIssue({ id: 1, status: 'done' }), makeIssue({ id: 1, status: 'done' }),
makeIssue({ id: 2, status: 'todo' }), makeIssue({ id: 2, status: 'todo' }),
]); ]);
milestonesStore.seed([{ id: 1, name: 'Active', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1, 2] }]); milestonesStore.seed([{ id: 1, name: 'Active', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1, 2], dependsOnIds: [] }]);
expect((component as any).activeMilestones().length).toBe(1); expect((component as any).activeMilestones().length).toBe(1);
}); });
}); });
@@ -93,6 +93,7 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
endDate: '', endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
+1
View File
@@ -100,6 +100,7 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
endDate: '', endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
@@ -98,6 +98,24 @@
background: #f3f4f6; background: #f3f4f6;
} }
.dep-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.6rem;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 999px;
font-size: 0.85rem;
color: #374151;
}
.dep-id {
font-weight: 700;
color: #6b7280;
font-size: 0.78rem;
}
.dep-remove { .dep-remove {
border: none; border: none;
background: transparent; background: transparent;
@@ -132,6 +132,40 @@
</div> </div>
</div> </div>
<!-- Dépendances -->
@if (!isNewRoute) {
<div class="card shadow-sm mb-3">
<div class="card-header section-header">Dépendances</div>
<div class="card-body">
@if (hasDependencies) {
<div class="d-flex flex-wrap gap-2 mb-3">
@for (depId of dependencyIds; track depId) {
<span class="dep-badge">
<span class="dep-id">#{{ depId }}</span>
{{ resolveDependency(depId)?.name || 'Sans nom' }}
<button type="button" class="dep-remove" (click)="removeDependency(depId)" title="Supprimer">×</button>
</span>
}
</div>
}
@if (showAddDependency) {
<div class="d-flex gap-2 flex-wrap">
<select aria-label="Choisir un milestone" class="form-select form-select-sm dep-select" [(ngModel)]="selectedCandidateMilestoneId">
<option [ngValue]="null">Choisir un milestone...</option>
@for (candidate of availableCandidates; track candidate.id) {
<option [ngValue]="candidate.id">#{{ candidate.id }} {{ candidate.name || 'Sans nom' }}</option>
}
</select>
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmAddDependency()" [disabled]="selectedCandidateMilestoneId === null">Ajouter</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelAddDependency()">Annuler</button>
</div>
} @else {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openAddDependency()">+ Ajouter une dépendance</button>
}
</div>
</div>
}
<!-- Issues liées --> <!-- Issues liées -->
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header section-header d-flex align-items-center justify-content-between"> <div class="card-header section-header d-flex align-items-center justify-content-between">
@@ -36,6 +36,7 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
endDate: '', endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
@@ -501,6 +502,87 @@ describe('MilestoneDetail', () => {
}); });
}); });
describe('dependencies', () => {
beforeEach(() => {
milestonesStore.seed([
makeMilestone({ id: 1, name: 'Sprint 1', dependsOnIds: [] }),
makeMilestone({ id: 2, name: 'Sprint 2', dependsOnIds: [] }),
makeMilestone({ id: 3, name: 'Sprint 3', dependsOnIds: [] }),
]);
(component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', dependsOnIds: [] });
});
it('hasDependencies returns false when dependsOnIds is empty', () => {
expect((component as any).hasDependencies).toBe(false);
});
it('hasDependencies returns true when dependsOnIds is non-empty', () => {
(component as any).milestone.dependsOnIds = [2];
expect((component as any).hasDependencies).toBe(true);
});
it('dependencyIds returns the dependsOnIds array', () => {
(component as any).milestone.dependsOnIds = [2, 3];
expect((component as any).dependencyIds).toEqual([2, 3]);
});
it('availableCandidates excludes the current milestone', () => {
const candidates: MilestoneEntity[] = (component as any).availableCandidates;
expect(candidates.some((m: MilestoneEntity) => m.id === 1)).toBe(false);
});
it('availableCandidates excludes already-added dependencies', () => {
(component as any).milestone.dependsOnIds = [2];
const candidates: MilestoneEntity[] = (component as any).availableCandidates;
expect(candidates.some((m: MilestoneEntity) => m.id === 2)).toBe(false);
expect(candidates.some((m: MilestoneEntity) => m.id === 3)).toBe(true);
});
it('resolveDependency returns the milestone with the given id', () => {
expect((component as any).resolveDependency(2)?.name).toBe('Sprint 2');
});
it('resolveDependency returns undefined for unknown id', () => {
expect((component as any).resolveDependency(999)).toBeUndefined();
});
it('openAddDependency shows the form and resets selection', () => {
(component as any).selectedCandidateMilestoneId = 2;
(component as any).openAddDependency();
expect((component as any).showAddDependency).toBe(true);
expect((component as any).selectedCandidateMilestoneId).toBeNull();
});
it('cancelAddDependency hides the form and resets selection', () => {
(component as any).showAddDependency = true;
(component as any).selectedCandidateMilestoneId = 2;
(component as any).cancelAddDependency();
expect((component as any).showAddDependency).toBe(false);
expect((component as any).selectedCandidateMilestoneId).toBeNull();
});
it('confirmAddDependency does nothing when no candidate is selected', async () => {
(component as any).selectedCandidateMilestoneId = null;
await (component as any).confirmAddDependency();
expect((component as any).milestone.dependsOnIds).toHaveLength(0);
});
it('confirmAddDependency adds the id and saves', async () => {
(component as any).selectedCandidateMilestoneId = 2;
await (component as any).confirmAddDependency();
expect((component as any).milestone.dependsOnIds).toContain(2);
expect((component as any).showAddDependency).toBe(false);
expect((component as any).selectedCandidateMilestoneId).toBeNull();
});
it('removeDependency removes the id and saves', async () => {
(component as any).milestone.dependsOnIds = [2, 3];
await (component as any).removeDependency(2);
expect((component as any).milestone.dependsOnIds).not.toContain(2);
expect((component as any).milestone.dependsOnIds).toContain(3);
});
});
describe('deleteMilestone', () => { describe('deleteMilestone', () => {
it('removes the milestone and navigates to /milestones', async () => { it('removes the milestone and navigates to /milestones', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
@@ -35,6 +35,8 @@ export class MilestoneDetail {
protected issueSearchQuery = ''; protected issueSearchQuery = '';
protected showIssueSuggestions = false; protected showIssueSuggestions = false;
protected moreMenuOpen = false; protected moreMenuOpen = false;
protected showAddDependency = false;
protected selectedCandidateMilestoneId: number | null = null;
constructor() { constructor() {
this.milestonesStore.load().then(() => { this.milestonesStore.load().then(() => {
@@ -60,6 +62,47 @@ export class MilestoneDetail {
}); });
} }
protected get hasDependencies(): boolean {
return this.milestone.dependsOnIds.length > 0;
}
protected get dependencyIds(): number[] {
return this.milestone.dependsOnIds;
}
protected get availableCandidates(): MilestoneEntity[] {
return this.milestonesStore.milestones().filter(
(m) => m.id !== this.milestone.id && !this.milestone.dependsOnIds.includes(m.id),
);
}
protected resolveDependency(id: number): MilestoneEntity | undefined {
return this.milestonesStore.getById(id);
}
protected openAddDependency(): void {
this.selectedCandidateMilestoneId = null;
this.showAddDependency = true;
}
protected cancelAddDependency(): void {
this.showAddDependency = false;
this.selectedCandidateMilestoneId = null;
}
protected async confirmAddDependency(): Promise<void> {
if (this.selectedCandidateMilestoneId === null) return;
this.milestone.dependsOnIds = [...this.milestone.dependsOnIds, this.selectedCandidateMilestoneId];
this.selectedCandidateMilestoneId = null;
this.showAddDependency = false;
await this.saveMilestone();
}
protected async removeDependency(id: number): Promise<void> {
this.milestone.dependsOnIds = this.milestone.dependsOnIds.filter((depId) => depId !== id);
await this.saveMilestone();
}
protected get linkedIssues(): IssueEntity[] { protected get linkedIssues(): IssueEntity[] {
return this.issues().filter((i) => this.milestone.issueIds.includes(i.id)); return this.issues().filter((i) => this.milestone.issueIds.includes(i.id));
} }
@@ -269,9 +312,9 @@ export class MilestoneDetail {
private buildMilestone(): MilestoneEntity { private buildMilestone(): MilestoneEntity {
if (this.route.snapshot.routeConfig?.path === 'milestones/new') { if (this.route.snapshot.routeConfig?.path === 'milestones/new') {
return { id: 0, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] }; return { id: 0, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] };
} }
const id = Number(this.route.snapshot.paramMap.get('id') ?? 0); const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
return this.milestonesStore.getById(id) ?? { id, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] }; return this.milestonesStore.getById(id) ?? { id, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] };
} }
} }
@@ -14,6 +14,7 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
endDate: '', endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
@@ -45,7 +46,7 @@ describe('MilestonesApiService', () => {
describe('create', () => { describe('create', () => {
it('sends POST /api/milestones with the body and returns the created milestone', () => { it('sends POST /api/milestones with the body and returns the created milestone', () => {
const body = { name: 'Sprint 2', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [] }; const body = { name: 'Sprint 2', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] };
const response = makeMilestone({ id: 2, name: 'Sprint 2' }); const response = makeMilestone({ id: 2, name: 'Sprint 2' });
let result: MilestoneEntity | undefined; let result: MilestoneEntity | undefined;
service.create(body).subscribe((data) => (result = data)); service.create(body).subscribe((data) => (result = data));
@@ -13,6 +13,7 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
endDate: '', endDate: '',
dueDate: '', dueDate: '',
issueIds: [], issueIds: [],
dependsOnIds: [],
...overrides, ...overrides,
}); });
@@ -144,5 +145,21 @@ describe('MilestonesStore', () => {
const result = await p; const result = await p;
expect(result.issueIds).toEqual([1]); expect(result.issueIds).toEqual([1]);
}); });
it('normalizes dependsOnIds to empty array when not an array', async () => {
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: null } as any;
const p = store.upsert(raw);
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ ...raw, dependsOnIds: null });
const result = await p;
expect(result.dependsOnIds).toEqual([]);
});
it('filters non-number values from dependsOnIds', async () => {
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2, 'bad', null] } as any;
const p = store.upsert(raw);
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2] });
const result = await p;
expect(result.dependsOnIds).toEqual([2]);
});
}); });
}); });
+4
View File
@@ -10,6 +10,7 @@ export type MilestoneEntity = {
endDate: string; endDate: string;
dueDate: string; dueDate: string;
issueIds: number[]; issueIds: number[];
dependsOnIds: number[];
}; };
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -75,6 +76,9 @@ export class MilestonesStore {
issueIds: Array.isArray(milestone.issueIds) issueIds: Array.isArray(milestone.issueIds)
? milestone.issueIds.filter((v): v is number => typeof v === 'number') ? milestone.issueIds.filter((v): v is number => typeof v === 'number')
: [], : [],
dependsOnIds: Array.isArray(milestone.dependsOnIds)
? milestone.dependsOnIds.filter((v): v is number => typeof v === 'number')
: [],
}; };
} }
} }