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:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+68
-16
@@ -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">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
[(ngModel)]="searchQuery"
|
||||||
|
style="max-width: 240px;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Filtre Type -->
|
||||||
|
<div class="dropdown">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm"
|
class="btn btn-sm d-flex align-items-center gap-1"
|
||||||
[class.btn-outline-secondary]="selectedType !== null"
|
[class.btn-outline-secondary]="selectedTypes.size === 0"
|
||||||
[class.btn-secondary]="selectedType === null"
|
[class.btn-primary]="selectedTypes.size > 0"
|
||||||
(click)="selectType(null)"
|
(click)="toggleDropdown('type', $event)"
|
||||||
>Tous</button>
|
|
||||||
@for (type of typeOptions; track type) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="filter-btn btn btn-sm d-flex align-items-center gap-2"
|
|
||||||
[class.btn-outline-secondary]="selectedType !== type"
|
|
||||||
[style.border-color]="selectedType === type ? typeIcon(type).bg : null"
|
|
||||||
[style.background]="selectedType === type ? typeIcon(type).bg : null"
|
|
||||||
[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) {
|
||||||
|
<span class="type-icon" style="background: rgba(255,255,255,0.3)">{{ typeIcon([...selectedTypes][0]).letter }}</span>
|
||||||
|
}
|
||||||
|
{{ typeDropdownLabel() }}
|
||||||
|
<span class="ms-1">▾</span>
|
||||||
|
</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 }}
|
{{ type }}
|
||||||
</button>
|
</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">
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('selectType', () => {
|
it('filters by status when selectedStatuses is set', () => {
|
||||||
it('sets selectedType when none is active', () => {
|
(component as any).selectedStatuses = new Set(['done']);
|
||||||
(component as any).selectedType = null;
|
const filtered: IssueEntity[] = (component as any).filteredIssues;
|
||||||
(component as any).selectType('Bug');
|
expect(filtered.every((i) => i.status === 'done')).toBe(true);
|
||||||
expect((component as any).selectedType).toBe('Bug');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears selectedType when the same type is selected again (toggle off)', () => {
|
it('filters by search query on issue name', () => {
|
||||||
(component as any).selectedType = 'Bug';
|
(component as any).searchQuery = 'Issue 1';
|
||||||
(component as any).selectType('Bug');
|
const filtered: IssueEntity[] = (component as any).filteredIssues;
|
||||||
expect((component as any).selectedType).toBeNull();
|
expect(filtered.length).toBe(1);
|
||||||
|
expect(filtered[0].name).toBe('Issue 1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches to a different type', () => {
|
describe('toggleType', () => {
|
||||||
(component as any).selectedType = 'Bug';
|
it('adds a type when not already selected', () => {
|
||||||
(component as any).selectType('Story');
|
(component as any).selectedTypes = new Set();
|
||||||
expect((component as any).selectedType).toBe('Story');
|
(component as any).toggleType('Bug', mockEvent);
|
||||||
|
expect((component as any).selectedTypes.has('Bug')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selectType(null) clears the filter', () => {
|
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(null);
|
(component as any).toggleType('Bug', mockEvent);
|
||||||
expect((component as any).selectedType).toBeNull();
|
expect((component as any).selectedTypes.has('Bug')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can select multiple types simultaneously', () => {
|
||||||
|
(component as any).selectedTypes = new Set();
|
||||||
|
(component as any).toggleType('Bug', mockEvent);
|
||||||
|
(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('removes a status when already selected (toggle off)', () => {
|
||||||
|
(component as any).selectedStatuses = new Set(['done']);
|
||||||
|
(component as any).toggleStatus('done', mockEvent);
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user