Dependence milestone
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user