Skip to content

Commit cf13301

Browse files
guguclaude
andcommitted
fix(frontend): address review findings on the public access panel
- Preload columns for every already-public table, not only the ones carrying a readableColumns whitelist. An unrestricted table still renders the column picker, so it previously showed an empty select with no options until the user unchecked and rechecked the table. - Clear tablesLoading when fetchTables fails. TablesService.fetchTables does not swallow errors, so a failure hit an absent error handler and pinned the panel body on the content loader indefinitely. - Disable the table checkboxes and column selects while a save is in flight. Editing during that window was the only case where the post-reload re-seed could discard an unsaved edit; a one-shot seeding guard was avoided because it would latch onto the pre-load empty state and would also suppress legitimate re-sync when public access changes elsewhere. - Type the mocked publicPermissions signals in users.component.spec.ts instead of casting them to any. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a7eacc0 commit cf13301

4 files changed

Lines changed: 71 additions & 17 deletions

File tree

frontend/src/app/components/users/public-access-panel/public-access-panel.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
<li class="public-access-table" [class.public-access-table_selected]="selected">
3131
<mat-checkbox
3232
[checked]="selected"
33+
[disabled]="submitting()"
3334
(change)="toggleTable(table.tableName, $event.checked)">
3435
{{ table.displayName }}
3536
</mat-checkbox>
@@ -42,6 +43,7 @@
4243
<mat-label>Readable columns</mat-label>
4344
<mat-select multiple
4445
[value]="selectedColumns(table.tableName)"
46+
[disabled]="submitting()"
4547
(valueChange)="setColumns(table.tableName, $event)"
4648
placeholder="All columns">
4749
@for (column of availableColumns(table.tableName); track column) {

frontend/src/app/components/users/public-access-panel/public-access-panel.component.spec.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Signal, signal, WritableSignal } from '@angular/core';
22
import { ComponentFixture, TestBed } from '@angular/core/testing';
33
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
4-
import { of } from 'rxjs';
4+
import { of, throwError } from 'rxjs';
55
import { PublicPermissions } from 'src/app/models/user';
66
import { ConnectionsService } from 'src/app/services/connections.service';
77
import { TablesService } from 'src/app/services/tables.service';
@@ -13,6 +13,7 @@ type PublicAccessPanelTestable = PublicAccessPanelComponent & {
1313
selectedCount: Signal<number>;
1414
canDisable: Signal<boolean>;
1515
tablesLoading: WritableSignal<boolean>;
16+
submitting: WritableSignal<boolean>;
1617
tables: Signal<Array<{ tableName: string; displayName: string }>>;
1718
};
1819

@@ -107,9 +108,19 @@ describe('PublicAccessPanelComponent', () => {
107108
expect(component.selectedColumns('orders')).toEqual([]);
108109
expect(testable.statusLabel()).toBe('2 tables');
109110
expect(testable.canDisable()).toBe(true);
110-
// Columns are only fetched for tables that already carry a whitelist.
111+
});
112+
113+
it('should preload columns for every stored table, restricted or not', () => {
114+
publicPermissions.set({
115+
enabled: true,
116+
tables: [{ tableName: 'customers', readableColumns: ['id'] }, { tableName: 'orders' }],
117+
});
118+
fixture.detectChanges();
119+
120+
// An unrestricted table still renders the column picker, so it needs its options too.
111121
expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'customers');
112-
expect(mockTablesService.fetchTableStructure).not.toHaveBeenCalledWith(CONNECTION_ID, 'orders');
122+
expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'orders');
123+
expect(component.availableColumns('orders')).toEqual(['id', 'name', 'secret']);
113124
});
114125

115126
it('should singularize the status label for one table', () => {
@@ -165,6 +176,38 @@ describe('PublicAccessPanelComponent', () => {
165176
]);
166177
});
167178

