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
@@ -98,6 +98,24 @@
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 {
border: none;
background: transparent;
@@ -132,6 +132,40 @@
</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 -->
<div class="card shadow-sm mb-3">
<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: '',
dueDate: '',
issueIds: [],
dependsOnIds: [],
...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', () => {
it('removes the milestone and navigates to /milestones', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
@@ -35,6 +35,8 @@ export class MilestoneDetail {
protected issueSearchQuery = '';
protected showIssueSuggestions = false;
protected moreMenuOpen = false;
protected showAddDependency = false;
protected selectedCandidateMilestoneId: number | null = null;
constructor() {
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[] {
return this.issues().filter((i) => this.milestone.issueIds.includes(i.id));
}
@@ -269,9 +312,9 @@ export class MilestoneDetail {
private buildMilestone(): MilestoneEntity {
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);
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: '',
dueDate: '',
issueIds: [],
dependsOnIds: [],
...overrides,
});
@@ -45,7 +46,7 @@ describe('MilestonesApiService', () => {
describe('create', () => {
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' });
let result: MilestoneEntity | undefined;
service.create(body).subscribe((data) => (result = data));
@@ -13,6 +13,7 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
endDate: '',
dueDate: '',
issueIds: [],
dependsOnIds: [],
...overrides,
});
@@ -144,5 +145,21 @@ describe('MilestonesStore', () => {
const result = await p;
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;
dueDate: string;
issueIds: number[];
dependsOnIds: number[];
};
@Injectable({ providedIn: 'root' })
@@ -75,6 +76,9 @@ export class MilestonesStore {
issueIds: Array.isArray(milestone.issueIds)
? 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')
: [],
};
}
}