Ajout filtre priorité

Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
2026-05-31 14:49:57 +02:00
parent fe1c346eac
commit f680c06208
5 changed files with 200 additions and 8 deletions
@@ -0,0 +1,30 @@
import { TestBed } from '@angular/core/testing';
import { IssuesFilterService } from './issues-filter.service';
describe('IssuesFilterService', () => {
let service: IssuesFilterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(IssuesFilterService);
});
it('initializes with empty filter state', () => {
expect(service.searchQuery).toBe('');
expect(service.selectedTypes.size).toBe(0);
expect(service.selectedStatuses.size).toBe(0);
expect(service.selectedPriorities.size).toBe(0);
expect(service.selectedMilestoneIds.size).toBe(0);
expect(service.showNoMilestone).toBe(false);
});
it('allows mutation of filter state', () => {
service.searchQuery = 'bug';
service.selectedTypes.add('Bug');
service.selectedPriorities.add('HAUTE');
expect(service.searchQuery).toBe('bug');
expect(service.selectedTypes.has('Bug')).toBe(true);
expect(service.selectedPriorities.has('HAUTE')).toBe(true);
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Injectable } from '@angular/core';
import { IssueType, IssueStatus, IssuePriority } from './issues.store';
@Injectable({ providedIn: 'root' })
export class IssuesFilterService {
searchQuery = '';
selectedTypes = new Set<IssueType>();
selectedStatuses = new Set<IssueStatus>();
selectedPriorities = new Set<IssuePriority>();
selectedMilestoneIds = new Set<number>();
showNoMilestone = false;
}
+38
View File
@@ -82,6 +82,44 @@
</ul>
</div>
<!-- Filtre Priorité -->
<div class="dropdown">
<button
type="button"
class="btn btn-sm d-flex align-items-center gap-1"
[class.btn-outline-secondary]="selectedPriorities.size === 0"
[class.btn-primary]="selectedPriorities.size > 0"
(click)="toggleDropdown('priority', $event)"
>
@if (selectedPriorities.size === 1) {
<span [style.color]="priorityDisplay([...selectedPriorities][0]).color" style="font-weight:700;">
{{ priorityDisplay([...selectedPriorities][0]).symbol }}
</span>
}
{{ priorityDropdownLabel() }}
<span class="ms-1"></span>
</button>
<ul class="dropdown-menu" [class.show]="openDropdown === 'priority'">
<li>
<button class="dropdown-item text-secondary small" [disabled]="selectedPriorities.size === 0" (click)="clearPriorities($event)">
Tout effacer
</button>
</li>
<li><hr class="dropdown-divider"></li>
@for (p of priorityOptions; track p) {
<li>
<button class="dropdown-item d-flex align-items-center gap-2" (click)="togglePriority(p, $event)">
<span class="filter-check">@if (selectedPriorities.has(p)) { ✓ }</span>
<span [style.color]="priorityDisplay(p).color" style="font-weight:700; min-width:1.4rem; display:inline-block;">
{{ priorityDisplay(p).symbol }}
</span>
{{ priorityDisplay(p).label }}
</button>
</li>
}
</ul>
</div>
<!-- Filtre Milestone -->
<div class="dropdown">
<button
+74
View File
@@ -5,6 +5,7 @@ import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { Issues } from './issues';
import { IssueEntity, IssuesStore } from './issues.store';
import { IssuesFilterService } from './issues-filter.service';
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
import { StatusesStore } from '../statuses/statuses.store';
import { ProjectContextService } from '../projects/project-context.service';
@@ -176,6 +177,7 @@ describe('Issues', () => {
{ provide: MilestonesStore, useValue: milestonesStore },
{ provide: StatusesStore, useValue: statusesStore },
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
{ provide: IssuesFilterService, useFactory: () => new IssuesFilterService() },
],
}).compileComponents();
@@ -282,6 +284,78 @@ describe('Issues', () => {
});
});
describe('filteredIssues — priority filter', () => {
beforeEach(() => {
store.upsert(makeIssue({ id: 10, name: 'High Prio', priority: 'HAUTE' }));
store.upsert(makeIssue({ id: 11, name: 'Low Prio', priority: 'BASSE' }));
});
it('shows all issues when no priority filter is active', () => {
(component as any).selectedPriorities = new Set();
expect((component as any).filteredIssues.length).toBe(store.issues().length);
});
it('shows only issues matching the selected priority', () => {
(component as any).selectedPriorities = new Set(['HAUTE']);
const filtered: IssueEntity[] = (component as any).filteredIssues;
expect(filtered.every((i) => i.priority === 'HAUTE')).toBe(true);
});
it('shows issues from multiple selected priorities', () => {
(component as any).selectedPriorities = new Set(['HAUTE', 'BASSE']);
const filtered: IssueEntity[] = (component as any).filteredIssues;
expect(filtered.every((i) => i.priority === 'HAUTE' || i.priority === 'BASSE')).toBe(true);
expect(filtered.some((i) => i.priority === 'HAUTE')).toBe(true);
expect(filtered.some((i) => i.priority === 'BASSE')).toBe(true);
});
});
describe('togglePriority', () => {
it('adds a priority when not already selected', () => {
(component as any).selectedPriorities = new Set();
(component as any).togglePriority('HAUTE', mockEvent);
expect((component as any).selectedPriorities.has('HAUTE')).toBe(true);
});
it('removes a priority when already selected (toggle off)', () => {
(component as any).selectedPriorities = new Set(['HAUTE']);
(component as any).togglePriority('HAUTE', mockEvent);
expect((component as any).selectedPriorities.has('HAUTE')).toBe(false);
});
it('can select multiple priorities simultaneously', () => {
(component as any).selectedPriorities = new Set();
(component as any).togglePriority('HAUTE', mockEvent);
(component as any).togglePriority('BASSE', mockEvent);
expect((component as any).selectedPriorities.size).toBe(2);
});
});
describe('clearPriorities', () => {
it('empties the priority selection', () => {
(component as any).selectedPriorities = new Set(['HAUTE', 'BASSE']);
(component as any).clearPriorities(mockEvent);
expect((component as any).selectedPriorities.size).toBe(0);
});
});
describe('priorityDropdownLabel', () => {
it('returns "Priorité" when nothing is selected', () => {
(component as any).selectedPriorities = new Set();
expect((component as any).priorityDropdownLabel()).toBe('Priorité');
});
it('returns the priority label when exactly one is selected', () => {
(component as any).selectedPriorities = new Set(['HAUTE']);
expect((component as any).priorityDropdownLabel()).toBe('Haute');
});
it('returns a count when multiple priorities are selected', () => {
(component as any).selectedPriorities = new Set(['HAUTE', 'BASSE']);
expect((component as any).priorityDropdownLabel()).toBe('Priorité (2)');
});
});
describe('createIssue', () => {
it('navigates to /projects/:pid/issues/new', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+46 -8
View File
@@ -1,7 +1,8 @@
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 { IssueEntity, IssueStatus, IssuePriority, IssuesStore } from './issues.store';
import { IssuesFilterService } from './issues-filter.service';
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
import { StatusEntity, StatusesStore } from '../statuses/statuses.store';
import { ProjectContextService } from '../projects/project-context.service';
@@ -18,6 +19,7 @@ export class Issues {
private readonly milestonesStore = inject(MilestonesStore);
private readonly statusesStore = inject(StatusesStore);
private readonly projectContext = inject(ProjectContextService);
private readonly filters = inject(IssuesFilterService);
constructor() {
const projectId = this.projectContext.projectId()!;
@@ -27,17 +29,34 @@ export class Issues {
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 selectedMilestoneIds = new Set<number>();
protected showNoMilestone = false;
protected openDropdown: 'type' | 'status' | 'milestone' | null = null;
protected openDropdown: 'type' | 'status' | 'priority' | 'milestone' | null = null;
protected get searchQuery(): string { return this.filters.searchQuery; }
protected set searchQuery(v: string) { this.filters.searchQuery = v; }
protected get selectedTypes(): Set<IssueEntity['type']> { return this.filters.selectedTypes; }
protected set selectedTypes(v: Set<IssueEntity['type']>) { this.filters.selectedTypes = v; }
protected get selectedStatuses(): Set<IssueStatus> { return this.filters.selectedStatuses; }
protected set selectedStatuses(v: Set<IssueStatus>) { this.filters.selectedStatuses = v; }
protected get selectedPriorities(): Set<IssuePriority> { return this.filters.selectedPriorities; }
protected set selectedPriorities(v: Set<IssuePriority>) { this.filters.selectedPriorities = v; }
protected get selectedMilestoneIds(): Set<number> { return this.filters.selectedMilestoneIds; }
protected set selectedMilestoneIds(v: Set<number>) { this.filters.selectedMilestoneIds = v; }
protected get showNoMilestone(): boolean { return this.filters.showNoMilestone; }
protected set showNoMilestone(v: boolean) { this.filters.showNoMilestone = v; }
protected readonly typeOptions: IssueEntity['type'][] = [
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
];
protected readonly priorityOptions: IssuePriority[] = [
'TRES_HAUTE', 'HAUTE', 'MOYENNE', 'BASSE', 'TRES_FAIBLE',
];
protected readonly statusOptions = this.statusesStore.statuses;
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
@@ -50,6 +69,7 @@ export class Issues {
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 (this.selectedPriorities.size > 0 && !this.selectedPriorities.has(i.priority)) return false;
if (milestoneActive) {
const m = this.getMilestoneForIssue(i.id);
const matchesMilestone = m !== undefined && this.selectedMilestoneIds.has(m.id);
@@ -61,7 +81,7 @@ export class Issues {
});
}
protected toggleDropdown(name: 'type' | 'status' | 'milestone', event: Event): void {
protected toggleDropdown(name: 'type' | 'status' | 'priority' | 'milestone', event: Event): void {
event.stopPropagation();
this.openDropdown = this.openDropdown === name ? null : name;
}
@@ -95,6 +115,18 @@ export class Issues {
this.selectedStatuses = new Set();
}
protected togglePriority(priority: IssuePriority, event: Event): void {
event.stopPropagation();
const next = new Set(this.selectedPriorities);
next.has(priority) ? next.delete(priority) : next.add(priority);
this.selectedPriorities = next;
}
protected clearPriorities(event: Event): void {
event.stopPropagation();
this.selectedPriorities = new Set();
}
protected toggleMilestone(id: number, event: Event): void {
event.stopPropagation();
const next = new Set(this.selectedMilestoneIds);
@@ -125,6 +157,12 @@ export class Issues {
return `Statut (${this.selectedStatuses.size})`;
}
protected priorityDropdownLabel(): string {
if (this.selectedPriorities.size === 0) return 'Priorité';
if (this.selectedPriorities.size === 1) return this.priorityDisplay([...this.selectedPriorities][0]).label;
return `Priorité (${this.selectedPriorities.size})`;
}
protected milestoneDropdownLabel(): string {
const count = this.selectedMilestoneIds.size + (this.showNoMilestone ? 1 : 0);
if (count === 0) return 'Milestone';