Ajout filtre priorité
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user