Throttle sidebar pending approvals badge refresh

This commit is contained in:
master
2026-03-11 15:51:22 +02:00
parent 9dd8592a2a
commit dc98d5a758
3 changed files with 143 additions and 7 deletions

View File

@@ -1,6 +1,9 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
import { AppSidebarComponent } from './app-sidebar.component';
import { APPROVAL_API, type ApprovalApi } from '../../core/api/approval.client';
import {
AUTH_SERVICE,
MockAuthService,
@@ -8,16 +11,32 @@ import {
StellaOpsScopes,
} from '../../core/auth';
@Component({
standalone: true,
template: '',
})
class DummyRouteComponent {}
describe('AppSidebarComponent', () => {
let authService: MockAuthService;
let approvalApi: jasmine.SpyObj<ApprovalApi>;
beforeEach(async () => {
authService = new MockAuthService();
approvalApi = jasmine.createSpyObj('ApprovalApi', ['listApprovals']) as jasmine.SpyObj<ApprovalApi>;
approvalApi.listApprovals.and.returnValue(of([]));
await TestBed.configureTestingModule({
imports: [AppSidebarComponent],
providers: [
provideRouter([]),
provideRouter([
{ path: '', component: DummyRouteComponent },
{ path: 'ops/integrations/scm', component: DummyRouteComponent },
{ path: 'ops/integrations/registries', component: DummyRouteComponent },
{ path: 'releases/approvals', component: DummyRouteComponent },
]),
{ provide: AUTH_SERVICE, useValue: authService },
{ provide: APPROVAL_API, useValue: approvalApi },
],
}).compileComponents();
});
@@ -168,6 +187,38 @@ describe('AppSidebarComponent', () => {
expect(hrefs).not.toContain('/setup/notifications');
});
it('does not refetch pending approvals badge on unrelated navigation within the refresh window', async () => {
setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.RELEASE_READ]);
const router = TestBed.inject(Router);
const fixture = createComponent();
expect(approvalApi.listApprovals.calls.count()).toBe(1);
await router.navigateByUrl('/ops/integrations/scm');
fixture.detectChanges();
await fixture.whenStable();
await router.navigateByUrl('/ops/integrations/registries');
fixture.detectChanges();
await fixture.whenStable();
expect(approvalApi.listApprovals.calls.count()).toBe(1);
});
it('forces a pending approvals refresh when navigating into approvals surfaces', async () => {
setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.RELEASE_READ]);
const router = TestBed.inject(Router);
const fixture = createComponent();
expect(approvalApi.listApprovals.calls.count()).toBe(1);
await router.navigateByUrl('/releases/approvals');
fixture.detectChanges();
await fixture.whenStable();
expect(approvalApi.listApprovals.calls.count()).toBe(2);
});
function setScopes(scopes: readonly StellaOpsScope[]): void {
const baseUser = authService.user();
if (!baseUser) {

View File

@@ -585,6 +585,8 @@ interface NavSectionGroup {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppSidebarComponent implements AfterViewInit {
private static readonly PendingApprovalsBadgeRefreshWindowMs = 30_000;
private readonly router = inject(Router);
private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly destroyRef = inject(DestroyRef);
@@ -618,6 +620,8 @@ export class AppSidebarComponent implements AfterViewInit {
private flyoutLeaveTimer: ReturnType<typeof setTimeout> | null = null;
private readonly pendingApprovalsCount = signal(0);
private readonly pendingApprovalsBadgeLoadedAt = signal<number | null>(null);
private readonly pendingApprovalsBadgeLoading = signal(false);
/**
* Navigation sections - pre-alpha canonical IA.
@@ -837,13 +841,13 @@ export class AppSidebarComponent implements AfterViewInit {
});
constructor() {
this.loadPendingApprovalsBadge();
this.loadPendingApprovalsBadge(true);
this.destroyRef.onDestroy(() => this.clearFlyoutTimers());
this.router.events
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.loadPendingApprovalsBadge();
this.loadPendingApprovalsBadge(this.shouldForcePendingApprovalsRefresh(event.urlAfterRedirects));
this.doctorTrendService.refresh();
this.closeFlyout();
}
@@ -955,19 +959,49 @@ export class AppSidebarComponent implements AfterViewInit {
}
}
private loadPendingApprovalsBadge(): void {
private loadPendingApprovalsBadge(force = false): void {
if (!this.approvalApi) {
return;
}
const now = Date.now();
const loadedAt = this.pendingApprovalsBadgeLoadedAt();
if (!force) {
if (this.pendingApprovalsBadgeLoading()) {
return;
}
if (
loadedAt !== null
&& now - loadedAt < AppSidebarComponent.PendingApprovalsBadgeRefreshWindowMs
) {
return;
}
}
this.pendingApprovalsBadgeLoading.set(true);
this.approvalApi.listApprovals({ statuses: ['pending'] }).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: (approvals) => this.pendingApprovalsCount.set(approvals.length),
error: () => this.pendingApprovalsCount.set(0),
next: (approvals) => {
this.pendingApprovalsCount.set(approvals.length);
this.pendingApprovalsBadgeLoadedAt.set(now);
this.pendingApprovalsBadgeLoading.set(false);
},
error: () => {
if (loadedAt === null) {
this.pendingApprovalsCount.set(0);
}
this.pendingApprovalsBadgeLoading.set(false);
},
});
}
private shouldForcePendingApprovalsRefresh(url: string): boolean {
const path = (url || '').split('?')[0] ?? '';
return path.startsWith('/releases/approvals') || path.startsWith('/releases/promotions');
}
/**
* Mouse-proximity auto-scroll for sidebar nav.
* When mouse is within 60px of top/bottom edges, scroll in that direction.