milestone dans tableau issue
This commit is contained in:
@@ -120,6 +120,20 @@
|
||||
}
|
||||
</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)">
|
||||
<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>
|
||||
|
||||
@@ -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> = {}): IssueEntity => ({
|
||||
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') {
|
||||
return {
|
||||
snapshot: {
|
||||
@@ -96,16 +142,19 @@ describe('IssueDetail — existing issue', () => {
|
||||
let component: IssueDetail;
|
||||
let fixture: ComponentFixture<IssueDetail>;
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
if (this.isNewIssueRoute && !explicit) return;
|
||||
if (!this.issue.name.trim()) return;
|
||||
|
||||
@@ -81,6 +81,42 @@
|
||||
}
|
||||
</ul>
|
||||
</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 class="card shadow-sm">
|
||||
@@ -93,6 +129,7 @@
|
||||
<th>Type</th>
|
||||
<th>Priorite</th>
|
||||
<th>Statut</th>
|
||||
<th>Milestone</th>
|
||||
<th>Assignee</th>
|
||||
<th>Progression</th>
|
||||
</tr>
|
||||
@@ -128,6 +165,7 @@
|
||||
[style.color]="statusBadge(issue.status).color"
|
||||
>{{ statusBadge(issue.status).label }}</span>
|
||||
</td>
|
||||
<td class="text-secondary small">{{ getMilestoneForIssue(issue.id)?.name ?? '—' }}</td>
|
||||
<td>{{ issue.assignee }}</td>
|
||||
<td class="progress-cell">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
|
||||
@@ -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> = {}): IssueEntity => ({
|
||||
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', () => {
|
||||
let component: Issues;
|
||||
let fixture: ComponentFixture<Issues>;
|
||||
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');
|
||||
|
||||
@@ -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<IssueEntity['type']>();
|
||||
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'][] = [
|
||||
'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']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user