From c4402801186ee9dc5ba076391a23bb9b10db4835 Mon Sep 17 00:00:00 2001 From: Kunghim Date: Sun, 2 Aug 2026 00:39:12 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(data-grid):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=96=87=E6=9C=AC=E8=AE=B0=E5=BD=95=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/DataGrid.layout.test.tsx | 25 ++- .../DataGridRecordViews.interaction.test.tsx | 105 +++++++++ .../src/components/DataGridRecordViews.tsx | 211 +++++++++++++++--- frontend/src/i18n/catalog.test.ts | 5 + shared/i18n/de-DE.json | 5 + shared/i18n/en-US.json | 5 + shared/i18n/ja-JP.json | 5 + shared/i18n/ru-RU.json | 5 + shared/i18n/zh-CN.json | 5 + shared/i18n/zh-TW.json | 5 + 10 files changed, 343 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/DataGridRecordViews.interaction.test.tsx diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 681746db..132bf244 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -934,6 +934,11 @@ describe('DataGrid layout', () => { 'data_grid.record_view.json_record_count': `${params?.count} JSON rows label`, 'data_grid.record_view.edit_json': 'Edit JSON label', 'data_grid.record_view.back_to_table': 'Back to table label', + 'data_grid.record_view.field': 'Field label', + 'data_grid.record_view.value': 'Value label', + 'data_grid.record_view.comment': 'Comment label', + 'data_grid.record_view.type': 'Type label', + 'data_grid.record_view.copy_value': 'Copy value label', 'data_grid.record_view.previous': 'Previous label', 'data_grid.record_view.next': 'Next label', 'data_grid.record_view.record_position': `Record label ${params?.current} of ${params?.total}`, @@ -1088,9 +1093,25 @@ 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('Field label'); + expect(textRecordMarkup).toContain('Value label'); + expect(textRecordMarkup).toContain('Comment label'); + expect(textRecordMarkup).toContain('Type label'); + expect(textRecordMarkup).toContain('data-grid-text-view-header="field"'); + expect(textRecordMarkup).toContain('data-grid-text-view-header="value"'); + expect(textRecordMarkup.indexOf('data-grid-text-view-header="field"')) + .toBeLessThan(textRecordMarkup.indexOf('data-grid-text-view-header="type"')); + expect(textRecordMarkup.indexOf('data-grid-text-view-header="type"')) + .toBeLessThan(textRecordMarkup.indexOf('data-grid-text-view-header="comment"')); + expect(textRecordMarkup.indexOf('data-grid-text-view-header="comment"')) + .toBeLessThan(textRecordMarkup.indexOf('data-grid-text-view-header="value"')); + expect(textRecordMarkup).toContain('data-grid-text-value-copy="true"'); + expect(textRecordMarkup).toContain('aria-label="Copy value label"'); + expect(textRecordMarkup).toContain('grid-template-columns:180px 140px 240px minmax(260px, 1fr)'); + expect(textRecordMarkup).toContain('text-overflow:ellipsis'); expect(textRecordMarkup).toContain('raw_sql'); - expect(textRecordMarkup).toContain('TYPE varchar(128)'); - expect(textRecordMarkup).toContain('COMMENT SQL text payload'); + expect(textRecordMarkup).toContain('varchar(128)'); + expect(textRecordMarkup).toContain('SQL text payload'); expect(textRecordMarkup).toContain('GitHub release HTTP 500 checksum abc123'); expect(textRecordMarkup).not.toContain('data_grid.record_view'); diff --git a/frontend/src/components/DataGridRecordViews.interaction.test.tsx b/frontend/src/components/DataGridRecordViews.interaction.test.tsx new file mode 100644 index 00000000..28bf969a --- /dev/null +++ b/frontend/src/components/DataGridRecordViews.interaction.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const messageApi = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), +})); + +vi.mock('antd', () => ({ + Button: ({ children, ...props }: { children?: React.ReactNode }) => , + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + message: messageApi, +})); + +vi.mock('./MonacoEditor', () => ({ + default: () =>
, +})); + +import { DataGridTextView } from './DataGridRecordViews'; + +const translate = (key: string): string => ({ + 'data_grid.record_view.empty': 'No rows', + 'data_grid.record_view.previous': 'Previous', + 'data_grid.record_view.next': 'Next', + 'data_grid.record_view.record_position': 'Record position', + 'data_grid.record_view.edit_current': 'Edit current', + 'data_grid.record_view.back_to_table': 'Back to table', + 'data_grid.record_view.field': 'Field', + 'data_grid.record_view.value': 'Value', + 'data_grid.record_view.comment': 'Comment', + 'data_grid.record_view.type': 'Type', + 'data_grid.record_view.copy_value': 'Copy value', + 'data_grid.message.copied_to_clipboard': 'Copied', + 'connection_modal.message.copy_failed': 'Copy failed', +}[key] ?? key); + +describe('DataGridTextView value copy', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { + clipboard: { writeText: vi.fn(() => Promise.resolve()) }, + }); + messageApi.error.mockReset(); + messageApi.success.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const renderView = () => create( + {}} + onNext={() => {}} + onEditCurrent={() => {}} + onReturnToTable={() => {}} + formatTextViewValue={(value) => String(value)} + />, + ); + + it('copies a formatted value on click and keyboard activation', async () => { + const renderer = renderView(); + const valueCell = renderer.root.find((node) => node.props['data-grid-text-value-copy'] === 'true'); + const writeText = navigator.clipboard.writeText as ReturnType; + + await act(async () => { + valueCell.props.onClick(); + await Promise.resolve(); + }); + expect(writeText).toHaveBeenCalledWith('formatted value'); + expect(messageApi.success).toHaveBeenCalledWith('Copied'); + + const preventDefault = vi.fn(); + await act(async () => { + valueCell.props.onKeyDown({ key: 'Enter', preventDefault }); + await Promise.resolve(); + }); + expect(preventDefault).toHaveBeenCalled(); + expect(writeText).toHaveBeenCalledTimes(2); + }); + + it('shows the existing copy failure message when the clipboard rejects', async () => { + const renderer = renderView(); + const valueCell = renderer.root.find((node) => node.props['data-grid-text-value-copy'] === 'true'); + const writeText = navigator.clipboard.writeText as ReturnType; + writeText.mockRejectedValueOnce(new Error('clipboard unavailable')); + + await act(async () => { + valueCell.props.onClick(); + await Promise.resolve(); + }); + + expect(messageApi.error).toHaveBeenCalledWith('Copy failed'); + expect(messageApi.success).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/DataGridRecordViews.tsx b/frontend/src/components/DataGridRecordViews.tsx index c3cf848b..68755696 100644 --- a/frontend/src/components/DataGridRecordViews.tsx +++ b/frontend/src/components/DataGridRecordViews.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Button } from 'antd'; +import { Button, message, Tooltip } from 'antd'; import Editor from './MonacoEditor'; import { t as defaultTranslate, type I18nParams } from '../i18n'; @@ -81,6 +81,68 @@ interface DataGridTextViewProps { formatTextViewValue: (value: any, columnName?: string) => string; } +interface DataGridTextCellProps extends React.HTMLAttributes { + 'data-grid-text-view-cell'?: string; + 'data-grid-text-value-copy'?: string; +} + +interface DataGridTextValueCellProps extends DataGridTextCellProps { + value: string; + cellBaseStyle: React.CSSProperties; + tooltipInnerStyle: React.CSSProperties; +} + +const DataGridTextValueCell: React.FC = ({ + value, + cellBaseStyle, + tooltipInnerStyle, + ...props +}) => { + const cellRef = React.useRef(null); + const [isTruncated, setIsTruncated] = React.useState(false); + + const measureTruncation = React.useCallback(() => { + const cell = cellRef.current; + const nextIsTruncated = Boolean(value) + && Boolean(cell?.clientWidth) + && Boolean(cell && cell.scrollWidth > cell.clientWidth); + setIsTruncated((previous) => (previous === nextIsTruncated ? previous : nextIsTruncated)); + }, [value]); + + React.useEffect(() => { + measureTruncation(); + const cell = cellRef.current; + if (!cell || typeof ResizeObserver === 'undefined') return undefined; + + const observer = new ResizeObserver(measureTruncation); + observer.observe(cell); + return () => observer.disconnect(); + }, [measureTruncation]); + + const cell = ( +
{ + measureTruncation(); + props.onMouseEnter?.(event); + }} + > + {value} +
+ ); + + return ( + {value} : undefined} + overlayInnerStyle={tooltipInnerStyle} + > + {cell} + + ); +}; + export const DataGridTextView: React.FC = ({ darkMode, rowCount, @@ -100,6 +162,47 @@ export const DataGridTextView: React.FC = ({ formatTextViewValue, }) => { 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)'; + const gridTemplateColumns = '180px 140px 240px minmax(260px, 1fr)'; + const gridMinWidth = 820; + const cellBaseStyle: React.CSSProperties = { + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + padding: '8px 10px', + lineHeight: '20px', + }; + const tooltipInnerStyle: React.CSSProperties = { + maxWidth: 560, + maxHeight: '60vh', + overflow: 'auto', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }; + + const copyValue = React.useCallback(async (value: string) => { + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable'); + await navigator.clipboard.writeText(value); + void message.success(translate('data_grid.message.copied_to_clipboard')); + } catch { + void message.error(translate('connection_modal.message.copy_failed')); + } + }, [translate]); + + const renderCell = ( + value: string, + style: React.CSSProperties, + props?: DataGridTextCellProps, + ) => { + return ( +
+ {value} +
+ ); + }; return (
@@ -125,38 +228,84 @@ export const DataGridTextView: React.FC = ({
- {currentTextRow ? displayOutputColumnNames.map((col) => { - const columnMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; - const columnType = String(columnMeta?.type || '').trim(); - const columnComment = String(columnMeta?.comment || '').trim(); - - return ( -
-
-
- {col} : -
- {showColumnType && columnType && ( -
- {translate('data_grid.column.type_tooltip', { type: columnType })} -
- )} - {showColumnComment && columnComment && ( -
- {translate('data_grid.column.comment_tooltip', { comment: columnComment })} -
- )} +
+
+ {[ + ['field', 'data_grid.record_view.field'], + ['type', 'data_grid.record_view.type'], + ['comment', 'data_grid.record_view.comment'], + ['value', 'data_grid.record_view.value'], + ].map(([key, label]) => ( +
+ {translate(label)}
-
- {formatTextViewValue(currentTextRow[col], col)} -
-
- ); - }) : ( -
- {translate('data_grid.record_view.empty')} + ))}
- )} + {currentTextRow ? displayOutputColumnNames.map((col) => { + const columnMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; + const columnType = String(columnMeta?.type || '').trim(); + 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)'; + + return ( +
+ {renderCell(col, { fontWeight: 600, color: primaryTextColor }, { 'data-grid-text-view-cell': 'field' })} + {renderCell( + showColumnType ? columnType : '', + { color: metaTextColor }, + { 'data-grid-text-view-cell': 'type' }, + )} + {renderCell( + showColumnComment ? columnComment : '', + { color: metaTextColor }, + { 'data-grid-text-view-cell': 'comment' }, + )} + { void copyValue(formattedValue); }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + void copyValue(formattedValue); + } + }} + /> +
+ ); + }) : ( +
+ {translate('data_grid.record_view.empty')} +
+ )} +
); diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index c18b3374..68ddff8a 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -558,6 +558,11 @@ describe("i18n catalog", () => { "data_grid.record_view.empty", "data_grid.record_view.json_record_count", "data_grid.record_view.edit_json", + "data_grid.record_view.field", + "data_grid.record_view.value", + "data_grid.record_view.comment", + "data_grid.record_view.type", + "data_grid.record_view.copy_value", "data_grid.record_view.previous", "data_grid.record_view.next", "data_grid.record_view.record_position", diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index fe9fd97a..9bc601c6 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -4317,6 +4317,11 @@ "data_grid.record_view.edit_current": "Aktuellen Datensatz bearbeiten", "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.value": "Wert", + "data_grid.record_view.comment": "Kommentar", + "data_grid.record_view.type": "Datentyp", + "data_grid.record_view.copy_value": "Feldwert kopieren", "data_grid.record_view.empty": "Keine Daten in der aktuellen Ergebnismenge", "data_grid.record_view.json_record_count": "Aktuelle Ergebnismenge: {{count}} Datensätze", "data_grid.record_view.next": "Weiter", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index e527e6d1..a16ed682 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -4317,6 +4317,11 @@ "data_grid.record_view.edit_current": "Edit current record", "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.value": "Value", + "data_grid.record_view.comment": "Comment", + "data_grid.record_view.type": "Type", + "data_grid.record_view.copy_value": "Copy field value", "data_grid.record_view.empty": "No data in the current result set", "data_grid.record_view.json_record_count": "Current result set: {{count}} records", "data_grid.record_view.next": "Next", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 7fb1c26c..0e47384f 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -4317,6 +4317,11 @@ "data_grid.record_view.edit_current": "現在のレコードを編集", "data_grid.record_view.edit_json": "JSON を編集", "data_grid.record_view.back_to_table": "テーブルに戻る", + "data_grid.record_view.field": "フィールド", + "data_grid.record_view.value": "値", + "data_grid.record_view.comment": "コメント", + "data_grid.record_view.type": "型", + "data_grid.record_view.copy_value": "フィールド値をコピー", "data_grid.record_view.empty": "現在の結果セットにデータはありません", "data_grid.record_view.json_record_count": "現在の結果セット: {{count}} 件のレコード", "data_grid.record_view.next": "次のレコード", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index a1abc4ff..8469a20b 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -4317,6 +4317,11 @@ "data_grid.record_view.edit_current": "Редактировать текущую запись", "data_grid.record_view.edit_json": "Редактировать JSON", "data_grid.record_view.back_to_table": "Вернуться к таблице", + "data_grid.record_view.field": "Поле", + "data_grid.record_view.value": "Значение", + "data_grid.record_view.comment": "Комментарий", + "data_grid.record_view.type": "Тип", + "data_grid.record_view.copy_value": "Копировать значение поля", "data_grid.record_view.empty": "В текущем наборе результатов нет данных", "data_grid.record_view.json_record_count": "Текущий набор результатов: {{count}} записей", "data_grid.record_view.next": "Следующая", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 64845898..e475e9b2 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -4317,6 +4317,11 @@ "data_grid.record_view.edit_current": "编辑当前记录", "data_grid.record_view.edit_json": "编辑 JSON", "data_grid.record_view.back_to_table": "返回表格", + "data_grid.record_view.field": "字段", + "data_grid.record_view.value": "值", + "data_grid.record_view.comment": "注释", + "data_grid.record_view.type": "类型", + "data_grid.record_view.copy_value": "复制字段值", "data_grid.record_view.empty": "当前结果集无数据", "data_grid.record_view.json_record_count": "当前结果集 {{count}} 条记录", "data_grid.record_view.next": "下一条", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 8c8ea882..b266c047 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -4317,6 +4317,11 @@ "data_grid.record_view.edit_current": "編輯目前記錄", "data_grid.record_view.edit_json": "編輯 JSON", "data_grid.record_view.back_to_table": "返回表格", + "data_grid.record_view.field": "欄位", + "data_grid.record_view.value": "值", + "data_grid.record_view.comment": "註解", + "data_grid.record_view.type": "類型", + "data_grid.record_view.copy_value": "複製欄位值", "data_grid.record_view.empty": "目前結果集沒有資料", "data_grid.record_view.json_record_count": "目前結果集 {{count}} 筆記錄", "data_grid.record_view.next": "下一筆",