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,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;
};