mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-31 08:59:48 +08:00
✨ feat(DataGrid): 支持外键字段表头跳转关联表
- 表头增强:外键字段显示跳转入口并提示关联表信息 - 交互优化:点击外键字段打开关联表标签页,避免触发表头排序 - 兼容验证:补充 legacy 与 v2 UI 下的跳转行为测试 Refs #486
This commit is contained in:
@@ -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 />,
|
||||
Table: ({ columns }: any) => {
|
||||
testRenderState.latestColumns = Array.isArray(columns) ? columns : [];
|
||||
return <table />;
|
||||
},
|
||||
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(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-1', id: 1, customer_id: 10 }]}
|
||||
columnNames={['id', 'customer_id']}
|
||||
loading={false}
|
||||
tableName="orders"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
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';
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
const [sortInfo, setSortInfo] = useState<Array<{ columnKey: string, order: string, enabled?: boolean }>>([]);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [columnMetaMap, setColumnMetaMap] = useState<Record<string, ColumnMeta>>({});
|
||||
const [foreignKeyMap, setForeignKeyMap] = useState<Record<string, ForeignKeyTarget>>({});
|
||||
const [uniqueKeyGroups, setUniqueKeyGroups] = useState<string[][]>([]);
|
||||
const columnMetaCacheRef = useRef<Record<string, Record<string, ColumnMeta>>>({});
|
||||
const columnMetaSeqRef = useRef(0);
|
||||
const foreignKeyCacheRef = useRef<Record<string, Record<string, ForeignKeyTarget>>>({});
|
||||
const foreignKeySeqRef = useRef(0);
|
||||
const uniqueKeyGroupsCacheRef = useRef<Record<string, string[][]>>({});
|
||||
const uniqueKeyGroupsSeqRef = useRef(0);
|
||||
|
||||
@@ -1700,13 +1712,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
});
|
||||
}, [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<string, ForeignKeyTarget> = {};
|
||||
(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<DataGridProps> = ({
|
||||
return next;
|
||||
}, [columnMetaMapByLowerName]);
|
||||
|
||||
const foreignKeyMapByLowerName = useMemo(() => {
|
||||
const next: Record<string, ForeignKeyTarget> = {};
|
||||
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<DataGridProps> = ({
|
||||
[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 ? (
|
||||
<button
|
||||
type="button"
|
||||
data-grid-fk-jump="true"
|
||||
data-column-name={normalizedName}
|
||||
data-ref-table-name={foreignKeyTarget.refTableName}
|
||||
title={`跳转到外键表:${foreignKeyTarget.refTableName}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openForeignKeyTarget(foreignKeyTarget);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{normalizedName}</span>
|
||||
<LinkOutlined style={{ fontSize: densityParams.metaFontSize + 1, color: columnMetaHintColor, flex: 'none' }} />
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
|
||||
);
|
||||
|
||||
const titleNode = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, lineHeight: 1.2 }}>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{normalizedName}</span>
|
||||
{fieldLabel}
|
||||
{showColumnType && meta?.type && (
|
||||
<span
|
||||
style={{
|
||||
@@ -1916,7 +2050,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<span style={{ display: 'inline-flex', maxWidth: '100%' }}>{titleNode}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [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<DataGridProps> = ({
|
||||
onResizeAutoFit: handleResizeAutoFit(key),
|
||||
onClickCapture: (event: React.MouseEvent<HTMLElement>) => {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user