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 52e8f9c48 fix(settings): isolate data source modal connection tests
(#6926)
52e8f9c48 is described below
commit 52e8f9c48722139499f1e3d7dd4ddf0dbdf9e665
Author: andy_chan <[email protected]>
AuthorDate: Mon Oct 26 16:33:15 2026 +0800
fix(settings): isolate data source modal connection tests (#6926)
* fix(settings): isolate data source modal connection tests
---
web/src/pages/settings/DataSourceTab.tsx | 51 ++++++---
.../settings/__tests__/DataSourceTab.test.tsx | 114 ++++++++++++++++++++-
2 files changed, 149 insertions(+), 16 deletions(-)
diff --git a/web/src/pages/settings/DataSourceTab.tsx
b/web/src/pages/settings/DataSourceTab.tsx
index e6de0b90d..9d164be74 100644
--- a/web/src/pages/settings/DataSourceTab.tsx
+++ b/web/src/pages/settings/DataSourceTab.tsx
@@ -132,6 +132,7 @@ export const DataSourceTab = () => {
const [dsForm] = Form.useForm();
const authValue = Form.useWatch('auth', dsForm);
const [testingKeys, setTestingKeys] = useState<Set<string>>(() => new Set());
+ const modalTestSessionRef = useRef(0);
const [submitting, setSubmitting] = useState(false);
const [exporting, setExporting] = useState(false);
const requestSeqRef = useRef(0);
@@ -193,6 +194,7 @@ export const DataSourceTab = () => {
useEffect(
() => () => {
requestSeqRef.current += 1;
+ modalTestSessionRef.current += 1;
},
[],
);
@@ -214,7 +216,10 @@ export const DataSourceTab = () => {
const handleTestConnection = async (
data: Pick<DataSource, 'type' | 'url' | 'auth'> & Partial<DataSource>,
key: string,
+ modalSession = modalTestSessionRef.current,
) => {
+ const isCurrent = () => key !== 'modal' || modalSession ===
modalTestSessionRef.current;
+ if (!isCurrent()) return;
if (key !== 'modal' && listActionsDisabled) return;
if (key !== 'modal' && authNeedsSecret(data.auth)) {
message.warning(t('settings.dataSourceAuthTestHint'));
@@ -223,20 +228,41 @@ export const DataSourceTab = () => {
setTestingKeys((previous) => new Set(previous).add(key));
try {
const result = await testDataSource(data);
+ if (!isCurrent()) return;
if (result.success) message.success(result.message);
else message.error(result.message);
} catch {
- message.error(t('settings.connectionTestFailed'));
+ if (isCurrent()) message.error(t('settings.connectionTestFailed'));
} finally {
- setTestingKeys((previous) => {
- const next = new Set(previous);
- next.delete(key);
- return next;
- });
+ if (isCurrent()) {
+ setTestingKeys((previous) => {
+ const next = new Set(previous);
+ next.delete(key);
+ return next;
+ });
+ }
}
};
+ const resetModalTest = () => {
+ // A closed dialog's request must not notify or clear a newer dialog's
spinner.
+ modalTestSessionRef.current += 1;
+ setTestingKeys((previous) => {
+ const next = new Set(previous);
+ next.delete('modal');
+ return next;
+ });
+ };
+
+ const closeModal = () => {
+ resetModalTest();
+ setModalOpen(false);
+ setEditingDataSource(null);
+ dsForm.resetFields();
+ };
+
const openCreateModal = () => {
+ resetModalTest();
setEditingDataSource(null);
dsForm.resetFields();
dsForm.setFieldValue('auth', 'None');
@@ -245,6 +271,7 @@ export const DataSourceTab = () => {
const openEditModal = (dataSource: DataSource) => {
if (listActionsDisabled) return;
+ resetModalTest();
setEditingDataSource(dataSource);
dsForm.setFieldsValue(dataSource);
setModalOpen(true);
@@ -271,8 +298,7 @@ export const DataSourceTab = () => {
message.success(
t(editingDataSource ? 'settings.dataSourceUpdated' :
'settings.dataSourceAdded'),
);
- setModalOpen(false);
- dsForm.resetFields();
+ closeModal();
} catch (error) {
if (error && typeof error === 'object' && 'errorFields' in error) {
return; // validation failure; antd already shows field-level errors
@@ -483,11 +509,7 @@ export const DataSourceTab = () => {
<Modal
title={t(editingDataSource ? 'settings.editDataSource' :
'settings.addDataSource')}
open={modalOpen}
- onCancel={() => {
- setModalOpen(false);
- setEditingDataSource(null);
- dsForm.resetFields();
- }}
+ onCancel={closeModal}
onOk={() => void handleSubmit()}
confirmLoading={submitting}
destroyOnHidden
@@ -591,9 +613,10 @@ export const DataSourceTab = () => {
icon={<ApiOutlined />}
loading={testingKeys.has('modal')}
onClick={() => {
+ const modalSession = modalTestSessionRef.current;
void dsForm
.validateFields(testFieldNames(authValue))
- .then((values) => handleTestConnection(values, 'modal'))
+ .then((values) => handleTestConnection(values, 'modal',
modalSession))
.catch(() => undefined);
}}
style={{ marginTop: 8 }}
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
index ab6475883..0295b103a 100644
--- a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -15,10 +15,10 @@
* limitations under the License.
*/
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
import { act, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { App } from 'antd';
+import { App, ConfigProvider, message } from 'antd';
import type { DataSource, DataSourcePage } from '../../../api/settings';
import {
createDataSource,
@@ -105,6 +105,10 @@ beforeAll(() => {
});
describe('DataSourceTab', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
beforeEach(() => {
vi.clearAllMocks();
localStorage.removeItem(LANGUAGE_STORAGE_KEY);
@@ -421,6 +425,112 @@ describe('DataSourceTab', () => {
});
});
+ it.each(['success', 'failure', 'rejection'] as const)(
+ 'ignores a previous modal connection test after reopening (%s) Test',
+ async (outcome) => {
+ const previousTest = deferred<{ success: boolean; message: string }>();
+ const currentTest = deferred<{ success: boolean; message: string }>();
+ vi.mocked(testDataSource)
+ .mockReturnValueOnce(previousTest.promise)
+ .mockReturnValueOnce(currentTest.promise);
+ const successMessage = vi.spyOn(message, 'success');
+ const errorMessage = vi.spyOn(message, 'error');
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+ <ConfigProvider theme={{ token: { motion: false } }}>
+ <App>
+ <LangProvider>
+ <DataSourceTab />
+ </LangProvider>
+ </App>
+ </ConfigProvider>,
+ );
+
+ const row = await screen.findByRole('row', { name: /Prometheus prod/ });
+ await user.click(within(row).getByRole('button', { name: /edit/i }));
+ const previousDialog = await screen.findByRole('dialog');
+ await user.click(within(previousDialog).getByRole('button', { name:
/test connection/i }));
+ await waitFor(() => expect(testDataSource).toHaveBeenCalledTimes(1));
+ await user.click(within(previousDialog).getByRole('button', { name:
/cancel/i }));
+ await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+
+ await user.click(within(row).getByRole('button', { name: /edit/i }));
+ const currentDialog = await screen.findByRole('dialog');
+ const testButton = within(currentDialog).getByRole('button', { name:
/test connection/i });
+ expect(testButton).not.toHaveClass('ant-btn-loading');
+ fireEvent.change(within(currentDialog).getByLabelText('URL'), {
+ target: { value: 'http://prometheus-new:9090' },
+ });
+ await user.click(testButton);
+ await waitFor(() => expect(testDataSource).toHaveBeenCalledTimes(2));
+ expect(testDataSource).toHaveBeenLastCalledWith({
+ type: 'Prometheus',
+ url: 'http://prometheus-new:9090',
+ auth: 'None',
+ });
+ expect(testButton).toHaveClass('ant-btn-loading');
+
+ await act(async () => {
+ if (outcome === 'rejection') previousTest.reject(new Error('old
request failed'));
+ else previousTest.resolve({ success: outcome === 'success', message:
'Old test result' });
+ });
+ expect(testButton).toHaveClass('ant-btn-loading');
+ expect(successMessage).not.toHaveBeenCalled();
+ expect(errorMessage).not.toHaveBeenCalled();
+
+ await act(async () => currentTest.resolve({ success: true, message:
'Current test result' }));
+ await waitFor(() =>
expect(testButton).not.toHaveClass('ant-btn-loading'));
+ expect(successMessage).toHaveBeenCalledWith('Current test result');
+ },
+ );
+
+ it.each(['cancel', 'save', 'unmount'] as const)(
+ 'ignores a pending modal connection test after %s Test',
+ async (action) => {
+ const pendingTest = deferred<{ success: boolean; message: string }>();
+ vi.mocked(testDataSource).mockReturnValueOnce(pendingTest.promise);
+ vi.mocked(updateDataSource).mockResolvedValueOnce(sources[0]);
+ const successMessage = vi.spyOn(message, 'success');
+ const errorMessage = vi.spyOn(message, 'error');
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ const { unmount } = render(
+ <ConfigProvider theme={{ token: { motion: false } }}>
+ <App>
+ <LangProvider>
+ <DataSourceTab />
+ </LangProvider>
+ </App>
+ </ConfigProvider>,
+ );
+
+ const row = await screen.findByRole('row', { name: /Prometheus prod/ });
+ await user.click(within(row).getByRole('button', { name: /edit/i }));
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getByRole('button', { name: /test
connection/i }));
+ await waitFor(() => expect(testDataSource).toHaveBeenCalledTimes(1));
+
+ if (action === 'unmount') unmount();
+ else {
+ await user.click(
+ within(dialog).getByRole('button', { name: action === 'save' ? 'OK'
: /cancel/i }),
+ );
+ await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ if (action === 'save')
expect(updateDataSource).toHaveBeenCalledTimes(1);
+ }
+ successMessage.mockClear();
+ errorMessage.mockClear();
+
+ await act(async () => {
+ if (action === 'unmount') pendingTest.reject(new Error('request failed
after unmount'));
+ else pendingTest.resolve({ success: true, message: 'Closed modal test
result' });
+ });
+ expect(successMessage).not.toHaveBeenCalled();
+ expect(errorMessage).not.toHaveBeenCalled();
+ },
+ );
+
it('exports all data sources that match the active filters without secrets',
async () => {
vi.mocked(listAllDataSources).mockResolvedValue([
{