From e20a00988242d13e230b7765bf956b48d041d65e Mon Sep 17 00:00:00 2001 From: Gato Date: Thu, 28 May 2026 05:57:33 +0200 Subject: [PATCH 1/2] milestone dans tableau issue --- .claude/settings.local.json | 4 +- .gitignore | 3 + src/app/issues/issue-detail/issue-detail.html | 14 ++ .../issues/issue-detail/issue-detail.spec.ts | 110 +++++++++++ src/app/issues/issue-detail/issue-detail.ts | 35 ++++ src/app/issues/issues.html | 38 ++++ src/app/issues/issues.spec.ts | 177 ++++++++++++++++++ src/app/issues/issues.ts | 54 +++++- 8 files changed, 432 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2c1ee00..3c1bf20 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,9 @@ "Read(//home/Gato/IdeaProjects/Bonsai-webapp/src/**)", "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/src/**)", "Read(//home/Gato/IdeaProjects/Bonsai-webapp/**)", - "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/**)" + "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/**)", + "Bash(grep -E \"\\\\.\\(ts|html|scss\\)$\")", + "Bash(grep -E \"\\\\.\\(ts|html\\)$\")" ], "additionalDirectories": [ "/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues", diff --git a/.gitignore b/.gitignore index 854acd5..1b76769 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ __screenshots__/ # System files .DS_Store Thumbs.db + +# Project file +api-issues \ No newline at end of file diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index c53941a..b44c135 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -120,6 +120,20 @@ } +
+ +
+ + @if (currentMilestoneId !== null) { + + } +
+
} diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index d239a4d..71bfd39 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -6,6 +6,7 @@ import { of } from 'rxjs'; import { vi } from 'vitest'; import { IssueDetail } from './issue-detail'; import { IssueEntity, IssuesStore } from '../issues.store'; +import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, @@ -81,6 +82,51 @@ 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); + } + + getById(id: number): MilestoneEntity | undefined { + return this._data().find((m) => m.id === id); + } + + 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(); + } +} + function makeRoute(id = '1', path = 'issues/:id') { return { snapshot: { @@ -96,16 +142,19 @@ describe('IssueDetail — existing issue', () => { let component: IssueDetail; let fixture: ComponentFixture; let store: FakeIssuesStore; + let milestonesStore: FakeMilestonesStore; let router: Router; beforeEach(async () => { store = new FakeIssuesStore(); + milestonesStore = new FakeMilestonesStore(); await TestBed.configureTestingModule({ imports: [IssueDetail], providers: [ provideRouter([]), { provide: ActivatedRoute, useValue: makeRoute('1') }, { provide: IssuesStore, useValue: store }, + { provide: MilestonesStore, useValue: milestonesStore }, ], }).compileComponents(); @@ -512,6 +561,66 @@ describe('IssueDetail — existing issue', () => { expect((component as any).descriptionHtml).toBeTruthy(); }); }); + + describe('milestone selection', () => { + it('currentMilestone returns the milestone that contains the current issue', () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]); + expect((component as any).currentMilestone?.id).toBe(10); + }); + + it('currentMilestone returns undefined when no milestone contains the issue', () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [99] })]); + expect((component as any).currentMilestone).toBeUndefined(); + }); + + it('currentMilestoneId returns the id of the linked milestone', () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]); + expect((component as any).currentMilestoneId).toBe(10); + }); + + it('currentMilestoneId returns null when no milestone is linked', () => { + milestonesStore.seed([]); + expect((component as any).currentMilestoneId).toBeNull(); + }); + + it('onMilestoneChange adds the issue to the selected milestone', async () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] })]); + await (component as any).onMilestoneChange(10); + expect(milestonesStore.getById(10)?.issueIds).toContain(1); + }); + + it('onMilestoneChange removes the issue from the previous milestone', async () => { + milestonesStore.seed([ + makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] }), + makeMilestone({ id: 20, name: 'Sprint B', issueIds: [] }), + ]); + await (component as any).onMilestoneChange(20); + expect(milestonesStore.getById(10)?.issueIds).not.toContain(1); + expect(milestonesStore.getById(20)?.issueIds).toContain(1); + }); + + it('onMilestoneChange with null removes the issue from the current milestone', async () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]); + await (component as any).onMilestoneChange(null); + expect(milestonesStore.getById(10)?.issueIds).not.toContain(1); + }); + }); + + describe('navigateToMilestone', () => { + it('navigates to the current milestone', () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]); + const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + (component as any).navigateToMilestone(); + expect(spy).toHaveBeenCalledWith(['/milestones', 10]); + }); + + it('does nothing when no milestone is linked', () => { + milestonesStore.seed([]); + const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + (component as any).navigateToMilestone(); + expect(spy).not.toHaveBeenCalled(); + }); + }); }); describe('IssueDetail — new issue route', () => { @@ -538,6 +647,7 @@ describe('IssueDetail — new issue route', () => { }, }, { provide: IssuesStore, useValue: store }, + { provide: MilestonesStore, useValue: new FakeMilestonesStore() }, ], }).compileComponents(); diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index a947f87..58e3d9d 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -7,6 +7,7 @@ import { marked } from 'marked'; import { IssueEntity, IssuesStore } from '../issues.store'; import { IssueComments } from '../issue-comments/issue-comments'; import { handleImagePaste, insertAtSelection } from '../paste-image.util'; +import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; @Component({ selector: 'app-issue-detail', @@ -18,11 +19,13 @@ export class IssueDetail { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly issuesStore = inject(IssuesStore); + private readonly milestonesStore = inject(MilestonesStore); private readonly sanitizer = inject(DomSanitizer); protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; protected issue: IssueEntity = this.buildIssue(); protected readonly issues = this.issuesStore.issues; + protected readonly milestones = this.milestonesStore.milestones; protected moreMenuOpen = false; protected statusMenuOpen = false; @@ -30,6 +33,7 @@ export class IssueDetail { const idParam = this.route.snapshot.paramMap.get('id'); const safeId = Number(idParam ?? 0); + this.milestonesStore.load(); this.issuesStore.load().then(() => { if (safeId) { const found = this.issuesStore.getById(safeId); @@ -280,6 +284,37 @@ export class IssueDetail { } } + protected get currentMilestone(): MilestoneEntity | undefined { + return this.milestones().find((m) => m.issueIds.includes(this.issue.id)); + } + + protected get currentMilestoneId(): number | null { + return this.currentMilestone?.id ?? null; + } + + protected async onMilestoneChange(newMilestoneId: number | null): Promise { + if (this.isNewIssueRoute) return; + const previous = this.currentMilestone; + if (previous) { + await this.milestonesStore.upsert({ + ...previous, + issueIds: previous.issueIds.filter((id) => id !== this.issue.id), + }); + } + if (newMilestoneId !== null) { + const target = this.milestones().find((m) => m.id === newMilestoneId); + if (target) { + await this.milestonesStore.upsert({ ...target, issueIds: [...target.issueIds, this.issue.id] }); + } + } + } + + protected navigateToMilestone(): void { + if (this.currentMilestoneId !== null) { + this.router.navigate(['/milestones', this.currentMilestoneId]); + } + } + protected async saveIssue(explicit = false): Promise { if (this.isNewIssueRoute && !explicit) return; if (!this.issue.name.trim()) return; diff --git a/src/app/issues/issues.html b/src/app/issues/issues.html index 7c29ff5..ebb6f03 100644 --- a/src/app/issues/issues.html +++ b/src/app/issues/issues.html @@ -81,6 +81,42 @@ } + + +
@@ -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']); } From 05bb1b58d9dfd34ac45c8e22af75ff56e81c85ea Mon Sep 17 00:00:00 2001 From: Gato Date: Thu, 28 May 2026 06:11:33 +0200 Subject: [PATCH 2/2] Gestion issue epic dans milestone --- .claude/settings.local.json | 4 +- src/app/issues/issue-detail/issue-detail.html | 34 +- .../issues/issue-detail/issue-detail.spec.ts | 101 +++ src/app/issues/issue-detail/issue-detail.ts | 39 +- src/app/menu/menu.spec.ts | 21 +- .../milestone-detail/milestone-detail.html | 2 +- .../milestone-detail/milestone-detail.spec.ts | 600 ++++++++++++++++++ .../milestone-detail/milestone-detail.ts | 17 +- .../milestones/milestones-api.service.spec.ts | 81 +++ src/app/milestones/milestones.store.spec.ts | 146 +++++ 10 files changed, 1024 insertions(+), 21 deletions(-) create mode 100644 src/app/milestones/milestone-detail/milestone-detail.spec.ts create mode 100644 src/app/milestones/milestones-api.service.spec.ts create mode 100644 src/app/milestones/milestones.store.spec.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3c1bf20..edd7e76 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,9 @@ "Read(//home/Gato/IdeaProjects/Bonsai-webapp/**)", "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/**)", "Bash(grep -E \"\\\\.\\(ts|html|scss\\)$\")", - "Bash(grep -E \"\\\\.\\(ts|html\\)$\")" + "Bash(grep -E \"\\\\.\\(ts|html\\)$\")", + "Bash(ls /var/home/Gato/IdeaProjects/Bonsai-webapp.wiki/ 2>/dev/null && echo \"---wiki exists---\" || echo \"---wiki not found---\")", + "Read(//var/home/Gato/IdeaProjects/Bonsai-webapp.wiki/**)" ], "additionalDirectories": [ "/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues", diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index b44c135..97a9f80 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -120,21 +120,27 @@ }
-
- -
- - @if (currentMilestoneId !== null) { - - } -
-
} +
+ +
+ + @if (currentMilestoneId !== null) { + + } +
+
diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 71bfd39..fecb909 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -621,6 +621,107 @@ describe('IssueDetail — existing issue', () => { expect(spy).not.toHaveBeenCalled(); }); }); + + describe('isChildOfEpic', () => { + it('is false when issue has no epic', () => { + (component as any).issue.epic = ''; + expect((component as any).isChildOfEpic).toBe(false); + }); + + it('is true when issue belongs to an epic', () => { + (component as any).issue.epic = 'My Epic'; + expect((component as any).isChildOfEpic).toBe(true); + }); + }); + + describe('onMilestoneChange — epic propagation', () => { + beforeEach(() => { + (component as any).issue.type = 'Epic'; + (component as any).issue.name = 'Big Epic'; + store.upsert(makeIssue({ id: 2, name: 'Child 1', epic: 'Big Epic' })); + store.upsert(makeIssue({ id: 3, name: 'Child 2', epic: 'Big Epic' })); + }); + + it('adds epic and all children to the selected milestone', async () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] })]); + await (component as any).onMilestoneChange(10); + expect(milestonesStore.getById(10)?.issueIds).toContain(1); + expect(milestonesStore.getById(10)?.issueIds).toContain(2); + expect(milestonesStore.getById(10)?.issueIds).toContain(3); + }); + + it('removes epic and all children from the previous milestone', async () => { + milestonesStore.seed([ + makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1, 2, 3] }), + makeMilestone({ id: 20, name: 'Sprint B', issueIds: [] }), + ]); + await (component as any).onMilestoneChange(20); + expect(milestonesStore.getById(10)?.issueIds).toHaveLength(0); + expect(milestonesStore.getById(20)?.issueIds).toContain(1); + expect(milestonesStore.getById(20)?.issueIds).toContain(2); + expect(milestonesStore.getById(20)?.issueIds).toContain(3); + }); + + it('removes epic and all children from milestone when set to null', async () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1, 2, 3] })]); + await (component as any).onMilestoneChange(null); + expect(milestonesStore.getById(10)?.issueIds).toHaveLength(0); + }); + }); + + describe('confirmCreateInEpic — milestone propagation', () => { + beforeEach(() => { + (component as any).issue.type = 'Epic'; + (component as any).issue.name = 'My Epic'; + }); + + it('adds the created issue to the epic milestone', async () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]); + (component as any).newIssueName = 'New Story'; + await (component as any).confirmCreateInEpic(); + const m = milestonesStore.getById(10); + expect(m?.issueIds.length).toBeGreaterThan(1); + }); + + it('does not touch milestones when epic has no milestone', async () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] })]); + (component as any).newIssueName = 'New Story'; + await (component as any).confirmCreateInEpic(); + expect(milestonesStore.getById(10)?.issueIds).toHaveLength(0); + }); + }); + + describe('confirmAddToEpic — milestone propagation', () => { + beforeEach(() => { + (component as any).issue.type = 'Epic'; + (component as any).issue.name = 'My Epic'; + }); + + it('adds the issue to the epic milestone when epic has one', async () => { + milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]); + (component as any).selectedEpicCandidateId = 2; + await (component as any).confirmAddToEpic(); + expect(milestonesStore.getById(10)?.issueIds).toContain(2); + }); + + it('moves the issue from its current milestone to the epic milestone', async () => { + milestonesStore.seed([ + makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] }), + makeMilestone({ id: 20, name: 'Sprint B', issueIds: [2] }), + ]); + (component as any).selectedEpicCandidateId = 2; + await (component as any).confirmAddToEpic(); + expect(milestonesStore.getById(10)?.issueIds).toContain(2); + expect(milestonesStore.getById(20)?.issueIds).not.toContain(2); + }); + + it('does not touch milestones when epic has no milestone', async () => { + milestonesStore.seed([makeMilestone({ id: 20, name: 'Sprint B', issueIds: [2] })]); + (component as any).selectedEpicCandidateId = 2; + await (component as any).confirmAddToEpic(); + expect(milestonesStore.getById(20)?.issueIds).toContain(2); + }); + }); }); describe('IssueDetail — new issue route', () => { diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 58e3d9d..5cb7f83 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -166,7 +166,7 @@ export class IssueDetail { protected async confirmCreateInEpic(): Promise { const name = this.newIssueName.trim(); if (!name) return; - await this.issuesStore.upsert({ + const created = await this.issuesStore.upsert({ id: 0, type: 'Story', assignee: '', @@ -181,6 +181,13 @@ export class IssueDetail { status: 'draft', progress: 0, }); + const epicMilestone = this.currentMilestone; + if (epicMilestone) { + await this.milestonesStore.upsert({ + ...epicMilestone, + issueIds: [...epicMilestone.issueIds, created.id], + }); + } this.showCreateInEpic = false; this.newIssueName = ''; } @@ -200,6 +207,22 @@ export class IssueDetail { const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId); if (target) { await this.issuesStore.upsert({ ...target, epic: this.issue.name }); + const epicMilestone = this.currentMilestone; + if (epicMilestone) { + const prevMilestone = this.milestones().find((m) => m.issueIds.includes(target.id)); + if (prevMilestone && prevMilestone.id !== epicMilestone.id) { + await this.milestonesStore.upsert({ + ...prevMilestone, + issueIds: prevMilestone.issueIds.filter((id) => id !== target.id), + }); + } + if (!epicMilestone.issueIds.includes(target.id)) { + await this.milestonesStore.upsert({ + ...epicMilestone, + issueIds: [...epicMilestone.issueIds, target.id], + }); + } + } } } this.showAddToEpic = false; @@ -210,6 +233,10 @@ export class IssueDetail { return this.issueTypeValue === 'Epic'; } + protected get isChildOfEpic(): boolean { + return !!this.issue.epic; + } + protected onDescriptionPaste(event: ClipboardEvent): void { const ta = event.target as HTMLTextAreaElement; const start = ta.selectionStart; @@ -294,17 +321,23 @@ export class IssueDetail { protected async onMilestoneChange(newMilestoneId: number | null): Promise { if (this.isNewIssueRoute) return; + const childIds = this.isEpicIssue + ? this.issues().filter((i) => i.epic === this.issue.name).map((i) => i.id) + : []; + const allIds = [this.issue.id, ...childIds]; + const previous = this.currentMilestone; if (previous) { await this.milestonesStore.upsert({ ...previous, - issueIds: previous.issueIds.filter((id) => id !== this.issue.id), + issueIds: previous.issueIds.filter((id) => !allIds.includes(id)), }); } if (newMilestoneId !== null) { const target = this.milestones().find((m) => m.id === newMilestoneId); if (target) { - await this.milestonesStore.upsert({ ...target, issueIds: [...target.issueIds, this.issue.id] }); + const toAdd = allIds.filter((id) => !target.issueIds.includes(id)); + await this.milestonesStore.upsert({ ...target, issueIds: [...target.issueIds, ...toAdd] }); } } } 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; + const keycloakMock = { + isAuthenticated: signal(false), + username: signal(undefined), + logout: vi.fn(), + login: vi.fn(), + }; beforeEach(async () => { + keycloakMock.logout = vi.fn(); + keycloakMock.login = vi.fn(); await TestBed.configureTestingModule({ imports: [Menu], - providers: [provideRouter([])], + providers: [ + provideRouter([]), + { provide: KeycloakService, useValue: keycloakMock }, + ], }).compileComponents(); fixture = TestBed.createComponent(Menu); @@ -35,4 +49,9 @@ describe('Menu', () => { const items = (component as any).menuItems as { label: string; path: string }[]; expect(items.some((i) => i.path === '/milestones')).toBe(true); }); + + it('logout calls keycloak.logout()', () => { + (component as any).logout(); + expect(keycloakMock.logout).toHaveBeenCalled(); + }); }); diff --git a/src/app/milestones/milestone-detail/milestone-detail.html b/src/app/milestones/milestone-detail/milestone-detail.html index 1d701a4..901ac47 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.html +++ b/src/app/milestones/milestone-detail/milestone-detail.html @@ -129,7 +129,7 @@ - @for (issue of linkedIssues; track issue.id) { + @for (issue of displayedIssues; track issue.id) { #{{ issue.id }} diff --git a/src/app/milestones/milestone-detail/milestone-detail.spec.ts b/src/app/milestones/milestone-detail/milestone-detail.spec.ts new file mode 100644 index 0000000..07d53b5 --- /dev/null +++ b/src/app/milestones/milestone-detail/milestone-detail.spec.ts @@ -0,0 +1,600 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { afterEach, vi } from 'vitest'; +import { MilestoneDetail } from './milestone-detail'; +import { IssueEntity, IssuesStore } from '../../issues/issues.store'; +import { MilestoneEntity, MilestonesStore } from '../milestones.store'; + +const makeIssue = (overrides: Partial = {}): IssueEntity => ({ + id: 99, + type: 'Story', + assignee: '', + epic: '', + name: 'Test Issue', + dueDate: '', + description: '', + estimatedTime: null, + dependsOnIds: [], + comments: [], + priority: 'MOYENNE', + status: 'draft', + progress: 0, + ...overrides, +}); + +const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({ + id: 1, + name: 'Sprint 1', + description: '', + dueDate: '', + issueIds: [], + ...overrides, +}); + +class FakeIssuesStore { + private _data = signal([]); + + readonly issues = this._data.asReadonly(); + readonly loading = signal(false); + readonly loaded = signal(true); + + seed(issues: IssueEntity[]): void { + this._data.set(issues); + } + + getById(id: number): IssueEntity | undefined { + return this._data().find((i) => i.id === id); + } + + load(): Promise { + return Promise.resolve(); + } + + upsert(issue: IssueEntity): Promise { + const normalized = { ...makeIssue(), ...issue }; + this._data.update((list) => { + const idx = list.findIndex((i) => i.id === normalized.id); + if (idx === -1) return [...list, { ...normalized, id: normalized.id || list.length + 1 }]; + const copy = [...list]; + copy[idx] = normalized; + return copy; + }); + return Promise.resolve(normalized); + } +} + +class FakeMilestonesStore { + private _data = signal([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); + + readonly milestones = this._data.asReadonly(); + readonly loading = signal(false); + readonly loaded = signal(true); + + seed(milestones: MilestoneEntity[]): void { + this._data.set(milestones); + } + + getById(id: number): MilestoneEntity | undefined { + return this._data().find((m) => m.id === id); + } + + load(): Promise { + return Promise.resolve(this._data()); + } + + 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(); + } +} + +function makeRoute(id = '1', path = 'milestones/:id') { + return { + snapshot: { + routeConfig: { path }, + paramMap: convertToParamMap(id ? { id } : {}), + }, + paramMap: of(convertToParamMap(id ? { id } : {})), + }; +} + +describe('MilestoneDetail', () => { + let component: MilestoneDetail; + let fixture: ComponentFixture; + let issuesStore: FakeIssuesStore; + let milestonesStore: FakeMilestonesStore; + let router: Router; + + beforeEach(async () => { + issuesStore = new FakeIssuesStore(); + milestonesStore = new FakeMilestonesStore(); + await TestBed.configureTestingModule({ + imports: [MilestoneDetail], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: makeRoute('1') }, + { provide: IssuesStore, useValue: issuesStore }, + { provide: MilestonesStore, useValue: milestonesStore }, + ], + }).compileComponents(); + + router = TestBed.inject(Router); + fixture = TestBed.createComponent(MilestoneDetail); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('linkedIssues', () => { + it('returns issues whose id is in milestone.issueIds', () => { + issuesStore.seed([makeIssue({ id: 1 }), makeIssue({ id: 2 })]); + (component as any).milestone.issueIds = [1]; + const linked: IssueEntity[] = (component as any).linkedIssues; + expect(linked.map((i) => i.id)).toEqual([1]); + }); + + it('returns empty array when milestone has no issues', () => { + issuesStore.seed([makeIssue({ id: 1 })]); + (component as any).milestone.issueIds = []; + expect((component as any).linkedIssues).toHaveLength(0); + }); + }); + + describe('availableIssues', () => { + beforeEach(() => { + issuesStore.seed([ + makeIssue({ id: 1, name: 'Issue 1', epic: '' }), + makeIssue({ id: 2, name: 'Issue 2', epic: '' }), + makeIssue({ id: 3, name: 'Issue 3', epic: '' }), + ]); + milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); + (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }); + }); + + it('returns all issues when milestone is empty', () => { + expect((component as any).availableIssues).toHaveLength(3); + }); + + it('excludes issues already in this milestone', () => { + (component as any).milestone.issueIds = [1]; + const available: IssueEntity[] = (component as any).availableIssues; + expect(available.some((i) => i.id === 1)).toBe(false); + expect(available).toHaveLength(2); + }); + + it('excludes issues assigned to another milestone', () => { + milestonesStore.seed([ + makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }), + makeMilestone({ id: 2, name: 'Sprint 2', issueIds: [2] }), + ]); + const available: IssueEntity[] = (component as any).availableIssues; + expect(available.some((i) => i.id === 2)).toBe(false); + expect(available).toHaveLength(2); + }); + + it('excludes children of an epic already in this milestone', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'My Epic', epic: '' }), + makeIssue({ id: 2, name: 'Child 1', epic: 'My Epic' }), + makeIssue({ id: 3, name: 'Independent', epic: '' }), + ]); + (component as any).milestone.issueIds = [1]; + const available: IssueEntity[] = (component as any).availableIssues; + expect(available.some((i) => i.id === 2)).toBe(false); + expect(available.some((i) => i.id === 3)).toBe(true); + }); + + it('does not exclude issues whose epic is not in the milestone', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'Other Epic', epic: '' }), + makeIssue({ id: 2, name: 'Child', epic: 'Other Epic' }), + makeIssue({ id: 3, name: 'Independent', epic: '' }), + ]); + (component as any).milestone.issueIds = []; + const available: IssueEntity[] = (component as any).availableIssues; + expect(available.some((i) => i.id === 2)).toBe(true); + }); + }); + + describe('displayedIssues', () => { + it('shows all linked issues when no epic is in the milestone', () => { + issuesStore.seed([ + makeIssue({ id: 1, name: 'Story A', epic: '' }), + makeIssue({ id: 2, name: 'Story B', epic: '' }), + ]); + (component as any).milestone = makeMilestone({ id: 1, issueIds: [1, 2] }); + expect((component as any).displayedIssues).toHaveLength(2); + }); + + it('hides children of an epic already in the milestone', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'My Epic', epic: '' }), + makeIssue({ id: 2, name: 'Child', epic: 'My Epic' }), + makeIssue({ id: 3, name: 'Independent', epic: '' }), + ]); + (component as any).milestone = makeMilestone({ id: 1, issueIds: [1, 2, 3] }); + const displayed: IssueEntity[] = (component as any).displayedIssues; + expect(displayed.some((i) => i.id === 1)).toBe(true); + expect(displayed.some((i) => i.id === 2)).toBe(false); + expect(displayed.some((i) => i.id === 3)).toBe(true); + }); + + it('shows children when their epic is not in the milestone', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'Other Epic', epic: '' }), + makeIssue({ id: 2, name: 'Child', epic: 'Other Epic' }), + ]); + (component as any).milestone = makeMilestone({ id: 1, issueIds: [2] }); + const displayed: IssueEntity[] = (component as any).displayedIssues; + expect(displayed.some((i) => i.id === 2)).toBe(true); + }); + }); + + describe('progress', () => { + it('returns 0 when milestone has no issues', () => { + issuesStore.seed([]); + (component as any).milestone.issueIds = []; + expect((component as any).progress).toBe(0); + }); + + it('returns 100 when all linked issues are done', () => { + issuesStore.seed([ + makeIssue({ id: 1, status: 'done' }), + makeIssue({ id: 2, status: 'done' }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).progress).toBe(100); + }); + + it('returns 50 when half the linked issues are done', () => { + issuesStore.seed([ + makeIssue({ id: 1, status: 'done' }), + makeIssue({ id: 2, status: 'todo' }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).progress).toBe(50); + }); + }); + + describe('issueSuggestions', () => { + beforeEach(() => { + issuesStore.seed([ + makeIssue({ id: 1, name: 'Fix login bug' }), + makeIssue({ id: 2, name: 'Add dashboard' }), + makeIssue({ id: 3, name: 'Fix signup bug' }), + ]); + milestonesStore.seed([makeMilestone({ id: 1, issueIds: [] })]); + (component as any).milestone = makeMilestone({ id: 1, issueIds: [] }); + }); + + it('returns all available issues when query is empty', () => { + (component as any).issueSearchQuery = ''; + expect((component as any).issueSuggestions).toHaveLength(3); + }); + + it('filters by name match', () => { + (component as any).issueSearchQuery = 'Fix'; + const suggestions: IssueEntity[] = (component as any).issueSuggestions; + expect(suggestions).toHaveLength(2); + expect(suggestions.every((i) => i.name.toLowerCase().includes('fix'))).toBe(true); + }); + + it('filters by id match', () => { + (component as any).issueSearchQuery = '2'; + const suggestions: IssueEntity[] = (component as any).issueSuggestions; + expect(suggestions.some((i) => i.id === 2)).toBe(true); + }); + }); + + describe('openAddIssue / cancelAddIssue', () => { + it('openAddIssue shows the add form', () => { + (component as any).openAddIssue(); + expect((component as any).showAddIssue).toBe(true); + expect((component as any).showCreateIssue).toBe(false); + }); + + it('cancelAddIssue hides the form', () => { + (component as any).showAddIssue = true; + (component as any).cancelAddIssue(); + expect((component as any).showAddIssue).toBe(false); + }); + }); + + describe('openCreateIssue / cancelCreateIssue', () => { + it('openCreateIssue shows the create form', () => { + (component as any).openCreateIssue(); + expect((component as any).showCreateIssue).toBe(true); + expect((component as any).showAddIssue).toBe(false); + }); + + it('cancelCreateIssue hides the form and clears name', () => { + (component as any).showCreateIssue = true; + (component as any).newIssueName = 'Draft'; + (component as any).cancelCreateIssue(); + expect((component as any).showCreateIssue).toBe(false); + expect((component as any).newIssueName).toBe(''); + }); + }); + + describe('navigateToIssue', () => { + it('navigates to the issue detail page', () => { + const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + (component as any).navigateToIssue(42); + expect(spy).toHaveBeenCalledWith(['/issues', 42]); + }); + }); + + describe('toggleMoreMenu / closeMoreMenu', () => { + it('toggleMoreMenu switches moreMenuOpen', () => { + expect((component as any).moreMenuOpen).toBe(false); + (component as any).toggleMoreMenu(); + expect((component as any).moreMenuOpen).toBe(true); + (component as any).toggleMoreMenu(); + expect((component as any).moreMenuOpen).toBe(false); + }); + + it('closeMoreMenu sets moreMenuOpen to false', () => { + (component as any).moreMenuOpen = true; + (component as any).closeMoreMenu(); + expect((component as any).moreMenuOpen).toBe(false); + }); + }); + + describe('addIssueFromSearch', () => { + it('adds the issue to milestone.issueIds and saves', async () => { + milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); + (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }); + await (component as any).addIssueFromSearch(5); + expect((component as any).milestone.issueIds).toContain(5); + expect((component as any).showAddIssue).toBe(false); + }); + }); + + describe('removeIssue', () => { + it('removes the issue from milestone.issueIds and saves', async () => { + milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [1, 2] })]); + (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [1, 2] }); + await (component as any).removeIssue(1); + expect((component as any).milestone.issueIds).not.toContain(1); + expect((component as any).milestone.issueIds).toContain(2); + }); + }); + + describe('confirmCreateIssue', () => { + beforeEach(() => { + milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); + (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }); + }); + + it('creates an issue and adds it to the milestone', async () => { + (component as any).newIssueName = 'New Task'; + await (component as any).confirmCreateIssue(); + expect((component as any).milestone.issueIds).toHaveLength(1); + expect((component as any).showCreateIssue).toBe(false); + expect((component as any).newIssueName).toBe(''); + }); + + it('does nothing when name is blank', async () => { + (component as any).newIssueName = ' '; + await (component as any).confirmCreateIssue(); + expect((component as any).milestone.issueIds).toHaveLength(0); + }); + }); + + describe('saveMilestone', () => { + it('persists the milestone to the store', async () => { + milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); + (component as any).milestone = makeMilestone({ id: 1, name: 'Updated', issueIds: [] }); + await (component as any).saveMilestone(); + expect(milestonesStore.getById(1)?.name).toBe('Updated'); + }); + + it('does nothing when name is blank', async () => { + (component as any).milestone.name = ' '; + await (component as any).saveMilestone(); + expect(milestonesStore.getById(1)?.name).toBe('Sprint 1'); + }); + }); + + describe('statusBadge', () => { + it('returns correct label and colors for draft', () => { + const badge = (component as any).statusBadge('draft'); + expect(badge.label).toBe('BROUILLON'); + }); + + it('returns correct label for done', () => { + expect((component as any).statusBadge('done').label).toBe('TERMINÉ'); + }); + + it('returns correct label for in-progress', () => { + expect((component as any).statusBadge('in-progress').label).toBe('EN COURS'); + }); + + it('returns correct label for todo', () => { + expect((component as any).statusBadge('todo').label).toBe('À FAIRE'); + }); + }); + + describe('priorityDisplay', () => { + it('returns correct symbol for TRES_HAUTE', () => { + expect((component as any).priorityDisplay('TRES_HAUTE').symbol).toBe('↑↑'); + }); + + it('returns correct symbol for BASSE', () => { + expect((component as any).priorityDisplay('BASSE').symbol).toBe('↓'); + }); + + it('returns correct symbol for MOYENNE', () => { + expect((component as any).priorityDisplay('MOYENNE').symbol).toBe('–'); + }); + }); + + describe('typeIcon', () => { + it('returns correct letter for Epic', () => { + expect((component as any).typeIcon('Epic').letter).toBe('E'); + }); + + it('returns correct letter for Bug', () => { + expect((component as any).typeIcon('Bug').letter).toBe('B'); + }); + }); + + describe('deleteMilestone', () => { + it('removes the milestone and navigates to /milestones', async () => { + const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + await (component as any).deleteMilestone(); + expect(spy).toHaveBeenCalledWith(['/milestones']); + }); + }); + + describe('cancelCreation', () => { + it('navigates to /milestones', () => { + const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + (component as any).cancelCreation(); + expect(spy).toHaveBeenCalledWith(['/milestones']); + }); + }); + + describe('descriptionHtml', () => { + it('returns sanitized HTML from markdown description', () => { + (component as any).milestone.description = '**bold**'; + const html = (component as any).descriptionHtml; + expect(html).toBeTruthy(); + }); + + it('returns sanitized HTML for empty description', () => { + (component as any).milestone.description = ''; + const html = (component as any).descriptionHtml; + expect(html).toBeTruthy(); + }); + }); + + describe('hideIssueSuggestions', () => { + afterEach(() => vi.useRealTimers()); + + it('hides suggestions after 150ms', () => { + vi.useFakeTimers(); + (component as any).showIssueSuggestions = true; + (component as any).hideIssueSuggestions(); + expect((component as any).showIssueSuggestions).toBe(true); + vi.advanceTimersByTime(150); + expect((component as any).showIssueSuggestions).toBe(false); + }); + }); + + describe('onDescriptionPaste', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('does not change description when clipboard has no image', () => { + const ta = document.createElement('textarea'); + const event = { + clipboardData: { items: [] }, + preventDefault: vi.fn(), + target: ta, + } as unknown as ClipboardEvent; + const before = (component as any).milestone.description; + (component as any).onDescriptionPaste(event); + expect((component as any).milestone.description).toBe(before); + }); + + it('updates description with markdown when an image is pasted', async () => { + vi.stubGlobal('FileReader', class { + readonly result = 'data:image/png;base64,abc'; + onload: ((e: any) => void) | null = null; + readAsDataURL(_file: File) { + Promise.resolve().then(() => this.onload?.({ target: { result: this.result } })); + } + }); + const file = new File([''], 'img.png', { type: 'image/png' }); + const ta = document.createElement('textarea'); + ta.value = ''; + const event = { + clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] }, + preventDefault: vi.fn(), + target: ta, + } as unknown as ClipboardEvent; + (component as any).milestone.description = ''; + (component as any).onDescriptionPaste(event); + await Promise.resolve(); + expect((component as any).milestone.description).toContain('![image]'); + }); + }); +}); + +describe('MilestoneDetail — new route', () => { + let component: MilestoneDetail; + let fixture: ComponentFixture; + let milestonesStore: FakeMilestonesStore; + let router: Router; + + beforeEach(async () => { + milestonesStore = new FakeMilestonesStore(); + await TestBed.configureTestingModule({ + imports: [MilestoneDetail], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + routeConfig: { path: 'milestones/new' }, + paramMap: convertToParamMap({}), + }, + paramMap: of(convertToParamMap({})), + }, + }, + { provide: IssuesStore, useValue: new FakeIssuesStore() }, + { provide: MilestonesStore, useValue: milestonesStore }, + ], + }).compileComponents(); + + router = TestBed.inject(Router); + fixture = TestBed.createComponent(MilestoneDetail); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('isNewRoute is true', () => { + expect((component as any).isNewRoute).toBe(true); + }); + + it('buildMilestone creates an empty milestone', () => { + expect((component as any).milestone.id).toBe(0); + expect((component as any).milestone.name).toBe(''); + }); + + it('saveMilestone without explicit flag does nothing', async () => { + (component as any).milestone.name = 'New Sprint'; + await (component as any).saveMilestone(); + expect(milestonesStore.getById(0)).toBeUndefined(); + }); + + it('saveMilestone with explicit=true saves and navigates to the new milestone', async () => { + const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + milestonesStore.seed([makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] })]); + (component as any).milestone = makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] }); + await (component as any).saveMilestone(true); + expect(spy).toHaveBeenCalledWith(['/milestones', 1]); + }); +}); diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts index a2e32c2..bc589f6 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.ts @@ -61,14 +61,29 @@ export class MilestoneDetail { return this.issues().filter((i) => this.milestone.issueIds.includes(i.id)); } + protected get displayedIssues(): IssueEntity[] { + const epicNamesInMilestone = new Set( + this.linkedIssues.filter((i) => i.type === 'Epic').map((i) => i.name), + ); + return this.linkedIssues.filter((i) => !epicNamesInMilestone.has(i.epic)); + } + protected get availableIssues(): IssueEntity[] { const assignedElsewhere = new Set( this.milestonesStore.milestones() .filter((m) => m.id !== this.milestone.id) .flatMap((m) => m.issueIds), ); + const epicNamesInMilestone = new Set( + this.issues() + .filter((i) => i.type === 'Epic' && this.milestone.issueIds.includes(i.id)) + .map((i) => i.name), + ); return this.issues().filter( - (i) => !this.milestone.issueIds.includes(i.id) && !assignedElsewhere.has(i.id), + (i) => + !this.milestone.issueIds.includes(i.id) && + !assignedElsewhere.has(i.id) && + !epicNamesInMilestone.has(i.epic), ); } diff --git a/src/app/milestones/milestones-api.service.spec.ts b/src/app/milestones/milestones-api.service.spec.ts new file mode 100644 index 0000000..7270385 --- /dev/null +++ b/src/app/milestones/milestones-api.service.spec.ts @@ -0,0 +1,81 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MilestonesApiService } from './milestones-api.service'; +import { MilestoneEntity } from './milestones.store'; + +const API = '/api'; + +const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({ + id: 1, + name: 'Sprint 1', + description: '', + dueDate: '', + issueIds: [], + ...overrides, +}); + +describe('MilestonesApiService', () => { + let service: MilestonesApiService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(MilestonesApiService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + describe('getAll', () => { + it('sends GET /api/milestones and returns milestones', () => { + const milestones = [makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]; + let result: MilestoneEntity[] | undefined; + service.getAll().subscribe((data) => (result = data)); + const req = http.expectOne(`${API}/milestones`); + expect(req.request.method).toBe('GET'); + req.flush(milestones); + expect(result).toEqual(milestones); + }); + }); + + describe('create', () => { + it('sends POST /api/milestones with the body and returns the created milestone', () => { + const body = { name: 'Sprint 2', description: '', dueDate: '', issueIds: [] }; + const response = makeMilestone({ id: 2, name: 'Sprint 2' }); + let result: MilestoneEntity | undefined; + service.create(body).subscribe((data) => (result = data)); + const req = http.expectOne(`${API}/milestones`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(body); + req.flush(response); + expect(result).toEqual(response); + }); + }); + + describe('update', () => { + it('sends PUT /api/milestones/:id with the body and returns the updated milestone', () => { + const milestone = makeMilestone({ id: 1, name: 'Updated Sprint' }); + let result: MilestoneEntity | undefined; + service.update(1, milestone).subscribe((data) => (result = data)); + const req = http.expectOne(`${API}/milestones/1`); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(milestone); + req.flush(milestone); + expect(result).toEqual(milestone); + }); + }); + + describe('remove', () => { + it('sends DELETE /api/milestones/:id and completes', () => { + let completed = false; + service.remove(1).subscribe({ complete: () => (completed = true) }); + const req = http.expectOne(`${API}/milestones/1`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + expect(completed).toBe(true); + }); + }); +}); diff --git a/src/app/milestones/milestones.store.spec.ts b/src/app/milestones/milestones.store.spec.ts new file mode 100644 index 0000000..6ae878c --- /dev/null +++ b/src/app/milestones/milestones.store.spec.ts @@ -0,0 +1,146 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MilestoneEntity, MilestonesStore } from './milestones.store'; + +const API = '/api'; + +const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({ + id: 1, + name: 'Sprint 1', + description: '', + dueDate: '', + issueIds: [], + ...overrides, +}); + +describe('MilestonesStore', () => { + let store: MilestonesStore; + let httpMock: HttpTestingController; + + const loadWith = async (milestones: MilestoneEntity[]) => { + const p = store.load(); + httpMock.expectOne(`${API}/milestones`).flush(milestones); + await p; + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + store = TestBed.inject(MilestonesStore); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('should be created', () => { + expect(store).toBeTruthy(); + }); + + describe('load', () => { + it('populates milestones from the API', async () => { + await loadWith([makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]); + expect(store.milestones().length).toBe(2); + }); + + it('sets loading to true during load and false after', async () => { + const p = store.load(); + expect(store.loading()).toBe(true); + httpMock.expectOne(`${API}/milestones`).flush([]); + await p; + expect(store.loading()).toBe(false); + expect(store.loaded()).toBe(true); + }); + + it('does not reload if already loaded', async () => { + await loadWith([]); + await store.load(); + httpMock.expectNone(`${API}/milestones`); + }); + }); + + describe('getById', () => { + beforeEach(async () => { + await loadWith([makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]); + }); + + it('returns the milestone with the given id', () => { + expect(store.getById(1)?.id).toBe(1); + }); + + it('returns undefined for an unknown id', () => { + expect(store.getById(9999)).toBeUndefined(); + }); + }); + + describe('upsert', () => { + beforeEach(async () => { + await loadWith([makeMilestone({ id: 1, name: 'Existing' }), makeMilestone({ id: 2 })]); + }); + + it('creates a new milestone via POST when id is 0', async () => { + const before = store.milestones().length; + const p = store.upsert(makeMilestone({ id: 0, name: 'New Sprint' })); + httpMock.expectOne({ method: 'POST', url: `${API}/milestones` }).flush(makeMilestone({ id: 99, name: 'New Sprint' })); + await p; + expect(store.milestones().length).toBe(before + 1); + expect(store.getById(99)?.name).toBe('New Sprint'); + }); + + it('updates an existing milestone via PUT', async () => { + const p = store.upsert(makeMilestone({ id: 1, name: 'Updated Sprint' })); + httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated Sprint' })); + await p; + expect(store.getById(1)?.name).toBe('Updated Sprint'); + expect(store.milestones().filter((m) => m.id === 1).length).toBe(1); + }); + + it('returns the normalized milestone after update', async () => { + const p = store.upsert(makeMilestone({ id: 1, name: 'Updated', issueIds: [1, 2] })); + httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated', issueIds: [1, 2] })); + const result = await p; + expect(result.issueIds).toEqual([1, 2]); + }); + + it('leaves list unchanged when PUT response id is not found in store', async () => { + const before = store.milestones().length; + const p = store.upsert(makeMilestone({ id: 999, name: 'Unknown' })); + httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/999` }).flush(makeMilestone({ id: 999, name: 'Unknown' })); + await p; + expect(store.milestones().length).toBe(before); + }); + }); + + describe('deleteById', () => { + beforeEach(async () => { + await loadWith([makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]); + }); + + it('removes the milestone from the store', async () => { + const p = store.deleteById(1); + httpMock.expectOne({ method: 'DELETE', url: `${API}/milestones/1` }).flush(null); + await p; + expect(store.getById(1)).toBeUndefined(); + expect(store.milestones().length).toBe(1); + }); + }); + + describe('normalize', () => { + it('normalizes issueIds to empty array when not an array', async () => { + const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: null } as any; + const p = store.upsert(raw); + httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ ...raw, issueIds: null }); + const result = await p; + expect(result.issueIds).toEqual([]); + }); + + it('filters non-number values from issueIds', async () => { + const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [1, '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: [1] }); + const result = await p; + expect(result.issueIds).toEqual([1]); + }); + }); +});