Finalize topbar status chip ownership split
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# Sprint 20260310-024 - FE Topbar Status Chip Ownership Split
|
||||
|
||||
## Topic & Scope
|
||||
- Finish and verify the topbar/context-controls split so global scope selectors stay in `ContextChipsComponent` while status chips live in the authenticated topbar row.
|
||||
- Capture the missed verification work for the already-implemented layout refactor and keep the commit scoped to the Web layout layer.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/implplan/SPRINT_20260310_024_FE_topbar_status_chip_ownership_split.md`.
|
||||
- Expected evidence: focused Angular layout specs and a live authenticated Playwright shell check.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the rebuilt local shell being reachable through `https://stella-ops.local`.
|
||||
- Safe parallelism: avoid mixing unrelated Mission Control or search work into this layout cleanup iteration.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `AGENTS.md`
|
||||
- `docs/qa/feature-checks/FLOW.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-TOPBAR-SPLIT-001 - Finalize topbar ownership for status chips
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: QA, Developer, Product Manager
|
||||
Task description:
|
||||
- Verify the refactor that moved system-status chips out of `ContextChipsComponent` and into the authenticated topbar row, then bring the stale unit tests and component comments in line with the new ownership split.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `ContextChipsComponent` describes and renders only shared scope controls.
|
||||
- [x] `AppTopbarComponent` focused spec covers the authenticated status-chip row.
|
||||
- [x] Live authenticated shell check confirms tenant, context controls, and status chips render together without console errors.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-10 | Sprint created while auditing forgotten uncommitted Web files; confirmed the live shell already renders tenant, context controls, and five status chips after authentication. | Developer |
|
||||
| 2026-03-10 | Updated the stale topbar/context-chips specs to match the ownership split, aligned the `ContextChipsComponent` contract text/ARIA label, and reran the focused Angular layout tests with `7/7` passing. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: keep status chips in the topbar rather than `ContextChipsComponent` so layout can separate persistent shell health state from the interactive scope controls.
|
||||
- Risk: unit tests can drift again when shell ownership changes without matching spec updates.
|
||||
- Mitigation: keep focused topbar/context-chips specs near the components and validate the live authenticated shell with Playwright when ownership changes.
|
||||
|
||||
## Next Checkpoints
|
||||
- Run the focused layout specs and commit the topbar/context-control split as its own FE iteration.
|
||||
@@ -19,6 +19,21 @@ class StubContextChipsComponent {}
|
||||
@Component({ selector: 'app-user-menu', standalone: true, template: '' })
|
||||
class StubUserMenuComponent {}
|
||||
|
||||
@Component({ selector: 'app-live-event-stream-chip', standalone: true, template: 'Events: CONNECTED' })
|
||||
class StubLiveEventStreamChipComponent {}
|
||||
|
||||
@Component({ selector: 'app-policy-baseline-chip', standalone: true, template: 'Policy: Core Policy Pack latest' })
|
||||
class StubPolicyBaselineChipComponent {}
|
||||
|
||||
@Component({ selector: 'app-evidence-mode-chip', standalone: true, template: 'Evidence: ON' })
|
||||
class StubEvidenceModeChipComponent {}
|
||||
|
||||
@Component({ selector: 'app-feed-snapshot-chip', standalone: true, template: 'Feed: Live' })
|
||||
class StubFeedSnapshotChipComponent {}
|
||||
|
||||
@Component({ selector: 'app-offline-status-chip', standalone: true, template: 'Offline: OK' })
|
||||
class StubOfflineStatusChipComponent {}
|
||||
|
||||
class MockAuthSessionStore {
|
||||
readonly isAuthenticated = signal(true);
|
||||
}
|
||||
@@ -79,8 +94,6 @@ describe('AppTopbarComponent', () => {
|
||||
let component: AppTopbarComponent;
|
||||
let sessionService: MockConsoleSessionService;
|
||||
let sessionStore: MockConsoleSessionStore;
|
||||
let i18nService: MockI18nService;
|
||||
let localePreferenceService: MockUserLocalePreferenceService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -101,6 +114,11 @@ describe('AppTopbarComponent', () => {
|
||||
StubGlobalSearchComponent,
|
||||
StubContextChipsComponent,
|
||||
StubUserMenuComponent,
|
||||
StubLiveEventStreamChipComponent,
|
||||
StubPolicyBaselineChipComponent,
|
||||
StubEvidenceModeChipComponent,
|
||||
StubFeedSnapshotChipComponent,
|
||||
StubOfflineStatusChipComponent,
|
||||
RouterLink,
|
||||
],
|
||||
},
|
||||
@@ -111,8 +129,6 @@ describe('AppTopbarComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
sessionService = TestBed.inject(ConsoleSessionService) as unknown as MockConsoleSessionService;
|
||||
sessionStore = TestBed.inject(ConsoleSessionStore) as unknown as MockConsoleSessionStore;
|
||||
i18nService = TestBed.inject(I18nService) as unknown as MockI18nService;
|
||||
localePreferenceService = TestBed.inject(UserLocalePreferenceService) as unknown as MockUserLocalePreferenceService;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -159,16 +175,15 @@ describe('AppTopbarComponent', () => {
|
||||
expect(sessionService.loadConsoleContext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches locale when a new locale is selected', async () => {
|
||||
const select = fixture.nativeElement.querySelector('#topbar-locale-select') as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
it('renders topbar status chips in the authenticated shell row', () => {
|
||||
const statusRow = fixture.nativeElement.querySelector('.topbar__status-chips') as HTMLElement;
|
||||
expect(statusRow).toBeTruthy();
|
||||
|
||||
select.value = 'de-DE';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(i18nService.setLocale).toHaveBeenCalledWith('de-DE');
|
||||
expect(localePreferenceService.setLocaleAsync).toHaveBeenCalledWith('de-DE');
|
||||
const text = statusRow.textContent ?? '';
|
||||
expect(text).toContain('Events: CONNECTED');
|
||||
expect(text).toContain('Policy: Core Policy Pack latest');
|
||||
expect(text).toContain('Evidence: ON');
|
||||
expect(text).toContain('Feed: Live');
|
||||
expect(text).toContain('Offline: OK');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,12 @@ import { ContextChipsComponent } from '../context-chips/context-chips.component'
|
||||
import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.component';
|
||||
import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
|
||||
import { OfflineStatusChipComponent } from '../context-chips/offline-status-chip.component';
|
||||
import { FeedSnapshotChipComponent } from '../context-chips/feed-snapshot-chip.component';
|
||||
import { PolicyBaselineChipComponent } from '../context-chips/policy-baseline-chip.component';
|
||||
import { EvidenceModeChipComponent } from '../context-chips/evidence-mode-chip.component';
|
||||
import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream-chip.component';
|
||||
|
||||
/**
|
||||
* AppTopbarComponent - Top bar with global search, context chips, tenant, and user menu.
|
||||
*
|
||||
@@ -40,7 +46,12 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
GlobalSearchComponent,
|
||||
ContextChipsComponent,
|
||||
RouterLink,
|
||||
UserMenuComponent
|
||||
UserMenuComponent,
|
||||
OfflineStatusChipComponent,
|
||||
FeedSnapshotChipComponent,
|
||||
PolicyBaselineChipComponent,
|
||||
EvidenceModeChipComponent,
|
||||
LiveEventStreamChipComponent,
|
||||
],
|
||||
template: `
|
||||
<header class="topbar" role="banner">
|
||||
@@ -121,79 +132,101 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Tenant (if multi) + Context chips + Locale -->
|
||||
<!-- Row 2: Tenant + Status chips + Context controls -->
|
||||
<div
|
||||
id="topbar-context-row"
|
||||
class="topbar__row topbar__row--secondary"
|
||||
[class.topbar__row--secondary-open]="mobileContextOpen()"
|
||||
>
|
||||
<!-- Tenant selector (only shown when multiple tenants) -->
|
||||
@if (showTenantSelector()) {
|
||||
<!-- Tenant (always shown when authenticated) -->
|
||||
@if (isAuthenticated()) {
|
||||
<div class="topbar__tenant">
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__tenant-btn"
|
||||
[class.topbar__tenant-btn--busy]="tenantSwitchInFlight()"
|
||||
[attr.aria-expanded]="tenantPanelOpen()"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="topbar-tenant-listbox"
|
||||
[attr.aria-label]="'Tenant selector. Current tenant: ' + activeTenantDisplayName()"
|
||||
(click)="toggleTenantPanel()"
|
||||
(keydown)="onTenantTriggerKeydown($event)"
|
||||
>
|
||||
<span class="topbar__tenant-label">{{ activeTenantDisplayName() }}</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
@if (tenants().length > 1) {
|
||||
<!-- Multi-tenant: dropdown -->
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__tenant-btn"
|
||||
[class.topbar__tenant-btn--busy]="tenantSwitchInFlight()"
|
||||
[attr.aria-expanded]="tenantPanelOpen()"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="topbar-tenant-listbox"
|
||||
[attr.aria-label]="'Tenant selector. Current tenant: ' + activeTenantDisplayName()"
|
||||
(click)="toggleTenantPanel()"
|
||||
(keydown)="onTenantTriggerKeydown($event)"
|
||||
>
|
||||
<span class="topbar__tenant-key">Tenant</span>
|
||||
<span class="topbar__tenant-label">{{ activeTenantDisplayName() }}</span>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (tenantPanelOpen()) {
|
||||
<div class="topbar__tenant-panel" role="dialog" aria-label="Tenant selection">
|
||||
@if (tenantLoading()) {
|
||||
<p class="topbar__tenant-state">Loading tenant catalog...</p>
|
||||
} @else if (tenantError(); as errorMessage) {
|
||||
<div class="topbar__tenant-state topbar__tenant-state--error">
|
||||
<p>{{ errorMessage }}</p>
|
||||
<button type="button" class="topbar__tenant-retry" (click)="refreshTenantCatalog()">Retry</button>
|
||||
</div>
|
||||
} @else if (tenants().length === 0) {
|
||||
<p class="topbar__tenant-state">No tenant assignments available.</p>
|
||||
} @else {
|
||||
<ul
|
||||
id="topbar-tenant-listbox"
|
||||
class="topbar__tenant-list"
|
||||
role="listbox"
|
||||
[attr.aria-activedescendant]="'topbar-tenant-option-' + (activeTenant() ?? '')"
|
||||
(keydown)="onTenantListKeydown($event)"
|
||||
>
|
||||
@for (tenant of tenants(); track tenant.id; let tenantIndex = $index) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__tenant-option"
|
||||
[id]="'topbar-tenant-option-' + tenant.id"
|
||||
[class.topbar__tenant-option--active]="tenant.id === activeTenant()"
|
||||
[disabled]="tenantSwitchInFlight()"
|
||||
role="option"
|
||||
[attr.data-tenant-option-index]="tenantIndex"
|
||||
[attr.aria-selected]="tenant.id === activeTenant()"
|
||||
(click)="onTenantSelected(tenant.id)"
|
||||
>
|
||||
<span class="topbar__tenant-option-name">{{ tenant.displayName }}</span>
|
||||
<span class="topbar__tenant-option-id">{{ tenant.id }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
@if (tenantPanelOpen()) {
|
||||
<div class="topbar__tenant-panel" role="dialog" aria-label="Tenant selection">
|
||||
@if (tenantLoading()) {
|
||||
<p class="topbar__tenant-state">Loading tenant catalog...</p>
|
||||
} @else if (tenantError(); as errorMessage) {
|
||||
<div class="topbar__tenant-state topbar__tenant-state--error">
|
||||
<p>{{ errorMessage }}</p>
|
||||
<button type="button" class="topbar__tenant-retry" (click)="refreshTenantCatalog()">Retry</button>
|
||||
</div>
|
||||
} @else if (tenants().length === 0) {
|
||||
<p class="topbar__tenant-state">No tenant assignments available.</p>
|
||||
} @else {
|
||||
<ul
|
||||
id="topbar-tenant-listbox"
|
||||
class="topbar__tenant-list"
|
||||
role="listbox"
|
||||
[attr.aria-activedescendant]="'topbar-tenant-option-' + (activeTenant() ?? '')"
|
||||
(keydown)="onTenantListKeydown($event)"
|
||||
>
|
||||
@for (tenant of tenants(); track tenant.id; let tenantIndex = $index) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__tenant-option"
|
||||
[id]="'topbar-tenant-option-' + tenant.id"
|
||||
[class.topbar__tenant-option--active]="tenant.id === activeTenant()"
|
||||
[disabled]="tenantSwitchInFlight()"
|
||||
role="option"
|
||||
[attr.data-tenant-option-index]="tenantIndex"
|
||||
[attr.aria-selected]="tenant.id === activeTenant()"
|
||||
(click)="onTenantSelected(tenant.id)"
|
||||
>
|
||||
<span class="topbar__tenant-option-name">{{ tenant.displayName }}</span>
|
||||
<span class="topbar__tenant-option-id">{{ tenant.id }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- Single-tenant: static badge -->
|
||||
<span class="topbar__tenant-badge">
|
||||
<span class="topbar__tenant-key">Tenant</span>
|
||||
<span class="topbar__tenant-label">{{ activeTenantDisplayName() }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="topbar__row-sep"></div>
|
||||
}
|
||||
|
||||
<app-context-chips></app-context-chips>
|
||||
|
||||
@if (isAuthenticated()) {
|
||||
<div class="topbar__row-sep"></div>
|
||||
|
||||
<!-- Status indicator chips -->
|
||||
<div class="topbar__status-chips">
|
||||
<app-live-event-stream-chip></app-live-event-stream-chip>
|
||||
<app-policy-baseline-chip></app-policy-baseline-chip>
|
||||
<app-evidence-mode-chip></app-evidence-mode-chip>
|
||||
<app-feed-snapshot-chip></app-feed-snapshot-chip>
|
||||
<app-offline-status-chip></app-offline-status-chip>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
@@ -565,12 +598,52 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.topbar__tenant-key {
|
||||
font-size: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topbar__tenant-label {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar__tenant-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
height: 24px;
|
||||
padding: 0 0.45rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Status chips (right-aligned) ---- */
|
||||
.topbar__status-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__status-chips {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -595,9 +668,6 @@ export class AppTopbarComponent {
|
||||
readonly activeTenantDisplayName = computed(() =>
|
||||
this.consoleStore.currentTenant()?.displayName ?? this.activeTenant() ?? 'Tenant',
|
||||
);
|
||||
readonly showTenantSelector = computed(() =>
|
||||
this.isAuthenticated() && this.tenants().length > 1,
|
||||
);
|
||||
readonly localePreferenceSyncAttempted = signal(false);
|
||||
readonly scopePanelOpen = signal(false);
|
||||
readonly mobileContextOpen = signal(false);
|
||||
|
||||
@@ -1,94 +1,89 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AUTH_SERVICE, MockAuthService } from '../../core/auth';
|
||||
import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { ContextChipsComponent } from './context-chips.component';
|
||||
|
||||
class OfflineModeServiceStub {
|
||||
readonly isOffline = signal(false);
|
||||
readonly bundleFreshness = signal<{
|
||||
status: 'fresh' | 'stale' | 'expired';
|
||||
bundleCreatedAt: string;
|
||||
ageInDays: number;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
readonly offlineBannerMessage = signal<string | null>(null);
|
||||
}
|
||||
|
||||
class PolicyPackStoreStub {
|
||||
getPacks() {
|
||||
return of([
|
||||
function createContextStore(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
initialize: jasmine.createSpy('initialize'),
|
||||
loading: () => false,
|
||||
error: () => null,
|
||||
regions: () => [{ regionId: 'us-east', displayName: 'US East' }],
|
||||
regionSummary: () => 'US East',
|
||||
selectedRegions: () => ['us-east'],
|
||||
setRegions: jasmine.createSpy('setRegions'),
|
||||
environments: () => [
|
||||
{
|
||||
id: 'pack-1',
|
||||
name: 'Core Policy',
|
||||
description: '',
|
||||
version: 'v3.1',
|
||||
status: 'active',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
environmentId: 'stage',
|
||||
displayName: 'Stage',
|
||||
regionId: 'us-east',
|
||||
environmentType: 'staging',
|
||||
},
|
||||
]);
|
||||
}
|
||||
],
|
||||
environmentSummary: () => 'Stage',
|
||||
selectedEnvironments: () => ['stage'],
|
||||
setEnvironments: jasmine.createSpy('setEnvironments'),
|
||||
timeWindow: () => '7d',
|
||||
setTimeWindow: jasmine.createSpy('setTimeWindow'),
|
||||
stage: () => 'prod',
|
||||
setStage: jasmine.createSpy('setStage'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ContextChipsComponent', () => {
|
||||
let offlineModeStub: OfflineModeServiceStub;
|
||||
let contextStore: ReturnType<typeof createContextStore>;
|
||||
|
||||
beforeEach(async () => {
|
||||
offlineModeStub = new OfflineModeServiceStub();
|
||||
contextStore = createContextStore();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContextChipsComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AUTH_SERVICE, useClass: MockAuthService },
|
||||
{ provide: OfflineModeService, useValue: offlineModeStub },
|
||||
{ provide: PolicyPackStore, useClass: PolicyPackStoreStub },
|
||||
{ provide: PlatformContextStore, useValue: contextStore },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('renders all context chips', () => {
|
||||
it('renders shared context controls and summaries', () => {
|
||||
const fixture = TestBed.createComponent(ContextChipsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
expect(text).toContain('Offline:');
|
||||
expect(text).toContain('Feed:');
|
||||
expect(text).toContain('Policy:');
|
||||
expect(text).toContain('Evidence:');
|
||||
expect(text).toContain('Region');
|
||||
expect(text).toContain('US East');
|
||||
expect(text).toContain('Env');
|
||||
expect(text).toContain('Stage');
|
||||
expect(text).toContain('Window');
|
||||
expect(text).toContain('7d');
|
||||
});
|
||||
|
||||
it('shows degraded offline status when offline mode is active', () => {
|
||||
offlineModeStub.isOffline.set(true);
|
||||
offlineModeStub.offlineBannerMessage.set('Offline mode enabled');
|
||||
it('updates region selection through the shared context store', () => {
|
||||
const fixture = TestBed.createComponent(ContextChipsComponent);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.onToggleRegion('eu-west');
|
||||
|
||||
expect(contextStore.setRegions).toHaveBeenCalledWith(['us-east', 'eu-west']);
|
||||
});
|
||||
|
||||
it('renders inline context errors after store refresh', async () => {
|
||||
const errorStore = createContextStore({
|
||||
error: () => 'Context temporarily unavailable',
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContextChipsComponent],
|
||||
providers: [
|
||||
{ provide: PlatformContextStore, useValue: errorStore },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(ContextChipsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
expect(text).toContain('DEGRADED');
|
||||
});
|
||||
|
||||
it('links evidence chip to the canonical evidence trust-signing route', () => {
|
||||
const fixture = TestBed.createComponent(ContextChipsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const links = Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll('a')
|
||||
) as HTMLAnchorElement[];
|
||||
const evidenceLink = links.find((link) =>
|
||||
(link.textContent ?? '').includes('Evidence:')
|
||||
);
|
||||
|
||||
expect(evidenceLink).toBeTruthy();
|
||||
expect(evidenceLink?.getAttribute('href')).toContain('/setup/trust-signing');
|
||||
expect(text).toContain('Context temporarily unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, ElementRef, HostListener } from '@angular/core';
|
||||
|
||||
import { OfflineStatusChipComponent } from './offline-status-chip.component';
|
||||
import { FeedSnapshotChipComponent } from './feed-snapshot-chip.component';
|
||||
import { PolicyBaselineChipComponent } from './policy-baseline-chip.component';
|
||||
import { EvidenceModeChipComponent } from './evidence-mode-chip.component';
|
||||
import { LiveEventStreamChipComponent } from './live-event-stream-chip.component';
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
|
||||
interface DropdownOption {
|
||||
@@ -15,22 +10,16 @@ interface DropdownOption {
|
||||
/**
|
||||
* ContextChipsComponent - Container for global context chips in the topbar.
|
||||
*
|
||||
* Displays compact dropdown selectors for Region/Environment/Window/Stage,
|
||||
* followed by status indicator chips. All four selectors use a consistent
|
||||
* custom dropdown control with filter input.
|
||||
* Displays compact dropdown selectors for Region/Environment/Window/Stage.
|
||||
* Status chips are rendered by the topbar so shell state and scope controls
|
||||
* can be arranged independently on wider screens.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-context-chips',
|
||||
standalone: true,
|
||||
imports: [
|
||||
OfflineStatusChipComponent,
|
||||
FeedSnapshotChipComponent,
|
||||
PolicyBaselineChipComponent,
|
||||
EvidenceModeChipComponent,
|
||||
LiveEventStreamChipComponent
|
||||
],
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="ctx" role="status" aria-label="Global context controls and system status indicators">
|
||||
<div class="ctx" role="status" aria-label="Global context controls">
|
||||
<div class="ctx__controls">
|
||||
<!-- Region dropdown (multi-select) -->
|
||||
<div class="ctx__dropdown">
|
||||
@@ -177,16 +166,6 @@ interface DropdownOption {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ctx__sep"></div>
|
||||
|
||||
<div class="ctx__chips">
|
||||
<app-offline-status-chip></app-offline-status-chip>
|
||||
<app-feed-snapshot-chip></app-feed-snapshot-chip>
|
||||
<app-policy-baseline-chip></app-policy-baseline-chip>
|
||||
<app-evidence-mode-chip></app-evidence-mode-chip>
|
||||
<app-live-event-stream-chip></app-live-event-stream-chip>
|
||||
</div>
|
||||
|
||||
@if (context.error()) {
|
||||
<span class="ctx__error">{{ context.error() }}</span>
|
||||
}
|
||||
@@ -359,24 +338,6 @@ interface DropdownOption {
|
||||
background: var(--color-brand-muted, rgba(245, 166, 35, 0.15));
|
||||
}
|
||||
|
||||
/* ---- Separator ---- */
|
||||
|
||||
.ctx__sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--color-border-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Status chips row ---- */
|
||||
|
||||
.ctx__chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.ctx__error {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-status-error-text);
|
||||
@@ -407,13 +368,6 @@ interface DropdownOption {
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.ctx__sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctx__chips {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
Reference in New Issue
Block a user