Polish empty states: policy packs, shadow mode prereqs, jobengine hint

- Policy Packs: show "No policy packs configured" with description and
  link to overview when pack list is empty
- Shadow Mode: add prerequisite text below disabled Enable/View Results
  buttons — "Shadow mode requires at least one active policy pack" with
  link to Packs tab. Applied to both indicator and dashboard components.
- JobEngine: show guidance "No jobs have been submitted yet..." when all
  counts are 0, auto-hides when jobs appear

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 21:48:56 +02:00
parent ad92f1c855
commit 092779f0f4
6 changed files with 139 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, forkJoin, of } from 'rxjs';
@@ -58,6 +58,12 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
</article>
</section>
@if (allCountsZero()) {
<p class="jobengine-dashboard__empty-hint">
No jobs have been submitted yet. Jobs are created automatically when releases are promoted, scans are triggered, or scheduled tasks run.
</p>
}
<section class="jobengine-dashboard__grid">
<a class="surface" [routerLink]="OPERATIONS_PATHS.jobEngineJobs">
<h2>Jobs</h2>
@@ -237,6 +243,15 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
font-size: 0.85rem;
}
.jobengine-dashboard__empty-hint {
margin: 0;
padding: 0.75rem 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.5;
border-left: 3px solid var(--color-border-primary);
}
.jobengine-dashboard__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -339,6 +354,16 @@ export class JobEngineDashboardComponent implements OnInit {
protected readonly jobSummary = signal<JobEngineJobSummary | null>(null);
protected readonly quotaSummary = signal<JobEngineQuotaSummary | null>(null);
protected readonly deadLetterStats = signal<JobEngineDeadLetterStatsResponse | null>(null);
protected readonly allCountsZero = computed(() => {
const jobs = this.jobSummary();
const quotas = this.quotaSummary();
const dl = this.deadLetterStats();
return !this.loading()
&& (jobs?.totalJobs ?? 0) === 0
&& (quotas?.totalQuotas ?? 0) === 0
&& (dl?.totalEntries ?? 0) === 0;
});
ngOnInit(): void {
this.refresh();
}

View File

@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';
@@ -96,7 +97,7 @@ describe('ShadowModeDashboardComponent', () => {
})
.overrideComponent(ShadowModeDashboardComponent, {
set: {
imports: [MockShadowModeIndicatorComponent, ReactiveFormsModule],
imports: [MockShadowModeIndicatorComponent, ReactiveFormsModule, RouterTestingModule],
providers: [{ provide: POLICY_SIMULATION_API, useValue: mockApi }],
},
})

View File

@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject, computed, OnInit } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { ShadowModeIndicatorComponent } from './shadow-mode-indicator.component';
import { ShadowModeStateService } from './shadow-mode-state.service';
@@ -12,7 +13,7 @@ import { ShadowModeStateService } from './shadow-mode-state.service';
*/
@Component({
selector: 'app-shadow-mode-dashboard',
imports: [CommonModule, ReactiveFormsModule, ShadowModeIndicatorComponent],
imports: [CommonModule, ReactiveFormsModule, RouterModule, ShadowModeIndicatorComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="shadow-dashboard" [attr.aria-busy]="loading()">
@@ -192,6 +193,10 @@ import { ShadowModeStateService } from './shadow-mode-state.service';
<button class="btn" (click)="onEnableShadowMode()" [disabled]="loading()">
Enable Shadow Mode
</button>
<p class="shadow-dashboard__prereq">
Shadow mode requires at least one active policy pack.
<a routerLink="/ops/policy/packs">Create a pack in the Packs tab first.</a>
</p>
</div>
}
</section>
@@ -530,6 +535,23 @@ import { ShadowModeStateService } from './shadow-mode-state.service';
.shadow-dashboard__empty p {
margin: 0 0 1.5rem;
}
.shadow-dashboard__prereq {
margin-top: 1rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
line-height: 1.5;
}
.shadow-dashboard__prereq a {
color: var(--color-status-info-border);
text-decoration: underline;
text-underline-offset: 2px;
}
.shadow-dashboard__prereq a:hover {
color: var(--color-status-info);
}
`,
]
})

View File

@@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ShadowModeIndicatorComponent } from './shadow-mode-indicator.component';
import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
@@ -31,7 +32,7 @@ describe('ShadowModeIndicatorComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ShadowModeIndicatorComponent],
imports: [ShadowModeIndicatorComponent, RouterTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(ShadowModeIndicatorComponent);
@@ -264,6 +265,47 @@ describe('ShadowModeIndicatorComponent', () => {
});
});
describe('Prerequisite Help Text', () => {
it('should show prerequisite text when disabled and actions visible', () => {
component.config = mockDisabledConfig;
component.showActions = true;
fixture.detectChanges();
const prereq = fixture.nativeElement.querySelector('.shadow-indicator__prereq');
expect(prereq).toBeTruthy();
expect(prereq.textContent).toContain('Shadow mode requires at least one active policy pack');
});
it('should include link to packs page', () => {
component.config = mockDisabledConfig;
component.showActions = true;
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.shadow-indicator__prereq a');
expect(link).toBeTruthy();
expect(link.textContent).toContain('Create a pack in the Packs tab first.');
expect(link.getAttribute('href')).toBe('/ops/policy/packs');
});
it('should hide prerequisite text when enabled', () => {
component.config = mockEnabledConfig;
component.showActions = true;
fixture.detectChanges();
const prereq = fixture.nativeElement.querySelector('.shadow-indicator__prereq');
expect(prereq).toBeFalsy();
});
it('should hide prerequisite text when actions hidden', () => {
component.config = mockDisabledConfig;
component.showActions = false;
fixture.detectChanges();
const prereq = fixture.nativeElement.querySelector('.shadow-indicator__prereq');
expect(prereq).toBeFalsy();
});
});
describe('Loading State', () => {
beforeEach(() => {
component.config = mockDisabledConfig;

View File

@@ -1,5 +1,6 @@
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
@@ -10,7 +11,7 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
*/
@Component({
selector: 'app-shadow-mode-indicator',
imports: [],
imports: [RouterModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
@@ -85,6 +86,13 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
</button>
</div>
}
@if (showActions && !config?.enabled) {
<p class="shadow-indicator__prereq">
Shadow mode requires at least one active policy pack.
<a routerLink="/ops/policy/packs">Create a pack in the Packs tab first.</a>
</p>
}
</div>
`,
styles: [
@@ -225,6 +233,24 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models';
opacity: 0.5;
cursor: not-allowed;
}
.shadow-indicator__prereq {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
line-height: 1.4;
max-width: 220px;
}
.shadow-indicator__prereq a {
color: var(--color-status-info-border);
text-decoration: underline;
text-underline-offset: 2px;
}
.shadow-indicator__prereq a:hover {
color: var(--color-status-info);
}
`,
]
})

View File

@@ -94,6 +94,19 @@ import { PolicyPackStore } from '../services/policy-pack.store';
</div>
</dl>
</article>
} @empty {
@if (!loading) {
<div class="workspace__empty" data-testid="policy-packs-empty-state">
<h3>No policy packs configured</h3>
<p>
Policy packs define the rules that govern release decisions.
Create a pack to start defining your organization's release policy.
</p>
<a routerLink="/ops/policy/overview" class="workspace__empty-link">
Learn about policy packs
</a>
</div>
}
}
</div>
<div class="workspace__footer">
@@ -126,6 +139,11 @@ import { PolicyPackStore } from '../services/policy-pack.store';
.workspace__banner { background: var(--color-status-warning-bg); border: 1px solid var(--color-status-warning-border); color: var(--color-status-warning-text); padding: 0.75rem 1rem; border-radius: var(--radius-xl); margin: 0.5rem 0 1rem; }
.workspace__footer { margin-top: 0.8rem; }
.workspace__footer button { background: var(--color-brand-primary); border: 1px solid var(--color-brand-primary); color: var(--color-text-inverse); border-radius: var(--radius-lg); padding: 0.45rem 0.8rem; cursor: pointer; }
.workspace__empty { grid-column: 1 / -1; text-align: center; padding: 3rem 1.5rem; border: 1px dashed var(--color-border-primary); border-radius: var(--radius-xl); background: var(--color-surface-elevated); }
.workspace__empty h3 { margin: 0 0 0.5rem; color: var(--color-text-heading); }
.workspace__empty p { margin: 0 0 1rem; color: var(--color-text-muted); max-width: 480px; margin-inline: auto; }
.workspace__empty-link { display: inline-block; color: var(--color-brand-primary); border: 1px solid var(--color-brand-primary); border-radius: var(--radius-lg); padding: 0.45rem 0.8rem; text-decoration: none; }
.workspace__empty-link:hover { background: var(--color-brand-primary); color: var(--color-text-inverse); }
`,
]
})