mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-06 22:53:35 +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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -851,6 +851,7 @@ const renderDataTableView = () => (
|
||||
rowCount={mergedDisplayData.length}
|
||||
canModifyData={canModifyData}
|
||||
jsonViewText={jsonViewText}
|
||||
displayOutputColumnNames={displayOutputColumnNames}
|
||||
translate={translateDataGrid}
|
||||
onReturnToTable={() => handleViewModeChange('table')}
|
||||
onOpenJsonEditor={handleOpenJsonEditor}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "型",
|
||||
|
||||
@@ -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": "Тип",
|
||||
|
||||
@@ -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": "类型",
|
||||
|
||||
@@ -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": "類型",
|
||||
|
||||
Reference in New Issue
Block a user