179+
it('should clear the loading state when the table list fails to load', () => {
180+
mockTablesService.fetchTables = vi.fn().mockReturnValue(throwError(() => new Error('boom')));
181+
182+
const failing = TestBed.createComponent(PublicAccessPanelComponent);
183+
failing.detectChanges();
184+
185+
expect((failing.componentInstance as PublicAccessPanelTestable).tablesLoading()).toBe(false);
186+
});
187+
188+
it('should block editing while a save is in flight', async () => {
189+
let resolveSave: () => void = () => {};
190+
mockUsersService.savePublicPermissions = vi
191+
.fn()
192+
.mockReturnValue(new Promise<void>((resolve) => (resolveSave = resolve)));
193+
194+
component.toggleTable('customers', true);
195+
const saving = component.save();
196+
fixture.detectChanges();
197+
198+
// The re-seed after reload can only discard an edit made in this window, so it is closed off.
199+
expect((component as PublicAccessPanelTestable).submitting()).toBe(true);
200+
const host: HTMLElement = fixture.nativeElement;
201+
fixture.nativeElement.querySelector('mat-expansion-panel-header').click();
202+
fixture.detectChanges();
203+
await fixture.whenStable();
204+
expect(host.querySelector('mat-checkbox input')?.hasAttribute('disabled')).toBe(true);
205+
206+
resolveSave();
207+
await saving;
208+
expect((component as PublicAccessPanelTestable).submitting()).toBe(false);
209+
});
210+
168211
it('should render the table list and column picker once expanded', async () => {
169212
publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers', readableColumns: ['id'] }] });
170213
fixture.detectChanges();

frontend/src/app/components/users/public-access-panel/public-access-panel.component.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,10 @@ export class PublicAccessPanelComponent implements OnInit {
7272
protected canDisable = computed(() => this.publicPermissions().enabled);
7373

7474
constructor() {
75-
// Seed the local editing state whenever the server state (re)loads.
75+
// Seed the local editing state whenever the server state (re)loads, so an external change
76+
// to public access is picked up. Editing is blocked while a save is in flight (see the
77+
// submitting() bindings in the template), which is the only window where this re-seed
78+
// could otherwise discard an unsaved edit.
7679
effect(() => {
7780
const stored = this.publicPermissions().tables;
7881
const seeded: TableSelection = new Map();
@@ -81,13 +84,13 @@ export class PublicAccessPanelComponent implements OnInit {
8184
}
8285
this.selection.set(seeded);
8386

87+
// Every already-public table needs its columns, not just the restricted ones: an
88+
// unrestricted table still renders the column picker, which would otherwise be empty.
8489
// Kept untracked: _loadColumns both reads and writes the column signals, so tracking it
8590
// would make a finished column fetch re-run this effect and clobber edits made meanwhile.
8691
untracked(() => {
8792
for (const table of stored) {
88-
if (table.readableColumns?.length) {
89-
this._loadColumns(table.tableName);
90-
}
93+
this._loadColumns(table.tableName);
9194
}
9295
});
9396
});
@@ -97,17 +100,22 @@ export class PublicAccessPanelComponent implements OnInit {
97100
this.connectionID = this._connections.currentConnectionID;
98101
this._usersService.loadPublicPermissions(this.connectionID);
99102

103+
// fetchTables does not swallow errors, so clear the loading state on failure too —
104+
// otherwise the panel body is stuck on the content loader forever.
100105
this._tablesService
101106
.fetchTables(this.connectionID)
102107
.pipe(takeUntilDestroyed(this._destroyRef))
103-
.subscribe((tables) => {
104-
this.tables.set(
105-
tables.map((t) => ({
106-
tableName: t.table,
107-
displayName: t.display_name || normalizeTableName(t.table),
108-
})),
109-
);
110-
this.tablesLoading.set(false);
108+
.subscribe({
109+
next: (tables) => {
110+
this.tables.set(
111+
tables.map((t) => ({
112+
tableName: t.table,
113+
displayName: t.display_name || normalizeTableName(t.table),
114+
})),
115+
);
116+
this.tablesLoading.set(false);
117+
},
118+
error: () => this.tablesLoading.set(false),
111119
});
112120
}
113121

frontend/src/app/components/users/users.component.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
66
import { provideRouter } from '@angular/router';
77
import { Angulartics2Module } from 'angulartics2';
88
import { of } from 'rxjs';
9+
import { PublicPermissions } from 'src/app/models/user';
910
import { CedarPermissionService } from 'src/app/services/cedar-permission.service';
1011
import { TablesService } from 'src/app/services/tables.service';
1112
import { UsersService } from 'src/app/services/users.service';
@@ -56,8 +57,8 @@ describe('UsersComponent', () => {
5657
fetchGroupUsers: vi.fn().mockResolvedValue([]),
5758
fetchAllGroupUsers: vi.fn().mockResolvedValue(undefined),
5859
fetchConnectionUsers: vi.fn(),
59-
publicPermissions: signal({ enabled: false, tables: [] }).asReadonly() as any,
60-
publicPermissionsLoading: signal(false).asReadonly() as any,
60+
publicPermissions: signal<PublicPermissions>({ enabled: false, tables: [] }).asReadonly(),
61+
publicPermissionsLoading: signal(false).asReadonly(),
6162
loadPublicPermissions: vi.fn(),
6263
savePublicPermissions: vi.fn().mockResolvedValue(undefined),
6364
};

0 commit comments

Comments
 (0)