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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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); }
|
||||
`,
|
||||
]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user