소스 검색

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.
maziggy 4 달 전
부모
커밋
3e18d42613
5개의 변경된 파일74개의 추가작업 그리고 4개의 파일을 삭제
  1. 0 0
      CHANGELOG.md
  2. 68 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  3. 5 3
      frontend/src/pages/SettingsPage.tsx
  4. 0 0
      static/assets/index-DRS5LiBG.js
  5. 1 1
      static/index.html

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
CHANGELOG.md


+ 68 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -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
frontend/src/pages/SettingsPage.tsx

@@ -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) => {

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-DRS5LiBG.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-JRaF_JhW.js"></script>
+    <script type="module" crossorigin src="/assets/index-DRS5LiBG.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-telVPl_h.css">
   </head>
   <body>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.