Merge pull request 'Filtre sur les issues' (#20) from feat/12-filtre-statuts into develop

Reviewed-on: Bonsai/Bonsai-webapp#20
This commit is contained in:
2026-05-26 18:34:40 +02:00
5 changed files with 214 additions and 49 deletions
+3 -1
View File
@@ -10,7 +10,9 @@
"Bash(xargs cat -n)", "Bash(xargs cat -n)",
"Bash(xargs ls -la)", "Bash(xargs ls -la)",
"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(//var/home/Gato/IdeaProjects/Bonsai-webapp/**)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues", "/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues",
+8
View File
@@ -24,5 +24,13 @@
text-align: right; text-align: right;
} }
.filter-check {
display: inline-block;
width: 1rem;
text-align: center;
color: #2563eb;
font-weight: 700;
}
+70 -18
View File
@@ -7,28 +7,80 @@
<button type="button" class="btn btn-primary" (click)="createIssue()">Creer</button> <button type="button" class="btn btn-primary" (click)="createIssue()">Creer</button>
</div> </div>
<div class="d-flex flex-wrap gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-3">
<button <input
type="button" type="search"
class="btn btn-sm" class="form-control form-control-sm"
[class.btn-outline-secondary]="selectedType !== null" placeholder="Rechercher..."
[class.btn-secondary]="selectedType === null" [(ngModel)]="searchQuery"
(click)="selectType(null)" style="max-width: 240px;"
>Tous</button> />
@for (type of typeOptions; track type) {
<!-- Filtre Type -->
<div class="dropdown">
<button <button
type="button" type="button"
class="filter-btn btn btn-sm d-flex align-items-center gap-2" class="btn btn-sm d-flex align-items-center gap-1"
[class.btn-outline-secondary]="selectedType !== type" [class.btn-outline-secondary]="selectedTypes.size === 0"
[style.border-color]="selectedType === type ? typeIcon(type).bg : null" [class.btn-primary]="selectedTypes.size > 0"
[style.background]="selectedType === type ? typeIcon(type).bg : null" (click)="toggleDropdown('type', $event)"
[style.color]="selectedType === type ? '#fff' : null"
(click)="selectType(type)"
> >
<span class="type-icon" [style.background]="selectedType === type ? 'rgba(255,255,255,0.3)' : typeIcon(type).bg">{{ typeIcon(type).letter }}</span> @if (selectedTypes.size === 1) {
{{ type }} <span class="type-icon" style="background: rgba(255,255,255,0.3)">{{ typeIcon([...selectedTypes][0]).letter }}</span>
}
{{ typeDropdownLabel() }}
<span class="ms-1"></span>
</button> </button>
} <ul class="dropdown-menu" [class.show]="openDropdown === 'type'">
<li>
<button class="dropdown-item text-secondary small" [disabled]="selectedTypes.size === 0" (click)="clearTypes($event)">
Tout effacer
</button>
</li>
<li><hr class="dropdown-divider"></li>
@for (type of typeOptions; track type) {
<li>
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleType(type, $event)">
<span class="filter-check">@if (selectedTypes.has(type)) { ✓ }</span>
<span class="type-icon" [style.background]="typeIcon(type).bg">{{ typeIcon(type).letter }}</span>
{{ type }}
</button>
</li>
}
</ul>
</div>
<!-- Filtre Statut -->
<div class="dropdown">
<button
type="button"
class="btn btn-sm d-flex align-items-center gap-1"
[class.btn-outline-secondary]="selectedStatuses.size === 0"
[class.btn-primary]="selectedStatuses.size > 0"
(click)="toggleDropdown('status', $event)"
>
{{ statusDropdownLabel() }}
<span class="ms-1"></span>
</button>
<ul class="dropdown-menu" [class.show]="openDropdown === 'status'">
<li>
<button class="dropdown-item text-secondary small" [disabled]="selectedStatuses.size === 0" (click)="clearStatuses($event)">
Tout effacer
</button>
</li>
<li><hr class="dropdown-divider"></li>
@for (status of statusOptions; track status) {
<li>
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleStatus(status, $event)">
<span class="filter-check">@if (selectedStatuses.has(status)) { ✓ }</span>
<span class="status-badge" [style.background]="statusBadge(status).bg" [style.color]="statusBadge(status).color">
{{ statusBadge(status).label }}
</span>
</button>
</li>
}
</ul>
</div>
</div> </div>
<div class="card shadow-sm"> <div class="card shadow-sm">
+70 -22
View File
@@ -114,48 +114,96 @@ describe('Issues', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
const mockEvent = { stopPropagation: () => {} } as unknown as Event;
describe('filteredIssues', () => { describe('filteredIssues', () => {
it('returns all issues when no type is selected', () => { it('returns all issues when no types are selected', () => {
(component as any).selectedType = null; (component as any).selectedTypes = new Set();
expect((component as any).filteredIssues.length).toBe(store.issues().length); expect((component as any).filteredIssues.length).toBe(store.issues().length);
}); });
it('returns only issues matching the selected type', () => { it('returns only issues matching the selected type', () => {
(component as any).selectedType = 'Bug'; (component as any).selectedTypes = new Set(['Bug']);
const filtered: IssueEntity[] = (component as any).filteredIssues; const filtered: IssueEntity[] = (component as any).filteredIssues;
expect(filtered.every((i) => i.type === 'Bug')).toBe(true); expect(filtered.every((i) => i.type === 'Bug')).toBe(true);
}); });
it('returns empty array when no issues match the selected type', () => { it('returns empty array when no issues match the selected types', () => {
(component as any).selectedType = 'Epic'; (component as any).selectedTypes = new Set(['Epic']);
const filtered: IssueEntity[] = (component as any).filteredIssues; const filtered: IssueEntity[] = (component as any).filteredIssues;
expect(filtered.every((i) => i.type === 'Epic')).toBe(true); expect(filtered.every((i) => i.type === 'Epic')).toBe(true);
}); });
it('returns issues matching any of multiple selected types', () => {
(component as any).selectedTypes = new Set(['Bug', 'Story']);
const filtered: IssueEntity[] = (component as any).filteredIssues;
expect(filtered.every((i) => i.type === 'Bug' || i.type === 'Story')).toBe(true);
});
it('filters by status when selectedStatuses is set', () => {
(component as any).selectedStatuses = new Set(['done']);
const filtered: IssueEntity[] = (component as any).filteredIssues;
expect(filtered.every((i) => i.status === 'done')).toBe(true);
});
it('filters by search query on issue name', () => {
(component as any).searchQuery = 'Issue 1';
const filtered: IssueEntity[] = (component as any).filteredIssues;
expect(filtered.length).toBe(1);
expect(filtered[0].name).toBe('Issue 1');
});
}); });
describe('selectType', () => { describe('toggleType', () => {
it('sets selectedType when none is active', () => { it('adds a type when not already selected', () => {
(component as any).selectedType = null; (component as any).selectedTypes = new Set();
(component as any).selectType('Bug'); (component as any).toggleType('Bug', mockEvent);
expect((component as any).selectedType).toBe('Bug'); expect((component as any).selectedTypes.has('Bug')).toBe(true);
}); });
it('clears selectedType when the same type is selected again (toggle off)', () => { it('removes a type when already selected (toggle off)', () => {
(component as any).selectedType = 'Bug'; (component as any).selectedTypes = new Set(['Bug']);
(component as any).selectType('Bug'); (component as any).toggleType('Bug', mockEvent);
expect((component as any).selectedType).toBeNull(); expect((component as any).selectedTypes.has('Bug')).toBe(false);
}); });
it('switches to a different type', () => { it('can select multiple types simultaneously', () => {
(component as any).selectedType = 'Bug'; (component as any).selectedTypes = new Set();
(component as any).selectType('Story'); (component as any).toggleType('Bug', mockEvent);
expect((component as any).selectedType).toBe('Story'); (component as any).toggleType('Story', mockEvent);
expect((component as any).selectedTypes.size).toBe(2);
expect((component as any).selectedTypes.has('Bug')).toBe(true);
expect((component as any).selectedTypes.has('Story')).toBe(true);
});
});
describe('clearTypes', () => {
it('empties the type selection', () => {
(component as any).selectedTypes = new Set(['Bug', 'Story']);
(component as any).clearTypes(mockEvent);
expect((component as any).selectedTypes.size).toBe(0);
});
});
describe('toggleStatus', () => {
it('adds a status when not already selected', () => {
(component as any).selectedStatuses = new Set();
(component as any).toggleStatus('done', mockEvent);
expect((component as any).selectedStatuses.has('done')).toBe(true);
}); });
it('selectType(null) clears the filter', () => { it('removes a status when already selected (toggle off)', () => {
(component as any).selectedType = 'Bug'; (component as any).selectedStatuses = new Set(['done']);
(component as any).selectType(null); (component as any).toggleStatus('done', mockEvent);
expect((component as any).selectedType).toBeNull(); expect((component as any).selectedStatuses.has('done')).toBe(false);
});
});
describe('clearStatuses', () => {
it('empties the status selection', () => {
(component as any).selectedStatuses = new Set(['todo', 'done']);
(component as any).clearStatuses(mockEvent);
expect((component as any).selectedStatuses.size).toBe(0);
}); });
}); });
+63 -8
View File
@@ -1,10 +1,11 @@
import { Component, inject } from '@angular/core'; import { Component, HostListener, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { IssueEntity, IssuesStore } from './issues.store'; import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
@Component({ @Component({
selector: 'app-issues', selector: 'app-issues',
imports: [], imports: [FormsModule],
templateUrl: './issues.html', templateUrl: './issues.html',
styleUrl: './issues.css', styleUrl: './issues.css',
}) })
@@ -17,19 +18,73 @@ export class Issues {
} }
protected readonly issues = this.issuesStore.issues; protected readonly issues = this.issuesStore.issues;
protected selectedType: IssueEntity['type'] | null = null; protected searchQuery = '';
protected selectedTypes = new Set<IssueEntity['type']>();
protected selectedStatuses = new Set<IssueStatus>();
protected openDropdown: 'type' | 'status' | 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',
]; ];
protected readonly statusOptions: IssueStatus[] = [
'draft', 'todo', 'in-progress', 'done',
];
protected get filteredIssues(): IssueEntity[] { protected get filteredIssues(): IssueEntity[] {
if (this.selectedType === null) return this.issues(); const q = this.searchQuery.trim().toLowerCase();
return this.issues().filter((i) => i.type === this.selectedType); 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 (q && !i.name.toLowerCase().includes(q)) return false;
return true;
});
} }
protected selectType(type: IssueEntity['type'] | null): void { protected toggleDropdown(name: 'type' | 'status', event: Event): void {
this.selectedType = this.selectedType === type ? null : type; event.stopPropagation();
this.openDropdown = this.openDropdown === name ? null : name;
}
@HostListener('document:click')
protected closeDropdowns(): void {
this.openDropdown = null;
}
protected toggleType(type: IssueEntity['type'], event: Event): void {
event.stopPropagation();
const next = new Set(this.selectedTypes);
next.has(type) ? next.delete(type) : next.add(type);
this.selectedTypes = next;
}
protected clearTypes(event: Event): void {
event.stopPropagation();
this.selectedTypes = new Set();
}
protected toggleStatus(status: IssueStatus, event: Event): void {
event.stopPropagation();
const next = new Set(this.selectedStatuses);
next.has(status) ? next.delete(status) : next.add(status);
this.selectedStatuses = next;
}
protected clearStatuses(event: Event): void {
event.stopPropagation();
this.selectedStatuses = new Set();
}
protected typeDropdownLabel(): string {
if (this.selectedTypes.size === 0) return 'Type';
if (this.selectedTypes.size === 1) return [...this.selectedTypes][0];
return `Type (${this.selectedTypes.size})`;
}
protected statusDropdownLabel(): string {
if (this.selectedStatuses.size === 0) return 'Statut';
if (this.selectedStatuses.size === 1) return this.statusBadge([...this.selectedStatuses][0]).label;
return `Statut (${this.selectedStatuses.size})`;
} }
protected createIssue(): void { protected createIssue(): void {