diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6b9aac8..2c1ee00 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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", diff --git a/src/app/issues/issues.css b/src/app/issues/issues.css index 99ee86a..6a9f3bf 100644 --- a/src/app/issues/issues.css +++ b/src/app/issues/issues.css @@ -24,5 +24,13 @@ text-align: right; } +.filter-check { + display: inline-block; + width: 1rem; + text-align: center; + color: #2563eb; + font-weight: 700; +} + diff --git a/src/app/issues/issues.html b/src/app/issues/issues.html index 1c3c238..7c29ff5 100644 --- a/src/app/issues/issues.html +++ b/src/app/issues/issues.html @@ -7,28 +7,80 @@ -
- - @for (type of typeOptions; track type) { +
+ + + + + + +
diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index 689c428..ebe2637 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -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); }); }); diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts index f46b37d..39b760b 100644 --- a/src/app/issues/issues.ts +++ b/src/app/issues/issues.ts @@ -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(); + protected selectedStatuses = new Set(); + 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 {