Skip to content

Commit be70b04

Browse files
nabramovitznorman-abramovitz
authored andcommitted
Show busy spinners for Service Keys page operations
The Service Keys page surfaced its busy states as text only ('Loading…', 'Creating…', 'Deleting…'). On a slow async-job create that read as an unresponsive page. Add an animate-spin indicator to every busy state — list load, credential load, create, and delete — each inheriting the surrounding text colour via border-current. Adds the component's first unit spec covering all four states. CF service keys have no update/edit operation (create/view/delete only), so there is no edit spinner.
1 parent 1de1cfd commit be70b04

2 files changed

Lines changed: 133 additions & 4 deletions

File tree

src/frontend/packages/cloud-foundry/src/features/services/service-keys/service-keys.component.html

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212
[value]="newKeyName()" (input)="newKeyName.set($any($event.target).value)"
1313
(keydown.enter)="createKey()" [disabled]="creating()" />
1414
<button type="button"
15-
class="rounded bg-primary px-3 py-1.5 text-sm text-white disabled:opacity-50"
15+
class="inline-flex items-center gap-2 rounded bg-primary px-3 py-1.5 text-sm text-white disabled:opacity-50"
1616
[disabled]="creating() || !newKeyName().trim()" (click)="createKey()">
17+
@if (creating()) {
18+
<span class="animate-spin inline-block h-4 w-4 rounded-full border-2 border-current border-t-transparent"
19+
aria-hidden="true"></span>
20+
}
1721
{{ creating() ? 'Creating…' : 'Create' }}
1822
</button>
1923
<button type="button" class="text-sm text-content-muted hover:underline disabled:opacity-50"
@@ -36,7 +40,11 @@
3640

