Filtre sur les issues
This commit is contained in:
@@ -10,7 +10,9 @@
|
||||
"Bash(xargs cat -n)",
|
||||
"Bash(xargs ls -la)",
|
||||
"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": [
|
||||
"/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues",
|
||||
|
||||
@@ -24,5 +24,13 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.filter-check {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+70
-18
@@ -7,28 +7,80 @@
|
||||
<button type="button" class="btn btn-primary" (click)="createIssue()">Creer</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
[class.btn-outline-secondary]="selectedType !== null"
|
||||
[class.btn-secondary]="selectedType === null"
|
||||
(click)="selectType(null)"
|
||||
>Tous</button>
|
||||
@for (type of typeOptions; track type) {
|
||||
<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
|
||||
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)"
|
||||
class="btn btn-sm d-flex align-items-center gap-1"
|
||||
[class.btn-outline-secondary]="selectedTypes.size === 0"
|
||||
[class.btn-primary]="selectedTypes.size > 0"
|
||||
(click)="toggleDropdown('type', $event)"
|
||||
>
|
||||
<span class="type-icon" [style.background]="selectedType === type ? 'rgba(255,255,255,0.3)' : typeIcon(type).bg">{{ typeIcon(type).letter }}</span>
|
||||
{{ type }}
|
||||
@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 }}
|
||||
</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 class="card shadow-sm">
|
||||
|
||||
@@ -114,48 +114,96 @@ describe('Issues', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
const mockEvent = { stopPropagation: () => {} } as unknown as Event;
|
||||
|
||||
describe('filteredIssues', () => {
|
||||
it('returns all issues when no type is selected', () => {
|
||||
(component as any).selectedType = null;
|
||||
it('returns all issues when no types are selected', () => {
|
||||
(component as any).selectedTypes = new Set();
|
||||
expect((component as any).filteredIssues.length).toBe(store.issues().length);
|
||||
});
|
||||
|
||||
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;
|
||||
expect(filtered.every((i) => i.type === 'Bug')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array when no issues match the selected type', () => {
|
||||
(component as any).selectedType = 'Epic';
|
||||
it('returns empty array when no issues match the selected types', () => {
|
||||
(component as any).selectedTypes = new Set(['Epic']);
|
||||
const filtered: IssueEntity[] = (component as any).filteredIssues;
|
||||
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', () => {
|
||||
it('sets selectedType when none is active', () => {
|
||||
(component as any).selectedType = null;
|
||||
(component as any).selectType('Bug');
|
||||
expect((component as any).selectedType).toBe('Bug');
|
||||
describe('toggleType', () => {
|
||||
it('adds a type when not already selected', () => {
|
||||
(component as any).selectedTypes = new Set();
|
||||
(component as any).toggleType('Bug', mockEvent);
|
||||
expect((component as any).selectedTypes.has('Bug')).toBe(true);
|
||||
});
|
||||
|
||||
it('clears selectedType when the same type is selected again (toggle off)', () => {
|
||||
(component as any).selectedType = 'Bug';
|
||||
(component as any).selectType('Bug');
|
||||
expect((component as any).selectedType).toBeNull();
|
||||
it('removes a type when already selected (toggle off)', () => {
|
||||
(component as any).selectedTypes = new Set(['Bug']);
|
||||
(component as any).toggleType('Bug', mockEvent);
|
||||
expect((component as any).selectedTypes.has('Bug')).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to a different type', () => {
|
||||
(component as any).selectedType = 'Bug';
|
||||
(component as any).selectType('Story');
|
||||
expect((component as any).selectedType).toBe('Story');
|
||||
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('selectType(null) clears the filter', () => {
|
||||
(component as any).selectedType = 'Bug';
|
||||
(component as any).selectType(null);
|
||||
expect((component as any).selectedType).toBeNull();
|
||||
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 { IssueEntity, IssuesStore } from './issues.store';
|
||||
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issues',
|
||||
imports: [],
|
||||
imports: [FormsModule],
|
||||
templateUrl: './issues.html',
|
||||
styleUrl: './issues.css',
|
||||
})
|
||||
@@ -17,19 +18,73 @@ export class 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'][] = [
|
||||
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
||||
];
|
||||
|
||||
protected readonly statusOptions: IssueStatus[] = [
|
||||
'draft', 'todo', 'in-progress', 'done',
|
||||
];
|
||||
|
||||
protected get filteredIssues(): IssueEntity[] {
|
||||
if (this.selectedType === null) return this.issues();
|
||||
return this.issues().filter((i) => i.type === this.selectedType);
|
||||
const q = this.searchQuery.trim().toLowerCase();
|
||||
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 {
|
||||
this.selectedType = this.selectedType === type ? null : type;
|
||||
protected toggleDropdown(name: 'type' | 'status', event: Event): void {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user