mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-18 04:44:18 +08:00
🐛 fix(table-designer): 修复外部 SQL 恢复并支持字段复制粘贴 (#717)
This commit is contained in:
@@ -97,6 +97,7 @@ const storeState = vi.hoisted(() => ({
|
||||
},
|
||||
},
|
||||
activeTabId: 'tab-1',
|
||||
tabs: [] as TabData[],
|
||||
aiPanelVisible: false,
|
||||
setAIPanelVisible: vi.fn(),
|
||||
sqlSnippets: [] as any[],
|
||||
@@ -792,6 +793,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
storeState.saveQuery.mockImplementation(async (query: SavedQuery) => query);
|
||||
storeState.savedQueries = [];
|
||||
storeState.activeTabId = 'tab-1';
|
||||
storeState.tabs = [];
|
||||
storeState.aiPanelVisible = false;
|
||||
storeState.setAIPanelVisible.mockReset();
|
||||
storeState.appearance.uiVersion = 'legacy';
|
||||
@@ -7334,6 +7336,46 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(editorState.editor.setValue).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('does not restore a closed external SQL file after unmount cleanup', async () => {
|
||||
const filePath = '/Users/me/Documents/gonavi-queries/closed.sql';
|
||||
const tab = createTab({ filePath, query: 'select 1;' });
|
||||
storeState.tabs = [tab];
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={tab} />);
|
||||
});
|
||||
await act(async () => {
|
||||
editorState.value = 'select 2;';
|
||||
editorState.latestOnChange?.(editorState.value);
|
||||
});
|
||||
expect(getSQLFileTabDraft('tab-1')).toBe('select 2;');
|
||||
|
||||
storeState.tabs = [];
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
expect(getSQLFileTabDraft('tab-1')).toBe('');
|
||||
});
|
||||
|
||||
it('writes the latest external SQL draft when the tab still exists on unmount', async () => {
|
||||
const filePath = '/Users/me/Documents/gonavi-queries/open.sql';
|
||||
const tab = createTab({ filePath, query: 'select 1;' });
|
||||
storeState.tabs = [tab];
|
||||
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={tab} />);
|
||||
});
|
||||
editorState.value = 'select 2;';
|
||||
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
expect(getSQLFileTabDraft('tab-1')).toBe('select 2;');
|
||||
});
|
||||
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
|
||||
|
||||
@@ -2291,10 +2291,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
dbName: currentDbRef.current,
|
||||
});
|
||||
return () => {
|
||||
persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), {
|
||||
connectionId: currentConnectionIdRef.current,
|
||||
dbName: currentDbRef.current,
|
||||
});
|
||||
const tabStillExists = useStore.getState().tabs.some((item) => item.id === draftSnapshotTab.id);
|
||||
if (tabStillExists) {
|
||||
persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), {
|
||||
connectionId: currentConnectionIdRef.current,
|
||||
dbName: currentDbRef.current,
|
||||
});
|
||||
} else {
|
||||
clearQueryTabDraft(draftSnapshotTab.id);
|
||||
}
|
||||
};
|
||||
}, [draftSnapshotTab, getCurrentQuery, isExternalSQLFileTab]);
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
resolveSqlDialect,
|
||||
} from '../utils/sqlDialect';
|
||||
import { splitQualifiedNameLast, stripIdentifierQuotes } from '../utils/qualifiedName';
|
||||
import {
|
||||
cloneTableDesignerColumnsForPaste,
|
||||
parseTableDesignerColumns,
|
||||
serializeTableDesignerColumns,
|
||||
} from './tableDesignerColumnClipboard';
|
||||
|
||||
interface EditableColumn extends ColumnDefinition {
|
||||
_key: string;
|
||||
@@ -1435,6 +1440,28 @@ ${selectedTrigger.statement}`;
|
||||
setColumns(prev => prev.filter(c => c._key !== key));
|
||||
};
|
||||
|
||||
const isNativeColumnEditorTarget = (target: EventTarget | null): boolean => {
|
||||
const element = target instanceof HTMLElement ? target : null;
|
||||
return !!element?.closest('input:not([type="checkbox"]):not([type="radio"]), textarea, select, [contenteditable="true"]');
|
||||
};
|
||||
|
||||
const handleColumnClipboardCopy = (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (readOnly || selectedColumns.length === 0 || isNativeColumnEditorTarget(event.target)) return;
|
||||
event.clipboardData.setData('text/plain', serializeTableDesignerColumns(selectedColumns));
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleColumnClipboardPaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (readOnly || isNativeColumnEditorTarget(event.target)) return;
|
||||
const pastedColumns = parseTableDesignerColumns(event.clipboardData.getData('text/plain'));
|
||||
if (!pastedColumns || pastedColumns.length === 0) return;
|
||||
const nextColumns = cloneTableDesignerColumnsForPaste(pastedColumns, columns) as EditableColumn[];
|
||||
setColumns(prev => [...prev, ...nextColumns]);
|
||||
setSelectedColumnRowKeys(nextColumns.map(column => column._key));
|
||||
pendingFocusColumnKeyRef.current = nextColumns[0]._key || null;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const selectedColumns = useMemo(() => {
|
||||
if (selectedColumnRowKeys.length === 0) return [];
|
||||
const selectedSet = new Set(selectedColumnRowKeys);
|
||||
@@ -2810,6 +2837,8 @@ END;`;
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`table-designer-wrapper${isV2Ui ? ' gn-v2-designer-table-shell' : ''}`}
|
||||
onCopy={handleColumnClipboardCopy}
|
||||
onPaste={handleColumnClipboardPaste}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
|
||||
64
frontend/src/components/tableDesignerColumnClipboard.test.ts
Normal file
64
frontend/src/components/tableDesignerColumnClipboard.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
cloneTableDesignerColumnsForPaste,
|
||||
parseTableDesignerColumns,
|
||||
serializeTableDesignerColumns,
|
||||
TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX,
|
||||
type TableDesignerClipboardColumn,
|
||||
} from './tableDesignerColumnClipboard';
|
||||
|
||||
const column = (overrides: Partial<TableDesignerClipboardColumn> = {}): TableDesignerClipboardColumn => ({
|
||||
_key: 'column-1',
|
||||
name: 'created_at',
|
||||
type: 'datetime',
|
||||
nullable: 'NO',
|
||||
key: '',
|
||||
extra: 'DEFAULT_GENERATED',
|
||||
comment: '创建时间',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
hasDefault: true,
|
||||
charset: 'utf8mb4',
|
||||
collation: 'utf8mb4_bin',
|
||||
isAutoIncrement: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('tableDesignerColumnClipboard', () => {
|
||||
it('serializes and parses column definitions without UI keys', () => {
|
||||
const text = serializeTableDesignerColumns([column()]);
|
||||
expect(text.startsWith(TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX)).toBe(true);
|
||||
expect(text).not.toContain('column-1');
|
||||
expect(parseTableDesignerColumns(text)).toEqual([expect.objectContaining({
|
||||
name: 'created_at',
|
||||
type: 'datetime',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
charset: 'utf8mb4',
|
||||
})]);
|
||||
});
|
||||
|
||||
it('rejects ordinary, malformed, and incomplete clipboard text', () => {
|
||||
expect(parseTableDesignerColumns('created_at')).toBeNull();
|
||||
expect(parseTableDesignerColumns(`${TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX}{`)).toBeNull();
|
||||
expect(parseTableDesignerColumns(`${TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX}${JSON.stringify({ version: 1, columns: [{ name: 'id' }] })}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('clones columns at the end with preserved definitions and unique names', () => {
|
||||
const pasted = cloneTableDesignerColumnsForPaste(
|
||||
[column({ name: 'id' }), column({ name: 'ID' })],
|
||||
[column({ name: 'id' }), column({ name: 'id_copy' })],
|
||||
);
|
||||
|
||||
expect(pasted).toHaveLength(2);
|
||||
expect(pasted.map(item => item.name)).toEqual(['id_copy_2', 'ID_copy_3']);
|
||||
expect(pasted.every(item => item.isNew && item._key && item._key !== 'column-1')).toBe(true);
|
||||
expect(pasted[0]).toEqual(expect.objectContaining({
|
||||
type: 'datetime',
|
||||
nullable: 'NO',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
hasDefault: true,
|
||||
extra: 'DEFAULT_GENERATED',
|
||||
comment: '创建时间',
|
||||
}));
|
||||
});
|
||||
});
|
||||
100
frontend/src/components/tableDesignerColumnClipboard.ts
Normal file
100
frontend/src/components/tableDesignerColumnClipboard.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export const TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX = 'gonavi-table-designer-columns-v1:';
|
||||
|
||||
export type TableDesignerClipboardColumn = {
|
||||
_key?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: string;
|
||||
key: string;
|
||||
default?: string;
|
||||
hasDefault?: boolean;
|
||||
extra: string;
|
||||
comment: string;
|
||||
charset?: string;
|
||||
collation?: string;
|
||||
isAutoIncrement?: boolean;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
||||
!!value && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
|
||||
const readRequiredText = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
return value;
|
||||
};
|
||||
|
||||
const normalizeColumn = (value: unknown): TableDesignerClipboardColumn | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const name = readRequiredText(value.name);
|
||||
const type = readRequiredText(value.type);
|
||||
const nullable = readRequiredText(value.nullable);
|
||||
const key = readRequiredText(value.key);
|
||||
const extra = readRequiredText(value.extra);
|
||||
const comment = readRequiredText(value.comment);
|
||||
if (name === null || type === null || nullable === null || key === null || extra === null || comment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const column: TableDesignerClipboardColumn = {
|
||||
name,
|
||||
type,
|
||||
nullable,
|
||||
key,
|
||||
extra,
|
||||
comment,
|
||||
};
|
||||
if (typeof value.default === 'string') column.default = value.default;
|
||||
if (typeof value.hasDefault === 'boolean') column.hasDefault = value.hasDefault;
|
||||
if (typeof value.charset === 'string') column.charset = value.charset;
|
||||
if (typeof value.collation === 'string') column.collation = value.collation;
|
||||
if (typeof value.isAutoIncrement === 'boolean') column.isAutoIncrement = value.isAutoIncrement;
|
||||
return column;
|
||||
};
|
||||
|
||||
export const serializeTableDesignerColumns = (columns: TableDesignerClipboardColumn[]): string => (
|
||||
`${TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX}${JSON.stringify({
|
||||
version: 1,
|
||||
columns: columns.map(({ _key: _ignored, ...column }) => column),
|
||||
})}`
|
||||
);
|
||||
|
||||
export const parseTableDesignerColumns = (text: string): TableDesignerClipboardColumn[] | null => {
|
||||
if (!String(text || '').startsWith(TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX)) return null;
|
||||
try {
|
||||
const payload = JSON.parse(String(text).slice(TABLE_DESIGNER_COLUMN_CLIPBOARD_PREFIX.length));
|
||||
if (!isRecord(payload) || payload.version !== 1 || !Array.isArray(payload.columns)) return null;
|
||||
const columns = payload.columns.map(normalizeColumn);
|
||||
return columns.every((column): column is TableDesignerClipboardColumn => column !== null)
|
||||
? columns
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const createUniqueColumnName = (sourceName: string, usedNames: Set<string>): string => {
|
||||
const baseName = sourceName.trim() || 'new_column';
|
||||
let candidate = `${baseName}_copy`;
|
||||
let suffix = 2;
|
||||
while (usedNames.has(candidate.toLowerCase())) {
|
||||
candidate = `${baseName}_copy_${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedNames.add(candidate.toLowerCase());
|
||||
return candidate;
|
||||
};
|
||||
|
||||
export const cloneTableDesignerColumnsForPaste = (
|
||||
columns: TableDesignerClipboardColumn[],
|
||||
existingColumns: TableDesignerClipboardColumn[],
|
||||
): TableDesignerClipboardColumn[] => {
|
||||
const usedNames = new Set(existingColumns.map((column) => String(column.name || '').trim().toLowerCase()));
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
name: createUniqueColumnName(column.name, usedNames),
|
||||
_key: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
isNew: true,
|
||||
}));
|
||||
};
|
||||
Reference in New Issue
Block a user