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>
|
</ul>
|
||||||
</div>
|
</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 -->
|
<!-- Filtre Milestone -->
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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 { IssuesFilterService } from './issues-filter.service';
|
||||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||||
import { StatusesStore } from '../statuses/statuses.store';
|
import { StatusesStore } from '../statuses/statuses.store';
|
||||||
import { ProjectContextService } from '../projects/project-context.service';
|
import { ProjectContextService } from '../projects/project-context.service';
|
||||||
@@ -176,6 +177,7 @@ describe('Issues', () => {
|
|||||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||||
{ provide: StatusesStore, useValue: statusesStore },
|
{ provide: StatusesStore, useValue: statusesStore },
|
||||||
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
||||||
|
{ provide: IssuesFilterService, useFactory: () => new IssuesFilterService() },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).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', () => {
|
describe('createIssue', () => {
|
||||||
it('navigates to /projects/:pid/issues/new', () => {
|
it('navigates to /projects/:pid/issues/new', () => {
|
||||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component, HostListener, inject } from '@angular/core';
|
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, IssuePriority, IssuesStore } from './issues.store';
|
||||||
|
import { IssuesFilterService } from './issues-filter.service';
|
||||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||||
import { StatusEntity, StatusesStore } from '../statuses/statuses.store';
|
import { StatusEntity, StatusesStore } from '../statuses/statuses.store';
|
||||||
import { ProjectContextService } from '../projects/project-context.service';
|
import { ProjectContextService } from '../projects/project-context.service';
|
||||||
@@ -18,6 +19,7 @@ export class Issues {
|
|||||||
private readonly milestonesStore = inject(MilestonesStore);
|
private readonly milestonesStore = inject(MilestonesStore);
|
||||||
private readonly statusesStore = inject(StatusesStore);
|
private readonly statusesStore = inject(StatusesStore);
|
||||||
private readonly projectContext = inject(ProjectContextService);
|
private readonly projectContext = inject(ProjectContextService);
|
||||||
|
private readonly filters = inject(IssuesFilterService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const projectId = this.projectContext.projectId()!;
|
const projectId = this.projectContext.projectId()!;
|
||||||
@@ -27,17 +29,34 @@ export class Issues {
|
|||||||
|
|
||||||
protected readonly issues = this.issuesStore.issues;
|
protected readonly issues = this.issuesStore.issues;
|
||||||
protected readonly milestones = this.milestonesStore.milestones;
|
protected readonly milestones = this.milestonesStore.milestones;
|
||||||
protected searchQuery = '';
|
protected openDropdown: 'type' | 'status' | 'priority' | 'milestone' | null = null;
|
||||||
protected selectedTypes = new Set<IssueEntity['type']>();
|
|
||||||
protected selectedStatuses = new Set<IssueStatus>();
|
protected get searchQuery(): string { return this.filters.searchQuery; }
|
||||||
protected selectedMilestoneIds = new Set<number>();
|
protected set searchQuery(v: string) { this.filters.searchQuery = v; }
|
||||||
protected showNoMilestone = false;
|
|
||||||
protected openDropdown: 'type' | 'status' | 'milestone' | null = null;
|
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'][] = [
|
protected readonly typeOptions: IssueEntity['type'][] = [
|
||||||
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
'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 readonly statusOptions = this.statusesStore.statuses;
|
||||||
|
|
||||||
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
|
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
|
||||||
@@ -50,6 +69,7 @@ export class Issues {
|
|||||||
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 (this.selectedPriorities.size > 0 && !this.selectedPriorities.has(i.priority)) return false;
|
||||||
if (milestoneActive) {
|
if (milestoneActive) {
|
||||||
const m = this.getMilestoneForIssue(i.id);
|
const m = this.getMilestoneForIssue(i.id);
|
||||||
const matchesMilestone = m !== undefined && this.selectedMilestoneIds.has(m.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();
|
event.stopPropagation();
|
||||||
this.openDropdown = this.openDropdown === name ? null : name;
|
this.openDropdown = this.openDropdown === name ? null : name;
|
||||||
}
|
}
|
||||||
@@ -95,6 +115,18 @@ export class Issues {
|
|||||||
this.selectedStatuses = new Set();
|
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 {
|
protected toggleMilestone(id: number, event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const next = new Set(this.selectedMilestoneIds);
|
const next = new Set(this.selectedMilestoneIds);
|
||||||
@@ -125,6 +157,12 @@ export class Issues {
|
|||||||
return `Statut (${this.selectedStatuses.size})`;
|
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 {
|
protected milestoneDropdownLabel(): string {
|
||||||
const count = this.selectedMilestoneIds.size + (this.showNoMilestone ? 1 : 0);
|
const count = this.selectedMilestoneIds.size + (this.showNoMilestone ? 1 : 0);
|
||||||
if (count === 0) return 'Milestone';
|
if (count === 0) return 'Milestone';
|
||||||
|
|||||||
Reference in New Issue
Block a user