feat(data-grid): 新增表数据页 DDL 查看与当前页查找

- 表数据页新增查看 DDL 入口,支持直接打开只读 SQL 预览弹窗
- 当前页查找支持大小写不敏感高亮,仅作用于已加载数据和显示列
- 查找结果新增上一个、下一个导航,并自动聚焦选中匹配单元格
- DDL 请求增加上下文过期保护,避免切表后展示旧表结构
- 补充 DataGrid 布局、DDL 交互和查找工具函数单元测试
Refs #417
This commit is contained in:
Syngnat
2026-04-28 12:39:51 +08:00
parent 5886b1ded8
commit a07eea7815
5 changed files with 902 additions and 8 deletions

View File

@@ -0,0 +1,312 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DataGrid from './DataGrid';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
},
queryOptions: {
showColumnComment: false,
showColumnType: false,
},
setQueryOptions: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
clearTableColumnOrder: vi.fn(),
tableHiddenColumns: {},
enableHiddenColumnMemory: false,
setTableHiddenColumns: vi.fn(),
setEnableHiddenColumnMemory: vi.fn(),
clearTableHiddenColumns: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
ImportData: vi.fn(),
ExportTable: vi.fn(),
ExportData: vi.fn(),
ExportQuery: vi.fn(),
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
loading: vi.fn(() => vi.fn()),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
}));
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('@monaco-editor/react', () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock('./ImportPreviewModal', () => ({
default: () => null,
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
ReloadOutlined: Icon,
ImportOutlined: Icon,
ExportOutlined: Icon,
DownOutlined: Icon,
PlusOutlined: Icon,
DeleteOutlined: Icon,
SaveOutlined: Icon,
UndoOutlined: Icon,
FilterOutlined: Icon,
CloseOutlined: Icon,
ConsoleSqlOutlined: Icon,
FileTextOutlined: Icon,
CopyOutlined: Icon,
ClearOutlined: Icon,
EditOutlined: Icon,
VerticalAlignBottomOutlined: Icon,
LeftOutlined: Icon,
RightOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
};
});
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: any) => <>{children}</>,
PointerSensor: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
closestCenter: vi.fn(),
}));
vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: any) => <>{children}</>,
useSortable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
})),
horizontalListSortingStrategy: vi.fn(),
arrayMove: (items: any[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
},
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Transform: {
toString: () => '',
},
},
}));
vi.mock('antd', () => {
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button type="button" disabled={disabled || loading} data-button-type={type} onClick={onClick} {...rest}>
{children}
</button>
);
const Input: any = ({ value, onChange, placeholder, ...rest }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} {...rest} />
);
Input.TextArea = ({ value, onChange, placeholder }: any) => (
<textarea value={value} onChange={onChange} placeholder={placeholder} />
);
const createForm = () => ({
resetFields: vi.fn(),
setFieldsValue: vi.fn(),
getFieldsValue: vi.fn(() => ({})),
getFieldValue: vi.fn(),
validateFields: vi.fn(() => Promise.resolve({})),
});
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [createForm()];
const Modal: any = ({ children, footer, open, title }: any) => (
open ? (
<section data-modal-title={title}>
<h2>{title}</h2>
{children}
<div>{footer}</div>
</section>
) : null
);
Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null];
const passthrough = ({ children }: any) => <>{children}</>;
return {
Table: () => <table />,
message: messageApi,
Input,
Button,
Dropdown: passthrough,
Form,
Pagination: () => null,
Select: () => null,
Modal,
Checkbox: ({ checked, onChange }: any) => <input type="checkbox" checked={checked} onChange={onChange} />,
Segmented: () => null,
Tooltip: passthrough,
Popover: passthrough,
DatePicker: () => null,
TimePicker: () => null,
AutoComplete: ({ children }: any) => <>{children}</>,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe('DataGrid DDL interactions', () => {
beforeEach(() => {
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
activeElement: null,
elementFromPoint: vi.fn(() => null),
createElement: vi.fn(() => ({
style: {},
getContext: vi.fn(() => ({ measureText: vi.fn(() => ({ width: 0 })) })),
})),
body: { style: {} },
});
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
innerHeight: 768,
innerWidth: 1024,
getComputedStyle: vi.fn(() => ({ font: '12px sans-serif' })),
});
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: '',
clipboard: { writeText: vi.fn(() => Promise.resolve()) },
});
vi.stubGlobal('HTMLElement', class {});
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
backendApp.ImportData.mockReset();
backendApp.ExportTable.mockReset();
backendApp.ExportData.mockReset();
backendApp.ExportQuery.mockReset();
backendApp.ApplyChanges.mockReset();
backendApp.DBGetColumns.mockReset();
backendApp.DBGetIndexes.mockReset();
backendApp.DBShowCreateTable.mockReset();
vi.unstubAllGlobals();
});
it('ignores stale DDL responses after the table context changes', async () => {
let resolveFirstRequest: (value: any) => void = () => {};
backendApp.DBShowCreateTable.mockReturnValueOnce(new Promise((resolve) => {
resolveFirstRequest = resolve;
}));
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await act(async () => {
renderer!.update(
<DataGrid
data={[{ __gonavi_row_key__: 'row-2', id: 2 }]}
columnNames={['id']}
loading={false}
tableName="orders"
dbName="main"
connectionId="conn-1"
/>,
);
resolveFirstRequest({ success: true, data: 'CREATE TABLE users' });
});
await waitForEffects();
expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users');
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
});
});

