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:
Syngnat
2026-08-06 12:11:54 +08:00
committed by GitHub
13 changed files with 845 additions and 41 deletions

View File

@@ -17,6 +17,7 @@ import DataGridPreviewPanel from './DataGridPreviewPanel';
import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews';
import DataGridResultViewSwitcher from './DataGridResultViewSwitcher';
import DataGridSecondaryActions from './DataGridSecondaryActions';
import { buildDataGridCssText } from './dataGridStyles';
import { DataGridV2DdlSideWorkspace, DataGridV2DdlView } from './DataGridV2DdlWorkspace';
import { DataGridV2ErView, DataGridV2FieldsView } from './DataGridV2MetadataViews';
import { I18nProvider } from '../i18n/provider';
@@ -956,6 +957,8 @@ describe('DataGrid layout', () => {
'data_grid.record_view.next': 'Next label',
'data_grid.record_view.record_position': `Record label ${params?.current} of ${params?.total}`,
'data_grid.record_view.edit_current': 'Edit current label',
'data_grid.record_view.field_or_comment_search_placeholder': 'Search field or comment label',
'data_grid.column_quick_find.placeholder': 'Search field label',
'data_grid.column.type_tooltip': `TYPE ${params?.type}`,
'data_grid.column.comment_tooltip': `COMMENT ${params?.comment}`,
'data_grid.preview_panel.no_cell_title': 'Select cell title',
@@ -1079,6 +1082,10 @@ describe('DataGrid layout', () => {
expect(jsonRecordMarkup).toContain('5 JSON rows label');
expect(jsonRecordMarkup).toContain('Edit JSON label');
expect(jsonRecordMarkup).toContain('Back to table label');
expect(jsonRecordMarkup).toContain('Search field label');
expect(jsonRecordMarkup).toContain('data-grid-record-field-search="true"');
expect(jsonRecordMarkup).toContain('data-grid-record-field-search--navigation');
expect(jsonRecordMarkup).toContain('data-grid-record-field-search-navigation');
expect(jsonRecordMarkup).not.toContain('data_grid.record_view');
const textRecordMarkup = renderToStaticMarkup(
@@ -1106,6 +1113,8 @@ describe('DataGrid layout', () => {
expect(textRecordMarkup).toContain('Record label 1 of 2');
expect(textRecordMarkup).toContain('Edit current label');
expect(textRecordMarkup).toContain('Back to table label');
expect(textRecordMarkup).toContain('Search field or comment label');
expect(textRecordMarkup).toContain('data-grid-record-field-search="true"');
expect(textRecordMarkup).toContain('Field label');
expect(textRecordMarkup).toContain('Value label');
expect(textRecordMarkup).toContain('Comment label');
@@ -1128,6 +1137,19 @@ describe('DataGrid layout', () => {
expect(textRecordMarkup).toContain('GitHub release HTTP 500 checksum abc123');
expect(textRecordMarkup).not.toContain('data_grid.record_view');
const recordSearchCss = buildDataGridCssText({
darkMode: false,
densityParams: { dataFontSize: 12 },
gridId: 'record-grid',
});
expect(recordSearchCss).toContain('.record-grid .data-grid-record-field-search-navigation.ant-btn');
expect(recordSearchCss).toContain('.record-grid .data-grid-record-field-search .ant-input-affix-wrapper-focused');
expect(recordSearchCss).toContain('.record-grid .data-grid-record-field-search-autocomplete.ant-select-focused .ant-select-selector');
expect(recordSearchCss).toContain('.record-grid .data-grid-record-field-search .ant-input::placeholder');
expect(recordSearchCss).toContain('font-size: 12px !important;');
expect(recordSearchCss).toContain('height: 24px !important;');
expect(recordSearchCss).toContain('box-shadow: none !important;');
const hiddenTextRecordMarkup = renderToStaticMarkup(
<DataGridTextView
darkMode={false}

View File

@@ -7,17 +7,48 @@ const messageApi = vi.hoisted(() => ({
success: vi.fn(),
}));
const monacoEditorState = vi.hoisted(() => ({
latestProps: null as any,
collection: {
clear: vi.fn(),
set: vi.fn(),
},
editor: {
createDecorationsCollection: vi.fn(),
getModel: vi.fn(),
revealRangeInCenterIfOutsideViewport: vi.fn(),
},
}));
vi.mock('antd', () => ({
Button: ({ children, ...props }: { children?: React.ReactNode }) => <button {...props}>{children}</button>,
AutoComplete: ({ children, options = [], onSelect, ...props }: any) => (
<div data-testid="record-field-autocomplete" {...props}>
{children}
{options.map((option: any) => (
<button
key={option.value}
data-record-field-option={option.value}
onClick={() => onSelect?.(option.value)}
>
{option.label}
</button>
))}
</div>
),
Input: ({ prefix: _prefix, ...props }: any) => <input {...props} />,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
message: messageApi,
}));
vi.mock('./MonacoEditor', () => ({
default: () => <div data-testid="record-view-editor" />,
default: (props: any) => {
monacoEditorState.latestProps = props;
return <div data-testid="record-view-editor" />;
},
}));
import { DataGridTextView } from './DataGridRecordViews';
import { DataGridJsonView, DataGridTextView } from './DataGridRecordViews';
const translate = (key: string): string => ({
'data_grid.record_view.empty': 'No rows',
@@ -31,6 +62,12 @@ const translate = (key: string): string => ({
'data_grid.record_view.comment': 'Comment',
'data_grid.record_view.type': 'Type',
'data_grid.record_view.copy_value': 'Copy value',
'data_grid.record_view.field_or_comment_search_placeholder': 'Search field or comment',
'data_grid.column_quick_find.placeholder': 'Search field',
'data_grid.page_find.previous': 'Previous match',
'data_grid.page_find.next': 'Next match',
'data_grid.record_view.json_record_count': 'JSON records',
'data_grid.record_view.edit_json': 'Edit JSON',
'data_grid.message.copied_to_clipboard': 'Copied',
'connection_modal.message.copy_failed': 'Copy failed',
}[key] ?? key);
@@ -42,6 +79,16 @@ describe('DataGridTextView value copy', () => {
});
messageApi.error.mockReset();
messageApi.success.mockReset();
monacoEditorState.latestProps = null;
monacoEditorState.collection.clear.mockReset();
monacoEditorState.collection.set.mockReset();
monacoEditorState.editor.createDecorationsCollection.mockReset();
monacoEditorState.editor.getModel.mockReset();
monacoEditorState.editor.revealRangeInCenterIfOutsideViewport.mockReset();
monacoEditorState.editor.createDecorationsCollection.mockImplementation((decorations: any[]) => {
monacoEditorState.collection.set(decorations);
return monacoEditorState.collection;
});
});
afterEach(() => {
@@ -102,4 +149,107 @@ describe('DataGridTextView value copy', () => {
expect(messageApi.error).toHaveBeenCalledWith('Copy failed');
expect(messageApi.success).not.toHaveBeenCalled();
});
it('locates one text-view field by its comment without rendering match navigation', async () => {
const scrollIntoView = vi.fn();
const renderer = create(
<DataGridTextView
darkMode={false}
rowCount={1}
textRecordIndex={0}
canModifyData={false}
currentTextRow={{ description: 'value', created_at: '2026-08-06' }}
displayOutputColumnNames={['description', 'created_at']}
columnMetaMap={{
description: { type: 'text', comment: 'A long description' },
created_at: { type: 'timestamp', comment: 'Creation time' },
}}
translate={translate}
onPrev={() => {}}
onNext={() => {}}
onEditCurrent={() => {}}
onReturnToTable={() => {}}
formatTextViewValue={(value) => String(value)}
/>,
{
createNodeMock: (element) => (
element.props['data-grid-record-field-name'] ? { scrollIntoView } : null
),
},
);
const input = renderer.root.findByProps({ 'data-grid-record-field-search-input': 'true' });
await act(async () => {
input.props.onChange({ target: { value: 'long desc' } });
});
expect(renderer.root.findByProps({ 'data-grid-record-field-name': 'description' }).props)
.toHaveProperty('data-grid-record-field-active', 'true');
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
expect(renderer.root.findAllByProps({ 'data-grid-record-field-search-next': 'true' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ 'data-grid-record-field-search-previous': 'true' })).toHaveLength(0);
});
it('navigates matching top-level JSON fields across records', async () => {
const jsonViewText = JSON.stringify([
{ id: 1, profile: { id: 10 } },
{ id: 2, profile: { id: 20 } },
], null, 2);
const offsetToPosition = (offset: number) => {
const prefix = jsonViewText.slice(0, offset);
const lines = prefix.split('\n');
return { lineNumber: lines.length, column: (lines[lines.length - 1] || '').length + 1 };
};
monacoEditorState.editor.getModel.mockReturnValue({ getPositionAt: offsetToPosition });
const renderer = create(
<DataGridJsonView
darkMode={false}
rowCount={2}
canModifyData={false}
jsonViewText={jsonViewText}
displayOutputColumnNames={['id', 'profile']}
translate={translate}
onOpenJsonEditor={() => {}}
onReturnToTable={() => {}}
/>,
);
await act(async () => {
monacoEditorState.latestProps.onMount(monacoEditorState.editor, {
Range: class Range {
constructor(
public startLineNumber: number,
public startColumn: number,
public endLineNumber: number,
public endColumn: number,
) {}
},
});
});
const input = renderer.root.findByProps({ 'data-grid-record-field-search-input': 'true' });
await act(async () => {
input.props.onChange({ target: { value: 'id' } });
});
const firstDecorationCalls = monacoEditorState.collection.set.mock.calls;
const firstDecorations = firstDecorationCalls[firstDecorationCalls.length - 1]?.[0] || [];
expect(firstDecorations).toHaveLength(2);
expect(firstDecorations[0].options.inlineClassName).toContain('data-grid-record-json-field-match-active');
expect(renderer.root.findByProps({ 'data-grid-record-field-search-position': 'true' }).children.join('')).toBe('1 / 2');
await act(async () => {
renderer.root.findByProps({ 'data-grid-record-field-search-next': 'true' }).props.onClick();
});
const nextDecorationCalls = monacoEditorState.collection.set.mock.calls;
const nextDecorations = nextDecorationCalls[nextDecorationCalls.length - 1]?.[0] || [];
expect(nextDecorations[1].options.inlineClassName).toContain('data-grid-record-json-field-match-active');
expect(renderer.root.findByProps({ 'data-grid-record-field-search-position': 'true' }).children.join('')).toBe('2 / 2');
expect(monacoEditorState.editor.revealRangeInCenterIfOutsideViewport).toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,13 @@
import React from 'react';
import { Button, message, Tooltip } from 'antd';
import { AutoComplete, Button, Input, message, Tooltip } from 'antd';
import { LeftOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons';
import Editor from './MonacoEditor';
import { t as defaultTranslate, type I18nParams } from '../i18n';
import {
collectDataGridRecordFieldCandidates,
findDataGridJsonFieldOccurrences,
resolveDataGridRecordFieldTarget,
} from '../utils/dataGridRecordFieldSearch';
export type DataGridRecordViewTranslate = (key: string, params?: I18nParams) => string;
@@ -10,57 +16,314 @@ interface DataGridJsonViewProps {
rowCount: number;
canModifyData: boolean;
jsonViewText: string;
displayOutputColumnNames?: string[];
translate?: DataGridRecordViewTranslate;
onOpenJsonEditor: () => void;
onReturnToTable: () => void;
}
interface DataGridRecordFieldSearchProps {
fieldNames: string[];
commentsByField?: Record<string, string>;
includeComments?: boolean;
searchText: string;
targetField: string;
activeMatchIndex?: number;
matchCount?: number;
showNavigation?: boolean;
translate: DataGridRecordViewTranslate;
onSearchTextChange: (value: string) => void;
onTargetFieldChange: (fieldName: string) => void;
onNavigatePrevious?: () => void;
onNavigateNext?: () => void;
}
const buildRecordFieldSearchCandidates = (
fieldNames: string[],
commentsByField: Record<string, string>,
query: string,
includeComments: boolean,
) => collectDataGridRecordFieldCandidates({
fieldNames,
commentsByField,
query,
includeComments,
});
const DataGridRecordFieldSearch: React.FC<DataGridRecordFieldSearchProps> = ({
fieldNames,
commentsByField = {},
includeComments = false,
searchText,
targetField,
activeMatchIndex = -1,
matchCount = 0,
showNavigation = false,
translate,
onSearchTextChange,
onTargetFieldChange,
onNavigatePrevious,
onNavigateNext,
}) => {
const candidates = React.useMemo(() => buildRecordFieldSearchCandidates(
fieldNames,
commentsByField,
searchText,
includeComments,
), [commentsByField, fieldNames, includeComments, searchText]);
const selectField = React.useCallback((fieldName: string) => {
onSearchTextChange(fieldName);
onTargetFieldChange(fieldName);
}, [onSearchTextChange, onTargetFieldChange]);
const handleSearchTextChange = React.useCallback((value: string) => {
onSearchTextChange(value);
const nextCandidates = buildRecordFieldSearchCandidates(
fieldNames,
commentsByField,
value,
includeComments,
);
onTargetFieldChange(resolveDataGridRecordFieldTarget(nextCandidates, value));
}, [commentsByField, fieldNames, includeComments, onSearchTextChange, onTargetFieldChange]);
const handleSubmit = React.useCallback(() => {
if (targetField) {
if (showNavigation && matchCount > 0) onNavigateNext?.();
return;
}
const firstCandidate = candidates[0];
if (firstCandidate) selectField(firstCandidate.fieldName);
}, [candidates, matchCount, onNavigateNext, selectField, showNavigation, targetField]);
const options = React.useMemo(() => candidates.slice(0, 12).map((candidate) => ({
value: candidate.fieldName,
label: (
<span style={{ display: 'flex', minWidth: 0, alignItems: 'baseline', gap: 8 }}>
<strong style={{ flex: '0 0 auto' }}>{candidate.fieldName}</strong>
{includeComments && candidate.comment ? (
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.66 }}>
{candidate.comment}
</span>
) : null}
</span>
),
})), [candidates, includeComments]);
return (
<div
className={`data-grid-record-field-search${showNavigation ? ' data-grid-record-field-search--navigation' : ''}`}
data-grid-record-field-search="true"
style={{ marginLeft: 'auto', minWidth: 0 }}
>
<AutoComplete
className="data-grid-record-field-search-autocomplete"
value={searchText}
options={options}
filterOption={false}
popupMatchSelectWidth={includeComments ? 380 : 280}
onChange={handleSearchTextChange}
onSelect={selectField}
style={{ width: includeComments ? 260 : 220 }}
>
<Input
className="data-grid-record-field-search-input"
data-grid-record-field-search-input="true"
allowClear
size="small"
prefix={<SearchOutlined />}
placeholder={translate(includeComments
? 'data_grid.record_view.field_or_comment_search_placeholder'
: 'data_grid.column_quick_find.placeholder')}
value={searchText}
onChange={(event) => handleSearchTextChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
handleSearchTextChange('');
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey && showNavigation && matchCount > 0) {
onNavigatePrevious?.();
} else {
handleSubmit();
}
}
}}
style={{ width: '100%' }}
/>
</AutoComplete>
{showNavigation ? (
<>
<Button
data-grid-record-field-search-previous="true"
className="data-grid-record-field-search-navigation"
size="small"
type="text"
icon={<LeftOutlined />}
aria-label={translate('data_grid.page_find.previous')}
title={translate('data_grid.page_find.previous')}
disabled={matchCount <= 1}
onClick={onNavigatePrevious}
/>
<Button
data-grid-record-field-search-next="true"
className="data-grid-record-field-search-navigation"
size="small"
type="text"
icon={<RightOutlined />}
aria-label={translate('data_grid.page_find.next')}
title={translate('data_grid.page_find.next')}
disabled={matchCount <= 1}
onClick={onNavigateNext}
/>
<span
data-grid-record-field-search-position="true"
className="data-grid-record-field-search-position"
aria-live="polite"
>
{targetField && matchCount > 0 ? `${activeMatchIndex + 1} / ${matchCount}` : '0 / 0'}
</span>
</>
) : null}
</div>
);
};
export const DataGridJsonView: React.FC<DataGridJsonViewProps> = ({
darkMode,
rowCount,
canModifyData,
jsonViewText,
displayOutputColumnNames = [],
translate = defaultTranslate,
onOpenJsonEditor,
onReturnToTable,
}) => (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 10px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{rowCount === 0
? translate('data_grid.record_view.empty')
: translate('data_grid.record_view.json_record_count', { count: rowCount })}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={onOpenJsonEditor} disabled={rowCount === 0}>
{translate('data_grid.record_view.edit_json')}
}) => {
const [fieldSearchText, setFieldSearchText] = React.useState('');
const [targetField, setTargetField] = React.useState('');
const [activeOccurrenceIndex, setActiveOccurrenceIndex] = React.useState(-1);
const editorRef = React.useRef<any>(null);
const monacoRef = React.useRef<any>(null);
const decorationCollectionRef = React.useRef<any>(null);
const occurrences = React.useMemo(
() => findDataGridJsonFieldOccurrences(jsonViewText, targetField),
[jsonViewText, targetField],
);
React.useEffect(() => {
setActiveOccurrenceIndex(occurrences.length > 0 ? 0 : -1);
}, [jsonViewText, targetField]);
const refreshJsonFieldDecorations = React.useCallback(() => {
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
if (!editor || !monaco?.Range || !model) return;
const decorations = occurrences.map((occurrence, index) => {
const start = model.getPositionAt(occurrence.start);
const end = model.getPositionAt(occurrence.end);
return {
range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column),
options: {
inlineClassName: index === activeOccurrenceIndex
? 'data-grid-record-json-field-match data-grid-record-json-field-match-active'
: 'data-grid-record-json-field-match',
},
};
});
if (!decorationCollectionRef.current) {
decorationCollectionRef.current = editor.createDecorationsCollection?.(decorations);
} else {
decorationCollectionRef.current.set?.(decorations);
}
const activeDecoration = decorations[activeOccurrenceIndex];
if (activeDecoration) {
editor.revealRangeInCenterIfOutsideViewport?.(activeDecoration.range);
}
}, [activeOccurrenceIndex, occurrences]);
React.useEffect(() => {
refreshJsonFieldDecorations();
}, [refreshJsonFieldDecorations]);
React.useEffect(() => () => {
decorationCollectionRef.current?.clear?.();
decorationCollectionRef.current = null;
}, []);
const handleEditorMount = React.useCallback((editor: any, monaco: any) => {
editorRef.current = editor;
monacoRef.current = monaco;
refreshJsonFieldDecorations();
}, [refreshJsonFieldDecorations]);
const navigateOccurrence = React.useCallback((direction: 'previous' | 'next') => {
setActiveOccurrenceIndex((current) => {
if (occurrences.length === 0) return -1;
if (direction === 'previous') return current <= 0 ? occurrences.length - 1 : current - 1;
return current < 0 || current >= occurrences.length - 1 ? 0 : current + 1;
});
}, [occurrences.length]);
return (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 10px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>
{rowCount === 0
? translate('data_grid.record_view.empty')
: translate('data_grid.record_view.json_record_count', { count: rowCount })}
</span>
{canModifyData && (
<Button size="small" type="primary" onClick={onOpenJsonEditor} disabled={rowCount === 0}>
{translate('data_grid.record_view.edit_json')}
</Button>
)}
<Button size="small" onClick={onReturnToTable}>
{translate('data_grid.record_view.back_to_table')}
</Button>
)}
<Button size="small" onClick={onReturnToTable}>
{translate('data_grid.record_view.back_to_table')}
</Button>
<DataGridRecordFieldSearch
fieldNames={displayOutputColumnNames}
searchText={fieldSearchText}
targetField={targetField}
activeMatchIndex={activeOccurrenceIndex}
matchCount={occurrences.length}
showNavigation
translate={translate}
onSearchTextChange={setFieldSearchText}
onTargetFieldChange={setTargetField}
onNavigatePrevious={() => navigateOccurrence('previous')}
onNavigateNext={() => navigateOccurrence('next')}
/>
</div>
<div style={{ flex: 1, minHeight: 0, padding: '8px 10px 10px 10px' }}>
<Editor
height="100%"
gonaviTypography="data"
defaultLanguage="json"
language="json"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={jsonViewText}
onMount={handleEditorMount}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'off',
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
</div>
</div>
<div style={{ flex: 1, minHeight: 0, padding: '8px 10px 10px 10px' }}>
<Editor
height="100%"
gonaviTypography="data"
defaultLanguage="json"
language="json"
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
value={jsonViewText}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'off',
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
</div>
</div>
);
);
};
interface DataGridTextViewProps {
darkMode: boolean;
@@ -161,6 +424,9 @@ export const DataGridTextView: React.FC<DataGridTextViewProps> = ({
onReturnToTable,
formatTextViewValue,
}) => {
const [fieldSearchText, setFieldSearchText] = React.useState('');
const [targetField, setTargetField] = React.useState('');
const fieldRowRefs = React.useRef(new Map<string, HTMLDivElement>());
const metaTextColor = darkMode ? 'rgba(255,255,255,0.52)' : 'rgba(0,0,0,0.48)';
const primaryTextColor = darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)';
const valueTextColor = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)';
@@ -182,6 +448,22 @@ export const DataGridTextView: React.FC<DataGridTextViewProps> = ({
wordBreak: 'break-word',
};
const commentsByField = React.useMemo(() => Object.fromEntries(
displayOutputColumnNames.map((fieldName) => {
const meta = columnMetaMap[fieldName] || columnMetaMapByLowerName[fieldName.toLowerCase()];
return [fieldName, String(meta?.comment || '').trim()];
}),
), [columnMetaMap, columnMetaMapByLowerName, displayOutputColumnNames]);
React.useEffect(() => {
if (!targetField) return;
fieldRowRefs.current.get(targetField)?.scrollIntoView?.({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}, [currentTextRow, targetField, textRecordIndex]);
const copyValue = React.useCallback(async (value: string) => {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable');
@@ -194,7 +476,7 @@ export const DataGridTextView: React.FC<DataGridTextViewProps> = ({
return (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Button size="small" onClick={onPrev} disabled={rowCount === 0 || textRecordIndex <= 0}>
{translate('data_grid.record_view.previous')}
</Button>
@@ -214,6 +496,16 @@ export const DataGridTextView: React.FC<DataGridTextViewProps> = ({
<Button size="small" onClick={onReturnToTable}>
{translate('data_grid.record_view.back_to_table')}
</Button>
<DataGridRecordFieldSearch
fieldNames={displayOutputColumnNames}
commentsByField={commentsByField}
includeComments
searchText={fieldSearchText}
targetField={targetField}
translate={translate}
onSearchTextChange={setFieldSearchText}
onTargetFieldChange={setTargetField}
/>
</div>
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
<div style={{ minWidth: gridMinWidth }}>
@@ -248,12 +540,29 @@ export const DataGridTextView: React.FC<DataGridTextViewProps> = ({
const columnComment = String(columnMeta?.comment || '').trim();
const formattedValue = formatTextViewValue(currentTextRow[col], col);
const borderBottom = darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)';
const fieldIsActive = col === targetField;
return (
<div
key={col}
role="row"
style={{ display: 'grid', gridTemplateColumns, borderBottom }}
ref={(node) => {
if (node) fieldRowRefs.current.set(col, node);
else fieldRowRefs.current.delete(col);
}}
data-grid-record-field-name={col}
data-grid-record-field-active={fieldIsActive ? 'true' : undefined}
style={{
display: 'grid',
gridTemplateColumns,
borderBottom,
background: fieldIsActive
? (darkMode ? 'rgba(246,196,83,0.16)' : 'rgba(255,193,7,0.14)')
: undefined,
boxShadow: fieldIsActive
? `inset 3px 0 0 ${darkMode ? '#f6c453' : '#d39e00'}`
: undefined,
}}
>
<DataGridTextOverflowCell
value={col}

View File

@@ -851,6 +851,7 @@ const renderDataTableView = () => (
rowCount={mergedDisplayData.length}
canModifyData={canModifyData}
jsonViewText={jsonViewText}
displayOutputColumnNames={displayOutputColumnNames}
translate={translateDataGrid}
onReturnToTable={() => handleViewModeChange('table')}
onOpenJsonEditor={handleOpenJsonEditor}

View File

@@ -617,6 +617,152 @@ export const buildDataGridCssText = ({
}
.${gridId} .data-grid-record-json-field-match {
padding: 0 1px;
border-radius: 3px;
background: ${darkMode ? 'rgba(246, 196, 83, 0.22)' : 'rgba(255, 193, 7, 0.22)'};
box-shadow: inset 0 -1px 0 ${darkMode ? 'rgba(246, 196, 83, 0.7)' : 'rgba(181, 132, 0, 0.65)'};
}
.${gridId} .data-grid-record-json-field-match-active {
background: ${darkMode ? 'rgba(246, 196, 83, 0.48)' : 'rgba(255, 193, 7, 0.48)'};
outline: 1px solid ${darkMode ? 'rgba(246, 196, 83, 0.92)' : 'rgba(181, 132, 0, 0.86)'};
}
.${gridId} .data-grid-record-field-search {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
min-height: 24px;
}
.${gridId} .data-grid-record-field-search-autocomplete,
.${gridId} .data-grid-record-field-search-autocomplete .ant-select-selector,
.${gridId} .data-grid-record-field-search .ant-input-affix-wrapper {
height: 24px !important;
min-height: 24px !important;
box-sizing: border-box !important;
}
.${gridId} .data-grid-record-field-search-autocomplete,
.${gridId} .data-grid-record-field-search .ant-input-affix-wrapper {
display: inline-flex !important;
align-items: center !important;
}
.${gridId} .data-grid-record-field-search .ant-input-affix-wrapper {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.${gridId} .data-grid-record-field-search .ant-input,
.${gridId} .data-grid-record-field-search .ant-input::placeholder {
font-size: 12px !important;
}
.${gridId} .data-grid-record-field-search .ant-input-affix-wrapper-focused,
.${gridId} .data-grid-record-field-search .ant-input-affix-wrapper:focus,
.${gridId} .data-grid-record-field-search .ant-input-affix-wrapper:focus-within,
.${gridId} .data-grid-record-field-search-autocomplete.ant-select-focused,
.${gridId} .data-grid-record-field-search-autocomplete.ant-select-focused .ant-select-selector,
.${gridId} .data-grid-record-field-search .ant-input:focus,
.${gridId} .data-grid-record-field-search .ant-input:focus-visible {
outline: none !important;
box-shadow: none !important;
}
.${gridId} .data-grid-record-field-search-navigation.ant-btn {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
align-self: center !important;
width: 24px !important;
min-width: 24px !important;
height: 24px !important;
min-height: 24px !important;
margin: 0 !important;
padding: 0 !important;
line-height: 1 !important;
box-sizing: border-box !important;
}
.${gridId} .data-grid-record-field-search-position {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 42px;
height: 24px;
font-size: 12px;
line-height: 24px;
opacity: 0.66;
white-space: nowrap;
text-align: right;
}
.${gridId} .editable-cell-value-wrap {
display: block;

View 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));
});
});

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

View File

@@ -4341,6 +4341,7 @@
"data_grid.record_view.edit_json": "JSON bearbeiten",
"data_grid.record_view.back_to_table": "Zur Tabelle",
"data_grid.record_view.field": "Feld",
"data_grid.record_view.field_or_comment_search_placeholder": "Feld oder Kommentar suchen...",
"data_grid.record_view.value": "Wert",
"data_grid.record_view.comment": "Kommentar",
"data_grid.record_view.type": "Datentyp",

View File

@@ -4341,6 +4341,7 @@
"data_grid.record_view.edit_json": "Edit JSON",
"data_grid.record_view.back_to_table": "Back to table",
"data_grid.record_view.field": "Field",
"data_grid.record_view.field_or_comment_search_placeholder": "Search field or comment...",
"data_grid.record_view.value": "Value",
"data_grid.record_view.comment": "Comment",
"data_grid.record_view.type": "Type",

View File

@@ -4341,6 +4341,7 @@
"data_grid.record_view.edit_json": "JSON を編集",
"data_grid.record_view.back_to_table": "テーブルに戻る",
"data_grid.record_view.field": "フィールド",
"data_grid.record_view.field_or_comment_search_placeholder": "フィールドまたはコメントを検索...",
"data_grid.record_view.value": "値",
"data_grid.record_view.comment": "コメント",
"data_grid.record_view.type": "型",

View File

@@ -4341,6 +4341,7 @@
"data_grid.record_view.edit_json": "Редактировать JSON",
"data_grid.record_view.back_to_table": "Вернуться к таблице",
"data_grid.record_view.field": "Поле",
"data_grid.record_view.field_or_comment_search_placeholder": "Поиск поля или комментария...",
"data_grid.record_view.value": "Значение",
"data_grid.record_view.comment": "Комментарий",
"data_grid.record_view.type": "Тип",

View File

@@ -4341,6 +4341,7 @@
"data_grid.record_view.edit_json": "编辑 JSON",
"data_grid.record_view.back_to_table": "返回表格",
"data_grid.record_view.field": "字段",
"data_grid.record_view.field_or_comment_search_placeholder": "搜索字段或注释...",
"data_grid.record_view.value": "值",
"data_grid.record_view.comment": "注释",
"data_grid.record_view.type": "类型",

View File

@@ -4341,6 +4341,7 @@
"data_grid.record_view.edit_json": "編輯 JSON",
"data_grid.record_view.back_to_table": "返回表格",
"data_grid.record_view.field": "欄位",
"data_grid.record_view.field_or_comment_search_placeholder": "搜尋欄位或註解...",
"data_grid.record_view.value": "值",
"data_grid.record_view.comment": "註解",
"data_grid.record_view.type": "類型",