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