View File

@@ -44,6 +44,7 @@ vi.mock('../../wailsjs/go/app/App', () => ({
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
vi.mock('@monaco-editor/react', () => ({
@@ -76,6 +77,52 @@ describe('DataGrid layout', () => {
expect(markup).toContain('data-grid-secondary-actions="true"');
expect(markup).toContain('data-grid-view-switcher="true"');
expect(markup).toContain('data-grid-page-find="true"');
expect(markup).toContain('data-grid-page-find-prev="true"');
expect(markup).toContain('data-grid-page-find-next="true"');
expect(markup).toContain('当前页查找...');
});
it('renders a DDL action for table data pages only', () => {
const tableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
expect(tableMarkup).toContain('查看 DDL');
const queryMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
exportScope="queryResult"
/>,
);
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
});
it('renders row copy and paste actions in editable table toolbar', () => {

View File

@@ -1,10 +1,10 @@
// cspell:ignore anticon sqls uuidv uuidv4 hscroll
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback, useDeferredValue } from 'react';
import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd';
import dayjs from 'dayjs';
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import {
DndContext,
@@ -23,7 +23,7 @@ import {
arrayMove
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes } from '../../wailsjs/go/app/App';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import type { ColumnDefinition, IndexDefinition } from '../types';
@@ -68,6 +68,17 @@ import {
resolveWhereConditionSuggestions,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
import {
attachDataGridFindRenderVersion,
collectDataGridFindMatches,
findDataGridTextRanges,
hasDataGridFindRenderVersionChanged,
normalizeDataGridFindQuery,
resolveDataGridFindNavigationIndex,
summarizeDataGridFindMatches,
type DataGridFindMatch,
type DataGridFindNavigationDirection,
} from '../utils/dataGridFind';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -185,9 +196,9 @@ const normalizeDateTimeString = (val: string) => {
};
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
const formatCellDisplayText = (val: any): string => {
try {
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
if (val === null) return 'NULL';
if (typeof val === 'object') {
if (!Array.isArray(val) && !isPlainObject(val)) {
return String(val);
@@ -222,6 +233,38 @@ const formatCellValue = (val: any) => {
}
};
const renderHighlightedCellText = (text: string, query: string): React.ReactNode => {
const ranges = findDataGridTextRanges(text, query);
if (ranges.length === 0) return text;
const nodes: React.ReactNode[] = [];
let cursor = 0;
ranges.forEach((range, index) => {
if (range.start > cursor) {
nodes.push(text.slice(cursor, range.start));
}
nodes.push(
<mark key={`${range.start}-${range.end}-${index}`} className="data-grid-find-highlight">
{text.slice(range.start, range.end)}
</mark>,
);
cursor = range.end;
});
if (cursor < text.length) {
nodes.push(text.slice(cursor));
}
return <>{nodes}</>;
};
const renderCellDisplayValue = (val: any, query: string): React.ReactNode => {
const text = formatCellDisplayText(val);
const content = renderHighlightedCellText(text, query);
if (val === null) return <span style={{ color: '#ccc' }}>{content}</span>;
return content;
};
const formatCellValue = (val: any) => renderCellDisplayValue(val, '');
const toEditableText = (val: any): string => {
if (val === null || val === undefined) return '';
if (typeof val === 'string') return val;
@@ -965,6 +1008,15 @@ const DataGrid: React.FC<DataGridProps> = ({
const [displayColumnNames, setDisplayColumnNames] = useState<string[]>([]);
const [localHiddenColumns, setLocalHiddenColumns] = useState<string[]>([]);
const [columnSearchText, setColumnSearchText] = useState('');
const [pageFindText, setPageFindText] = useState('');
const [activePageFindMatchIndex, setActivePageFindMatchIndex] = useState(-1);
const deferredPageFindText = useDeferredValue(pageFindText);
const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(deferredPageFindText), [deferredPageFindText]);
useEffect(() => {
setPageFindText('');
setActivePageFindMatchIndex(-1);
}, [connectionId, dbName, tableName]);
// Sync hidden columns from store
useEffect(() => {
@@ -1081,6 +1133,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const isQueryResultExport = exportScope === 'queryResult';
const canImport = exportScope === 'table' && !!tableName;
const canExport = !!connectionId && (isQueryResultExport || !!tableName);
const canViewDdl = exportScope === 'table' && !!connectionId && !!dbName && !!tableName;
const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]);
const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0;
@@ -1192,6 +1245,10 @@ const DataGrid: React.FC<DataGridProps> = ({
const cellEditorApplyRef = useRef<((val: string) => void) | null>(null);
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonEditorValue, setJsonEditorValue] = useState('');
const [ddlModalOpen, setDdlModalOpen] = useState(false);
const [ddlLoading, setDdlLoading] = useState(false);
const [ddlText, setDdlText] = useState('');
const ddlRequestSeqRef = useRef(0);
// --- Data Preview Panel State ---
const [dataPanelOpen, setDataPanelOpen] = useState(false);
@@ -1755,6 +1812,12 @@ const DataGrid: React.FC<DataGridProps> = ({
.${gridId} .ant-table-sticky-scroll {
display: none !important;
}
.${gridId} .data-grid-find-highlight {
padding: 0 1px;
border-radius: 3px;
background: ${darkMode ? 'rgba(246, 196, 83, 0.42)' : 'rgba(255, 193, 7, 0.42)'};
color: inherit;
}
/* 虚拟表列对齐:阻止 header <table> 通过 min-width:100% 拉伸到视口,
使 header 列宽与虚拟 body 单元格宽度精确一致 */
.${gridId} .ant-table-header > table {
@@ -2294,6 +2357,10 @@ const DataGrid: React.FC<DataGridProps> = ({
rowEditorBaseRawRef.current = {};
rowEditorDisplayRef.current = {};
rowEditorNullColsRef.current = new Set();
ddlRequestSeqRef.current += 1;
setDdlModalOpen(false);
setDdlLoading(false);
setDdlText('');
rowEditorForm.resetFields();
closeCellEditor();
form.resetFields();
@@ -3184,6 +3251,43 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}, [displayData, modifiedRows]);
const pageFindMatches = useMemo(() => collectDataGridFindMatches(
mergedDisplayData,
displayColumnNames,
normalizedPageFindText,
(value) => formatCellDisplayText(value),
(row, rowIndex) => String(row?.[GONAVI_ROW_KEY] ?? `row-${rowIndex}`),
), [mergedDisplayData, displayColumnNames, normalizedPageFindText]);
const pageFindSummary = useMemo(() => summarizeDataGridFindMatches(
mergedDisplayData,
displayColumnNames,
normalizedPageFindText,
(value) => formatCellDisplayText(value),
), [mergedDisplayData, displayColumnNames, normalizedPageFindText]);
useEffect(() => {
setActivePageFindMatchIndex(-1);
}, [normalizedPageFindText, mergedDisplayData, displayColumnNames]);
useEffect(() => {
if (normalizedPageFindText) return;
const emptySelection = new Set<string>();
setSelectedCells(emptySelection);
currentSelectionRef.current = emptySelection;
selectionStartRef.current = null;
updateCellSelection(emptySelection);
}, [normalizedPageFindText, updateCellSelection]);
const activePageFindPosition = activePageFindMatchIndex >= 0 && activePageFindMatchIndex < pageFindMatches.length
? activePageFindMatchIndex + 1
: 0;
const tableRenderData = useMemo(
() => attachDataGridFindRenderVersion(mergedDisplayData, normalizedPageFindText),
[mergedDisplayData, normalizedPageFindText]
);
useEffect(() => {
setTextRecordIndex(prev => {
if (mergedDisplayData.length === 0) return 0;
@@ -3532,12 +3636,13 @@ const DataGrid: React.FC<DataGridProps> = ({
editable: canModifyData, // Only editable if table name known and not readonly
render: (text: any) => (
<div style={CELL_ELLIPSIS_STYLE}>
{formatCellValue(text)}
{renderCellDisplayValue(text, normalizedPageFindText)}
</div>
),
shouldCellUpdate: (record: Item, prevRecord: Item) => {
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
if (rowKeyChanged) return true;
if (hasDataGridFindRenderVersionChanged(record, prevRecord)) return true;
return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]);
},
onHeaderCell: (column: any) => ({
@@ -3568,7 +3673,7 @@ const DataGrid: React.FC<DataGridProps> = ({
},
}),
}));
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]);
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode, normalizedPageFindText]);
const mergedColumns = useMemo(() => columns.map((col): ColumnType<any> => {
const dataIndex = String(col.dataIndex);
@@ -3840,6 +3945,43 @@ const DataGrid: React.FC<DataGridProps> = ({
void message.success("Copied to clipboard");
}, []);
const handleOpenTableDdl = useCallback(async () => {
if (!canViewDdl || !currentConnConfig || !tableName) {
void message.error('当前表缺少连接或表名,无法查看 DDL');
return;
}
const requestSeq = ++ddlRequestSeqRef.current;
setDdlModalOpen(true);
setDdlLoading(true);
setDdlText('');
try {
const res = await DBShowCreateTable(buildRpcConnectionConfig(currentConnConfig) as any, dbName || '', tableName);
if (requestSeq !== ddlRequestSeqRef.current) return;
if (res.success) {
setDdlText(String(res.data ?? ''));
return;
}
void message.error(res.message || '获取 DDL 失败');
} catch (error: any) {
if (requestSeq !== ddlRequestSeqRef.current) return;
void message.error(error?.message || '获取 DDL 失败');
} finally {
if (requestSeq === ddlRequestSeqRef.current) {
setDdlLoading(false);
}
}
}, [canViewDdl, currentConnConfig, dbName, tableName]);
const handleCopyDdl = useCallback(() => {
if (!ddlText.trim()) {
void message.info('暂无可复制的 DDL');
return;
}
navigator.clipboard.writeText(ddlText)
.then(() => message.success('DDL 已复制到剪贴板'))
.catch(() => message.error('复制 DDL 失败'));
}, [ddlText]);
const handleCopySelectedCellsToClipboard = useCallback(() => {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) {
@@ -4564,6 +4706,67 @@ const DataGrid: React.FC<DataGridProps> = ({
return virtualHolder || rcVirtualHolder || body;
}, []);
const focusPageFindMatch = useCallback((match: DataGridFindMatch) => {
if (!match) return;
const nextSelection = new Set([makeCellKey(match.rowKey, match.columnName)]);
setSelectedCells(nextSelection);
currentSelectionRef.current = nextSelection;
selectionStartRef.current = {
rowKey: match.rowKey,
colName: match.columnName,
rowIndex: match.rowIndex,
colIndex: match.columnIndex,
};
const targetRow = mergedDisplayData[match.rowIndex] || mergedDisplayData.find((row) => {
const rowKey = row?.[GONAVI_ROW_KEY];
return rowKey !== undefined && rowKey !== null && rowKeyStr(rowKey) === match.rowKey;
});
if (targetRow && dataPanelOpenRef.current) {
updateFocusedCell(targetRow, match.columnName);
}
const applyVisibleFocus = () => {
const root = containerRef.current;
if (!root) return false;
const cell = Array.from(root.querySelectorAll('.ant-table-cell[data-row-key][data-col-name]')).find((node) => {
const el = node as HTMLElement;
return el.getAttribute('data-row-key') === match.rowKey && el.getAttribute('data-col-name') === match.columnName;
}) as HTMLElement | undefined;
updateCellSelection(nextSelection);
if (!cell) return false;
cell.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
return true;
};
if (applyVisibleFocus()) return;
const tableContainer = tableContainerRef.current;
if (tableContainer instanceof HTMLElement) {
const verticalTarget = pickVerticalScrollTarget(tableContainer);
if (verticalTarget) {
const firstCell = tableContainer.querySelector('.ant-table-cell[data-row-key]') as HTMLElement | null;
const rowHeight = Math.max(24, Math.ceil(firstCell?.getBoundingClientRect().height || 38));
verticalTarget.scrollTop = Math.max(0, (match.rowIndex - 1) * rowHeight);
}
}
requestAnimationFrame(() => {
if (applyVisibleFocus()) return;
requestAnimationFrame(() => {
applyVisibleFocus();
});
});
}, [mergedDisplayData, pickVerticalScrollTarget, rowKeyStr, updateCellSelection, updateFocusedCell]);
const handleNavigatePageFind = useCallback((direction: DataGridFindNavigationDirection) => {
const nextIndex = resolveDataGridFindNavigationIndex(activePageFindMatchIndex, pageFindMatches.length, direction);
if (nextIndex < 0) return;
setActivePageFindMatchIndex(nextIndex);
const match = pageFindMatches[nextIndex];
if (match) focusPageFindMatch(match);
}, [activePageFindMatchIndex, pageFindMatches, focusPageFindMatch]);
const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => {
const externalScroll = externalHorizontalScrollRef.current;
if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') {
@@ -5619,6 +5822,39 @@ const DataGrid: React.FC<DataGridProps> = ({
/>
)}
</Modal>
<Modal
title={tableName ? `DDL - ${tableName}` : 'DDL'}
open={ddlModalOpen}
onCancel={() => setDdlModalOpen(false)}
destroyOnHidden
width={960}
footer={[
<Button key="copy" icon={<CopyOutlined />} onClick={handleCopyDdl} disabled={!ddlText.trim()}>
DDL
</Button>,
<Button key="close" type="primary" onClick={() => setDdlModalOpen(false)}>
</Button>,
]}
>
{ddlModalOpen && (
<Editor
height="56vh"
language="sql"
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={ddlLoading ? '正在加载 DDL...' : ddlText}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "off",
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
)}
</Modal>
{viewMode === 'table' ? (
<div
@@ -5640,7 +5876,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<SortableContext items={displayColumnNames} strategy={horizontalListSortingStrategy}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
dataSource={tableRenderData}
columns={mergedColumns}
showSorterTooltip={{ target: 'sorter-icon' }}
size="small"
@@ -6110,6 +6346,53 @@ const DataGrid: React.FC<DataGridProps> = ({
>
<Button icon={<FileTextOutlined />}></Button>
</Popover>
{canViewDdl && (
<Button
data-grid-ddl-action="true"
icon={<FileTextOutlined />}
loading={ddlLoading}
onClick={handleOpenTableDdl}
>
DDL
</Button>
)}
<Tooltip title="仅查找当前页已加载数据,不改变 WHERE 条件">
<div data-grid-page-find="true" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Input
{...noAutoCapInputProps}
allowClear
size="small"
prefix={<SearchOutlined />}
placeholder="当前页查找..."
value={pageFindText}
onChange={(event) => setPageFindText(event.target.value)}
style={{ width: 220 }}
/>
<Button
data-grid-page-find-prev="true"
size="small"
icon={<LeftOutlined />}
disabled={pageFindMatches.length === 0}
onClick={() => handleNavigatePageFind('previous')}
>
</Button>
<Button
data-grid-page-find-next="true"
size="small"
icon={<RightOutlined />}
disabled={pageFindMatches.length === 0}
onClick={() => handleNavigatePageFind('next')}
>
</Button>
{normalizedPageFindText && (
<span aria-live="polite" style={{ fontSize: 12, color: darkMode ? '#999' : '#666', whiteSpace: 'nowrap' }}>
{pageFindMatches.length > 0 ? `${activePageFindPosition} / ${pageFindMatches.length} · ` : ''} {pageFindSummary.occurrenceCount} / {pageFindSummary.matchedCellCount}
</span>
)}
</div>
</Tooltip>
</div>
<div data-grid-view-switcher="true" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}></span>

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import {
attachDataGridFindRenderVersion,
collectDataGridFindMatches,
findDataGridTextRanges,
hasDataGridFindRenderVersionChanged,
normalizeDataGridFindQuery,
resolveDataGridFindNavigationIndex,
summarizeDataGridFindMatches,
} from './dataGridFind';
describe('dataGridFind', () => {
it('normalizes blank queries to an empty search value without changing non-blank text', () => {
expect(normalizeDataGridFindQuery(' alpha ')).toBe(' alpha ');
expect(normalizeDataGridFindQuery(' ')).toBe('');
expect(normalizeDataGridFindQuery(null)).toBe('');
});
it('finds case-insensitive non-overlapping text ranges', () => {
expect(findDataGridTextRanges('Alpha beta ALPHA', 'alpha')).toEqual([
{ start: 0, end: 5 },
{ start: 11, end: 16 },
]);
});
it('treats special characters as plain text', () => {
expect(findDataGridTextRanges('a+b a.b a+b', 'a+b')).toEqual([
{ start: 0, end: 3 },
{ start: 8, end: 11 },
]);
});
it('preserves whitespace in non-blank plain text queries', () => {
expect(findDataGridTextRanges(' alpha alpha ', ' alpha')).toEqual([
{ start: 0, end: 6 },
{ start: 6, end: 12 },
]);
expect(findDataGridTextRanges('alpha beta alphabeta', 'alpha ')).toEqual([
{ start: 0, end: 6 },
]);
});
it('returns no ranges for empty query or empty text', () => {
expect(findDataGridTextRanges('alpha', '')).toEqual([]);
expect(findDataGridTextRanges('', 'alpha')).toEqual([]);
});
it('summarizes matches across selected columns only', () => {
const rows = [
{ id: 1, name: 'Alpha', note: 'alpha beta', hidden: 'alpha' },
{ id: 2, name: 'Gamma', note: 'none', hidden: 'alpha' },
];
expect(
summarizeDataGridFindMatches(rows, ['name', 'note'], 'alpha', (value) => String(value ?? '')),
).toEqual({ matchedCellCount: 2, occurrenceCount: 2 });
});
it('collects ordered cell matches with row and column coordinates', () => {
const rows = [
{ __gonavi_row_key__: 'row-1', name: 'Alpha alpha', note: 'beta Alpha' },
{ __gonavi_row_key__: 'row-2', name: 'none', note: 'alpha' },
];
expect(
collectDataGridFindMatches(
rows,
['name', 'note'],
'alpha',
(value) => String(value ?? ''),
(row) => String(row.__gonavi_row_key__),
),
).toEqual([
{ rowIndex: 0, rowKey: 'row-1', columnName: 'name', columnIndex: 0, occurrenceIndex: 0, start: 0, end: 5 },
{ rowIndex: 0, rowKey: 'row-1', columnName: 'name', columnIndex: 0, occurrenceIndex: 1, start: 6, end: 11 },
{ rowIndex: 0, rowKey: 'row-1', columnName: 'note', columnIndex: 1, occurrenceIndex: 0, start: 5, end: 10 },
{ rowIndex: 1, rowKey: 'row-2', columnName: 'note', columnIndex: 1, occurrenceIndex: 0, start: 0, end: 5 },
]);
});
it('resolves previous and next navigation indexes with wrapping', () => {
expect(resolveDataGridFindNavigationIndex(-1, 4, 'next')).toBe(0);
expect(resolveDataGridFindNavigationIndex(0, 4, 'next')).toBe(1);
expect(resolveDataGridFindNavigationIndex(3, 4, 'next')).toBe(0);
expect(resolveDataGridFindNavigationIndex(-1, 4, 'previous')).toBe(3);
expect(resolveDataGridFindNavigationIndex(0, 4, 'previous')).toBe(3);
expect(resolveDataGridFindNavigationIndex(2, 4, 'previous')).toBe(1);
expect(resolveDataGridFindNavigationIndex(0, 0, 'next')).toBe(-1);
});
it('tracks render version changes without exposing metadata as row data', () => {
const rows = [{ id: 1, name: 'Alpha' }];
expect(attachDataGridFindRenderVersion(rows, '')).toBe(rows);
const alphaRows = attachDataGridFindRenderVersion(rows, 'alpha');
const betaRows = attachDataGridFindRenderVersion(rows, 'beta');
expect(alphaRows).not.toBe(rows);
expect(alphaRows[0]).not.toBe(rows[0]);
expect(Object.keys(alphaRows[0])).toEqual(['id', 'name']);
expect(hasDataGridFindRenderVersionChanged(alphaRows[0], rows[0])).toBe(true);
expect(hasDataGridFindRenderVersionChanged(betaRows[0], alphaRows[0])).toBe(true);
expect(hasDataGridFindRenderVersionChanged(rows[0], alphaRows[0])).toBe(true);
});
});

View File

@@ -0,0 +1,145 @@
export interface DataGridTextRange {
start: number;
end: number;
}
export interface DataGridFindSummary {
matchedCellCount: number;
occurrenceCount: number;
}
export interface DataGridFindMatch extends DataGridTextRange {
rowIndex: number;
rowKey: string;
columnName: string;
columnIndex: number;
occurrenceIndex: number;
}
export type DataGridFindNavigationDirection = 'previous' | 'next';
export const DATA_GRID_FIND_RENDER_VERSION = Symbol('DATA_GRID_FIND_RENDER_VERSION');
export const normalizeDataGridFindQuery = (value: unknown): string => {
const text = String(value ?? '');
return text.trim().length === 0 ? '' : text;
};
export const findDataGridTextRanges = (text: string, query: string): DataGridTextRange[] => {
const normalizedQuery = normalizeDataGridFindQuery(query);
if (!text || !normalizedQuery) return [];
const source = String(text);
const lowerSource = source.toLocaleLowerCase();
const lowerQuery = normalizedQuery.toLocaleLowerCase();
const ranges: DataGridTextRange[] = [];
let startIndex = 0;
while (startIndex < source.length) {
const matchIndex = lowerSource.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const end = matchIndex + normalizedQuery.length;
ranges.push({ start: matchIndex, end });
startIndex = end;
}
return ranges;
};
export const attachDataGridFindRenderVersion = <T>(rows: T[], query: string): T[] => {
const normalizedQuery = normalizeDataGridFindQuery(query);
if (!normalizedQuery) return rows;
return rows.map((row) => {
if (!row || typeof row !== 'object') return row;
const nextRow = { ...(row as object) } as T;
Object.defineProperty(nextRow, DATA_GRID_FIND_RENDER_VERSION, {
value: normalizedQuery,
enumerable: false,
});
return nextRow;
});
};
export const hasDataGridFindRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => {
const nextVersion = nextRecord && typeof nextRecord === 'object'
? (nextRecord as Record<symbol, unknown>)[DATA_GRID_FIND_RENDER_VERSION]
: undefined;
const previousVersion = previousRecord && typeof previousRecord === 'object'
? (previousRecord as Record<symbol, unknown>)[DATA_GRID_FIND_RENDER_VERSION]
: undefined;
return nextVersion !== previousVersion;
};
export const summarizeDataGridFindMatches = <T>(
rows: T[],
columnNames: string[],
query: string,
getCellText: (value: unknown, row: T, columnName: string) => string,
): DataGridFindSummary => {
const normalizedQuery = normalizeDataGridFindQuery(query);
if (!normalizedQuery) {
return { matchedCellCount: 0, occurrenceCount: 0 };
}
let matchedCellCount = 0;
let occurrenceCount = 0;
rows.forEach((row) => {
columnNames.forEach((columnName) => {
const record = row as Record<string, unknown>;
const ranges = findDataGridTextRanges(getCellText(record[columnName], row, columnName), normalizedQuery);
if (ranges.length > 0) {
matchedCellCount += 1;
occurrenceCount += ranges.length;
}
});
});
return { matchedCellCount, occurrenceCount };
};
export const collectDataGridFindMatches = <T>(
rows: T[],
columnNames: string[],
query: string,
getCellText: (value: unknown, row: T, columnName: string) => string,
getRowKey: (row: T, rowIndex: number) => string,
): DataGridFindMatch[] => {
const normalizedQuery = normalizeDataGridFindQuery(query);
if (!normalizedQuery) return [];
const matches: DataGridFindMatch[] = [];
rows.forEach((row, rowIndex) => {
const record = row as Record<string, unknown>;
const rowKey = getRowKey(row, rowIndex);
columnNames.forEach((columnName, columnIndex) => {
findDataGridTextRanges(getCellText(record[columnName], row, columnName), normalizedQuery).forEach((range, occurrenceIndex) => {
matches.push({
rowIndex,
rowKey,
columnName,
columnIndex,
occurrenceIndex,
start: range.start,
end: range.end,
});
});
});
});
return matches;
};
export const resolveDataGridFindNavigationIndex = (
currentIndex: number,
matchCount: number,
direction: DataGridFindNavigationDirection,
): number => {
if (matchCount <= 0) return -1;
if (direction === 'previous') {
return currentIndex <= 0 ? matchCount - 1 : currentIndex - 1;
}
return currentIndex < 0 || currentIndex >= matchCount - 1 ? 0 : currentIndex + 1;
};