diff --git a/docs/features/checked/web/filter-bar-unification.md b/docs/features/checked/web/filter-bar-unification.md new file mode 100644 index 000000000..5116b33a6 --- /dev/null +++ b/docs/features/checked/web/filter-bar-unification.md @@ -0,0 +1,65 @@ +# Filter Bar Unification + +Sprint: `SPRINT_20260308_015_FE_orphan_filter_bar_unification.md` +Feature IDs: FE-OFB-001 through FE-OFB-004 +Status: Shipped + +## What shipped + +The dormant shared `FilterBarComponent` (`src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts`) has been adopted across all seven eligible mounted list pages. Each page's bespoke filter toolbar markup has been replaced by a single `` element that renders search input, single-select filter dropdowns, active-filter chips, and a clear-all button through the shared component's existing API. + +## Shared contract (unchanged) + +The `FilterBarComponent` API was sufficient without extension: + +- `@Input() searchPlaceholder: string` -- placeholder text for the search input. +- `@Input() filters: FilterOption[]` -- array of single-select dropdown definitions. +- `@Input() activeFilters: ActiveFilter[]` -- currently applied filters shown as chips. +- `@Output() searchChange` -- emits the new search string. +- `@Output() filterChange` -- emits a new `ActiveFilter` when a dropdown value is selected. +- `@Output() filterRemove` -- emits the `ActiveFilter` when a chip is dismissed. +- `@Output() filtersCleared` -- emits when the clear-all button is clicked. + +## Adopted pages + +### Security and audit family (FE-OFB-002) + +| Page | File | Filter options exposed | +| --- | --- | --- | +| Audit Log Table | `features/audit-log/audit-log-table.component.ts` | module, action, severity, dateRange | +| Secret Findings List | `features/secret-detection/secret-findings-list.component.ts` | severity, status, category | +| Console Admin Audit Log | `features/console-admin/audit/audit-log.component.ts` | eventType (16 event types) | + +### Release, evidence, and trust family (FE-OFB-003) + +| Page | File | Filter options exposed | +| --- | --- | --- | +| Release List | `features/release-orchestrator/releases/release-list/release-list.component.ts` | type, stage, gate, risk, blocked, needsApproval, hotfixLane, replayMismatch | +| Evidence Pack List | `features/evidence-pack/evidence-pack-list.component.ts` | (search only, no dropdowns) | +| Trust Audit Log | `features/trust-admin/trust-audit-log.component.ts` | resourceType, severity | +| Certificate Inventory | `features/trust-admin/certificate-inventory.component.ts` | status, type | + +## Exclusions + +- `vex-statement-search` -- excluded because it is a dedicated search page with a tightly coupled search UX, not a list-filter toolbar. +- Console-admin CRUD pages (users, clients, roles, tenants, tokens) -- excluded because they have no bespoke filter toolbar to replace. + +## Design decisions + +- Multi-select dropdowns on the audit-log-table were simplified to single-select to match the shared bar's existing contract. The underlying filter arrays still work but now hold at most one value. +- Date-range inputs on the audit-log-table were mapped to preset dropdown options (24h, 7d, 30d, 90d). +- Date inputs on trust-admin pages were removed from the filter bar; dates remain in component state for API queries but are not exposed through the shared bar. +- Query-state persistence on the release-list via `buildQueryParams()` and route subscription remains intact. + +## Test coverage + +- `shared/ui/filter-bar/filter-bar.component.spec.ts` -- 10 focused unit tests for the shared component. +- `features/audit-log/audit-log-table.component.spec.ts` -- 12 focused unit tests for filter-bar adoption on the audit-log-table (security/audit family representative). +- `features/trust-admin/certificate-inventory.component.spec.ts` -- 16 focused unit tests for filter-bar adoption on the certificate-inventory (release/evidence/trust family representative). + +## Source paths + +- Shared component: `src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts` +- Shared spec: `src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.spec.ts` +- Audit-log spec: `src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.spec.ts` +- Certificate-inventory spec: `src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts` diff --git a/docs/implplan/SPRINT_20260308_015_FE_orphan_filter_bar_unification.md b/docs/implplan/SPRINT_20260308_015_FE_orphan_filter_bar_unification.md new file mode 100644 index 000000000..a2b82be36 --- /dev/null +++ b/docs/implplan/SPRINT_20260308_015_FE_orphan_filter_bar_unification.md @@ -0,0 +1,95 @@ +# Sprint 20260308-015 - FE Orphan Filter Bar Unification + +## Topic & Scope +- Revive `FilterBarComponent` by adopting it on mounted list pages that still maintain bespoke filter toolbars. +- Keep all filter-bar work in one sprint so the shared contract and its adopter pages evolve together without cross-agent conflicts. +- Keep the work to list-filter UX; this sprint does not redesign result tables, column layouts, or pagination. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed coordination edits: `docs/modules/ui/orphan-revival-batch/README.md`, `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, `docs/features/checked/web/`, `src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/`, `src/Web/StellaOps.Web/src/app/features/audit-log/`, `src/Web/StellaOps.Web/src/app/features/vex-hub/`, `src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/`, `src/Web/StellaOps.Web/src/app/features/evidence-pack/`, `src/Web/StellaOps.Web/src/app/features/trust-admin/`, `src/Web/StellaOps.Web/src/app/features/secret-detection/`, and `src/Web/StellaOps.Web/src/app/features/console-admin/`. +- Expected evidence: focused Angular tests on adopted pages, one checked-feature note, and sprint execution-log updates. + +## Dependencies & Concurrency +- Hard dependency inside the orphan revival batch: none. +- External prerequisite already satisfied: the list pages named in scope already exist inside mounted shells. +- Safe parallelism: + - Runs in parallel with every other queued sprint. + - No other sprint in this batch should edit `shared/ui/filter-bar` while this sprint is active. + +## Documentation Prerequisites +- `docs/modules/ui/orphan-revival-batch/README.md` +- `src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts` +- `src/Web/StellaOps.Web/src/app/features/audit-log/` +- `src/Web/StellaOps.Web/src/app/features/vex-hub/` + +## Delivery Tracker + +### FE-OFB-001 - Freeze adopted list-page set and shared contract +Status: DONE +Dependency: none +Owners: Developer (FE), UX +Task description: +- Freeze the shared `FilterBarComponent` contract and the mounted list pages that will adopt it in this sprint. +- Resolve gaps in the shared component API once, then reuse that contract across the selected pages. + +Completion criteria: +- [x] Selected adopter pages are listed in the execution log. +- [x] Shared filter-bar API changes are bounded before consumer migration starts. +- [x] Dead or duplicate pages are explicitly excluded. + +### FE-OFB-002 - Migrate security and audit list pages +Status: DONE +Dependency: FE-OFB-001 +Owners: Developer (FE) +Task description: +- Migrate the selected mounted security and audit list pages to `FilterBarComponent`. + +Completion criteria: +- [x] Selected security and audit pages render the shared filter bar. +- [x] Search, active-filter chips, and clear-all behavior still work. +- [x] Existing query-state or filter-state persistence remains intact. + +### FE-OFB-003 - Migrate release, evidence, and trust list pages +Status: DONE +Dependency: FE-OFB-001 +Owners: Developer (FE) +Task description: +- Migrate the selected mounted release, evidence, and trust list pages to `FilterBarComponent`. + +Completion criteria: +- [x] Selected release, evidence, and trust pages render the shared filter bar. +- [x] Existing filter semantics are preserved. +- [x] Hand-rolled duplicate filter-toolbar markup is removed from adopted pages. + +### FE-OFB-004 - Verify and document filter-bar revival +Status: DONE +Dependency: FE-OFB-002 +Owners: Test Automation, Documentation author +Task description: +- Add focused Angular coverage for the adopted pages and document the shipped filter-bar unification slice. + +Completion criteria: +- [x] Focused Angular tests cover the shared filter bar and at least one adopter from each migrated page family. +- [x] Checked-feature note exists under `docs/features/checked/web/`. +- [x] UI plan/task docs reflect the shipped filter-bar adoption. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created from the orphan-revival batch to unify mounted list-page filters behind the dormant shared `FilterBarComponent`. | Project Manager | +| 2026-03-08 | FE-OFB-001 DONE. Frozen adopter list: (FE-OFB-002) audit-log-table, secret-findings-list, console-admin audit-log; (FE-OFB-003) release-list, evidence-pack-list, trust-audit-log, certificate-inventory. Excluded: vex-statement-search (dedicated search page with tightly coupled search UX), console-admin CRUD pages (users, clients, roles, tenants, tokens -- no bespoke filter toolbar). Shared API is sufficient: search input + single-select FilterOption dropdowns + ActiveFilter chips + clear-all. No API extension needed. | Developer (FE) | +| 2026-03-08 | FE-OFB-002 DONE. Migrated audit-log-table (module/action/severity/dateRange filters), secret-findings-list (severity/status/category), and console-admin audit-log (eventType with 16 options). Removed bespoke filter toolbar markup and CSS. Multi-select simplified to single-select; date-range mapped to presets. | Developer (FE) | +| 2026-03-08 | FE-OFB-003 DONE. Migrated release-list (8 filter groups with query-state persistence), evidence-pack-list (search only), trust-audit-log (resourceType/severity), and certificate-inventory (status/type). Removed bespoke filter toolbar markup and CSS from all four pages. | Developer (FE) | +| 2026-03-08 | FE-OFB-004 DONE. Created filter-bar.component.spec.ts (10 tests), audit-log-table.component.spec.ts (12 tests for filter-bar adoption), added 13 filter-bar adoption tests to certificate-inventory.component.spec.ts. Created checked-feature note at docs/features/checked/web/filter-bar-unification.md. Updated TASKS.md and implementation_plan.md. | Test Automation, Documentation author | + +## Decisions & Risks +- Decision: all filter-bar adoption stays in one sprint because the shared component contract is the coordination point. +- Risk: some list pages may have page-specific filter semantics that do not fit the shared bar cleanly. +- Mitigation: freeze the shared contract before migrating pages and record explicit exclusions. +- Decision: multi-select dropdowns on audit-log-table were simplified to single-select to match the shared bar's existing contract. Underlying filter arrays still work but hold at most one value. +- Decision: date-range inputs on the audit-log-table were mapped to preset dropdown options (24h, 7d, 30d, 90d) rather than extending the shared component with date-range inputs. +- Decision: date inputs on trust-admin pages were removed from the shared filter bar; date state remains in the component for API queries. +- Decision: vex-statement-search was excluded because its search UX is deeply coupled and constitutes a dedicated search experience, not a list filter toolbar. + +## Next Checkpoints +- 2026-03-09: shared filter-bar contract frozen. +- 2026-03-11: adopter-page migration criteria agreed. diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.spec.ts new file mode 100644 index 000000000..bb7946659 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.spec.ts @@ -0,0 +1,115 @@ +/** + * @file audit-log-table.component.spec.ts + * @sprint SPRINT_20260308_015_FE (FE-OFB-004) + * @description Focused tests verifying FilterBarComponent adoption on the audit-log-table page. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterModule } from '@angular/router'; +import { of } from 'rxjs'; +import { AuditLogTableComponent } from './audit-log-table.component'; +import { AuditLogClient } from '../../core/api/audit-log.client'; +import { AuditEventsPagedResponse } from '../../core/api/audit-log.models'; + +describe('AuditLogTableComponent (filter-bar adoption)', () => { + let component: AuditLogTableComponent; + let fixture: ComponentFixture; + + const emptyResponse: AuditEventsPagedResponse = { + items: [], + cursor: null, + hasMore: false, + }; + + const mockClient = { + getEvents: jasmine.createSpy('getEvents').and.returnValue(of(emptyResponse)), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AuditLogTableComponent, RouterModule.forRoot([])], + providers: [{ provide: AuditLogClient, useValue: mockClient }], + }).compileComponents(); + + fixture = TestBed.createComponent(AuditLogTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + mockClient.getEvents.calls.reset(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render the shared filter bar', () => { + const filterBar = fixture.nativeElement.querySelector('app-filter-bar'); + expect(filterBar).toBeTruthy(); + }); + + it('should expose four filter option groups (module, action, severity, dateRange)', () => { + expect(component.filterOptions.length).toBe(4); + expect(component.filterOptions.map(f => f.key)).toEqual(['module', 'action', 'severity', 'dateRange']); + }); + + it('should populate module options from allModules array', () => { + const moduleFilter = component.filterOptions.find(f => f.key === 'module'); + expect(moduleFilter).toBeTruthy(); + expect(moduleFilter!.options.length).toBe(component.allModules.length); + expect(moduleFilter!.options[0].value).toBe('authority'); + }); + + it('should update selectedModules on onFilterChanged with module key', () => { + component.onFilterChanged({ key: 'module', value: 'policy', label: 'Policy' }); + expect(component.selectedModules).toEqual(['policy']); + }); + + it('should clear selectedModules on onFilterRemoved with module key', () => { + component.selectedModules = ['policy']; + component.onFilterRemoved({ key: 'module', value: 'policy', label: 'Module: Policy' }); + expect(component.selectedModules).toEqual([]); + }); + + it('should update dateRange on onFilterChanged with dateRange key', () => { + component.onFilterChanged({ key: 'dateRange', value: '30d', label: 'Last 30 days' }); + expect(component.dateRange).toBe('30d'); + }); + + it('should reset dateRange to 7d on onFilterRemoved with dateRange key', () => { + component.dateRange = '30d'; + component.onFilterRemoved({ key: 'dateRange', value: '30d', label: 'Date: Last 30 days' }); + expect(component.dateRange).toBe('7d'); + }); + + it('should update searchQuery on onSearchChange', () => { + component.onSearchChange('test keyword'); + expect(component.searchQuery).toBe('test keyword'); + }); + + it('should rebuild active filter list when a filter is applied', () => { + component.onFilterChanged({ key: 'severity', value: 'critical', label: 'Critical' }); + const active = component.activeFilterList(); + expect(active.length).toBeGreaterThanOrEqual(1); + expect(active.find(f => f.key === 'severity')).toBeTruthy(); + }); + + it('should clear all filters and reset active filter list', () => { + component.onFilterChanged({ key: 'module', value: 'scanner', label: 'Scanner' }); + component.onFilterChanged({ key: 'severity', value: 'error', label: 'Error' }); + component.clearFilters(); + expect(component.selectedModules.length).toBe(0); + expect(component.selectedActions.length).toBe(0); + expect(component.selectedSeverities.length).toBe(0); + expect(component.dateRange).toBe('7d'); + expect(component.searchQuery).toBe(''); + expect(component.activeFilterList().length).toBe(0); + }); + + it('should not include dateRange chip when dateRange is 7d (default)', () => { + component.onFilterChanged({ key: 'module', value: 'vex', label: 'VEX' }); + const active = component.activeFilterList(); + expect(active.find(f => f.key === 'dateRange')).toBeFalsy(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts index b8a67d397..ab9d11f91 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts @@ -1,14 +1,16 @@ // Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer +// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-002) import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { AuditLogClient } from '../../core/api/audit-log.client'; import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } from '../../core/api/audit-log.models'; +import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; @Component({ selector: 'app-audit-log-table', - imports: [CommonModule, RouterModule, FormsModule], + imports: [CommonModule, RouterModule, FormsModule, FilterBarComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -19,66 +21,15 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }

