feat(structure): keep newly created tables open for editing

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-24 12:20:33 +08:00
1 parent 1aa8b21a87
commit 1b2e4e1a6f
18 files changed
+476 -10

No files matched your search

@@ -126,6 +126,7 @@ import {
toColumnNames,
copySourceColumnDetails,
matchesCopySourceColumnSearch,
foldCreatedTableName,
} from "@/lib/table/tableStructureEditorState";
import { CREATE_DATABASE_CHARSET_OPTIONS, createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, normalizeCreateDatabaseCharsetKey, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import type { CreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
@@ -194,7 +195,7 @@ const props = defineProps<{
const emit = defineEmits<{
"update:draft": [draft: TableStructureEditorDraft | undefined];
saved: [commentChanged: boolean];
saved: [commentChanged: boolean, createdTableName?: string];
close: [];
openSettings: [initialTab?: string, initialSection?: string];
/** Jump from the DDL view to the table's data tab (issue #6724). */
@@ -4414,7 +4415,7 @@ async function applyChanges() {
await invalidateObjectDdl(ddlRequest());
loadedMetadataFacets.clear();
}
toast(t("structureEditor.saved"), 2500);
toast(t(isCreateMode.value ? "structureEditor.created" : "structureEditor.saved"), 2500);
sqlPreviewPending.value = false;
sqlPreviewLoading.value = false;
pendingStatements.value = [];
@@ -4428,8 +4429,11 @@ async function applyChanges() {
ddlDraft.value = null;
if (isCreateMode.value) {
clearDraft();
emit("saved", tableComment.value !== originalTableComment.value);
emit("close");
// The created tab must query the name the server actually stored: plain
// Oracle names are created unquoted and stored upper-folded, plain
// Informix-family names lower-folded, and every quoted name keeps its
// exact spelling — mirror the DDL generator's quoting rule here.
emit("saved", tableComment.value !== originalTableComment.value, foldCreatedTableName(newTableName.value, databaseType.value));
} else {
// Refresh persisted keys after successful renames/additions before metadata reloads.
persistLocalColumnOrder(false);
@@ -0,0 +1,374 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Regression: after creating a table the editor converts itself into the edit
// tab of the new table, so the emitted created name must be the name the
// server actually stored. The CREATE DDL emits plain Oracle identifiers
// unquoted (stored upper-folded) and plain Informix-family identifiers
// unquoted (stored lower-folded); dialects that quote new names keep the
// as-typed spelling. Emitting the as-typed name on a folding dialect left the
// converted edit tab querying a case that matches nothing.
const mocks = vi.hoisted(() => ({
connection: {
id: "structure-create-mode",
name: "Oracle",
db_type: "oracle",
driver_label: "Oracle",
},
ensureConnected: vi.fn(),
executeQuery: vi.fn(),
executeBatch: vi.fn(),
listDataTypes: vi.fn(),
buildTableStructureChangeSql: vi.fn(),
buildCreateTableSql: vi.fn(),
buildMysqlAutoIncrementSql: vi.fn(),
buildTableOwnerChangeSql: vi.fn(),
getTablePartitionStatus: vi.fn(),
getTableOwner: vi.fn(),
updateEditorSettings: vi.fn(),
loadObjectDdl: vi.fn(),
invalidateObjectDdl: vi.fn(),
loadObjectMetadataFacet: vi.fn(),
invalidateObjectMetadataCache: vi.fn(),
invalidateTableMetadataCache: vi.fn(),
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", async () => {
const { defineComponent, h } = await import("vue");
const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") });
return {
AlertTriangle: Icon,
Check: Icon,
ChevronDown: Icon,
ChevronLeft: Icon,
ChevronRight: Icon,
ChevronUp: Icon,
ClipboardList: Icon,
Copy: Icon,
Database: Icon,
Info: Icon,
Keyboard: Icon,
KeyRound: Icon,
ListChevronsUpDown: Icon,
Loader2: Icon,
Maximize2: Icon,
Pencil: Icon,
Plus: Icon,
RefreshCw: Icon,
RotateCcw: Icon,
Rows3: Icon,
Save: Icon,
Search: Icon,
Settings: Icon,
SlidersHorizontal: Icon,
Trash2: Icon,
UserRound: Icon,
X: Icon,
};
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
name: "Button",
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
}),
};
});
vi.mock("@/components/ui/input", async () => {
const { defineComponent, h } = await import("vue");
return {
Input: defineComponent({
name: "Input",
inheritAttrs: false,
props: { modelValue: { type: [String, Number], default: "" } },
emits: ["update:modelValue"],
setup:
(props, { attrs, emit }) =>
() =>
h("input", {
...attrs,
value: props.modelValue,
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
}),
}),
};
});
vi.mock("@/components/ui/badge", async () => {
const { defineComponent, h } = await import("vue");
return {
Badge: defineComponent({
name: "Badge",
inheritAttrs: false,
setup:
(_props, { attrs }) =>
() =>
h("span", attrs),
}),
};
});
vi.mock("@/components/ui/tabs", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs }) =>
() =>
h("div", attrs),
});
const TabsContent = defineComponent({
name: "MockTabsContent",
inheritAttrs: false,
setup:
(_props, { attrs }) =>
() =>
h("div", attrs),
});
const TabsTrigger = defineComponent({
name: "MockTabsTrigger",
inheritAttrs: false,
props: { value: { type: String, required: true } },
setup:
(props, { attrs }) =>
() =>
h("button", { ...attrs, type: "button", "data-tab-trigger": props.value }),
});
return { Tabs: Div, TabsContent, TabsList: Div, TabsTrigger };
});
vi.mock("@/components/ui/dropdown-menu", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs }) =>
() =>
h("div", attrs),
});
return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Div, DropdownMenuTrigger: Div };
});
vi.mock("@/components/ui/popover", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs }) =>
() =>
h("div", attrs),
});
return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div };
});
vi.mock("@/components/ui/tooltip", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs }) =>
() =>
h("div", attrs),
});
return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div };
});
vi.mock("@/components/ui/searchable-select", async () => {
const { defineComponent, h } = await import("vue");
return {
SearchableSelect: defineComponent({
name: "SearchableSelect",
inheritAttrs: false,
props: { modelValue: { type: String, default: "" } },
emits: ["update:modelValue"],
setup:
(props, { attrs }) =>
() =>
h("button", { ...attrs, type: "button", "data-model-value": props.modelValue }),
}),
};
});
vi.mock("@/components/ui/select", async () => {
const { defineComponent, h } = await import("vue");
const Div = defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs }) =>
() =>
h("div", attrs),
});
return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div };
});
vi.mock("@/components/editor/EditorSearchPanel.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
name: "MockEditorSearchPanel",
setup: () => ({ openSearch: () => false, closeSearch: () => false }),
render: () => h("div", { "data-editor-search-panel": "true" }),
}),
};
});
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: mocks.ensureConnected,
getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined),
}),
}));
vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) }));
vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [], fontSize: 13, fontFamily: "monospace", theme: "default", generateSqlQuoteIdentifiers: true },
updateEditorSettings: mocks.updateEditorSettings,
}),
}));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "pearl" } }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) }));
vi.mock("@/lib/sql/sqlFormatter", () => ({
formatSqlForDisplay: vi.fn(async (sql: string) => sql),
sqlFormatDialectForDbType: vi.fn(() => "mysql"),
}));
vi.mock("@/lib/editor/editorThemes", () => ({ loadEditorTheme: vi.fn(async () => []), editorFontTheme: vi.fn(() => []) }));
vi.mock("@/lib/metadata/objectDdlCache", () => ({
loadObjectDdl: mocks.loadObjectDdl,
invalidateObjectDdl: mocks.invalidateObjectDdl,
}));
vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache }));
vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache }));
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
executeBatch: mocks.executeBatch,
listDataTypes: mocks.listDataTypes,
buildTableStructureChangeSql: mocks.buildTableStructureChangeSql,
buildCreateTableSql: mocks.buildCreateTableSql,
buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql,
buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql,
getTablePartitionStatus: mocks.getTablePartitionStatus,
getTableOwner: mocks.getTableOwner,
}));
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
const mountedApps: App[] = [];
async function settle() {
for (let i = 0; i < 30; i++) {
await nextTick();
await Promise.resolve();
}
}
type SavedPayload = { commentChanged: boolean; createdTableName?: string };
/**
* Mount the editor in create mode, run the generated CREATE batch to
* completion, and return what the `saved` event carried — the exact string
* the converted edit tab would be assigned as its table name.
*/
async function createTableAndCaptureSaved(dbType: string, newTableName: string): Promise<SavedPayload> {
const previousDbType = mocks.connection.db_type;
mocks.connection.db_type = dbType;
let saved: SavedPayload | undefined;
try {
const root = document.createElement("div");
document.body.append(root);
const app = createApp(TableStructureEditor, {
connectionId: mocks.connection.id,
database: "test",
tableName: "",
draft: {
dirty: true,
activeTab: "columns",
newTableName,
tableComment: "",
originalTableComment: "",
columns: [{ id: "new:id", name: "id", dataType: "number", isNullable: false, defaultValue: "", comment: "", isPrimaryKey: false, extra: {}, markedForDrop: false }],
indexes: [],
foreignKeys: [],
triggers: [],
initialized: true,
},
onSaved: (commentChanged: boolean, createdName?: string) => {
saved = { commentChanged, createdTableName: createdName };
},
});
mountedApps.push(app);
const editor = app.mount(root) as unknown as { applyChanges: () => Promise<boolean> };
// The SQL preview is debounced (300ms), and a hydrated draft schedules a
// second refresh on mount; poll the apply entry point — its guard returns
// false without side effects while the preview is still pending.
await vi.waitFor(() => expect(mocks.buildCreateTableSql).toHaveBeenCalled(), { timeout: 3000 });
await vi.waitFor(
async () => {
const applied = await editor.applyChanges();
expect(applied).toBe(true);
},
{ timeout: 3000 },
);
expect(mocks.executeBatch).toHaveBeenCalled();
await settle();
} finally {
mocks.connection.db_type = previousDbType;
}
expect(saved).toBeDefined();
return saved!;
}
beforeEach(() => {
vi.clearAllMocks();
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] });
mocks.executeBatch.mockResolvedValue({ rowsAffected: 0 });
mocks.listDataTypes.mockResolvedValue([]);
mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false });
mocks.getTableOwner.mockResolvedValue("");
mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] });
mocks.buildCreateTableSql.mockResolvedValue({ statements: ["CREATE TABLE MyTable (id number)"], warnings: [] });
mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE MyTable (id number)", cacheStatus: "remote" });
mocks.loadObjectMetadataFacet.mockImplementation(async (_request: unknown, facet: string) => ({
value: facet === "comment" ? "" : [],
cacheStatus: "remote",
}));
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("TableStructureEditor created-table name write-back", () => {
it("emits the upper-folded storage name for a plain Oracle table", async () => {
const saved = await createTableAndCaptureSaved("oracle", "MyTable");
expect(saved.createdTableName).toBe("MYTABLE");
});
it("emits the lower-folded storage name for a plain Informix-family table", async () => {
const saved = await createTableAndCaptureSaved("informix", "MyTable");
expect(saved.createdTableName).toBe("mytable");
});
it("emits the as-typed name unchanged on dialects that quote new identifiers", async () => {
const postgres = await createTableAndCaptureSaved("postgres", "MyTable");
expect(postgres.createdTableName).toBe("MyTable");
const mysql = await createTableAndCaptureSaved("mysql", "MyTable");
expect(mysql.createdTableName).toBe("MyTable");
});
it("keeps a quoting-required Oracle name exactly as typed", async () => {
const saved = await createTableAndCaptureSaved("oracle", "my table");
expect(saved.createdTableName).toBe("my table");
});
});