feat(ui): unify filter bars on mounted list pages [SPRINT-015]

Migrate audit-log, evidence-pack, release-list, secret-detection,
certificate-inventory, and trust-audit-log to shared FilterBarComponent
with consistent FilterOption/ActiveFilter interfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-08 19:24:56 +02:00
parent 44cd1827c2
commit 646fccd641
11 changed files with 996 additions and 550 deletions

View File

@@ -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 `<app-filter-bar>` 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`

View File

@@ -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.

View File

@@ -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<AuditLogTableComponent>;
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();
});
});

View File

@@ -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: `
<div class="audit-table-page">
@@ -19,66 +21,15 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
<h1>Audit Events</h1>
</header>
<div class="filters-bar">
<div class="filter-row">
<div class="filter-group">
<label>Modules</label>
<select multiple [(ngModel)]="selectedModules" (change)="applyFilters()">
@for (m of allModules; track m) {
<option [value]="m">{{ formatModule(m) }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Actions</label>
<select multiple [(ngModel)]="selectedActions" (change)="applyFilters()">
@for (a of allActions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Severity</label>
<select multiple [(ngModel)]="selectedSeverities" (change)="applyFilters()">
@for (s of allSeverities; track s) {
<option [value]="s">{{ s }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Date Range</label>
<select [(ngModel)]="dateRange" (change)="applyFilters()">
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="custom">Custom</option>
</select>
</div>
@if (dateRange === 'custom') {
<div class="filter-group">
<label>Start</label>
<input type="date" [(ngModel)]="customStartDate" (change)="applyFilters()" />
</div>
<div class="filter-group">
<label>End</label>
<input type="date" [(ngModel)]="customEndDate" (change)="applyFilters()" />
</div>
}
</div>
<div class="filter-row">
<div class="filter-group search-group">
<label>Search</label>
<input type="text" [(ngModel)]="searchQuery" placeholder="Search events..." (keyup.enter)="applyFilters()" />
<button class="btn-sm" (click)="applyFilters()">Search</button>
</div>
<div class="filter-group">
<label>Actor</label>
<input type="text" [(ngModel)]="actorFilter" placeholder="Username or email" (keyup.enter)="applyFilters()" />
</div>
<button class="btn-secondary" (click)="clearFilters()">Clear Filters</button>
</div>
</div>
<app-filter-bar
searchPlaceholder="Search events by keyword or actor..."
[filters]="filterOptions"
[activeFilters]="activeFilterList()"
(searchChange)="onSearchChange($event)"
(filterChange)="onFilterChanged($event)"
(filterRemove)="onFilterRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
@if (loading()) {
<div class="loading">Loading events...</div>
@@ -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<ActiveFilter[]>([]);
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<string, string> = { '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()!);

View File

@@ -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: `
<div class="evidence-pack-list">
<!-- Header with filters -->
@@ -35,20 +37,19 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
<h2 class="list-title">Decision Capsules</h2>
<p class="list-subtitle">Browse signed evidence packs that explain release, policy, and operator decisions.</p>
</div>
<div class="filters">
<input
type="text"
class="filter-input"
placeholder="Filter by CVE ID..."
[(ngModel)]="filterCveId"
(ngModelChange)="onFilterChange()"
/>
@if (runId) {
<span class="run-filter">Run: {{ runId }}</span>
}
</div>
@if (runId) {
<span class="run-filter">Run: {{ runId }}</span>
}
</header>
<app-filter-bar
searchPlaceholder="Filter by CVE ID..."
[filters]="[]"
[activeFilters]="[]"
(searchChange)="onSearchChanged($event)"
(filtersCleared)="onSearchChanged('')"
></app-filter-bar>
<!-- List content -->
<div class="list-content">
@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();

View File

@@ -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: `
<div class="release-list">
<header class="list-header">
@@ -52,73 +54,15 @@ import {
</button>
</section>
<section class="filters" aria-label="Release filters">
<input
type="text"
placeholder="Search by digest, release version name, or slug"
[(ngModel)]="searchTerm"
(ngModelChange)="applyFilters(false)"
/>
<select [(ngModel)]="typeFilter" (ngModelChange)="applyFilters(false)">
<option value="all">Type: All</option>
<option value="standard">Standard</option>
<option value="hotfix">Hotfix</option>
</select>
<select [(ngModel)]="stageFilter" (ngModelChange)="applyFilters(false)">
<option value="all">Stage: All</option>
<option value="draft">Draft</option>
<option value="ready">Ready</option>
<option value="deploying">Deploying</option>
<option value="deployed">Deployed</option>
<option value="failed">Failed</option>
<option value="rolled_back">Rolled Back</option>
</select>
<select [(ngModel)]="gateFilter" (ngModelChange)="applyFilters(false)">
<option value="all">Gate: All</option>
<option value="pass">Pass</option>
<option value="warn">Warn</option>
<option value="pending">Pending</option>
<option value="block">Block</option>
<option value="unknown">Unknown</option>
</select>
<select [(ngModel)]="riskFilter" (ngModelChange)="applyFilters(false)">
<option value="all">Risk: All</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="none">None</option>
<option value="unknown">Unknown</option>
</select>
<select [(ngModel)]="blockedFilter" (ngModelChange)="applyFilters(false)">
<option value="all">Blocked: All</option>
<option value="true">Blocked</option>
<option value="false">Unblocked</option>
</select>
<select [(ngModel)]="needsApprovalFilter" (ngModelChange)="applyFilters(false)">
<option value="all">Needs Approval: All</option>
<option value="true">Needs Approval</option>
<option value="false">No Approval Needed</option>
</select>
<select [(ngModel)]="hotfixLaneFilter" (ngModelChange)="applyFilters(false)">
<option value="all">Hotfix Lane: All</option>
<option value="true">Hotfix Lane</option>
<option value="false">Standard Lane</option>
</select>
<select [(ngModel)]="replayMismatchFilter" (ngModelChange)="applyFilters(false)">
<option value="all">Replay Mismatch: All</option>
<option value="true">Mismatch</option>
<option value="false">No Mismatch</option>
</select>
</section>
<app-filter-bar
searchPlaceholder="Search by digest, release version name, or slug"
[filters]="releaseFilterOptions"
[activeFilters]="activeReleaseFilters()"
(searchChange)="onReleaseSearch($event)"
(filterChange)="onReleaseFilterChanged($event)"
(filterRemove)="onReleaseFilterRemoved($event)"
(filtersCleared)="clearAllReleaseFilters()"
></app-filter-bar>
@if (store.error()) {
<div class="status-banner 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<Set<string>>(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<ActiveFilter[]>([]);
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<string, string> = {
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<string, string> = {
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([], {

View File

@@ -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: `
<div class="secret-findings">
@@ -44,16 +47,6 @@ import { SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.m
</div>
<div class="findings-header__actions">
<button
type="button"
class="btn btn--secondary"
(click)="toggleFilters()">
<span class="btn__icon">F</span>
Filters
@if (activeFilterCount() > 0) {
<span class="btn__badge">{{ activeFilterCount() }}</span>
}
</button>
<button
type="button"
class="btn btn--secondary"
@@ -64,76 +57,15 @@ import { SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.m
</div>
</header>
@if (showFilters()) {
<div class="filters-panel">
<div class="filters-row">
<div class="filter-group">
<label class="filter-label">Search</label>
<input
type="text"
class="filter-input"
placeholder="Search by file, rule, type..."
[value]="searchText()"
(input)="onSearchChange($event)" />
</div>
<div class="filter-group">
<label class="filter-label">Severity</label>
<div class="filter-checkboxes">
@for (sev of severityOptions; track sev) {
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="isSeveritySelected(sev)"
(change)="toggleSeverity(sev)" />
<span class="filter-checkbox__label" [attr.data-severity]="sev">
{{ SEVERITY_DISPLAY[sev].label }}
</span>
</label>
}
</div>
</div>
<div class="filter-group">
<label class="filter-label">Status</label>
<div class="filter-checkboxes">
@for (status of statusOptions; track status) {
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="isStatusSelected(status)"
(change)="toggleStatus(status)" />
<span class="filter-checkbox__label">
{{ STATUS_DISPLAY[status].label }}
</span>
</label>
}
</div>
</div>
<div class="filter-group">
<label class="filter-label">Category</label>
<select
class="filter-select"
[value]="selectedCategory()"
(change)="onCategoryChange($event)">
<option value="">All Categories</option>
@for (cat of categoryOptions; track cat.category) {
<option [value]="cat.category">{{ cat.label }}</option>
}
</select>
</div>
</div>
@if (activeFilterCount() > 0) {
<div class="filters-footer">
<button type="button" class="btn btn--text" (click)="clearFilters()">
Clear all filters
</button>
</div>
}
</div>
}
<app-filter-bar
searchPlaceholder="Search by file, rule, type..."
[filters]="filterBarOptions"
[activeFilters]="activeFilterBarList()"
(searchChange)="onFilterBarSearch($event)"
(filterChange)="onFilterBarChanged($event)"
(filterRemove)="onFilterBarRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
<div class="findings-table-container">
@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<SecretSeverity[]>([]);
readonly selectedStatuses = signal<SecretFindingStatus[]>([]);
@@ -670,6 +528,27 @@ export class SecretFindingsListComponent implements OnInit {
readonly currentSort = signal<SecretFindingsSortField>('severity');
readonly sortDirection = signal<SecretFindingsSortDirection>('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<ActiveFilter[]>([]);
// 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 {

View File

@@ -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);
}));
});

View File

@@ -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: `
<div class="cert-inventory">
@@ -56,56 +58,16 @@ import {
</div>
}
<!-- Filters -->
<div class="cert-inventory__filters">
<div class="filter-group">
<label for="search">Search</label>
<input
id="search"
type="text"
placeholder="Search certificates..."
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event); onSearch()"
/>
</div>
<div class="filter-group">
<label for="status">Status</label>
<select
id="status"
[ngModel]="selectedStatus()"
(ngModelChange)="selectedStatus.set($event); onFilterChange()"
>
<option value="all">All Statuses</option>
<option value="valid">Valid</option>
<option value="expiring_soon">Expiring Soon</option>
<option value="expired">Expired</option>
<option value="revoked">Revoked</option>
</select>
</div>
<div class="filter-group">
<label for="type">Type</label>
<select
id="type"
[ngModel]="selectedType()"
(ngModelChange)="selectedType.set($event); onFilterChange()"
>
<option value="all">All Types</option>
<option value="root_ca">Root CA</option>
<option value="intermediate_ca">Intermediate CA</option>
<option value="leaf">Leaf</option>
<option value="mtls_client">mTLS Client</option>
<option value="mtls_server">mTLS Server</option>
</select>
</div>
@if (hasFilters()) {
<button type="button" class="btn-link" (click)="clearFilters()">
Clear filters
</button>
}
</div>
<!-- Shared Filter Bar -->
<app-filter-bar
searchPlaceholder="Search certificates..."
[filters]="certFilterOptions"
[activeFilters]="activeCertFilters()"
(searchChange)="onCertSearch($event)"
(filterChange)="onCertFilterChanged($event)"
(filterRemove)="onCertFilterRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
<!-- Certificates Table -->
<div class="cert-inventory__table-container">
@@ -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<ActiveFilter[]>([]);
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();
}

View File

@@ -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: `
<div class="audit-log">
<!-- Filters -->
<div class="audit-log__filters">
<div class="filter-group">
<label for="search">Search</label>
<input
id="search"
type="text"
placeholder="Search events..."
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event); onSearch()"
/>
</div>
<div class="filter-group">
<label for="resourceType">Resource Type</label>
<select
id="resourceType"
[ngModel]="selectedResourceType()"
(ngModelChange)="selectedResourceType.set($event); onFilterChange()"
>
<option value="all">All Resources</option>
<option value="key">Keys</option>
<option value="issuer">Issuers</option>
<option value="certificate">Certificates</option>
<option value="config">Config</option>
</select>
</div>
<div class="filter-group">
<label for="severity">Severity</label>
<select
id="severity"
[ngModel]="selectedSeverity()"
(ngModelChange)="selectedSeverity.set($event); onFilterChange()"
>
<option value="all">All Severities</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="filter-group">
<label for="startDate">Start Date</label>
<input
id="startDate"
type="date"
[ngModel]="startDate()"
(ngModelChange)="startDate.set($event); onFilterChange()"
/>
</div>
<div class="filter-group">
<label for="endDate">End Date</label>
<input
id="endDate"
type="date"
[ngModel]="endDate()"
(ngModelChange)="endDate.set($event); onFilterChange()"
/>
</div>
<!-- Shared Filter Bar -->
<div class="audit-log__actions">
<button
type="button"
class="btn-export"
@@ -94,14 +35,18 @@ import {
>
{{ exporting() ? 'Exporting...' : 'Export' }}
</button>
@if (hasFilters()) {
<button type="button" class="btn-link" (click)="clearFilters()">
Clear filters
</button>
}
</div>
<app-filter-bar
searchPlaceholder="Search events..."
[filters]="trustFilterOptions"
[activeFilters]="activeTrustFilters()"
(searchChange)="onTrustSearch($event)"
(filterChange)="onTrustFilterChanged($event)"
(filterRemove)="onTrustFilterRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
<!-- Events List -->
<div class="audit-log__content">
@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<ActiveFilter[]>([]);
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();
}

View File

@@ -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<FilterBarComponent>;
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<HTMLSelectElement>;
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);
});
});