diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 506d44a..cfd8859 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -35,6 +35,8 @@ const storeState = vi.hoisted(() => ({ showColumnType: false, }, setQueryOptions: vi.fn(), + addTab: vi.fn(), + setActiveContext: vi.fn(), tableColumnOrders: {}, enableColumnOrderMemory: false, setTableColumnOrder: vi.fn(), @@ -57,9 +59,14 @@ const backendApp = vi.hoisted(() => ({ ApplyChanges: vi.fn(), DBGetColumns: vi.fn(), DBGetIndexes: vi.fn(), + DBGetForeignKeys: vi.fn(), DBShowCreateTable: vi.fn(), })); +const testRenderState = vi.hoisted(() => ({ + latestColumns: [] as any[], +})); + const messageApi = vi.hoisted(() => ({ error: vi.fn(), info: vi.fn(), @@ -115,6 +122,7 @@ vi.mock('@ant-design/icons', () => { RightOutlined: Icon, RobotOutlined: Icon, SearchOutlined: Icon, + LinkOutlined: Icon, TableOutlined: Icon, DatabaseOutlined: Icon, NodeIndexOutlined: Icon, @@ -212,7 +220,10 @@ vi.mock('antd', () => { ); return { - Table: () => , + Table: ({ columns }: any) => { + testRenderState.latestColumns = Array.isArray(columns) ? columns : []; + return
; + }, message: messageApi, Input, Button, @@ -458,7 +469,12 @@ describe('DataGrid DDL interactions', () => { beforeEach(() => { backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] }); + backendApp.DBGetForeignKeys.mockResolvedValue({ success: true, data: [] }); backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' }); + storeState.appearance.uiVersion = 'legacy'; + storeState.addTab.mockReset(); + storeState.setActiveContext.mockReset(); + testRenderState.latestColumns = []; vi.stubGlobal('document', { addEventListener: vi.fn(), @@ -500,6 +516,7 @@ describe('DataGrid DDL interactions', () => { backendApp.ApplyChanges.mockReset(); backendApp.DBGetColumns.mockReset(); backendApp.DBGetIndexes.mockReset(); + backendApp.DBGetForeignKeys.mockReset(); backendApp.DBShowCreateTable.mockReset(); vi.unstubAllGlobals(); }); @@ -548,6 +565,58 @@ describe('DataGrid DDL interactions', () => { expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0); }); + it.each(['legacy', 'v2'] as const)( + 'opens the referenced table when clicking a foreign-key column header in %s UI', + async (uiVersion) => { + storeState.appearance.uiVersion = uiVersion; + backendApp.DBGetForeignKeys.mockResolvedValueOnce({ + success: true, + data: [{ + columnName: 'customer_id', + refTableName: 'customers', + refColumnName: 'id', + constraintName: 'fk_orders_customer', + }], + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + const fkColumn = testRenderState.latestColumns.find((column) => column.key === 'customer_id'); + expect(fkColumn).toBeTruthy(); + const headerRenderer = create(<>{fkColumn.title}); + const fkJump = headerRenderer.root.findByProps({ 'data-grid-fk-jump': 'true' }); + await act(async () => { + fkJump.props.onClick({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + }); + + expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' }); + expect(storeState.addTab).toHaveBeenCalledWith({ + id: 'conn-1-main-table-customers', + title: 'customers', + type: 'table', + connectionId: 'conn-1', + dbName: 'main', + tableName: 'customers', + }); + }, + ); + it('switches the v2 footer field tab into the main fields view', async () => { storeState.appearance.uiVersion = 'v2'; diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 8a9a1b7..5e301bc 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -20,12 +20,15 @@ vi.mock('../store', () => ({ blur: 0, showDataTableVerticalBorders: false, dataTableDensity: 'comfortable', + uiVersion: 'v2', }, queryOptions: { showColumnComment: false, showColumnType: false, }, setQueryOptions: vi.fn(), + addTab: vi.fn(), + setActiveContext: vi.fn(), tableColumnOrders: {}, enableColumnOrderMemory: false, setTableColumnOrder: vi.fn(), @@ -49,6 +52,7 @@ vi.mock('../../wailsjs/go/app/App', () => ({ ApplyChanges: vi.fn(), DBGetColumns: vi.fn(), DBGetIndexes: vi.fn(), + DBGetForeignKeys: vi.fn(), DBShowCreateTable: vi.fn(), })); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index b7ab98c..58fae5f 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'; import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd'; import dayjs from 'dayjs'; import type { SortOrder, ColumnType } from 'antd/es/table/interface'; -import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined } from '@ant-design/icons'; +import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined, LinkOutlined } from '@ant-design/icons'; import Editor from './MonacoEditor'; import { DndContext, @@ -23,10 +23,10 @@ import { arrayMove } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App'; +import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App'; import ImportPreviewModal from './ImportPreviewModal'; import { useStore } from '../store'; -import type { ColumnDefinition, IndexDefinition } from '../types'; +import type { ColumnDefinition, ForeignKeyDefinition, IndexDefinition } from '../types'; import { v4 as generateUuid } from 'uuid'; import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; @@ -1040,6 +1040,13 @@ type ColumnMeta = { comment: string; }; +type ForeignKeyTarget = { + columnName: string; + refTableName: string; + refColumnName: string; + constraintName: string; +}; + const EXACT_GRID_FILTER_OPERATOR = '='; const CONTAINS_GRID_FILTER_OPERATOR = 'CONTAINS'; const STRING_LIKE_GRID_FILTER_TYPES = new Set([ @@ -1212,6 +1219,8 @@ const DataGrid: React.FC = ({ scrollSnapshot, onScrollSnapshotChange }) => { const connections = useStore(state => state.connections); + const addTab = useStore(state => state.addTab); + const setActiveContext = useStore(state => state.setActiveContext); const addSqlLog = useStore(state => state.addSqlLog); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); @@ -1681,9 +1690,12 @@ const DataGrid: React.FC = ({ const [sortInfo, setSortInfo] = useState>([]); const [columnWidths, setColumnWidths] = useState>({}); const [columnMetaMap, setColumnMetaMap] = useState>({}); + const [foreignKeyMap, setForeignKeyMap] = useState>({}); const [uniqueKeyGroups, setUniqueKeyGroups] = useState([]); const columnMetaCacheRef = useRef>>({}); const columnMetaSeqRef = useRef(0); + const foreignKeyCacheRef = useRef>>({}); + const foreignKeySeqRef = useRef(0); const uniqueKeyGroupsCacheRef = useRef>({}); const uniqueKeyGroupsSeqRef = useRef(0); @@ -1700,13 +1712,16 @@ const DataGrid: React.FC = ({ const normalizedDbName = String(dbName || '').trim(); if (!connectionId || !normalizedTableName) { setColumnMetaMap({}); + setForeignKeyMap({}); setUniqueKeyGroups([]); return; } const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {}); + foreignKeySeqRef.current += 1; + setForeignKeyMap(exportScope === 'table' ? (foreignKeyCacheRef.current[cacheKey] || {}) : {}); setUniqueKeyGroups(uniqueKeyGroupsCacheRef.current[cacheKey] || []); - }, [connectionId, dbName, tableName]); + }, [connectionId, dbName, tableName, exportScope]); useEffect(() => { const normalizedTableName = String(tableName || '').trim(); @@ -1756,6 +1771,59 @@ const DataGrid: React.FC = ({ }); }, [connections, connectionId, dbName, tableName]); + useEffect(() => { + const normalizedTableName = String(tableName || '').trim(); + const normalizedDbName = String(dbName || '').trim(); + if (!connectionId || !normalizedTableName || exportScope !== 'table') return; + + const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`; + if (foreignKeyCacheRef.current[cacheKey]) return; + + const conn = connections.find(c => c.id === connectionId); + if (!conn) { + setForeignKeyMap({}); + return; + } + + const config = { + ...conn.config, + port: Number(conn.config.port), + password: conn.config.password || "", + database: conn.config.database || "", + useSSH: conn.config.useSSH || false, + ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } + }; + + const seq = ++foreignKeySeqRef.current; + DBGetForeignKeys(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName) + .then((res) => { + if (seq !== foreignKeySeqRef.current) return; + if (!res.success || !Array.isArray(res.data)) { + setForeignKeyMap({}); + return; + } + const nextMap: Record = {}; + (res.data as ForeignKeyDefinition[]).forEach((fk: any) => { + const columnName = String(fk?.columnName ?? fk?.ColumnName ?? '').trim(); + const refTableName = String(fk?.refTableName ?? fk?.RefTableName ?? '').trim(); + if (!columnName || !refTableName || refTableName === '-') return; + const target: ForeignKeyTarget = { + columnName, + refTableName, + refColumnName: String(fk?.refColumnName ?? fk?.RefColumnName ?? '').trim(), + constraintName: String(fk?.constraintName ?? fk?.ConstraintName ?? fk?.name ?? fk?.Name ?? '').trim(), + }; + nextMap[columnName] = target; + }); + foreignKeyCacheRef.current[cacheKey] = nextMap; + setForeignKeyMap(nextMap); + }) + .catch(() => { + if (seq !== foreignKeySeqRef.current) return; + setForeignKeyMap({}); + }); + }, [connections, connectionId, dbName, tableName, exportScope]); + useEffect(() => { const normalizedTableName = String(tableName || '').trim(); const normalizedDbName = String(dbName || '').trim(); @@ -1817,6 +1885,16 @@ const DataGrid: React.FC = ({ return next; }, [columnMetaMapByLowerName]); + const foreignKeyMapByLowerName = useMemo(() => { + const next: Record = {}; + Object.entries(foreignKeyMap).forEach(([name, target]) => { + const lowerName = String(name || '').toLowerCase(); + if (!lowerName || next[lowerName]) return; + next[lowerName] = target; + }); + return next; + }, [foreignKeyMap]); + const getColumnFilterType = useCallback((columnName: string): string => { const normalizedName = String(columnName || '').trim(); if (!normalizedName) return ''; @@ -1863,16 +1941,72 @@ const DataGrid: React.FC = ({ [columnMetaMap, columnMetaMapByLowerName] ); + const openForeignKeyTarget = useCallback((target: ForeignKeyTarget) => { + const refTableName = String(target?.refTableName || '').trim(); + if (!connectionId || !refTableName || refTableName === '-') return; + const targetDbName = String(dbName || '').trim(); + const tabId = `${connectionId}-${targetDbName}-table-${refTableName}`; + setActiveContext({ connectionId, dbName: targetDbName }); + addTab({ + id: tabId, + title: refTableName, + type: 'table', + connectionId, + dbName: targetDbName, + tableName: refTableName, + }); + }, [addTab, connectionId, dbName, setActiveContext]); + const renderColumnTitle = useCallback((name: string): React.ReactNode => { const normalizedName = String(name || ''); const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; + const foreignKeyTarget = foreignKeyMap[normalizedName] || foreignKeyMapByLowerName[normalizedName.toLowerCase()]; const hoverLines: string[] = []; if (meta?.type) hoverLines.push(`类型:${meta.type}`); if (meta?.comment) hoverLines.push(`备注:${meta.comment}`); + if (foreignKeyTarget?.refTableName) { + const refColumnText = foreignKeyTarget.refColumnName ? `.${foreignKeyTarget.refColumnName}` : ''; + hoverLines.push(`外键:${foreignKeyTarget.refTableName}${refColumnText}`); + } + + const fieldLabel = foreignKeyTarget?.refTableName ? ( + + ) : ( + {normalizedName} + ); const titleNode = (
- {normalizedName} + {fieldLabel} {showColumnType && meta?.type && ( = ({ {titleNode} ); - }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType, densityParams]); + }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, foreignKeyMap, foreignKeyMapByLowerName, showColumnComment, showColumnType, densityParams, openForeignKeyTarget]); const closeCellEditor = useCallback(() => { setCellEditorOpen(false); @@ -4169,6 +4303,8 @@ const DataGrid: React.FC = ({ onResizeAutoFit: handleResizeAutoFit(key), onClickCapture: (event: React.MouseEvent) => { if (!onSort) return; + const eventTarget = event.target as HTMLElement | null; + if (eventTarget?.closest?.('[data-grid-fk-jump="true"]')) return; const headerCell = event.currentTarget as HTMLElement; const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null; const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null;