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);