fix(settings): deleted API key stays on screen until manual reload

The delete-API-key mutation called queryClient.invalidateQueries on the
  ['api-keys'] cache, which in react-query v5 should also refetch active
  queries — but in practice the deleted row remained visible until the
  user reloaded the page. Switched onSuccess to setQueryData so the
  deleted key is filtered out of the cache synchronously the moment the
  API confirms; no refetch round-trip required, no invalidation→refetch
  race possible. Create-path is unchanged (invalidateQueries was working
  there).

  Pins the contract with a new SettingsPage test that mocks the GET/DELETE
  endpoints, clicks delete, confirms, and asserts the row is gone without
  any reload.
This commit is contained in:
maziggy
2026-04-26 11:59:47 +02:00
parent e9200449ae
commit 3e18d42613
5 changed files with 99 additions and 27 deletions
@@ -422,4 +422,72 @@ describe('SettingsPage', () => {
});
});
});
describe('API Keys tab — delete flow', () => {
// Without setQueryData on success the deleted row stayed visible until a
// manual reload — invalidateQueries didn't reliably trigger a UI swap on
// every browser. Pin the synchronous-removal contract here.
it('removes a deleted key from the list without a page reload', async () => {
const initialKeys = [
{
id: 42,
name: 'CI deploy key',
key_prefix: 'bk_abcd1234',
can_queue: true,
can_control_printer: false,
can_read_status: true,
printer_ids: null,
enabled: true,
last_used: null,
created_at: '2026-01-01T00:00:00Z',
expires_at: null,
},
];
let deleteCallCount = 0;
server.use(
http.get('/api/v1/api-keys/', () => HttpResponse.json(initialKeys)),
http.delete('/api/v1/api-keys/:id', ({ params }) => {
deleteCallCount += 1;
expect(params.id).toBe('42');
return HttpResponse.json({ message: 'API key deleted' });
})
);
const user = userEvent.setup();
render(<SettingsPage />);
// Switch to API Keys tab. Both desktop tab + mobile dropdown render
// the label, so just grab the button form.
await waitFor(() => {
expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
});
const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
expect(tabButton).toBeDefined();
await user.click(tabButton!);
// Key is listed
await waitFor(() => {
expect(screen.getByText('CI deploy key')).toBeInTheDocument();
});
// Click the trash button on the row
const cards = screen.getByText('CI deploy key').closest('.flex.items-center.justify-between');
expect(cards).not.toBeNull();
const trashButton = cards!.querySelectorAll('button');
await user.click(trashButton[trashButton.length - 1]);
// Confirm the deletion in the modal
const confirmButton = await screen.findByRole('button', { name: /delete/i });
await user.click(confirmButton);
// The deleted key disappears from the list immediately — no manual
// reload required. setQueryData drops it before any refetch could fire.
await waitFor(() => {
expect(screen.queryByText('CI deploy key')).not.toBeInTheDocument();
});
expect(deleteCallCount).toBe(1);
});
});
});
+5 -3
View File
@@ -6,7 +6,7 @@ import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { formatDateOnly } from '../utils/date';
import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
import { CameraTokensSection } from './CameraTokensPage';
import { Collapsible } from '../components/Collapsible';
@@ -402,8 +402,10 @@ export function SettingsPage() {
const deleteAPIKeyMutation = useMutation({
mutationFn: (id: number) => api.deleteAPIKey(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
onSuccess: (_data, deletedId) => {
queryClient.setQueryData<APIKey[]>(['api-keys'], (old) =>
(old ?? []).filter((key) => key.id !== deletedId)
);
showToast(t('settings.toast.apiKeyDeleted'));
},
onError: (error: Error) => {