From cf8397d310b9f7dfb76ea75af1e5e61d1e4d696a Mon Sep 17 00:00:00 2001 From: kunghim Date: Thu, 6 Aug 2026 11:33:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(data-grid):=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=BB=93=E6=9E=9C=E8=A7=86=E5=9B=BE=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/DataGrid.layout.test.tsx | 22 + .../DataGridRecordViews.interaction.test.tsx | 154 ++++++- .../src/components/DataGridRecordViews.tsx | 387 ++++++++++++++++-- frontend/src/components/DataGridShell.tsx | 1 + frontend/src/components/dataGridStyles.ts | 146 +++++++ .../utils/dataGridRecordFieldSearch.test.ts | 66 +++ .../src/utils/dataGridRecordFieldSearch.ts | 104 +++++ shared/i18n/de-DE.json | 1 + shared/i18n/en-US.json | 1 + shared/i18n/ja-JP.json | 1 + shared/i18n/ru-RU.json | 1 + shared/i18n/zh-CN.json | 1 + shared/i18n/zh-TW.json | 1 + 13 files changed, 845 insertions(+), 41 deletions(-) create mode 100644 frontend/src/utils/dataGridRecordFieldSearch.test.ts create mode 100644 frontend/src/utils/dataGridRecordFieldSearch.ts diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 6fdf8847..7e15ee95 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -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( ({ 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 }) => , + AutoComplete: ({ children, options = [], onSelect, ...props }: any) => ( +
+ {children} + {options.map((option: any) => ( + + ))} +
+ ), + Input: ({ prefix: _prefix, ...props }: any) => , Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, message: messageApi, })); vi.mock('./MonacoEditor', () => ({ - default: () =>
, + default: (props: any) => { + monacoEditorState.latestProps = props; + return
; + }, })); -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( + {}} + 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( + {}} + 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(); + }); }); diff --git a/frontend/src/components/DataGridRecordViews.tsx b/frontend/src/components/DataGridRecordViews.tsx index 2cd62737..8aebfc93 100644 --- a/frontend/src/components/DataGridRecordViews.tsx +++ b/frontend/src/components/DataGridRecordViews.tsx @@ -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; + 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, + query: string, + includeComments: boolean, +) => collectDataGridRecordFieldCandidates({ + fieldNames, + commentsByField, + query, + includeComments, +}); + +const DataGridRecordFieldSearch: React.FC = ({ + 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: ( + + {candidate.fieldName} + {includeComments && candidate.comment ? ( + + {candidate.comment} + + ) : null} + + ), + })), [candidates, includeComments]); + + return ( +
+ + } + 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%' }} + /> + + {showNavigation ? ( + <> +
+ ); +}; + export const DataGridJsonView: React.FC = ({ darkMode, rowCount, canModifyData, jsonViewText, + displayOutputColumnNames = [], translate = defaultTranslate, onOpenJsonEditor, onReturnToTable, -}) => ( -
-
- - {rowCount === 0 - ? translate('data_grid.record_view.empty') - : translate('data_grid.record_view.json_record_count', { count: rowCount })} - - {canModifyData && ( - + )} + - )} - + navigateOccurrence('previous')} + onNavigateNext={() => navigateOccurrence('next')} + /> +
+
+ +
-
- -
-
-); + ); +}; interface DataGridTextViewProps { darkMode: boolean; @@ -161,6 +424,9 @@ export const DataGridTextView: React.FC = ({ onReturnToTable, formatTextViewValue, }) => { + const [fieldSearchText, setFieldSearchText] = React.useState(''); + const [targetField, setTargetField] = React.useState(''); + const fieldRowRefs = React.useRef(new Map()); 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 = ({ 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 = ({ return (
-
+
@@ -214,6 +496,16 @@ export const DataGridTextView: React.FC = ({ +
@@ -248,12 +540,29 @@ export const DataGridTextView: React.FC = ({ 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 (
{ + 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, + }} > ( rowCount={mergedDisplayData.length} canModifyData={canModifyData} jsonViewText={jsonViewText} + displayOutputColumnNames={displayOutputColumnNames} translate={translateDataGrid} onReturnToTable={() => handleViewModeChange('table')} onOpenJsonEditor={handleOpenJsonEditor} diff --git a/frontend/src/components/dataGridStyles.ts b/frontend/src/components/dataGridStyles.ts index f615a38d..3c95b187 100644 --- a/frontend/src/components/dataGridStyles.ts +++ b/frontend/src/components/dataGridStyles.ts @@ -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; diff --git a/frontend/src/utils/dataGridRecordFieldSearch.test.ts b/frontend/src/utils/dataGridRecordFieldSearch.test.ts new file mode 100644 index 00000000..e902a8b2 --- /dev/null +++ b/frontend/src/utils/dataGridRecordFieldSearch.test.ts @@ -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)); + }); +}); diff --git a/frontend/src/utils/dataGridRecordFieldSearch.ts b/frontend/src/utils/dataGridRecordFieldSearch.ts new file mode 100644 index 00000000..5c226f83 --- /dev/null +++ b/frontend/src/utils/dataGridRecordFieldSearch.ts @@ -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 = { + '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; + 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; +}; diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 575e8d45..3d1a94ac 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -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", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index d04f8732..b49d0b85 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -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", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index ce40b03a..3a65ecfc 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -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": "型", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index c849e5d3..81be8858 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -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": "Тип", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index da9de69e..112c90d2 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -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": "类型", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 5561aba9..be08d5fc 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -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": "類型",