@@ -93,6 +129,7 @@
Type |
Priorite |
Statut |
+
Milestone |
Assignee |
Progression |
@@ -128,6 +165,7 @@
[style.color]="statusBadge(issue.status).color"
>{{ statusBadge(issue.status).label }}
+
{{ getMilestoneForIssue(issue.id)?.name ?? '—' }} |
{{ issue.assignee }} |
diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts
index ebe2637..609aba3 100644
--- a/src/app/issues/issues.spec.ts
+++ b/src/app/issues/issues.spec.ts
@@ -5,6 +5,7 @@ import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { Issues } from './issues';
import { IssueEntity, IssuesStore } from './issues.store';
+import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
const makeIssue = (overrides: Partial = {}): IssueEntity => ({
id: 99,
@@ -88,19 +89,63 @@ class FakeIssuesStore {
}
}
+const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({
+ id: 1,
+ name: 'Sprint 1',
+ description: '',
+ dueDate: '',
+ issueIds: [],
+ ...overrides,
+});
+
+class FakeMilestonesStore {
+ private _data = signal([]);
+
+ readonly milestones = this._data.asReadonly();
+ readonly loading = signal(false);
+ readonly loaded = signal(true);
+
+ seed(milestones: MilestoneEntity[]): void {
+ this._data.set(milestones);
+ }
+
+ load(): Promise {
+ return Promise.resolve();
+ }
+
+ upsert(milestone: MilestoneEntity): Promise {
+ this._data.update((list) => {
+ const idx = list.findIndex((m) => m.id === milestone.id);
+ if (idx === -1) return [...list, milestone];
+ const copy = [...list];
+ copy[idx] = milestone;
+ return copy;
+ });
+ return Promise.resolve(milestone);
+ }
+
+ deleteById(id: number): Promise {
+ this._data.update((list) => list.filter((m) => m.id !== id));
+ return Promise.resolve();
+ }
+}
+
describe('Issues', () => {
let component: Issues;
let fixture: ComponentFixture;
let store: FakeIssuesStore;
+ let milestonesStore: FakeMilestonesStore;
let router: Router;
beforeEach(async () => {
store = new FakeIssuesStore();
+ milestonesStore = new FakeMilestonesStore();
await TestBed.configureTestingModule({
imports: [Issues],
providers: [
provideRouter([]),
{ provide: IssuesStore, useValue: store },
+ { provide: MilestonesStore, useValue: milestonesStore },
],
}).compileComponents();
@@ -259,6 +304,138 @@ describe('Issues', () => {
});
});
+ describe('getMilestoneForIssue', () => {
+ it('returns the milestone that contains the issue', () => {
+ milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]);
+ const m = (component as any).getMilestoneForIssue(1);
+ expect(m?.id).toBe(10);
+ });
+
+ it('returns undefined when no milestone contains the issue', () => {
+ milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [99] })]);
+ expect((component as any).getMilestoneForIssue(1)).toBeUndefined();
+ });
+ });
+
+ describe('filteredIssues — milestone filter', () => {
+ beforeEach(() => {
+ milestonesStore.seed([
+ makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] }),
+ makeMilestone({ id: 20, name: 'Sprint B', issueIds: [2] }),
+ ]);
+ });
+
+ it('shows all issues when no milestone filter is active', () => {
+ expect((component as any).filteredIssues.length).toBe(3);
+ });
+
+ it('shows only issues of the selected milestone', () => {
+ (component as any).selectedMilestoneIds = new Set([10]);
+ const filtered: IssueEntity[] = (component as any).filteredIssues;
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].id).toBe(1);
+ });
+
+ it('shows issues from multiple selected milestones', () => {
+ (component as any).selectedMilestoneIds = new Set([10, 20]);
+ const filtered: IssueEntity[] = (component as any).filteredIssues;
+ expect(filtered.map((i) => i.id).sort()).toEqual([1, 2]);
+ });
+
+ it('shows only issues without milestone when showNoMilestone is true', () => {
+ (component as any).showNoMilestone = true;
+ const filtered: IssueEntity[] = (component as any).filteredIssues;
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].id).toBe(3);
+ });
+
+ it('combines milestone selection and no-milestone option as OR', () => {
+ (component as any).selectedMilestoneIds = new Set([10]);
+ (component as any).showNoMilestone = true;
+ const filtered: IssueEntity[] = (component as any).filteredIssues;
+ expect(filtered.map((i) => i.id).sort()).toEqual([1, 3]);
+ });
+ });
+
+ describe('toggleMilestone', () => {
+ it('adds a milestone id when not already selected', () => {
+ (component as any).toggleMilestone(10, mockEvent);
+ expect((component as any).selectedMilestoneIds.has(10)).toBe(true);
+ });
+
+ it('removes a milestone id when already selected', () => {
+ (component as any).selectedMilestoneIds = new Set([10]);
+ (component as any).toggleMilestone(10, mockEvent);
+ expect((component as any).selectedMilestoneIds.has(10)).toBe(false);
+ });
+ });
+
+ describe('toggleNoMilestone', () => {
+ it('sets showNoMilestone to true when false', () => {
+ (component as any).showNoMilestone = false;
+ (component as any).toggleNoMilestone(mockEvent);
+ expect((component as any).showNoMilestone).toBe(true);
+ });
+
+ it('sets showNoMilestone to false when true', () => {
+ (component as any).showNoMilestone = true;
+ (component as any).toggleNoMilestone(mockEvent);
+ expect((component as any).showNoMilestone).toBe(false);
+ });
+ });
+
+ describe('clearMilestones', () => {
+ it('clears selected milestone ids and showNoMilestone', () => {
+ (component as any).selectedMilestoneIds = new Set([10, 20]);
+ (component as any).showNoMilestone = true;
+ (component as any).clearMilestones(mockEvent);
+ expect((component as any).selectedMilestoneIds.size).toBe(0);
+ expect((component as any).showNoMilestone).toBe(false);
+ });
+ });
+
+ describe('milestoneDropdownLabel', () => {
+ it('returns "Milestone" when nothing is selected', () => {
+ expect((component as any).milestoneDropdownLabel()).toBe('Milestone');
+ });
+
+ it('returns "Sans milestone" when only showNoMilestone is true', () => {
+ (component as any).showNoMilestone = true;
+ expect((component as any).milestoneDropdownLabel()).toBe('Sans milestone');
+ });
+
+ it('returns the milestone name when exactly one milestone is selected', () => {
+ milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] })]);
+ (component as any).selectedMilestoneIds = new Set([10]);
+ expect((component as any).milestoneDropdownLabel()).toBe('Sprint A');
+ });
+
+ it('returns a count when multiple filters are active', () => {
+ milestonesStore.seed([
+ makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] }),
+ makeMilestone({ id: 20, name: 'Sprint B', issueIds: [] }),
+ ]);
+ (component as any).selectedMilestoneIds = new Set([10, 20]);
+ expect((component as any).milestoneDropdownLabel()).toBe('Milestone (2)');
+ });
+ });
+
+ describe('milestoneFilterActive', () => {
+ it('is false when nothing is selected', () => {
+ expect((component as any).milestoneFilterActive).toBe(false);
+ });
+
+ it('is true when a milestone id is selected', () => {
+ (component as any).selectedMilestoneIds = new Set([10]);
+ expect((component as any).milestoneFilterActive).toBe(true);
+ });
+
+ it('is true when showNoMilestone is true', () => {
+ (component as any).showNoMilestone = true;
+ expect((component as any).milestoneFilterActive).toBe(true);
+ });
+ });
+
describe('typeBadgeClass', () => {
it('maps Bug to text-bg-danger', () => {
expect((component as any).typeBadgeClass('Bug')).toBe('text-bg-danger');
diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts
index 39b760b..f4715ea 100644
--- a/src/app/issues/issues.ts
+++ b/src/app/issues/issues.ts
@@ -2,6 +2,7 @@ import { Component, HostListener, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
+import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
@Component({
selector: 'app-issues',
@@ -12,16 +13,21 @@ import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
export class Issues {
private readonly router = inject(Router);
private readonly issuesStore = inject(IssuesStore);
+ private readonly milestonesStore = inject(MilestonesStore);
constructor() {
this.issuesStore.load();
+ this.milestonesStore.load();
}
protected readonly issues = this.issuesStore.issues;
+ protected readonly milestones = this.milestonesStore.milestones;
protected searchQuery = '';
protected selectedTypes = new Set();
protected selectedStatuses = new Set();
- protected openDropdown: 'type' | 'status' | null = null;
+ protected selectedMilestoneIds = new Set();
+ protected showNoMilestone = false;
+ protected openDropdown: 'type' | 'status' | 'milestone' | null = null;
protected readonly typeOptions: IssueEntity['type'][] = [
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
@@ -31,17 +37,28 @@ export class Issues {
'draft', 'todo', 'in-progress', 'done',
];
+ protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
+ return this.milestones().find((m) => m.issueIds.includes(issueId));
+ }
+
protected get filteredIssues(): IssueEntity[] {
const q = this.searchQuery.trim().toLowerCase();
+ const milestoneActive = this.selectedMilestoneIds.size > 0 || this.showNoMilestone;
return this.issues().filter((i) => {
if (this.selectedTypes.size > 0 && !this.selectedTypes.has(i.type)) return false;
if (this.selectedStatuses.size > 0 && !this.selectedStatuses.has(i.status)) return false;
+ if (milestoneActive) {
+ const m = this.getMilestoneForIssue(i.id);
+ const matchesMilestone = m !== undefined && this.selectedMilestoneIds.has(m.id);
+ const matchesNoMilestone = this.showNoMilestone && m === undefined;
+ if (!matchesMilestone && !matchesNoMilestone) return false;
+ }
if (q && !i.name.toLowerCase().includes(q)) return false;
return true;
});
}
- protected toggleDropdown(name: 'type' | 'status', event: Event): void {
+ protected toggleDropdown(name: 'type' | 'status' | 'milestone', event: Event): void {
event.stopPropagation();
this.openDropdown = this.openDropdown === name ? null : name;
}
@@ -75,6 +92,24 @@ export class Issues {
this.selectedStatuses = new Set();
}
+ protected toggleMilestone(id: number, event: Event): void {
+ event.stopPropagation();
+ const next = new Set(this.selectedMilestoneIds);
+ next.has(id) ? next.delete(id) : next.add(id);
+ this.selectedMilestoneIds = next;
+ }
+
+ protected toggleNoMilestone(event: Event): void {
+ event.stopPropagation();
+ this.showNoMilestone = !this.showNoMilestone;
+ }
+
+ protected clearMilestones(event: Event): void {
+ event.stopPropagation();
+ this.selectedMilestoneIds = new Set();
+ this.showNoMilestone = false;
+ }
+
protected typeDropdownLabel(): string {
if (this.selectedTypes.size === 0) return 'Type';
if (this.selectedTypes.size === 1) return [...this.selectedTypes][0];
@@ -87,6 +122,21 @@ export class Issues {
return `Statut (${this.selectedStatuses.size})`;
}
+ protected milestoneDropdownLabel(): string {
+ const count = this.selectedMilestoneIds.size + (this.showNoMilestone ? 1 : 0);
+ if (count === 0) return 'Milestone';
+ if (count === 1 && this.showNoMilestone) return 'Sans milestone';
+ if (count === 1) {
+ const id = [...this.selectedMilestoneIds][0];
+ return this.milestones().find((m) => m.id === id)?.name ?? 'Milestone';
+ }
+ return `Milestone (${count})`;
+ }
+
+ protected get milestoneFilterActive(): boolean {
+ return this.selectedMilestoneIds.size > 0 || this.showNoMilestone;
+ }
+
protected createIssue(): void {
this.router.navigate(['/issues/new']);
}
diff --git a/src/app/menu/menu.spec.ts b/src/app/menu/menu.spec.ts
index 0d47234..ec177ac 100644
--- a/src/app/menu/menu.spec.ts
+++ b/src/app/menu/menu.spec.ts
@@ -1,15 +1,29 @@
+import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
+import { vi } from 'vitest';
+import { KeycloakService } from '../auth/keycloak.service';
import { Menu } from './menu';
describe('Menu', () => {
let component: Menu;
let fixture: ComponentFixture |