Merge pull request 'Feat/14 ajoute milestone depuis issue' (#26) from feat/14-ajoute-milestone-depuis-issue into develop
Reviewed-on: Bonsai/Bonsai-webapp#26
This commit is contained in:
@@ -12,7 +12,11 @@
|
|||||||
"Read(//home/Gato/IdeaProjects/Bonsai-webapp/src/**)",
|
"Read(//home/Gato/IdeaProjects/Bonsai-webapp/src/**)",
|
||||||
"Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/src/**)",
|
"Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/src/**)",
|
||||||
"Read(//home/Gato/IdeaProjects/Bonsai-webapp/**)",
|
"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\\)$\")",
|
||||||
|
"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": [
|
"additionalDirectories": [
|
||||||
"/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues",
|
"/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues",
|
||||||
|
|||||||
@@ -42,3 +42,6 @@ __screenshots__/
|
|||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project file
|
||||||
|
api-issues
|
||||||
@@ -121,6 +121,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div>
|
||||||
|
<label class="field-label">Milestone</label>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<select
|
||||||
|
aria-label="Milestone"
|
||||||
|
class="form-select form-select-sm"
|
||||||
|
[ngModel]="currentMilestoneId"
|
||||||
|
(ngModelChange)="onMilestoneChange($event)"
|
||||||
|
[disabled]="isChildOfEpic"
|
||||||
|
>
|
||||||
|
<option [ngValue]="null">—</option>
|
||||||
|
@for (m of milestones(); track m.id) {
|
||||||
|
<option [ngValue]="m.id">{{ m.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (currentMilestoneId !== null) {
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" (click)="navigateToMilestone()" title="Voir le Milestone">↗</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { of } from 'rxjs';
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { IssueDetail } from './issue-detail';
|
import { IssueDetail } from './issue-detail';
|
||||||
import { IssueEntity, IssuesStore } from '../issues.store';
|
import { IssueEntity, IssuesStore } from '../issues.store';
|
||||||
|
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
|
||||||
|
|
||||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||||
id: 99,
|
id: 99,
|
||||||
@@ -81,6 +82,51 @@ class FakeIssuesStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntity => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Sprint 1',
|
||||||
|
description: '',
|
||||||
|
dueDate: '',
|
||||||
|
issueIds: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeMilestonesStore {
|
||||||
|
private _data = signal<MilestoneEntity[]>([]);
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(milestone: MilestoneEntity): Promise<MilestoneEntity> {
|
||||||
|
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<void> {
|
||||||
|
this._data.update((list) => list.filter((m) => m.id !== id));
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeRoute(id = '1', path = 'issues/:id') {
|
function makeRoute(id = '1', path = 'issues/:id') {
|
||||||
return {
|
return {
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -96,16 +142,19 @@ describe('IssueDetail — existing issue', () => {
|
|||||||
let component: IssueDetail;
|
let component: IssueDetail;
|
||||||
let fixture: ComponentFixture<IssueDetail>;
|
let fixture: ComponentFixture<IssueDetail>;
|
||||||
let store: FakeIssuesStore;
|
let store: FakeIssuesStore;
|
||||||
|
let milestonesStore: FakeMilestonesStore;
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
store = new FakeIssuesStore();
|
store = new FakeIssuesStore();
|
||||||
|
milestonesStore = new FakeMilestonesStore();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [IssueDetail],
|
imports: [IssueDetail],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: ActivatedRoute, useValue: makeRoute('1') },
|
{ provide: ActivatedRoute, useValue: makeRoute('1') },
|
||||||
{ provide: IssuesStore, useValue: store },
|
{ provide: IssuesStore, useValue: store },
|
||||||
|
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
@@ -512,6 +561,167 @@ describe('IssueDetail — existing issue', () => {
|
|||||||
expect((component as any).descriptionHtml).toBeTruthy();
|
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('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', () => {
|
describe('IssueDetail — new issue route', () => {
|
||||||
@@ -538,6 +748,7 @@ describe('IssueDetail — new issue route', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ provide: IssuesStore, useValue: store },
|
{ provide: IssuesStore, useValue: store },
|
||||||
|
{ provide: MilestonesStore, useValue: new FakeMilestonesStore() },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { marked } from 'marked';
|
|||||||
import { IssueEntity, IssuesStore } from '../issues.store';
|
import { IssueEntity, IssuesStore } from '../issues.store';
|
||||||
import { IssueComments } from '../issue-comments/issue-comments';
|
import { IssueComments } from '../issue-comments/issue-comments';
|
||||||
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
|
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
|
||||||
|
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-issue-detail',
|
selector: 'app-issue-detail',
|
||||||
@@ -18,11 +19,13 @@ export class IssueDetail {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly issuesStore = inject(IssuesStore);
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
|
private readonly milestonesStore = inject(MilestonesStore);
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
||||||
|
|
||||||
protected issue: IssueEntity = this.buildIssue();
|
protected issue: IssueEntity = this.buildIssue();
|
||||||
protected readonly issues = this.issuesStore.issues;
|
protected readonly issues = this.issuesStore.issues;
|
||||||
|
protected readonly milestones = this.milestonesStore.milestones;
|
||||||
protected moreMenuOpen = false;
|
protected moreMenuOpen = false;
|
||||||
protected statusMenuOpen = false;
|
protected statusMenuOpen = false;
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ export class IssueDetail {
|
|||||||
const idParam = this.route.snapshot.paramMap.get('id');
|
const idParam = this.route.snapshot.paramMap.get('id');
|
||||||
const safeId = Number(idParam ?? 0);
|
const safeId = Number(idParam ?? 0);
|
||||||
|
|
||||||
|
this.milestonesStore.load();
|
||||||
this.issuesStore.load().then(() => {
|
this.issuesStore.load().then(() => {
|
||||||
if (safeId) {
|
if (safeId) {
|
||||||
const found = this.issuesStore.getById(safeId);
|
const found = this.issuesStore.getById(safeId);
|
||||||
@@ -162,7 +166,7 @@ export class IssueDetail {
|
|||||||
protected async confirmCreateInEpic(): Promise<void> {
|
protected async confirmCreateInEpic(): Promise<void> {
|
||||||
const name = this.newIssueName.trim();
|
const name = this.newIssueName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
await this.issuesStore.upsert({
|
const created = await this.issuesStore.upsert({
|
||||||
id: 0,
|
id: 0,
|
||||||
type: 'Story',
|
type: 'Story',
|
||||||
assignee: '',
|
assignee: '',
|
||||||
@@ -177,6 +181,13 @@ export class IssueDetail {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
});
|
});
|
||||||
|
const epicMilestone = this.currentMilestone;
|
||||||
|
if (epicMilestone) {
|
||||||
|
await this.milestonesStore.upsert({
|
||||||
|
...epicMilestone,
|
||||||
|
issueIds: [...epicMilestone.issueIds, created.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
this.showCreateInEpic = false;
|
this.showCreateInEpic = false;
|
||||||
this.newIssueName = '';
|
this.newIssueName = '';
|
||||||
}
|
}
|
||||||
@@ -196,6 +207,22 @@ export class IssueDetail {
|
|||||||
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
|
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
|
||||||
if (target) {
|
if (target) {
|
||||||
await this.issuesStore.upsert({ ...target, epic: this.issue.name });
|
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;
|
this.showAddToEpic = false;
|
||||||
@@ -206,6 +233,10 @@ export class IssueDetail {
|
|||||||
return this.issueTypeValue === 'Epic';
|
return this.issueTypeValue === 'Epic';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected get isChildOfEpic(): boolean {
|
||||||
|
return !!this.issue.epic;
|
||||||
|
}
|
||||||
|
|
||||||
protected onDescriptionPaste(event: ClipboardEvent): void {
|
protected onDescriptionPaste(event: ClipboardEvent): void {
|
||||||
const ta = event.target as HTMLTextAreaElement;
|
const ta = event.target as HTMLTextAreaElement;
|
||||||
const start = ta.selectionStart;
|
const start = ta.selectionStart;
|
||||||
@@ -280,6 +311,43 @@ 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<void> {
|
||||||
|
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) => !allIds.includes(id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (newMilestoneId !== null) {
|
||||||
|
const target = this.milestones().find((m) => m.id === newMilestoneId);
|
||||||
|
if (target) {
|
||||||
|
const toAdd = allIds.filter((id) => !target.issueIds.includes(id));
|
||||||
|
await this.milestonesStore.upsert({ ...target, issueIds: [...target.issueIds, ...toAdd] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected navigateToMilestone(): void {
|
||||||
|
if (this.currentMilestoneId !== null) {
|
||||||
|
this.router.navigate(['/milestones', this.currentMilestoneId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected async saveIssue(explicit = false): Promise<void> {
|
protected async saveIssue(explicit = false): Promise<void> {
|
||||||
if (this.isNewIssueRoute && !explicit) return;
|
if (this.isNewIssueRoute && !explicit) return;
|
||||||
if (!this.issue.name.trim()) return;
|
if (!this.issue.name.trim()) return;
|
||||||
|
|||||||
@@ -81,6 +81,42 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtre Milestone -->
|
||||||
|
<div class="dropdown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm d-flex align-items-center gap-1"
|
||||||
|
[class.btn-outline-secondary]="!milestoneFilterActive"
|
||||||
|
[class.btn-primary]="milestoneFilterActive"
|
||||||
|
(click)="toggleDropdown('milestone', $event)"
|
||||||
|
>
|
||||||
|
{{ milestoneDropdownLabel() }}
|
||||||
|
<span class="ms-1">▾</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" [class.show]="openDropdown === 'milestone'">
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item text-secondary small" [disabled]="!milestoneFilterActive" (click)="clearMilestones($event)">
|
||||||
|
Tout effacer
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleNoMilestone($event)">
|
||||||
|
<span class="filter-check">@if (showNoMilestone) { ✓ }</span>
|
||||||
|
<span class="text-secondary fst-italic">Sans milestone</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
@for (m of milestones(); track m.id) {
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleMilestone(m.id, $event)">
|
||||||
|
<span class="filter-check">@if (selectedMilestoneIds.has(m.id)) { ✓ }</span>
|
||||||
|
{{ m.name }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
@@ -93,6 +129,7 @@
|
|||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Priorite</th>
|
<th>Priorite</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
|
<th>Milestone</th>
|
||||||
<th>Assignee</th>
|
<th>Assignee</th>
|
||||||
<th>Progression</th>
|
<th>Progression</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -128,6 +165,7 @@
|
|||||||
[style.color]="statusBadge(issue.status).color"
|
[style.color]="statusBadge(issue.status).color"
|
||||||
>{{ statusBadge(issue.status).label }}</span>
|
>{{ statusBadge(issue.status).label }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-secondary small">{{ getMilestoneForIssue(issue.id)?.name ?? '—' }}</td>
|
||||||
<td>{{ issue.assignee }}</td>
|
<td>{{ issue.assignee }}</td>
|
||||||
<td class="progress-cell">
|
<td class="progress-cell">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { Issues } from './issues';
|
import { Issues } from './issues';
|
||||||
import { IssueEntity, IssuesStore } from './issues.store';
|
import { IssueEntity, IssuesStore } from './issues.store';
|
||||||
|
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||||
|
|
||||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||||
id: 99,
|
id: 99,
|
||||||
@@ -88,19 +89,63 @@ class FakeIssuesStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntity => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Sprint 1',
|
||||||
|
description: '',
|
||||||
|
dueDate: '',
|
||||||
|
issueIds: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeMilestonesStore {
|
||||||
|
private _data = signal<MilestoneEntity[]>([]);
|
||||||
|
|
||||||
|
readonly milestones = this._data.asReadonly();
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly loaded = signal(true);
|
||||||
|
|
||||||
|
seed(milestones: MilestoneEntity[]): void {
|
||||||
|
this._data.set(milestones);
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(milestone: MilestoneEntity): Promise<MilestoneEntity> {
|
||||||
|
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<void> {
|
||||||
|
this._data.update((list) => list.filter((m) => m.id !== id));
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('Issues', () => {
|
describe('Issues', () => {
|
||||||
let component: Issues;
|
let component: Issues;
|
||||||
let fixture: ComponentFixture<Issues>;
|
let fixture: ComponentFixture<Issues>;
|
||||||
let store: FakeIssuesStore;
|
let store: FakeIssuesStore;
|
||||||
|
let milestonesStore: FakeMilestonesStore;
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
store = new FakeIssuesStore();
|
store = new FakeIssuesStore();
|
||||||
|
milestonesStore = new FakeMilestonesStore();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Issues],
|
imports: [Issues],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: IssuesStore, useValue: store },
|
{ provide: IssuesStore, useValue: store },
|
||||||
|
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).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', () => {
|
describe('typeBadgeClass', () => {
|
||||||
it('maps Bug to text-bg-danger', () => {
|
it('maps Bug to text-bg-danger', () => {
|
||||||
expect((component as any).typeBadgeClass('Bug')).toBe('text-bg-danger');
|
expect((component as any).typeBadgeClass('Bug')).toBe('text-bg-danger');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Component, HostListener, inject } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
|
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
|
||||||
|
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-issues',
|
selector: 'app-issues',
|
||||||
@@ -12,16 +13,21 @@ import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
|
|||||||
export class Issues {
|
export class Issues {
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly issuesStore = inject(IssuesStore);
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
|
private readonly milestonesStore = inject(MilestonesStore);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.issuesStore.load();
|
this.issuesStore.load();
|
||||||
|
this.milestonesStore.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly issues = this.issuesStore.issues;
|
protected readonly issues = this.issuesStore.issues;
|
||||||
|
protected readonly milestones = this.milestonesStore.milestones;
|
||||||
protected searchQuery = '';
|
protected searchQuery = '';
|
||||||
protected selectedTypes = new Set<IssueEntity['type']>();
|
protected selectedTypes = new Set<IssueEntity['type']>();
|
||||||
protected selectedStatuses = new Set<IssueStatus>();
|
protected selectedStatuses = new Set<IssueStatus>();
|
||||||
protected openDropdown: 'type' | 'status' | null = null;
|
protected selectedMilestoneIds = new Set<number>();
|
||||||
|
protected showNoMilestone = false;
|
||||||
|
protected openDropdown: 'type' | 'status' | 'milestone' | null = null;
|
||||||
|
|
||||||
protected readonly typeOptions: IssueEntity['type'][] = [
|
protected readonly typeOptions: IssueEntity['type'][] = [
|
||||||
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
||||||
@@ -31,17 +37,28 @@ export class Issues {
|
|||||||
'draft', 'todo', 'in-progress', 'done',
|
'draft', 'todo', 'in-progress', 'done',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
|
||||||
|
return this.milestones().find((m) => m.issueIds.includes(issueId));
|
||||||
|
}
|
||||||
|
|
||||||
protected get filteredIssues(): IssueEntity[] {
|
protected get filteredIssues(): IssueEntity[] {
|
||||||
const q = this.searchQuery.trim().toLowerCase();
|
const q = this.searchQuery.trim().toLowerCase();
|
||||||
|
const milestoneActive = this.selectedMilestoneIds.size > 0 || this.showNoMilestone;
|
||||||
return this.issues().filter((i) => {
|
return this.issues().filter((i) => {
|
||||||
if (this.selectedTypes.size > 0 && !this.selectedTypes.has(i.type)) return false;
|
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 (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;
|
if (q && !i.name.toLowerCase().includes(q)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected toggleDropdown(name: 'type' | 'status', event: Event): void {
|
protected toggleDropdown(name: 'type' | 'status' | 'milestone', event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.openDropdown = this.openDropdown === name ? null : name;
|
this.openDropdown = this.openDropdown === name ? null : name;
|
||||||
}
|
}
|
||||||
@@ -75,6 +92,24 @@ export class Issues {
|
|||||||
this.selectedStatuses = new Set();
|
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 {
|
protected typeDropdownLabel(): string {
|
||||||
if (this.selectedTypes.size === 0) return 'Type';
|
if (this.selectedTypes.size === 0) return 'Type';
|
||||||
if (this.selectedTypes.size === 1) return [...this.selectedTypes][0];
|
if (this.selectedTypes.size === 1) return [...this.selectedTypes][0];
|
||||||
@@ -87,6 +122,21 @@ export class Issues {
|
|||||||
return `Statut (${this.selectedStatuses.size})`;
|
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 {
|
protected createIssue(): void {
|
||||||
this.router.navigate(['/issues/new']);
|
this.router.navigate(['/issues/new']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { KeycloakService } from '../auth/keycloak.service';
|
||||||
import { Menu } from './menu';
|
import { Menu } from './menu';
|
||||||
|
|
||||||
describe('Menu', () => {
|
describe('Menu', () => {
|
||||||
let component: Menu;
|
let component: Menu;
|
||||||
let fixture: ComponentFixture<Menu>;
|
let fixture: ComponentFixture<Menu>;
|
||||||
|
const keycloakMock = {
|
||||||
|
isAuthenticated: signal(false),
|
||||||
|
username: signal<string | undefined>(undefined),
|
||||||
|
logout: vi.fn(),
|
||||||
|
login: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
keycloakMock.logout = vi.fn();
|
||||||
|
keycloakMock.login = vi.fn();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Menu],
|
imports: [Menu],
|
||||||
providers: [provideRouter([])],
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: KeycloakService, useValue: keycloakMock },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Menu);
|
fixture = TestBed.createComponent(Menu);
|
||||||
@@ -35,4 +49,9 @@ describe('Menu', () => {
|
|||||||
const items = (component as any).menuItems as { label: string; path: string }[];
|
const items = (component as any).menuItems as { label: string; path: string }[];
|
||||||
expect(items.some((i) => i.path === '/milestones')).toBe(true);
|
expect(items.some((i) => i.path === '/milestones')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logout calls keycloak.logout()', () => {
|
||||||
|
(component as any).logout();
|
||||||
|
expect(keycloakMock.logout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (issue of linkedIssues; track issue.id) {
|
@for (issue of displayedIssues; track issue.id) {
|
||||||
<tr>
|
<tr>
|
||||||
<td class="ps-3 text-secondary small">#{{ issue.id }}</td>
|
<td class="ps-3 text-secondary small">#{{ issue.id }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -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> = {}): 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> = {}): MilestoneEntity => ({
|
||||||
|
id: 1,
|
||||||
|
name: 'Sprint 1',
|
||||||
|
description: '',
|
||||||
|
dueDate: '',
|
||||||
|
issueIds: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeIssuesStore {
|
||||||
|
private _data = signal<IssueEntity[]>([]);
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(issue: IssueEntity): Promise<IssueEntity> {
|
||||||
|
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<MilestoneEntity[]>([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<MilestoneEntity[]> {
|
||||||
|
return Promise.resolve(this._data());
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(milestone: MilestoneEntity): Promise<MilestoneEntity> {
|
||||||
|
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<void> {
|
||||||
|
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<MilestoneDetail>;
|
||||||
|
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<MilestoneDetail>;
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,14 +61,29 @@ export class MilestoneDetail {
|
|||||||
return this.issues().filter((i) => this.milestone.issueIds.includes(i.id));
|
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[] {
|
protected get availableIssues(): IssueEntity[] {
|
||||||
const assignedElsewhere = new Set(
|
const assignedElsewhere = new Set(
|
||||||
this.milestonesStore.milestones()
|
this.milestonesStore.milestones()
|
||||||
.filter((m) => m.id !== this.milestone.id)
|
.filter((m) => m.id !== this.milestone.id)
|
||||||
.flatMap((m) => m.issueIds),
|
.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(
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user