3741
<!-- List (accordion) -->
3842
@if (loading()) {
39-
<p class="text-content-muted">Loading service keys…</p>
43+
<p class="flex items-center gap-2 text-content-muted">
44+
<span class="animate-spin inline-block h-4 w-4 rounded-full border-2 border-current border-t-transparent"
45+
aria-hidden="true"></span>
46+
Loading service keys…
47+
</p>
4048
} @else if (keys().length === 0) {
4149
<p class="text-content-muted">This service instance has no service keys.</p>
4250
} @else {
@@ -64,9 +72,13 @@
6472
{{ copiedAllGuid() === key.guid ? 'Copied' : 'Copy all (JSON)' }}
6573
</button>
6674
<button type="button"
67-
class="text-danger-strong hover:underline disabled:opacity-50 shrink-0"
75+
class="inline-flex items-center gap-1.5 text-danger-strong hover:underline disabled:opacity-50 shrink-0"
6876
[disabled]="rowStatus(key.guid) === 'busy'"
6977
(click)="deleteKey(key.guid)">
78+
@if (rowStatus(key.guid) === 'busy') {
79+
<span class="animate-spin inline-block h-3.5 w-3.5 rounded-full border-2 border-current border-t-transparent"
80+
aria-hidden="true"></span>
81+
}
7082
{{ rowStatus(key.guid) === 'busy' ? 'Deleting…' : 'Delete' }}
7183
</button>
7284
</div>
@@ -75,7 +87,11 @@
7587
@if (isOpen(key.guid)) {
7688
<div class="px-4 py-3 border-t border-content-border">
7789
@if (credsLoading(key.guid)) {
78-
<p class="text-content-muted">Loading credentials…</p>
90+
<p class="flex items-center gap-2 text-content-muted">
91+
<span class="animate-spin inline-block h-4 w-4 rounded-full border-2 border-current border-t-transparent"
92+
aria-hidden="true"></span>
93+
Loading credentials…
94+
</p>
7995
} @else if (credsError(key.guid); as cerr) {
8096
<p class="text-danger-strong">Failed to load credentials: {{ cerr }}</p>
8197
} @else if (credentialFields(key.guid).length === 0) {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { provideZonelessChangeDetection, signal, WritableSignal } from '@angular/core';
2+
import { provideHttpClient } from '@angular/common/http';
3+
import { provideHttpClientTesting } from '@angular/common/http/testing';
4+
import { provideRouter } from '@angular/router';
5+
import { provideNoopAnimations } from '@angular/platform-browser/animations';
6+
import { ComponentFixture, TestBed } from '@angular/core/testing';
7+
import { describe, it, expect, beforeEach } from 'vitest';
8+
9+
import { ServiceKeysComponent } from './service-keys.component';
10+
import { ServiceCatalogDataService, ServiceKeyView } from '../../../services/endpoint-data/service-catalog-data.service';
11+
12+
// SignalSource triple with a static value — null instance ⇒ the component
13+
// builds without firing the offering/bindable fetch.
14+
function source<T>(value: T) {
15+
return {
16+
value: signal(value).asReadonly(),
17+
isLoading: signal(false).asReadonly(),
18+
error: signal(null).asReadonly(),
19+
};
20+
}
21+
22+
const spinner = (el: HTMLElement) => el.querySelector('.animate-spin');
23+
24+
describe('ServiceKeysComponent — busy-state spinners', () => {
25+
let component: ServiceKeysComponent;
26+
let fixture: ComponentFixture<ServiceKeysComponent>;
27+
let keysVal: WritableSignal<ServiceKeyView[]>;
28+
let keysLoading: WritableSignal<boolean>;
29+
30+
beforeEach(async () => {
31+
keysVal = signal<ServiceKeyView[]>([]);
32+
keysLoading = signal(false);
33+
34+
await TestBed.configureTestingModule({
35+
imports: [ServiceKeysComponent],
36+
providers: [
37+
provideZonelessChangeDetection(),
38+
provideHttpClient(),
39+
provideHttpClientTesting(),
40+
provideRouter([]),
41+
provideNoopAnimations(),
42+
{
43+
provide: ServiceCatalogDataService,
44+
useValue: {
45+
serviceInstance: () => source(null),
46+
// Backed by the writable signals above so tests can drive the
47+
// list value + loading flag through the component's source.
48+
serviceKeysForInstance: () => ({
49+
value: keysVal.asReadonly(),
50+
isLoading: keysLoading.asReadonly(),
51+
error: signal(null).asReadonly(),
52+
}),
53+
},
54+
},
55+
],
56+
}).compileComponents();
57+
58+
fixture = TestBed.createComponent(ServiceKeysComponent);
59+
component = fixture.componentInstance;
60+
fixture.detectChanges();
61+
});
62+
63+
it('no spinner when idle', () => {
64+
expect(spinner(fixture.nativeElement)).toBeNull();
65+
});
66+
67+
it('shows a spinner while the key list is loading (GET)', () => {
68+
keysLoading.set(true);
69+
fixture.detectChanges();
70+
expect(spinner(fixture.nativeElement)).not.toBeNull();
71+
72+
keysLoading.set(false);
73+
fixture.detectChanges();
74+
expect(spinner(fixture.nativeElement)).toBeNull();
75+
});
76+
77+
it('shows a spinner in the create form while creating', () => {
78+
component.isAdding.set(true);
79+
fixture.detectChanges();
80+
expect(spinner(fixture.nativeElement)).toBeNull();
81+
82+
component.creating.set(true);
83+
fixture.detectChanges();
84+
expect(spinner(fixture.nativeElement)).not.toBeNull();
85+
86+
component.creating.set(false);
87+
fixture.detectChanges();
88+
expect(spinner(fixture.nativeElement)).toBeNull();
89+
});
90+
91+
it('shows a spinner while a key\'s credentials are loading (GET)', () => {
92+
keysVal.set([{ guid: 'k1', name: 'key-one', createdAt: '' }]);
93+
fixture.detectChanges();
94+
expect(spinner(fixture.nativeElement)).toBeNull();
95+
96+
// Expanding triggers the lazy credential GET; the request stays pending
97+
// under HttpTestingController, so credsLoading('k1') stays true.
98+
component.toggleOpen('k1');
99+
fixture.detectChanges();
100+
expect(spinner(fixture.nativeElement)).not.toBeNull();
101+
});
102+
103+
it('shows a spinner on the delete action while deleting', () => {
104+
keysVal.set([{ guid: 'k1', name: 'key-one', createdAt: '' }]);
105+
fixture.detectChanges();
106+
expect(spinner(fixture.nativeElement)).toBeNull();
107+
108+
// Fire-and-forget: the DELETE stays pending, rowStatus('k1') === 'busy'.
109+
void component.deleteKey('k1');
110+
fixture.detectChanges();
111+
expect(spinner(fixture.nativeElement)).not.toBeNull();
112+
});
113+
});

0 commit comments

Comments
 (0)