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 {