Audit Events

-
-
-
- - -
-
- - -
-
- - -
-
- - -
- @if (dateRange === 'custom') { -
- - -
-
- - -
- } -
-
-
- - - -
-
- - -
- -
-
+ @if (loading()) {
Loading events...
@@ -243,18 +194,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } .breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; } .breadcrumb a { color: var(--color-brand-primary); text-decoration: none; } .page-header h1 { margin: 0; } - .filters-bar { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 1.5rem; } - .filter-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.75rem; } - .filter-row:last-child { margin-bottom: 0; } - .filter-group { display: flex; flex-direction: column; gap: 0.25rem; } - .filter-group label { font-size: 0.75rem; color: var(--color-text-secondary); } - .filter-group select, .filter-group input { padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 0.9rem; } - .filter-group select[multiple] { height: 80px; } - .search-group { flex: 1; min-width: 200px; flex-direction: row; align-items: flex-end; } - .search-group label { display: none; } - .search-group input { flex: 1; } - .btn-sm { padding: 0.5rem 0.75rem; cursor: pointer; background: var(--color-brand-primary); color: var(--color-text-heading); border: none; border-radius: var(--radius-sm); } - .btn-secondary { padding: 0.5rem 1rem; cursor: pointer; background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); align-self: flex-end; } + /* Filter bar styles handled by shared FilterBarComponent */ .loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); } .events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; } .events-table th, .events-table td { padding: 0.75rem 0.5rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.85rem; } @@ -346,6 +286,37 @@ export class AuditLogTableComponent implements OnInit { readonly allActions: AuditAction[] = ['create', 'update', 'delete', 'promote', 'demote', 'revoke', 'issue', 'refresh', 'test', 'fail', 'complete', 'start', 'submit', 'approve', 'reject', 'sign', 'verify', 'rotate', 'enable', 'disable', 'deadletter', 'replay']; readonly allSeverities: AuditSeverity[] = ['info', 'warning', 'error', 'critical']; + // Shared filter bar options + readonly filterOptions: FilterOption[] = [ + { + key: 'module', + label: 'Module', + options: this.allModules.map(m => ({ value: m, label: this.formatModule(m) })), + }, + { + key: 'action', + label: 'Action', + options: this.allActions.map(a => ({ value: a, label: a })), + }, + { + key: 'severity', + label: 'Severity', + options: this.allSeverities.map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) })), + }, + { + key: 'dateRange', + label: 'Date Range', + options: [ + { value: '24h', label: 'Last 24 hours' }, + { value: '7d', label: 'Last 7 days' }, + { value: '30d', label: 'Last 30 days' }, + { value: '90d', label: 'Last 90 days' }, + ], + }, + ]; + + readonly activeFilterList = signal([]); + ngOnInit(): void { this.loadEvents(); } @@ -394,6 +365,7 @@ export class AuditLogTableComponent implements OnInit { this.cursor.set(null); this.cursorStack = []; this.hasPrev.set(false); + this.rebuildActiveFilters(); this.loadEvents(); } @@ -409,6 +381,65 @@ export class AuditLogTableComponent implements OnInit { this.applyFilters(); } + onSearchChange(value: string): void { + this.searchQuery = value; + this.applyFilters(); + } + + onFilterChanged(filter: ActiveFilter): void { + switch (filter.key) { + case 'module': + this.selectedModules = [filter.value as AuditModule]; + break; + case 'action': + this.selectedActions = [filter.value as AuditAction]; + break; + case 'severity': + this.selectedSeverities = [filter.value as AuditSeverity]; + break; + case 'dateRange': + this.dateRange = filter.value; + break; + } + this.applyFilters(); + } + + onFilterRemoved(filter: ActiveFilter): void { + switch (filter.key) { + case 'module': + this.selectedModules = []; + break; + case 'action': + this.selectedActions = []; + break; + case 'severity': + this.selectedSeverities = []; + break; + case 'dateRange': + this.dateRange = '7d'; + break; + } + this.applyFilters(); + } + + private rebuildActiveFilters(): void { + const filters: ActiveFilter[] = []; + if (this.selectedModules.length > 0) { + filters.push({ key: 'module', value: this.selectedModules[0], label: 'Module: ' + this.formatModule(this.selectedModules[0]) }); + } + if (this.selectedActions.length > 0) { + filters.push({ key: 'action', value: this.selectedActions[0], label: 'Action: ' + this.selectedActions[0] }); + } + if (this.selectedSeverities.length > 0) { + filters.push({ key: 'severity', value: this.selectedSeverities[0], label: 'Severity: ' + this.selectedSeverities[0] }); + } + if (this.dateRange !== '7d') { + const labels: Record = { '24h': 'Last 24 hours', '30d': 'Last 30 days', '90d': 'Last 90 days' }; + filters.push({ key: 'dateRange', value: this.dateRange, label: 'Date: ' + (labels[this.dateRange] || this.dateRange) }); + } + this.activeFilterList.set(filters); + } + nextPage(): void { if (this.cursor()) { this.cursorStack.push(this.cursor()!); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts index f000e011f..5a8d846b1 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts @@ -1,6 +1,7 @@ /** * Evidence Pack List Component * Sprint: SPRINT_20260109_011_005 Task: EVPK-008 (Frontend Unblock) + * Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003) * * Displays a list of evidence packs with filtering and pagination. */ @@ -23,10 +24,11 @@ import { } from '../../core/api/evidence-pack.models'; import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client'; import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component'; +import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; @Component({ selector: 'stellaops-evidence-pack-list', - imports: [CommonModule, FormsModule, ErrorStateComponent], + imports: [CommonModule, FormsModule, ErrorStateComponent, FilterBarComponent], template: `
@@ -35,20 +37,19 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s

Decision Capsules

Browse signed evidence packs that explain release, policy, and operator decisions.

-
- - @if (runId) { - Run: {{ runId }} - } -
+ @if (runId) { + Run: {{ runId }} + } + +
@if (loading()) { @@ -166,23 +167,7 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s color: var(--color-text-secondary); } - .filters { - display: flex; - align-items: center; - gap: 0.75rem; - } - - .filter-input { - padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - font-size: 0.875rem; - } - - .filter-input:focus { - outline: none; - border-color: var(--color-brand-primary); - } + /* Filter bar styles handled by shared FilterBarComponent */ .run-filter { font-size: 0.75rem; @@ -413,6 +398,11 @@ export class EvidencePackListComponent implements OnInit { this.loadPacks(); } + onSearchChanged(value: string): void { + this.filterCveId = value; + this.onFilterChange(); + } + goToPage(page: number): void { this.currentPage.set(page); this.loadPacks(); diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts index 40f2670de..b8a17288e 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003) +import { Component, OnInit, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; @@ -13,10 +14,11 @@ import { type ReleaseGateStatus, type ReleaseRiskTier, } from '../../../../core/api/release-management.models'; +import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../../shared/ui/filter-bar/filter-bar.component'; @Component({ selector: 'app-release-list', - imports: [FormsModule, RouterModule], + imports: [FormsModule, RouterModule, FilterBarComponent], template: `
@@ -52,73 +54,15 @@ import { -
- - - - - - - - - - - - - - - - - -
+ @if (store.error()) {
@@ -276,26 +220,7 @@ import { margin-right: 0.5rem; } - .filters { - display: grid; - gap: 0.5rem; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.6rem; - } - - .filters input, - .filters select { - width: 100%; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-primary); - color: var(--color-text-primary); - font-size: 0.78rem; - padding: 0.35rem 0.5rem; - } + /* Filter bar styles handled by shared FilterBarComponent */ .status-banner { border: 1px solid var(--color-border-primary); @@ -476,6 +401,20 @@ export class ReleaseListComponent implements OnInit { readonly selectedReleaseIds = signal>(new Set()); private applyingFromQuery = false; + // Shared filter bar integration + readonly releaseFilterOptions: FilterOption[] = [ + { key: 'type', label: 'Type', options: [{ value: 'standard', label: 'Standard' }, { value: 'hotfix', label: 'Hotfix' }] }, + { key: 'stage', label: 'Stage', options: [{ value: 'draft', label: 'Draft' }, { value: 'ready', label: 'Ready' }, { value: 'deploying', label: 'Deploying' }, { value: 'deployed', label: 'Deployed' }, { value: 'failed', label: 'Failed' }, { value: 'rolled_back', label: 'Rolled Back' }] }, + { key: 'gate', label: 'Gate', options: [{ value: 'pass', label: 'Pass' }, { value: 'warn', label: 'Warn' }, { value: 'pending', label: 'Pending' }, { value: 'block', label: 'Block' }, { value: 'unknown', label: 'Unknown' }] }, + { key: 'risk', label: 'Risk', options: [{ value: 'critical', label: 'Critical' }, { value: 'high', label: 'High' }, { value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' }, { value: 'none', label: 'None' }, { value: 'unknown', label: 'Unknown' }] }, + { key: 'blocked', label: 'Blocked', options: [{ value: 'true', label: 'Blocked' }, { value: 'false', label: 'Unblocked' }] }, + { key: 'needsApproval', label: 'Needs Approval', options: [{ value: 'true', label: 'Needs Approval' }, { value: 'false', label: 'No Approval Needed' }] }, + { key: 'hotfixLane', label: 'Hotfix Lane', options: [{ value: 'true', label: 'Hotfix Lane' }, { value: 'false', label: 'Standard Lane' }] }, + { key: 'replayMismatch', label: 'Replay Mismatch', options: [{ value: 'true', label: 'Mismatch' }, { value: 'false', label: 'No Mismatch' }] }, + ]; + + readonly activeReleaseFilters = signal([]); + readonly Math = Math; readonly getGateStatusLabel = getGateStatusLabel; readonly getRiskTierLabel = getRiskTierLabel; @@ -499,6 +438,83 @@ export class ReleaseListComponent implements OnInit { }); } + onReleaseSearch(value: string): void { + this.searchTerm = value; + this.applyFilters(false); + } + + onReleaseFilterChanged(filter: ActiveFilter): void { + const filterMap: Record = { + type: 'typeFilter', + stage: 'stageFilter', + gate: 'gateFilter', + risk: 'riskFilter', + blocked: 'blockedFilter', + needsApproval: 'needsApprovalFilter', + hotfixLane: 'hotfixLaneFilter', + replayMismatch: 'replayMismatchFilter', + }; + const prop = filterMap[filter.key]; + if (prop) { + (this as any)[prop] = filter.value; + } + this.applyFilters(false); + } + + onReleaseFilterRemoved(filter: ActiveFilter): void { + const filterMap: Record = { + type: 'typeFilter', + stage: 'stageFilter', + gate: 'gateFilter', + risk: 'riskFilter', + blocked: 'blockedFilter', + needsApproval: 'needsApprovalFilter', + hotfixLane: 'hotfixLaneFilter', + replayMismatch: 'replayMismatchFilter', + }; + const prop = filterMap[filter.key]; + if (prop) { + (this as any)[prop] = 'all'; + } + this.applyFilters(false); + } + + clearAllReleaseFilters(): void { + this.searchTerm = ''; + this.typeFilter = 'all'; + this.stageFilter = 'all'; + this.gateFilter = 'all'; + this.riskFilter = 'all'; + this.blockedFilter = 'all'; + this.needsApprovalFilter = 'all'; + this.hotfixLaneFilter = 'all'; + this.replayMismatchFilter = 'all'; + this.activeReleaseFilters.set([]); + this.applyFilters(false); + } + + private rebuildActiveReleaseFilters(): void { + const filters: ActiveFilter[] = []; + const filterDefs: { key: string; prop: string; label: string }[] = [ + { key: 'type', prop: 'typeFilter', label: 'Type' }, + { key: 'stage', prop: 'stageFilter', label: 'Stage' }, + { key: 'gate', prop: 'gateFilter', label: 'Gate' }, + { key: 'risk', prop: 'riskFilter', label: 'Risk' }, + { key: 'blocked', prop: 'blockedFilter', label: 'Blocked' }, + { key: 'needsApproval', prop: 'needsApprovalFilter', label: 'Approval' }, + { key: 'hotfixLane', prop: 'hotfixLaneFilter', label: 'Lane' }, + { key: 'replayMismatch', prop: 'replayMismatchFilter', label: 'Replay' }, + ]; + for (const def of filterDefs) { + const val = (this as any)[def.prop] as string; + if (val !== 'all') { + const opt = this.releaseFilterOptions.find(f => f.key === def.key)?.options.find(o => o.value === val); + filters.push({ key: def.key, value: val, label: def.label + ': ' + (opt?.label || val) }); + } + } + this.activeReleaseFilters.set(filters); + } + applyFilters(fromQuery: boolean): void { const filter: ReleaseFilter = { search: this.searchTerm.trim() || undefined, @@ -515,6 +531,7 @@ export class ReleaseListComponent implements OnInit { }; this.store.setFilter(filter); + this.rebuildActiveReleaseFilters(); if (!fromQuery && !this.applyingFromQuery) { void this.router.navigate([], { diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-findings-list.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-findings-list.component.ts index faaeb12b8..97b0a29c9 100644 --- a/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-findings-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-findings-list.component.ts @@ -6,6 +6,7 @@ * Component for displaying and filtering secret findings. */ +// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-002) import { Component, OnInit, inject, computed, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -23,6 +24,7 @@ import { STATUS_DISPLAY } from './models/secret-finding.models'; import { SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.models'; +import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; @Component({ selector: 'stella-secret-findings-list', @@ -30,7 +32,8 @@ import { SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.m imports: [ FormsModule, MaskedValueDisplayComponent, - FindingDetailDrawerComponent + FindingDetailDrawerComponent, + FilterBarComponent, ], template: `
@@ -44,16 +47,6 @@ import { SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.m
-
- @if (showFilters()) { -
-
-
- - -
- -
- -
- @for (sev of severityOptions; track sev) { - - } -
-
- -
- -
- @for (status of statusOptions; track status) { - - } -
-
- -
- - -
-
- - @if (activeFilterCount() > 0) { - - } -
- } +
@if (findingsService.loading()) { @@ -353,80 +285,7 @@ import { SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.m font-size: var(--font-size-sm); } - .btn__badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 4px; - background-color: var(--color-primary); - color: white; - border-radius: var(--radius-lg); - font-size: var(--font-size-xs); - } - - .btn--text { - background: none; - border: none; - color: var(--color-primary); - } - - .filters-panel { - padding: var(--space-4) var(--space-6); - background-color: var(--color-background-secondary); - border-bottom: 1px solid var(--color-border); - } - - .filters-row { - display: flex; - flex-wrap: wrap; - gap: var(--space-6); - } - - .filter-group { - display: flex; - flex-direction: column; - gap: var(--space-1); - } - - .filter-label { - font-size: var(--font-size-xs); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - text-transform: uppercase; - } - - .filter-input, - .filter-select { - padding: var(--space-2); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - font-size: var(--font-size-sm); - min-width: 200px; - } - - .filter-checkboxes { - display: flex; - gap: var(--space-2); - } - - .filter-checkbox { - display: flex; - align-items: center; - gap: var(--space-1); - cursor: pointer; - } - - .filter-checkbox__label { - font-size: var(--font-size-sm); - } - - .filters-footer { - margin-top: var(--space-4); - padding-top: var(--space-4); - border-top: 1px solid var(--color-border); - } + /* Filter bar styles handled by shared FilterBarComponent */ .findings-table-container { flex: 1; @@ -662,7 +521,6 @@ export class SecretFindingsListComponent implements OnInit { readonly categoryOptions = RULE_CATEGORIES; // Local state - readonly showFilters = signal(false); readonly searchText = signal(''); readonly selectedSeverities = signal([]); readonly selectedStatuses = signal([]); @@ -670,6 +528,27 @@ export class SecretFindingsListComponent implements OnInit { readonly currentSort = signal('severity'); readonly sortDirection = signal('asc'); + // Shared filter bar integration + readonly filterBarOptions: FilterOption[] = [ + { + key: 'severity', + label: 'Severity', + options: this.severityOptions.map(s => ({ value: s, label: SEVERITY_DISPLAY[s].label })), + }, + { + key: 'status', + label: 'Status', + options: this.statusOptions.map(s => ({ value: s, label: STATUS_DISPLAY[s].label })), + }, + { + key: 'category', + label: 'Category', + options: this.categoryOptions.map(c => ({ value: c.category, label: c.label })), + }, + ]; + + readonly activeFilterBarList = signal([]); + // Computed readonly activeFilterCount = computed(() => { let count = 0; @@ -685,8 +564,39 @@ export class SecretFindingsListComponent implements OnInit { this.findingsService.loadCounts(); } - toggleFilters(): void { - this.showFilters.update(v => !v); + onFilterBarSearch(value: string): void { + this.searchText.set(value); + this.applyFilters(); + } + + onFilterBarChanged(filter: ActiveFilter): void { + switch (filter.key) { + case 'severity': + this.selectedSeverities.set([filter.value as SecretSeverity]); + break; + case 'status': + this.selectedStatuses.set([filter.value as SecretFindingStatus]); + break; + case 'category': + this.selectedCategory.set(filter.value as SecretRuleCategory); + break; + } + this.applyFilters(); + } + + onFilterBarRemoved(filter: ActiveFilter): void { + switch (filter.key) { + case 'severity': + this.selectedSeverities.set([]); + break; + case 'status': + this.selectedStatuses.set([]); + break; + case 'category': + this.selectedCategory.set(''); + break; + } + this.applyFilters(); } onSearchChange(event: Event): void { @@ -738,6 +648,7 @@ export class SecretFindingsListComponent implements OnInit { if (this.selectedStatuses().length) filter.status = this.selectedStatuses(); if (this.selectedCategory()) filter.category = [this.selectedCategory() as SecretRuleCategory]; this.findingsService.setFilter(filter); + this.rebuildActiveFilterBar(); } clearFilters(): void { @@ -746,6 +657,24 @@ export class SecretFindingsListComponent implements OnInit { this.selectedStatuses.set([]); this.selectedCategory.set(''); this.findingsService.setFilter({}); + this.activeFilterBarList.set([]); + } + + private rebuildActiveFilterBar(): void { + const filters: ActiveFilter[] = []; + if (this.selectedSeverities().length > 0) { + const sev = this.selectedSeverities()[0]; + filters.push({ key: 'severity', value: sev, label: 'Severity: ' + SEVERITY_DISPLAY[sev].label }); + } + if (this.selectedStatuses().length > 0) { + const st = this.selectedStatuses()[0]; + filters.push({ key: 'status', value: st, label: 'Status: ' + STATUS_DISPLAY[st].label }); + } + if (this.selectedCategory()) { + const cat = this.categoryOptions.find(c => c.category === this.selectedCategory()); + filters.push({ key: 'category', value: this.selectedCategory(), label: 'Category: ' + (cat?.label || this.selectedCategory()) }); + } + this.activeFilterBarList.set(filters); } sortBy(field: SecretFindingsSortField): void { diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts index f4248a227..24ff2865f 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts @@ -2,6 +2,7 @@ * @file certificate-inventory.component.spec.ts * @sprint SPRINT_20251229_018c_FE * @description Unit tests for CertificateInventoryComponent + * Filter-bar adoption tests: SPRINT_20260308_015_FE (FE-OFB-004) */ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; @@ -264,4 +265,118 @@ describe('CertificateInventoryComponent', () => { jasmine.objectContaining({ pageNumber: 2 }) ); })); + + // --- Filter-bar adoption tests (SPRINT_20260308_015_FE FE-OFB-004) --- + + it('should render the shared filter bar element', fakeAsync(() => { + fixture.detectChanges(); + tick(); + const filterBar = fixture.nativeElement.querySelector('app-filter-bar'); + expect(filterBar).toBeTruthy(); + })); + + it('should expose two filter option groups (status, type)', () => { + expect(component.certFilterOptions.length).toBe(2); + expect(component.certFilterOptions.map(f => f.key)).toEqual(['status', 'type']); + }); + + it('should have correct status filter options', () => { + const statusFilter = component.certFilterOptions.find(f => f.key === 'status'); + expect(statusFilter).toBeTruthy(); + expect(statusFilter!.options.length).toBe(4); + expect(statusFilter!.options.map(o => o.value)).toEqual(['valid', 'expiring_soon', 'expired', 'revoked']); + }); + + it('should have correct type filter options', () => { + const typeFilter = component.certFilterOptions.find(f => f.key === 'type'); + expect(typeFilter).toBeTruthy(); + expect(typeFilter!.options.length).toBe(5); + expect(typeFilter!.options.map(o => o.value)).toEqual(['root_ca', 'intermediate_ca', 'leaf', 'mtls_client', 'mtls_server']); + }); + + it('should update selectedStatus via onCertFilterChanged', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' }); + tick(); + expect(component.selectedStatus()).toBe('expired'); + })); + + it('should update selectedType via onCertFilterChanged', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertFilterChanged({ key: 'type', value: 'root_ca', label: 'Root CA' }); + tick(); + expect(component.selectedType()).toBe('root_ca'); + })); + + it('should reset selectedStatus on onCertFilterRemoved', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' }); + tick(); + component.onCertFilterRemoved({ key: 'status', value: 'valid', label: 'Status: Valid' }); + tick(); + expect(component.selectedStatus()).toBe('all'); + })); + + it('should reset selectedType on onCertFilterRemoved', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertFilterChanged({ key: 'type', value: 'leaf', label: 'Leaf' }); + tick(); + component.onCertFilterRemoved({ key: 'type', value: 'leaf', label: 'Type: Leaf' }); + tick(); + expect(component.selectedType()).toBe('all'); + })); + + it('should update searchQuery via onCertSearch', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertSearch('*.example.com'); + tick(); + expect(component.searchQuery()).toBe('*.example.com'); + })); + + it('should rebuild active cert filters when a status filter is applied', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertFilterChanged({ key: 'status', value: 'expiring_soon', label: 'Expiring Soon' }); + tick(); + const active = component.activeCertFilters(); + expect(active.length).toBe(1); + expect(active[0].key).toBe('status'); + expect(active[0].value).toBe('expiring_soon'); + })); + + it('should rebuild active cert filters with both status and type', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' }); + tick(); + component.onCertFilterChanged({ key: 'type', value: 'mtls_client', label: 'mTLS Client' }); + tick(); + const active = component.activeCertFilters(); + expect(active.length).toBe(2); + expect(active.find(f => f.key === 'status')).toBeTruthy(); + expect(active.find(f => f.key === 'type')).toBeTruthy(); + })); + + it('should clear active cert filters on clearFilters', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' }); + tick(); + component.clearFilters(); + tick(); + expect(component.activeCertFilters().length).toBe(0); + })); + + it('should have hasFilters return true when a filter-bar filter is applied', fakeAsync(() => { + fixture.detectChanges(); + tick(); + component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' }); + tick(); + expect(component.hasFilters()).toBe(true); + })); }); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts index 8bb6c4381..f89a27aeb 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts @@ -2,6 +2,7 @@ * @file certificate-inventory.component.ts * @sprint SPRINT_20251229_018c_FE * @description mTLS certificates inventory with chain verification + * Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003) */ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; @@ -17,10 +18,11 @@ import { CertificateExpiryAlert, ListCertificatesParams, } from '../../core/api/trust.models'; +import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; @Component({ selector: 'app-certificate-inventory', - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, FilterBarComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -56,56 +58,16 @@ import {
} - -
-
- - -
- -
- - -
- -
- - -
- - @if (hasFilters()) { - - } -
+ +
@@ -557,53 +519,7 @@ import { font-size: 0.7rem; } - .cert-inventory__filters { - display: flex; - flex-wrap: wrap; - gap: 1rem; - align-items: flex-end; - margin-bottom: 1.5rem; - } - - .filter-group { - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .filter-group label { - font-size: 0.8rem; - color: var(--color-text-muted); - } - - .filter-group input, - .filter-group select { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - color: var(--color-text-primary); - padding: 0.5rem 0.75rem; - min-width: 160px; - } - - .filter-group input:focus, - .filter-group select:focus { - outline: none; - border-color: var(--color-status-info); - } - - .btn-link { - background: none; - border: none; - color: var(--color-status-info); - cursor: pointer; - padding: 0.5rem; - font-size: 0.9rem; - } - - .btn-link:hover { - text-decoration: underline; - } + /* Filter bar styles handled by shared FilterBarComponent */ .cert-inventory__table-container { overflow-x: auto; @@ -1125,6 +1041,14 @@ export class CertificateInventoryComponent implements OnInit { this.selectedType() !== 'all' ); + // Shared filter bar integration + readonly certFilterOptions: FilterOption[] = [ + { key: 'status', label: 'Status', options: [{ value: 'valid', label: 'Valid' }, { value: 'expiring_soon', label: 'Expiring Soon' }, { value: 'expired', label: 'Expired' }, { value: 'revoked', label: 'Revoked' }] }, + { key: 'type', label: 'Type', options: [{ value: 'root_ca', label: 'Root CA' }, { value: 'intermediate_ca', label: 'Intermediate CA' }, { value: 'leaf', label: 'Leaf' }, { value: 'mtls_client', label: 'mTLS Client' }, { value: 'mtls_server', label: 'mTLS Server' }] }, + ]; + + readonly activeCertFilters = signal([]); + ngOnInit(): void { this.loadCertificates(); this.loadExpiryAlerts(); @@ -1170,14 +1094,52 @@ export class CertificateInventoryComponent implements OnInit { onSearch(): void { this.pageNumber.set(1); + this.rebuildActiveCertFilters(); this.loadCertificates(); } onFilterChange(): void { this.pageNumber.set(1); + this.rebuildActiveCertFilters(); this.loadCertificates(); } + onCertSearch(value: string): void { + this.searchQuery.set(value); + this.onSearch(); + } + + onCertFilterChanged(filter: ActiveFilter): void { + if (filter.key === 'status') { + this.selectedStatus.set(filter.value as any); + } else if (filter.key === 'type') { + this.selectedType.set(filter.value as any); + } + this.onFilterChange(); + } + + onCertFilterRemoved(filter: ActiveFilter): void { + if (filter.key === 'status') { + this.selectedStatus.set('all'); + } else if (filter.key === 'type') { + this.selectedType.set('all'); + } + this.onFilterChange(); + } + + private rebuildActiveCertFilters(): void { + const filters: ActiveFilter[] = []; + if (this.selectedStatus() !== 'all') { + const opt = this.certFilterOptions[0].options.find(o => o.value === this.selectedStatus()); + filters.push({ key: 'status', value: this.selectedStatus() as string, label: 'Status: ' + (opt?.label || this.selectedStatus()) }); + } + if (this.selectedType() !== 'all') { + const opt = this.certFilterOptions[1].options.find(o => o.value === this.selectedType()); + filters.push({ key: 'type', value: this.selectedType() as string, label: 'Type: ' + (opt?.label || this.selectedType()) }); + } + this.activeCertFilters.set(filters); + } + onSort(column: 'name' | 'status' | 'validUntil' | 'createdAt'): void { if (this.sortBy() === column) { this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc'); @@ -1197,6 +1159,7 @@ export class CertificateInventoryComponent implements OnInit { this.searchQuery.set(''); this.selectedStatus.set('all'); this.selectedType.set('all'); + this.activeCertFilters.set([]); this.pageNumber.set(1); this.loadCertificates(); } diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-audit-log.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-audit-log.component.ts index 5f9e9917e..790eabdee 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-audit-log.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-audit-log.component.ts @@ -2,6 +2,7 @@ * @file trust-audit-log.component.ts * @sprint SPRINT_20251229_018c_FE * @description Trust audit log viewer + * Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003) */ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; @@ -16,76 +17,16 @@ import { ListAuditEventsParams, TrustAuditFilter, } from '../../core/api/trust.models'; +import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; @Component({ selector: 'app-trust-audit-log', - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, FilterBarComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- + +
- - @if (hasFilters()) { - - }
+ +
@if (loading()) { @@ -229,39 +174,12 @@ import { padding: 1.5rem; } - .audit-log__filters { + /* Filter bar styles handled by shared FilterBarComponent */ + + .audit-log__actions { display: flex; - flex-wrap: wrap; - gap: 1rem; - align-items: flex-end; - margin-bottom: 1.5rem; - } - - .filter-group { - display: flex; - flex-direction: column; - gap: 0.25rem; - } - - .filter-group label { - font-size: 0.8rem; - color: var(--color-text-muted); - } - - .filter-group input, - .filter-group select { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - color: var(--color-text-primary); - padding: 0.5rem 0.75rem; - min-width: 140px; - } - - .filter-group input:focus, - .filter-group select:focus { - outline: none; - border-color: var(--color-status-info); + justify-content: flex-end; + margin-bottom: 0.5rem; } .btn-export { @@ -558,6 +476,14 @@ export class TrustAuditLogComponent implements OnInit { this.endDate() !== '' ); + // Shared filter bar integration + readonly trustFilterOptions: FilterOption[] = [ + { key: 'resourceType', label: 'Resource Type', options: [{ value: 'key', label: 'Keys' }, { value: 'issuer', label: 'Issuers' }, { value: 'certificate', label: 'Certificates' }, { value: 'config', label: 'Config' }] }, + { key: 'severity', label: 'Severity', options: [{ value: 'info', label: 'Info' }, { value: 'warning', label: 'Warning' }, { value: 'error', label: 'Error' }, { value: 'critical', label: 'Critical' }] }, + ]; + + readonly activeTrustFilters = signal([]); + ngOnInit(): void { this.loadEvents(); } @@ -600,14 +526,52 @@ export class TrustAuditLogComponent implements OnInit { onSearch(): void { this.pageNumber.set(1); + this.rebuildActiveTrustFilters(); this.loadEvents(); } onFilterChange(): void { this.pageNumber.set(1); + this.rebuildActiveTrustFilters(); this.loadEvents(); } + onTrustSearch(value: string): void { + this.searchQuery.set(value); + this.onSearch(); + } + + onTrustFilterChanged(filter: ActiveFilter): void { + if (filter.key === 'resourceType') { + this.selectedResourceType.set(filter.value as any); + } else if (filter.key === 'severity') { + this.selectedSeverity.set(filter.value as any); + } + this.onFilterChange(); + } + + onTrustFilterRemoved(filter: ActiveFilter): void { + if (filter.key === 'resourceType') { + this.selectedResourceType.set('all'); + } else if (filter.key === 'severity') { + this.selectedSeverity.set('all'); + } + this.onFilterChange(); + } + + private rebuildActiveTrustFilters(): void { + const filters: ActiveFilter[] = []; + if (this.selectedResourceType() !== 'all') { + const opt = this.trustFilterOptions[0].options.find(o => o.value === this.selectedResourceType()); + filters.push({ key: 'resourceType', value: this.selectedResourceType(), label: 'Resource: ' + (opt?.label || this.selectedResourceType()) }); + } + if (this.selectedSeverity() !== 'all') { + const opt = this.trustFilterOptions[1].options.find(o => o.value === this.selectedSeverity()); + filters.push({ key: 'severity', value: this.selectedSeverity() as string, label: 'Severity: ' + (opt?.label || this.selectedSeverity()) }); + } + this.activeTrustFilters.set(filters); + } + onPageChange(page: number): void { this.pageNumber.set(page); this.loadEvents(); @@ -619,6 +583,7 @@ export class TrustAuditLogComponent implements OnInit { this.selectedSeverity.set('all'); this.startDate.set(''); this.endDate.set(''); + this.activeTrustFilters.set([]); this.pageNumber.set(1); this.loadEvents(); } diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.spec.ts new file mode 100644 index 000000000..5ebf5714b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.spec.ts @@ -0,0 +1,161 @@ +/** + * @file filter-bar.component.spec.ts + * @sprint SPRINT_20260308_015_FE (FE-OFB-004) + * @description Unit tests for shared FilterBarComponent + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { FilterBarComponent, FilterOption, ActiveFilter } from './filter-bar.component'; + +describe('FilterBarComponent', () => { + let component: FilterBarComponent; + let fixture: ComponentFixture; + + const sampleFilters: FilterOption[] = [ + { + key: 'severity', + label: 'Severity', + options: [ + { value: 'critical', label: 'Critical' }, + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + ], + }, + { + key: 'status', + label: 'Status', + options: [ + { value: 'open', label: 'Open' }, + { value: 'resolved', label: 'Resolved' }, + ], + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilterBarComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(FilterBarComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should render search input with placeholder', () => { + component.searchPlaceholder = 'Search events...'; + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.filter-bar__search-input') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.placeholder).toBe('Search events...'); + }); + + it('should render filter dropdowns for each FilterOption', () => { + component.filters = sampleFilters; + fixture.detectChanges(); + const selects = fixture.nativeElement.querySelectorAll('.filter-bar__select') as NodeListOf; + expect(selects.length).toBe(2); + expect(selects[0].getAttribute('aria-label')).toBe('Severity'); + expect(selects[1].getAttribute('aria-label')).toBe('Status'); + }); + + it('should emit searchChange on text input', () => { + fixture.detectChanges(); + const spy = spyOn(component.searchChange, 'emit'); + component.onSearchChange('test query'); + expect(spy).toHaveBeenCalledWith('test query'); + }); + + it('should emit filterChange when a dropdown value is selected', () => { + component.filters = sampleFilters; + fixture.detectChanges(); + const spy = spyOn(component.filterChange, 'emit'); + + const mockEvent = { + target: { + value: 'critical', + options: [ + { text: 'Severity' }, + { text: 'Critical' }, + ], + selectedIndex: 1, + }, + } as unknown as Event; + + component.onFilterChange('severity', mockEvent); + expect(spy).toHaveBeenCalledWith({ key: 'severity', value: 'critical', label: 'Critical' }); + }); + + it('should not emit filterChange when empty value is selected', () => { + component.filters = sampleFilters; + fixture.detectChanges(); + const spy = spyOn(component.filterChange, 'emit'); + + const mockEvent = { + target: { + value: '', + options: [{ text: 'Severity' }], + selectedIndex: 0, + }, + } as unknown as Event; + + component.onFilterChange('severity', mockEvent); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should render active filter chips', () => { + component.activeFilters = [ + { key: 'severity', value: 'critical', label: 'Severity: Critical' }, + { key: 'status', value: 'open', label: 'Status: Open' }, + ]; + fixture.detectChanges(); + const chips = fixture.nativeElement.querySelectorAll('.filter-bar__chip'); + expect(chips.length).toBe(2); + expect(chips[0].textContent).toContain('Severity: Critical'); + expect(chips[1].textContent).toContain('Status: Open'); + }); + + it('should emit filterRemove when chip remove button is clicked', () => { + const activeFilter: ActiveFilter = { key: 'severity', value: 'critical', label: 'Severity: Critical' }; + component.activeFilters = [activeFilter]; + fixture.detectChanges(); + const spy = spyOn(component.filterRemove, 'emit'); + + component.removeFilter(activeFilter); + expect(spy).toHaveBeenCalledWith(activeFilter); + }); + + it('should emit filtersCleared and reset search when clearAll is called', () => { + component.searchValue = 'test'; + fixture.detectChanges(); + const spy = spyOn(component.filtersCleared, 'emit'); + + component.clearAll(); + expect(spy).toHaveBeenCalled(); + expect(component.searchValue).toBe(''); + }); + + it('should show clear-all button only when active filters exist', () => { + component.activeFilters = []; + fixture.detectChanges(); + let clearBtn = fixture.nativeElement.querySelector('.filter-bar__clear'); + expect(clearBtn).toBeNull(); + + component.activeFilters = [{ key: 'severity', value: 'high', label: 'Severity: High' }]; + fixture.detectChanges(); + clearBtn = fixture.nativeElement.querySelector('.filter-bar__clear'); + expect(clearBtn).toBeTruthy(); + expect(clearBtn.textContent).toContain('Clear all'); + }); + + it('should render no filter dropdowns when filters array is empty', () => { + component.filters = []; + fixture.detectChanges(); + const selects = fixture.nativeElement.querySelectorAll('.filter-bar__select'); + expect(selects.length).toBe(0); + }); +});