mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
🐛 fix(sidebar): 为表库删除增加五秒确认
- 统一侧边栏与表概览的 DROP 表库确认流程 - 倒计时期间禁用危险按钮并阻止提前执行,关闭时清理计时器 - 补齐六语言文案及倒计时边界与入口接线测试
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import { showCountdownDangerConfirm } from './common/countdownDangerConfirm';
|
||||
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Input, Spin, Empty, Dropdown, message, Tooltip, Button } from 'antd';
|
||||
@@ -646,10 +647,9 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const handleDeleteTable = useCallback((tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
Modal.confirm({
|
||||
showCountdownDangerConfirm({
|
||||
title: t('table_overview.modal.delete_table.title'),
|
||||
content: t('table_overview.modal.delete_table.content', { table: tableName }),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName);
|
||||
if (res.success) {
|
||||
|
||||
134
frontend/src/components/common/countdownDangerConfirm.test.tsx
Normal file
134
frontend/src/components/common/countdownDangerConfirm.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { ModalFuncProps } from 'antd';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./ResizableDraggableModal', () => ({
|
||||
default: {
|
||||
confirm: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getCurrentLanguage, setCurrentLanguage } from '../../i18n';
|
||||
import Modal from './ResizableDraggableModal';
|
||||
import {
|
||||
DANGER_CONFIRM_COUNTDOWN_SECONDS,
|
||||
showCountdownDangerConfirm,
|
||||
} from './countdownDangerConfirm';
|
||||
|
||||
describe('showCountdownDangerConfirm', () => {
|
||||
let previousLanguage: string;
|
||||
let update: ReturnType<typeof vi.fn>;
|
||||
let destroy: ReturnType<typeof vi.fn>;
|
||||
|
||||
const getLatestUpdate = (): ModalFuncProps => (
|
||||
update.mock.calls[update.mock.calls.length - 1]?.[0] as ModalFuncProps
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
previousLanguage = getCurrentLanguage();
|
||||
setCurrentLanguage('en-US');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-23T00:00:00Z'));
|
||||
update = vi.fn();
|
||||
destroy = vi.fn();
|
||||
vi.mocked(Modal.confirm).mockReset();
|
||||
vi.mocked(Modal.confirm).mockReturnValue({ update, destroy });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
setCurrentLanguage(previousLanguage);
|
||||
});
|
||||
|
||||
it('keeps the danger action locked for the full five seconds', async () => {
|
||||
const onOk = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
showCountdownDangerConfirm({
|
||||
title: 'Delete table',
|
||||
content: 'Delete users?',
|
||||
onOk,
|
||||
});
|
||||
|
||||
expect(DANGER_CONFIRM_COUNTDOWN_SECONDS).toBe(5);
|
||||
expect(Modal.confirm).toHaveBeenCalledTimes(1);
|
||||
const initialConfig = vi.mocked(Modal.confirm).mock.calls[0][0] as ModalFuncProps;
|
||||
expect(initialConfig.autoFocusButton).toBe('cancel');
|
||||
expect(initialConfig.okText).toBe('Delete (5s)');
|
||||
expect(initialConfig.okButtonProps).toMatchObject({ danger: true, disabled: true });
|
||||
|
||||
expect(initialConfig.onOk?.()).toBe(false);
|
||||
expect(onOk).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(4_999);
|
||||
const lockedUpdate = getLatestUpdate();
|
||||
expect(lockedUpdate.okText).toBe('Delete (1s)');
|
||||
expect(lockedUpdate.okButtonProps?.disabled).toBe(true);
|
||||
expect(onOk).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
const unlockedUpdate = getLatestUpdate();
|
||||
expect(typeof unlockedUpdate).toBe('object');
|
||||
expect(unlockedUpdate.okText).toBe('Delete');
|
||||
expect(unlockedUpdate.okButtonProps).toMatchObject({ danger: true, disabled: false });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
await initialConfig.onOk?.();
|
||||
expect(onOk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves an explicitly disabled confirm button after the countdown', () => {
|
||||
showCountdownDangerConfirm({
|
||||
title: 'Delete table',
|
||||
content: 'Delete users?',
|
||||
countdownSeconds: 1,
|
||||
okButtonProps: { disabled: true, className: 'custom-danger-button' },
|
||||
onOk: vi.fn(),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
const unlockedUpdate = getLatestUpdate();
|
||||
expect(unlockedUpdate.okButtonProps).toMatchObject({
|
||||
danger: true,
|
||||
disabled: true,
|
||||
className: 'custom-danger-button',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops updating after cancellation or after the modal closes', () => {
|
||||
const onCancel = vi.fn();
|
||||
const afterClose = vi.fn();
|
||||
showCountdownDangerConfirm({
|
||||
title: 'Delete table',
|
||||
content: 'Delete users?',
|
||||
onOk: vi.fn(),
|
||||
onCancel,
|
||||
afterClose,
|
||||
});
|
||||
|
||||
const initialConfig = vi.mocked(Modal.confirm).mock.calls[0][0] as ModalFuncProps;
|
||||
initialConfig.onCancel?.('cancel');
|
||||
expect(onCancel).toHaveBeenCalledWith('cancel');
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
initialConfig.afterClose?.();
|
||||
expect(afterClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up the countdown when destroyed through the returned reference', () => {
|
||||
const modalRef = showCountdownDangerConfirm({
|
||||
title: 'Delete database',
|
||||
content: 'Delete app?',
|
||||
onOk: vi.fn(),
|
||||
});
|
||||
|
||||
modalRef.destroy();
|
||||
expect(destroy).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
148
frontend/src/components/common/countdownDangerConfirm.tsx
Normal file
148
frontend/src/components/common/countdownDangerConfirm.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type React from 'react';
|
||||
import type { ModalFuncProps } from 'antd';
|
||||
|
||||
import { t } from '../../i18n';
|
||||
import Modal from './ResizableDraggableModal';
|
||||
|
||||
export const DANGER_CONFIRM_COUNTDOWN_SECONDS = 5;
|
||||
|
||||
export type CountdownDangerConfirmOptions = Omit<
|
||||
ModalFuncProps,
|
||||
'content' | 'okText' | 'onOk' | 'onCancel' | 'afterClose'
|
||||
> & {
|
||||
content: React.ReactNode;
|
||||
confirmText?: string;
|
||||
countdownSeconds?: number;
|
||||
onOk: NonNullable<ModalFuncProps['onOk']>;
|
||||
onCancel?: ModalFuncProps['onCancel'];
|
||||
afterClose?: ModalFuncProps['afterClose'];
|
||||
};
|
||||
|
||||
const countdownStatusStyle: React.CSSProperties = {
|
||||
marginTop: 12,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
opacity: 0.78,
|
||||
};
|
||||
|
||||
export const showCountdownDangerConfirm = ({
|
||||
content,
|
||||
confirmText = t('common.delete'),
|
||||
countdownSeconds = DANGER_CONFIRM_COUNTDOWN_SECONDS,
|
||||
okButtonProps,
|
||||
cancelText = t('common.cancel'),
|
||||
autoFocusButton = 'cancel',
|
||||
modalRender,
|
||||
onOk,
|
||||
onCancel,
|
||||
afterClose,
|
||||
...modalProps
|
||||
}: CountdownDangerConfirmOptions): ReturnType<typeof Modal.confirm> => {
|
||||
const normalizedSeconds = Number.isFinite(countdownSeconds)
|
||||
? Math.max(0, Math.ceil(countdownSeconds))
|
||||
: DANGER_CONFIRM_COUNTDOWN_SECONDS;
|
||||
const originallyDisabled = okButtonProps?.disabled === true;
|
||||
let remainingSeconds = normalizedSeconds;
|
||||
let deadline = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let modalRef: ReturnType<typeof Modal.confirm> | null = null;
|
||||
let closed = false;
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer === null) return;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
closed = true;
|
||||
stopTimer();
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div>
|
||||
<div>{content}</div>
|
||||
<div role="status" aria-live="polite" aria-atomic="true" style={countdownStatusStyle}>
|
||||
{remainingSeconds > 0
|
||||
? t('common.destructive_confirm.countdown', { seconds: remainingSeconds })
|
||||
: t('common.destructive_confirm.ready')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const buildOkButtonProps = () => ({
|
||||
...okButtonProps,
|
||||
danger: true,
|
||||
disabled: originallyDisabled || remainingSeconds > 0,
|
||||
});
|
||||
|
||||
const buildOkText = () => (
|
||||
remainingSeconds > 0
|
||||
? t('common.destructive_confirm.action_countdown', {
|
||||
action: confirmText,
|
||||
seconds: remainingSeconds,
|
||||
})
|
||||
: confirmText
|
||||
);
|
||||
|
||||
const handleOk: NonNullable<ModalFuncProps['onOk']> = (...args) => {
|
||||
if (remainingSeconds > 0) return false;
|
||||
finish();
|
||||
return onOk(...args);
|
||||
};
|
||||
|
||||
const handleCancel: NonNullable<ModalFuncProps['onCancel']> = (...args) => {
|
||||
finish();
|
||||
return onCancel?.(...args);
|
||||
};
|
||||
|
||||
const handleAfterClose = () => {
|
||||
finish();
|
||||
afterClose?.();
|
||||
};
|
||||
|
||||
try {
|
||||
modalRef = Modal.confirm({
|
||||
...modalProps,
|
||||
content: renderContent(),
|
||||
okText: buildOkText(),
|
||||
cancelText,
|
||||
autoFocusButton,
|
||||
modalRender,
|
||||
okButtonProps: buildOkButtonProps(),
|
||||
onOk: handleOk,
|
||||
onCancel: handleCancel,
|
||||
afterClose: handleAfterClose,
|
||||
});
|
||||
} catch (error) {
|
||||
finish();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!closed && remainingSeconds > 0) {
|
||||
deadline = performance.now() + normalizedSeconds * 1000;
|
||||
timer = setInterval(() => {
|
||||
if (closed || !modalRef) return;
|
||||
const nextRemainingSeconds = Math.max(0, Math.ceil((deadline - performance.now()) / 1000));
|
||||
if (nextRemainingSeconds === remainingSeconds) return;
|
||||
|
||||
remainingSeconds = nextRemainingSeconds;
|
||||
modalRef.update({
|
||||
content: renderContent(),
|
||||
okText: buildOkText(),
|
||||
okButtonProps: buildOkButtonProps(),
|
||||
modalRender,
|
||||
});
|
||||
if (remainingSeconds === 0) stopTimer();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const rawDestroy = modalRef.destroy;
|
||||
return {
|
||||
...modalRef,
|
||||
destroy: () => {
|
||||
finish();
|
||||
rawDestroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const sidebarObjectActionsSource = readFileSync(
|
||||
new URL('../sidebar/useSidebarObjectActions.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const tableOverviewSource = readFileSync(
|
||||
new URL('../TableOverview.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
|
||||
|
||||
const sliceBetween = (source: string, start: string, end: string): string => {
|
||||
const startIndex = source.indexOf(start);
|
||||
const endIndex = source.indexOf(end, startIndex + start.length);
|
||||
expect(startIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(endIndex).toBeGreaterThan(startIndex);
|
||||
return source.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
describe('countdown danger confirmation wiring', () => {
|
||||
it('protects every table and database right-click DROP entry point', () => {
|
||||
const sidebarDeleteDatabase = sliceBetween(
|
||||
sidebarObjectActionsSource,
|
||||
'const handleDeleteDatabase =',
|
||||
'const handleRenameTable =',
|
||||
);
|
||||
const sidebarDeleteTable = sliceBetween(
|
||||
sidebarObjectActionsSource,
|
||||
'const handleDeleteTable =',
|
||||
'const handleTableDataDangerAction =',
|
||||
);
|
||||
const overviewDeleteTable = sliceBetween(
|
||||
tableOverviewSource,
|
||||
'const handleDeleteTable =',
|
||||
'const handleTableDataDangerAction =',
|
||||
);
|
||||
|
||||
[sidebarDeleteDatabase, sidebarDeleteTable, overviewDeleteTable].forEach((source) => {
|
||||
expect(source).toContain('showCountdownDangerConfirm({');
|
||||
expect(source).not.toContain('Modal.confirm({');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps countdown text and placeholders available in every locale', () => {
|
||||
locales.forEach((locale) => {
|
||||
const catalog = JSON.parse(readFileSync(
|
||||
new URL(`../../../../shared/i18n/${locale}.json`, import.meta.url),
|
||||
'utf8',
|
||||
)) as Record<string, string>;
|
||||
|
||||
expect(catalog['common.destructive_confirm.action_countdown'], `${locale}:action`).toContain('{{action}}');
|
||||
expect(catalog['common.destructive_confirm.action_countdown'], `${locale}:seconds`).toContain('{{seconds}}');
|
||||
expect(catalog['common.destructive_confirm.countdown'], `${locale}:countdown`).toContain('{{seconds}}');
|
||||
expect(catalog['common.destructive_confirm.ready'], `${locale}:ready`).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { message } from 'antd';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
|
||||
import Modal from '../common/ResizableDraggableModal';
|
||||
import { showCountdownDangerConfirm } from '../common/countdownDangerConfirm';
|
||||
import type { SavedConnection, SavedQuery } from '../../types';
|
||||
import { useStore } from '../../store';
|
||||
import { t } from '../../i18n';
|
||||
@@ -598,10 +599,9 @@ export const useSidebarObjectActions = ({
|
||||
const conn = node.dataRef;
|
||||
const dbName = String(conn.dbName || '').trim();
|
||||
if (!dbName) return;
|
||||
Modal.confirm({
|
||||
showCountdownDangerConfirm({
|
||||
title: t('sidebar.modal.confirm_delete_database.title'),
|
||||
content: t('sidebar.modal.confirm_delete_database.content', { name: dbName }),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropDatabase(buildRpcConnectionConfig(config) as any, dbName);
|
||||
@@ -653,10 +653,9 @@ export const useSidebarObjectActions = ({
|
||||
const conn = node.dataRef;
|
||||
const tableName = String(conn.tableName || '').trim();
|
||||
if (!tableName) return;
|
||||
Modal.confirm({
|
||||
showCountdownDangerConfirm({
|
||||
title: t('sidebar.modal.confirm_delete_table.title'),
|
||||
content: t('sidebar.modal.confirm_delete_table.content', { name: tableName }),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName);
|
||||
|
||||
@@ -3139,6 +3139,9 @@
|
||||
"common.confirm": "Bestätigen",
|
||||
"common.continue": "Fortfahren",
|
||||
"common.delete": "Löschen",
|
||||
"common.destructive_confirm.action_countdown": "{{action}} ({{seconds}} s)",
|
||||
"common.destructive_confirm.countdown": "Lesen Sie den Warnhinweis sorgfältig. Sie können in {{seconds}} s bestätigen.",
|
||||
"common.destructive_confirm.ready": "Die Wartezeit ist abgelaufen. Prüfen Sie das Ziel vor der Bestätigung erneut.",
|
||||
"common.edit": "Bearbeiten",
|
||||
"common.error": "Fehler",
|
||||
"common.loading": "Wird geladen",
|
||||
|
||||
@@ -3139,6 +3139,9 @@
|
||||
"common.confirm": "Confirm",
|
||||
"common.continue": "Continue",
|
||||
"common.delete": "Delete",
|
||||
"common.destructive_confirm.action_countdown": "{{action}} ({{seconds}}s)",
|
||||
"common.destructive_confirm.countdown": "Review the warning carefully. You can confirm in {{seconds}}s.",
|
||||
"common.destructive_confirm.ready": "The waiting period has ended. Check the target again before confirming.",
|
||||
"common.edit": "Edit",
|
||||
"common.error": "Error",
|
||||
"common.loading": "Loading",
|
||||
|
||||
@@ -3139,6 +3139,9 @@
|
||||
"common.confirm": "確認",
|
||||
"common.continue": "続行",
|
||||
"common.delete": "削除",
|
||||
"common.destructive_confirm.action_countdown": "{{action}}({{seconds}} 秒)",
|
||||
"common.destructive_confirm.countdown": "警告をよく確認してください。{{seconds}} 秒後に確認できます。",
|
||||
"common.destructive_confirm.ready": "待機時間が終了しました。対象を再確認してから実行してください。",
|
||||
"common.edit": "編集",
|
||||
"common.error": "エラー",
|
||||
"common.loading": "読み込み中",
|
||||
|
||||
@@ -3139,6 +3139,9 @@
|
||||
"common.confirm": "Подтвердить",
|
||||
"common.continue": "Продолжить",
|
||||
"common.delete": "Удалить",
|
||||
"common.destructive_confirm.action_countdown": "{{action}} ({{seconds}} с)",
|
||||
"common.destructive_confirm.countdown": "Внимательно прочитайте предупреждение. Подтверждение станет доступно через {{seconds}} с.",
|
||||
"common.destructive_confirm.ready": "Время ожидания истекло. Перед подтверждением ещё раз проверьте объект.",
|
||||
"common.edit": "Изменить",
|
||||
"common.error": "Ошибка",
|
||||
"common.loading": "Загрузка",
|
||||
|
||||
@@ -3139,6 +3139,9 @@
|
||||
"common.confirm": "确认",
|
||||
"common.continue": "继续",
|
||||
"common.delete": "删除",
|
||||
"common.destructive_confirm.action_countdown": "{{action}}({{seconds}} 秒)",
|
||||
"common.destructive_confirm.countdown": "请仔细阅读风险提示,{{seconds}} 秒后才能确认。",
|
||||
"common.destructive_confirm.ready": "等待结束,请再次确认操作对象。",
|
||||
"common.edit": "编辑",
|
||||
"common.error": "错误",
|
||||
"common.loading": "加载中",
|
||||
|
||||
@@ -3139,6 +3139,9 @@
|
||||
"common.confirm": "確認",
|
||||
"common.continue": "繼續",
|
||||
"common.delete": "刪除",
|
||||
"common.destructive_confirm.action_countdown": "{{action}}({{seconds}} 秒)",
|
||||
"common.destructive_confirm.countdown": "請仔細閱讀風險提示,{{seconds}} 秒後才能確認。",
|
||||
"common.destructive_confirm.ready": "等待結束,請再次確認操作對象。",
|
||||
"common.edit": "編輯",
|
||||
"common.error": "錯誤",
|
||||
"common.loading": "載入中",
|
||||
|
||||
Reference in New Issue
Block a user