This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 782ca495f fix(settings): refresh current query after deletion (#6198)
782ca495f is described below

commit 782ca495fbd8c2d52f3eb162161b292ce4827b37
Author: zmuxuny <[email protected]>
AuthorDate: Mon Oct 12 00:18:58 2026 -0700

    fix(settings): refresh current query after deletion (#6198)
    
    Both settings tabs refreshed after a delete by awaiting the list loader 
captured when the operator confirmed (`CloudCredentialTab.tsx:231-246`, 
`DataSourceTab.tsx:277-289`). Those loaders are `useCallback`s bound to the 
vendor filter, debounced search, page and page size of that moment, so changing 
the search while the delete was in flight made the continuation re-issue the 
old query and overwrite the new result; the `setPage(page - 1)` branch also 
moved the operator off a page they h [...]
    
    Thirteen instances across the two test files. Reverting to the captured 
loader breaks the `failed=false`/`failed=true` search-change cases, which 
assert the list service was last called with the new search and that the stale 
rows are gone from the DOM; restoring `total > 0` breaks the `remaining: 0` row 
of the page-clamp cases. `does not fetch credentials after a pending deletion 
finishes on an unmounted tab` (and its data-source twin) plus `keeps the 
debounced search result when an e [...]
---
 web/src/pages/settings/CloudCredentialTab.tsx      |  14 +--
 web/src/pages/settings/DataSourceTab.tsx           |  13 +-
 .../settings/__tests__/CloudCredentialTab.test.tsx | 130 ++++++++++++++++++++
 .../settings/__tests__/DataSourceTab.test.tsx      | 132 ++++++++++++++++++++-
 4 files changed, 273 insertions(+), 16 deletions(-)

diff --git a/web/src/pages/settings/CloudCredentialTab.tsx 
b/web/src/pages/settings/CloudCredentialTab.tsx
index d8627f764..d16fe7b4c 100644
--- a/web/src/pages/settings/CloudCredentialTab.tsx
+++ b/web/src/pages/settings/CloudCredentialTab.tsx
@@ -80,6 +80,7 @@ export const CloudCredentialTab = () => {
   const [submitting, setSubmitting] = useState(false);
   const [exporting, setExporting] = useState(false);
   const requestSeqRef = useRef(0);
+  const [deleteRefreshVersion, setDeleteRefreshVersion] = useState(0);
   const submitInFlightRef = useRef(false);
   const queryKey = JSON.stringify([vendorFilter, debouncedSearch, page, 
pageSize]);
 
@@ -100,7 +101,7 @@ export const CloudCredentialTab = () => {
       try {
         const result = await listCloudCredentials(vendorFilter, 
debouncedSearch, page, pageSize);
         if (requestId !== requestSeqRef.current) return;
-        if (result.items.length === 0 && result.total > 0 && page > 1) {
+        if (result.items.length === 0 && page > 1) {
           const lastPage = Math.max(1, Math.ceil(result.total / result.size));
           if (page > lastPage) {
             setPage(lastPage);
@@ -127,7 +128,7 @@ export const CloudCredentialTab = () => {
 
   useEffect(() => {
     void loadCredentials();
-  }, [loadCredentials]);
+  }, [loadCredentials, deleteRefreshVersion]);
 
   useEffect(
     () => () => {
@@ -233,12 +234,9 @@ export const CloudCredentialTab = () => {
       return;
     try {
       await deleteCloudCredential(credential.id);
-      const remainingOnPage = credentials.length - 1;
-      if (remainingOnPage === 0 && page > 1) {
-        setPage(page - 1);
-      } else {
-        await loadCredentials();
-      }
+      // Refresh through the effect so a completed deletion uses the current 
filters/page,
+      // not the query captured when the operator confirmed it. The latest 
total clamps pages.
+      setDeleteRefreshVersion((version) => version + 1);
       message.success(t('settings.credentialDeleted'));
     } catch {
       message.error(t('settings.credentialDeleteFailed'));
diff --git a/web/src/pages/settings/DataSourceTab.tsx 
b/web/src/pages/settings/DataSourceTab.tsx
index 54fcff517..49d69c09d 100644
--- a/web/src/pages/settings/DataSourceTab.tsx
+++ b/web/src/pages/settings/DataSourceTab.tsx
@@ -134,6 +134,7 @@ export const DataSourceTab = () => {
   const [submitting, setSubmitting] = useState(false);
   const [exporting, setExporting] = useState(false);
   const requestSeqRef = useRef(0);
+  const [deleteRefreshVersion, setDeleteRefreshVersion] = useState(0);
 
   useEffect(() => {
     const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 
300);
@@ -156,7 +157,7 @@ export const DataSourceTab = () => {
           pageSize,
         });
         if (requestId !== requestSeqRef.current) return;
-        if (result.items.length === 0 && result.total > 0 && page > 1) {
+        if (result.items.length === 0 && page > 1) {
           const lastPage = Math.max(1, Math.ceil(result.total / result.size));
           if (page > lastPage) {
             setPage(lastPage);
@@ -181,7 +182,7 @@ export const DataSourceTab = () => {
 
   useEffect(() => {
     void loadDataSources();
-  }, [loadDataSources]);
+  }, [loadDataSources, deleteRefreshVersion]);
 
   useEffect(
     () => () => {
@@ -277,11 +278,9 @@ export const DataSourceTab = () => {
   const handleDelete = async (dataSource: DataSource) => {
     try {
       await deleteDataSource(dataSource.key);
-      if (dataSources.length === 1 && page > 1) {
-        setPage(page - 1);
-      } else {
-        await loadDataSources();
-      }
+      // Refresh through the effect so a completed deletion uses the current 
filters/page,
+      // not the query captured when the operator confirmed it. The latest 
total clamps pages.
+      setDeleteRefreshVersion((version) => version + 1);
       message.success(t('settings.dataSourceDeleted'));
     } catch {
       message.error(t('settings.dataSourceDeleteFailed'));
diff --git a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx 
b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
index 6c847a431..56ff46441 100644
--- a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
+++ b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
@@ -358,6 +358,136 @@ describe('CloudCredentialTab', () => {
     );
   });
 
+  it.each([false, true])(
+    'refreshes the current credential query when a deletion completes after 
search changes (failed=%s)',
+    async (failed) => {
+      const pendingDelete = deferred<void>();
+      const prod = { ...credentials.items[0], id: 3, name: 'prod-key' };
+      const archive = { ...credentials.items[0], id: 2, name: 'archive-key' };
+      let deleted = false;
+      
vi.mocked(deleteCloudCredential).mockReturnValueOnce(pendingDelete.promise);
+      vi.mocked(listCloudCredentials).mockImplementation(async (_vendor, 
search) => {
+        const items =
+          search === 'prod'
+            ? [prod]
+            : deleted
+              ? [archive, prod]
+              : [credentials.items[0], archive, prod];
+        return { ...credentials, items, total: items.length };
+      });
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      renderTab();
+      const initialRow = await screen.findByRole('row', { name: /aliyun-test/ 
});
+      await user.click(within(initialRow).getByRole('button', { name: /删除/ }));
+      await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
+      await waitFor(() => 
expect(deleteCloudCredential).toHaveBeenCalledWith(1));
+      fireEvent.change(screen.getByPlaceholderText('搜索凭据名称'), { target: { 
value: 'prod' } });
+      await waitFor(() =>
+        expect(listCloudCredentials).toHaveBeenLastCalledWith(undefined, 
'prod', 1, 20),
+      );
+      await waitFor(() => 
expect(screen.queryByText('archive-key')).not.toBeInTheDocument());
+      deleted = !failed;
+      await act(async () => {
+        if (failed) pendingDelete.reject(new Error('delete unavailable'));
+        else pendingDelete.resolve();
+      });
+      await waitFor(() => 
expect(listCloudCredentials).toHaveBeenCalledTimes(failed ? 2 : 3));
+      expect(listCloudCredentials).toHaveBeenLastCalledWith(undefined, 'prod', 
1, 20);
+      expect(screen.getByText('prod-key')).toBeInTheDocument();
+      expect(screen.queryByText('archive-key')).not.toBeInTheDocument();
+    },
+  );
+
+  it.each([
+    { navigated: false, remaining: 40 },
+    { navigated: true, remaining: 40 },
+    { navigated: false, remaining: 0 },
+  ])(
+    'refreshes the current page after deleting its last credential 
(scenario=%j)',
+    async ({ navigated, remaining }) => {
+      const pendingDelete = deferred<void>();
+      const all = Array.from({ length: 41 }, (_, index) => ({
+        ...credentials.items[0],
+        id: index + 1,
+        name: `credential-${index + 1}`,
+      }));
+      let deleted = false;
+      
vi.mocked(deleteCloudCredential).mockReturnValueOnce(pendingDelete.promise);
+      vi.mocked(listCloudCredentials).mockImplementation(
+        async (_vendor, _search, page = 1, size = 20) => {
+          const rows = deleted ? all.slice(0, remaining) : all;
+          return {
+            items: rows.slice((page - 1) * size, page * size),
+            total: rows.length,
+            page,
+            size,
+          };
+        },
+      );
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      renderTab();
+      await screen.findByText('credential-1');
+      fireEvent.click(document.querySelector('.ant-pagination-item-3')!);
+      const lastRow = await screen.findByRole('row', { name: /credential-41/ 
});
+      await user.click(within(lastRow).getByRole('button', { name: /删除/ }));
+      await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
+      await waitFor(() => 
expect(deleteCloudCredential).toHaveBeenCalledWith(41));
+      if (navigated) {
+        fireEvent.click(document.querySelector('.ant-pagination-item-1')!);
+        await screen.findByText('credential-1');
+      }
+      deleted = true;
+      await act(async () => pendingDelete.resolve());
+      const expectedPage = navigated || remaining === 0 ? 1 : 2;
+      await waitFor(() =>
+        expect(listCloudCredentials).toHaveBeenLastCalledWith(undefined, '', 
expectedPage, 20),
+      );
+      if (remaining > 0)
+        expect(
+          await screen.findByText(navigated ? 'credential-1' : 
'credential-21'),
+        ).toBeInTheDocument();
+      else expect(screen.queryByText('credential-41')).not.toBeInTheDocument();
+    },
+  );
+
+  it('does not fetch credentials after a pending deletion finishes on an 
unmounted tab', async () => {
+    const pendingDelete = deferred<void>();
+    
vi.mocked(deleteCloudCredential).mockReturnValueOnce(pendingDelete.promise);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    const { unmount } = renderTab();
+    await screen.findByText('aliyun-test');
+    await user.click(screen.getByRole('button', { name: /删除/ }));
+    await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
+    await waitFor(() => expect(deleteCloudCredential).toHaveBeenCalledWith(1));
+    unmount();
+    await act(async () => pendingDelete.resolve());
+    expect(listCloudCredentials).toHaveBeenCalledTimes(1);
+  });
+
+  it('keeps the debounced search result when an earlier post-delete refresh 
finishes late', async () => {
+    const pendingDelete = deferred<void>();
+    const oldRefresh = deferred<CloudCredentialPage>();
+    const prod = { ...credentials.items[0], id: 2, name: 'prod-key' };
+    
vi.mocked(deleteCloudCredential).mockReturnValueOnce(pendingDelete.promise);
+    vi.mocked(listCloudCredentials)
+      .mockResolvedValueOnce(credentials)
+      .mockReturnValueOnce(oldRefresh.promise)
+      .mockResolvedValue({ ...credentials, items: [prod] });
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderTab();
+    await screen.findByText('aliyun-test');
+    await user.click(screen.getByRole('button', { name: /删除/ }));
+    await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
+    await waitFor(() => expect(deleteCloudCredential).toHaveBeenCalledWith(1));
+    fireEvent.change(screen.getByPlaceholderText('搜索凭据名称'), { target: { value: 
'prod' } });
+    await act(async () => pendingDelete.resolve());
+    await waitFor(() => expect(listCloudCredentials).toHaveBeenCalledTimes(2));
+    await screen.findByText('prod-key');
+    await act(async () => oldRefresh.resolve(credentials));
+    expect(screen.queryByText('aliyun-test')).not.toBeInTheDocument();
+    expect(screen.getByText('prod-key')).toBeInTheDocument();
+  });
+
   it('deletes a credential after confirmation', async () => {
     vi.mocked(deleteCloudCredential).mockResolvedValue(undefined);
     const user = userEvent.setup({ pointerEventsCheck: 0 });
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx 
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
index e72d389e0..5254ae9df 100644
--- a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -16,12 +16,13 @@
  */
 
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor, within } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import type { DataSource, DataSourcePage } from '../../../api/settings';
 import {
   createDataSource,
+  deleteDataSource,
   listAllDataSources,
   listDataSourcesPage,
   testDataSource,
@@ -111,6 +112,135 @@ describe('DataSourceTab', () => {
     vi.mocked(listAllDataSources).mockResolvedValue(sources);
   });
 
+  it.each([false, true])(
+    'refreshes the current data-source query when a deletion completes after 
search changes (failed=%s)',
+    async (failed) => {
+      const pendingDelete = deferred<void>();
+      const remaining = { ...sources[0], key: 'prom-stage', name: 'Prometheus 
stage' };
+      let deleted = false;
+      vi.mocked(deleteDataSource).mockReturnValueOnce(pendingDelete.promise);
+      vi.mocked(listDataSourcesPage).mockImplementation(async (params) => {
+        const items =
+          params?.search === 'stage'
+            ? [remaining]
+            : deleted
+              ? [sources[1], remaining]
+              : [...sources, remaining];
+        return { ...sourcePage, items, total: items.length };
+      });
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      render(
+        <App>
+          <LangProvider>
+            <DataSourceTab />
+          </LangProvider>
+        </App>,
+      );
+      const initialRow = await screen.findByRole('row', { name: /Prometheus 
prod/ });
+      await user.click(within(initialRow).getByRole('button', { name: /删除/ }));
+      await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
+      await waitFor(() => 
expect(deleteDataSource).toHaveBeenCalledWith('prom-prod'));
+      fireEvent.change(screen.getByPlaceholderText('搜索数据源名称'), {
+        target: { value: 'stage' },
+      });
+      await waitFor(() =>
+        expect(listDataSourcesPage).toHaveBeenLastCalledWith(
+          expect.objectContaining({ search: 'stage' }),
+        ),
+      );
+      await waitFor(() => expect(screen.queryByText('Thanos 
DR')).not.toBeInTheDocument());
+      deleted = !failed;
+      await act(async () => {
+        if (failed) pendingDelete.reject(new Error('delete unavailable'));
+        else pendingDelete.resolve();
+      });
+      await waitFor(() => 
expect(listDataSourcesPage).toHaveBeenCalledTimes(failed ? 2 : 3));
+      expect(listDataSourcesPage).toHaveBeenLastCalledWith(
+        expect.objectContaining({ search: 'stage' }),
+      );
+      expect(screen.getByText('Prometheus stage')).toBeInTheDocument();
+      expect(screen.queryByText('Thanos DR')).not.toBeInTheDocument();
+    },
+  );
+
+  it.each([
+    { navigated: false, remaining: 40 },
+    { navigated: true, remaining: 40 },
+    { navigated: false, remaining: 0 },
+  ])(
+    'refreshes the current page after deleting its last data source 
(scenario=%j)',
+    async ({ navigated, remaining }) => {
+      const pendingDelete = deferred<void>();
+      const all = Array.from({ length: 41 }, (_, index) => ({
+        ...sources[0],
+        key: `source-${index + 1}`,
+        name: `Source ${index + 1}`,
+      }));
+      let deleted = false;
+      vi.mocked(deleteDataSource).mockReturnValueOnce(pendingDelete.promise);
+      vi.mocked(listDataSourcesPage).mockImplementation(async (params) => {
+        const page = params?.page ?? 1;
+        const size = params?.pageSize ?? 20;
+        const rows = deleted ? all.slice(0, remaining) : all;
+        return {
+          items: rows.slice((page - 1) * size, page * size),
+          total: rows.length,
+          page,
+          size,
+        };
+      });
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      render(
+        <App>
+          <LangProvider>
+            <DataSourceTab />
+          </LangProvider>
+        </App>,
+      );
+      await screen.findByText('Source 1');
+      fireEvent.click(document.querySelector('.ant-pagination-item-3')!);
+      const lastRow = await screen.findByRole('row', { name: /Source 41/ });
+      await user.click(within(lastRow).getByRole('button', { name: /删除/ }));
+      await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
+      await waitFor(() => 
expect(deleteDataSource).toHaveBeenCalledWith('source-41'));
+      if (navigated) {
+        fireEvent.click(document.querySelector('.ant-pagination-item-1')!);
+        await screen.findByText('Source 1');
+      }
+      deleted = true;
+      await act(async () => pendingDelete.resolve());
+      const expectedPage = navigated || remaining === 0 ? 1 : 2;
+      await waitFor(() =>
+        expect(listDataSourcesPage).toHaveBeenLastCalledWith(
+          expect.objectContaining({ page: expectedPage }),
+        ),
+      );
+      if (remaining > 0)
+        expect(await screen.findByText(navigated ? 'Source 1' : 'Source 
21')).toBeInTheDocument();
+      else expect(screen.queryByText('Source 41')).not.toBeInTheDocument();
+    },
+  );
+
+  it('does not fetch data sources after a pending deletion finishes on an 
unmounted tab', async () => {
+    const pendingDelete = deferred<void>();
+    vi.mocked(deleteDataSource).mockReturnValueOnce(pendingDelete.promise);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    const { unmount } = render(
+      <App>
+        <LangProvider>
+          <DataSourceTab />
+        </LangProvider>
+      </App>,
+    );
+    const row = await screen.findByRole('row', { name: /Prometheus prod/ });
+    await user.click(within(row).getByRole('button', { name: /删除/ }));
+    await user.click(await screen.findByRole('button', { name: /确\s*定/ }));
+    await waitFor(() => 
expect(deleteDataSource).toHaveBeenCalledWith('prom-prod'));
+    unmount();
+    await act(async () => pendingDelete.resolve());
+    expect(listDataSourcesPage).toHaveBeenCalledTimes(1);
+  });
+
   it('keeps data source creation disabled until the initial list is ready', 
async () => {
     const initialList = deferred<DataSourcePage>();
     vi.mocked(listDataSourcesPage).mockReturnValue(initialList.promise);

Reply via email to