mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
✨ feat(data-grid): 新增表数据页 DDL 查看与当前页查找
- 表数据页新增查看 DDL 入口,支持直接打开只读 SQL 预览弹窗 - 当前页查找支持大小写不敏感高亮,仅作用于已加载数据和显示列 - 查找结果新增上一个、下一个导航,并自动聚焦选中匹配单元格 - DDL 请求增加上下文过期保护,避免切表后展示旧表结构 - 补充 DataGrid 布局、DDL 交互和查找工具函数单元测试 Refs #417
This commit is contained in:
312
frontend/src/components/DataGrid.ddl.test.tsx
Normal file
312
frontend/src/components/DataGrid.ddl.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
frontend/src/utils/dataGridFind.test.ts
Normal file
107
frontend/src/utils/dataGridFind.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
frontend/src/utils/dataGridFind.ts
Normal file
145
frontend/src/utils/dataGridFind.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user