mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-12 01:24:12 +08:00
✨ feat(data-grid): 增加结果视图字段搜索定位 (#854)
## 关联 Issue Closes #845 ## 改动说明 - 在文本结果视图增加字段搜索,支持字段名和字段注释的前缀、包含匹配 - 文本视图选中唯一字段后自动滚动定位并高亮,不显示无意义的上下项导航 - 在 JSON 结果视图增加字段搜索,只定位每条记录的顶层真实字段键,避免命中字段值或嵌套 JSON 同名键 - JSON 视图支持上一项、下一项循环定位,并显示“当前 / 总数” - 匹配顺序为字段完全匹配、字段前缀、字段包含、注释前缀、注释包含,忽略大小写并保留真实字段名 - 补充 Enter、Shift+Enter、Escape 键盘操作以及六种语言文案 - 统一搜索框与导航按钮为 24px 高度,移除叠加焦点光晕,并将输入及占位文字调整为 12px ## 验证 - `npm test -- --run src/utils/dataGridRecordFieldSearch.test.ts src/components/DataGridRecordViews.interaction.test.tsx src/components/DataGrid.layout.test.tsx`(46 tests passed) - `npm run build` - `go test ./shared/i18n`
This commit is contained in:
66
frontend/src/utils/dataGridRecordFieldSearch.test.ts
Normal file
66
frontend/src/utils/dataGridRecordFieldSearch.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectDataGridRecordFieldCandidates,
|
||||
findDataGridJsonFieldOccurrences,
|
||||
resolveDataGridRecordFieldTarget,
|
||||
} from './dataGridRecordFieldSearch';
|
||||
|
||||
describe('dataGridRecordFieldSearch', () => {
|
||||
it('ranks exact, prefix, contains, and comment matches in a stable order', () => {
|
||||
const candidates = collectDataGridRecordFieldCandidates({
|
||||
fieldNames: ['legacy_title', 'title', 'title_suffix', 'display_name', 'summary'],
|
||||
commentsByField: {
|
||||
display_name: 'Title shown to users',
|
||||
summary: 'Article title summary',
|
||||
},
|
||||
query: 'title',
|
||||
includeComments: true,
|
||||
});
|
||||
|
||||
expect(candidates.map(({ fieldName, matchKind }) => [fieldName, matchKind])).toEqual([
|
||||
['title', 'field-exact'],
|
||||
['title_suffix', 'field-prefix'],
|
||||
['legacy_title', 'field-contains'],
|
||||
['display_name', 'comment-prefix'],
|
||||
['summary', 'comment-contains'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches without case sensitivity and excludes comments when requested', () => {
|
||||
expect(collectDataGridRecordFieldCandidates({
|
||||
fieldNames: ['USER_ID', 'display_name'],
|
||||
commentsByField: { display_name: 'User label' },
|
||||
query: 'user',
|
||||
}).map((candidate) => candidate.fieldName)).toEqual(['USER_ID']);
|
||||
});
|
||||
|
||||
it('resolves only an exact or unique candidate automatically', () => {
|
||||
const candidates = collectDataGridRecordFieldCandidates({
|
||||
fieldNames: ['user_id', 'user_name'],
|
||||
query: 'user',
|
||||
});
|
||||
expect(resolveDataGridRecordFieldTarget(candidates, 'user')).toBe('');
|
||||
expect(resolveDataGridRecordFieldTarget(candidates, 'user_id')).toBe('user_id');
|
||||
expect(resolveDataGridRecordFieldTarget(candidates.slice(0, 1), 'user')).toBe('user_id');
|
||||
});
|
||||
|
||||
it('finds only top-level row properties in formatted JSON', () => {
|
||||
const jsonText = JSON.stringify([
|
||||
{ id: 1, profile: { id: 10 }, note: '"id": value text' },
|
||||
{ id: 2, profile: { id: 20 } },
|
||||
], null, 2);
|
||||
const occurrences = findDataGridJsonFieldOccurrences(jsonText, 'id');
|
||||
|
||||
expect(occurrences).toHaveLength(2);
|
||||
expect(occurrences.map((range) => jsonText.slice(range.start, range.end))).toEqual(['"id"', '"id"']);
|
||||
});
|
||||
|
||||
it('handles field names that require JSON escaping', () => {
|
||||
const fieldName = 'quoted"field';
|
||||
const jsonText = JSON.stringify([{ [fieldName]: 1 }], null, 2);
|
||||
const [occurrence] = findDataGridJsonFieldOccurrences(jsonText, fieldName);
|
||||
|
||||
expect(jsonText.slice(occurrence.start, occurrence.end)).toBe(JSON.stringify(fieldName));
|
||||
});
|
||||
});
|
||||
104
frontend/src/utils/dataGridRecordFieldSearch.ts
Normal file
104
frontend/src/utils/dataGridRecordFieldSearch.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
export interface DataGridRecordFieldCandidate {
|
||||
fieldName: string;
|
||||
comment: string;
|
||||
sourceIndex: number;
|
||||
matchKind: 'field-exact' | 'field-prefix' | 'field-contains' | 'comment-prefix' | 'comment-contains';
|
||||
}
|
||||
|
||||
export interface DataGridJsonFieldOccurrence {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const normalizeSearchText = (value: unknown): string => String(value ?? '').trim().toLocaleLowerCase();
|
||||
|
||||
const resolveMatchKind = (
|
||||
fieldName: string,
|
||||
comment: string,
|
||||
query: string,
|
||||
includeComments: boolean,
|
||||
): DataGridRecordFieldCandidate['matchKind'] | null => {
|
||||
const normalizedFieldName = normalizeSearchText(fieldName);
|
||||
if (normalizedFieldName === query) return 'field-exact';
|
||||
if (normalizedFieldName.startsWith(query)) return 'field-prefix';
|
||||
if (normalizedFieldName.includes(query)) return 'field-contains';
|
||||
if (!includeComments) return null;
|
||||
|
||||
const normalizedComment = normalizeSearchText(comment);
|
||||
if (normalizedComment.startsWith(query)) return 'comment-prefix';
|
||||
if (normalizedComment.includes(query)) return 'comment-contains';
|
||||
return null;
|
||||
};
|
||||
|
||||
const MATCH_KIND_RANK: Record<DataGridRecordFieldCandidate['matchKind'], number> = {
|
||||
'field-exact': 0,
|
||||
'field-prefix': 1,
|
||||
'field-contains': 2,
|
||||
'comment-prefix': 3,
|
||||
'comment-contains': 4,
|
||||
};
|
||||
|
||||
export const collectDataGridRecordFieldCandidates = ({
|
||||
fieldNames,
|
||||
commentsByField = {},
|
||||
query,
|
||||
includeComments = false,
|
||||
}: {
|
||||
fieldNames: string[];
|
||||
commentsByField?: Record<string, string>;
|
||||
query: unknown;
|
||||
includeComments?: boolean;
|
||||
}): DataGridRecordFieldCandidate[] => {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
if (!normalizedQuery) return [];
|
||||
|
||||
return fieldNames
|
||||
.map((rawFieldName, sourceIndex) => {
|
||||
const fieldName = String(rawFieldName || '').trim();
|
||||
if (!fieldName) return null;
|
||||
const comment = String(commentsByField[fieldName] || '').trim();
|
||||
const matchKind = resolveMatchKind(fieldName, comment, normalizedQuery, includeComments);
|
||||
return matchKind ? { fieldName, comment, sourceIndex, matchKind } : null;
|
||||
})
|
||||
.filter((candidate): candidate is DataGridRecordFieldCandidate => candidate !== null)
|
||||
.sort((left, right) => (
|
||||
MATCH_KIND_RANK[left.matchKind] - MATCH_KIND_RANK[right.matchKind]
|
||||
|| left.sourceIndex - right.sourceIndex
|
||||
));
|
||||
};
|
||||
|
||||
export const resolveDataGridRecordFieldTarget = (
|
||||
candidates: DataGridRecordFieldCandidate[],
|
||||
query: unknown,
|
||||
): string => {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
if (!normalizedQuery || candidates.length === 0) return '';
|
||||
const exactMatch = candidates.find((candidate) => (
|
||||
normalizeSearchText(candidate.fieldName) === normalizedQuery
|
||||
));
|
||||
if (exactMatch) return exactMatch.fieldName;
|
||||
return candidates.length === 1 ? candidates[0].fieldName : '';
|
||||
};
|
||||
|
||||
export const findDataGridJsonFieldOccurrences = (
|
||||
jsonText: string,
|
||||
fieldName: string,
|
||||
): DataGridJsonFieldOccurrence[] => {
|
||||
const normalizedFieldName = String(fieldName || '');
|
||||
if (!jsonText || !normalizedFieldName) return [];
|
||||
|
||||
const serializedFieldName = JSON.stringify(normalizedFieldName);
|
||||
const linePrefix = ` ${serializedFieldName}:`;
|
||||
const occurrences: DataGridJsonFieldOccurrence[] = [];
|
||||
let lineStart = 0;
|
||||
|
||||
String(jsonText).split('\n').forEach((line) => {
|
||||
if (line.startsWith(linePrefix)) {
|
||||
const start = lineStart + 4;
|
||||
occurrences.push({ start, end: start + serializedFieldName.length });
|
||||
}
|
||||
lineStart += line.length + 1;
|
||||
});
|
||||
|
||||
return occurrences;
|
||||
};
|
||||
Reference in New Issue
Block a user