fix(structure): stop replaying consumed initial tab on remount
Closes #8419
This commit is contained in:
3 files changed
+424
-2
No files matched your search
@@ -1160,6 +1160,7 @@ let skipNextRefreshVersion = false;
|
||||
let restoringDraft = false;
|
||||
let syncingDraft = false;
|
||||
let draftHydrated = false;
|
||||
let lastAppliedInitialTabRequestId: number | undefined;
|
||||
let hydratingRestoredDraft = false;
|
||||
let structureScrollFrame = 0;
|
||||
let structureHorizontalScrollbarThumbLeftPercent = 0;
|
||||
@@ -1361,6 +1362,7 @@ function createCurrentDraft(initialized = true): TableStructureEditorDraft {
|
||||
triggersLoaded: triggersLoaded.value,
|
||||
loadedMetadataFacets: [...loadedMetadataFacets],
|
||||
scrollPositions: cloneDraftValue(structureScrollPositions.value),
|
||||
appliedInitialTabRequestId: lastAppliedInitialTabRequestId,
|
||||
initialized,
|
||||
};
|
||||
}
|
||||
@@ -1376,6 +1378,7 @@ function syncDraftToParent() {
|
||||
function restoreDraft(draft: TableStructureEditorDraft) {
|
||||
restoringDraft = true;
|
||||
draftHydrated = false;
|
||||
lastAppliedInitialTabRequestId = draft.appliedInitialTabRequestId;
|
||||
activeTab.value = draft.activeTab || "columns";
|
||||
// Restore the DDL baseline alongside the edit, otherwise the restored script
|
||||
// would read as dirty (or clean) against the wrong reference text.
|
||||
@@ -3750,14 +3753,17 @@ function unregisterStructureEditorShortcuts() {
|
||||
|
||||
onMounted(() => {
|
||||
resetState();
|
||||
applyInitialStructureTab();
|
||||
// With an initialized draft the restore below owns the tab (plus any
|
||||
// unconsumed initial tab); applying the stale initial tab first would only
|
||||
// flash the wrong facet and kick its metadata load (#8419).
|
||||
if (!props.draft?.initialized) applyInitialStructureTab();
|
||||
applyInitialStructureTarget();
|
||||
registerStructureEditorShortcuts();
|
||||
void loadDynamicDataTypeOptions();
|
||||
if (props.draft?.initialized) {
|
||||
restoreDraft(props.draft);
|
||||
// A restored draft owns its saved tab unless navigation explicitly requested another one.
|
||||
applyInitialStructureTab(false);
|
||||
applyPendingInitialStructureTab();
|
||||
applyInitialStructureTarget();
|
||||
}
|
||||
structureEditorReady = true;
|
||||
@@ -3840,11 +3846,23 @@ function resolveStructureMetadataTab(tab: TableInfoTab | undefined, capabilities
|
||||
function applyInitialStructureTab(useDefault = true) {
|
||||
if (props.initialTab) {
|
||||
activeTab.value = resolveStructureMetadataTab(props.initialTab);
|
||||
lastAppliedInitialTabRequestId = props.initialTabRequestId;
|
||||
} else if (useDefault) {
|
||||
activeTab.value = resolveStructureMetadataTab(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// The tab's structureInitialTab stays populated after it was consumed once
|
||||
// (e.g. the side panel opened the editor on its foreign-keys facet). Replaying
|
||||
// it on every remount would override the draft-restored tab the user last
|
||||
// selected, so an initial tab only applies again when navigation bumped the
|
||||
// request id (#8419).
|
||||
function applyPendingInitialStructureTab() {
|
||||
if (!props.initialTab) return;
|
||||
if (props.initialTabRequestId !== undefined && props.initialTabRequestId === lastAppliedInitialTabRequestId) return;
|
||||
applyInitialStructureTab(false);
|
||||
}
|
||||
|
||||
function initialTargetKey(target: TableStructureEditorTarget): string {
|
||||
return `${props.initialTabRequestId ?? 0}:${target.kind}:${target.name}`;
|
||||
}
|
||||
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const TABLE_DDL = "CREATE TABLE `users` (\n `id` bigint NOT NULL AUTO_INCREMENT,\n `email` varchar(255) DEFAULT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connection: {
|
||||
id: "structure-ddl-tab",
|
||||
name: "MySQL",
|
||||
db_type: "mysql",
|
||||
driver_label: "MySQL",
|
||||
},
|
||||
ensureConnected: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
executeBatch: vi.fn(),
|
||||
listDataTypes: vi.fn(),
|
||||
buildTableStructureChangeSql: 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,
|
||||
Copy: Icon,
|
||||
Database: Icon,
|
||||
Info: Icon,
|
||||
Keyboard: Icon,
|
||||
KeyRound: Icon,
|
||||
ListChevronsUpDown: Icon,
|
||||
Loader2: Icon,
|
||||
Maximize2: Icon,
|
||||
Pencil: Icon,
|
||||
Plus: Icon,
|
||||
RefreshCw: Icon,
|
||||
RotateCcw: 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, slots }) =>
|
||||
() =>
|
||||
h("span", attrs, slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
// Tabs mock that reproduces the one detail this regression depends on: reka-ui's
|
||||
// TabsContent renders its slot through Presence, and `usePresence` awaits a
|
||||
// `nextTick` before dispatching MOUNT. So the pane (and every template ref
|
||||
// inside it) mounts one tick *after* the tab became active. A mock that renders
|
||||
// panes unconditionally — like the other structure specs use — cannot see the
|
||||
// bug at all.
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { computed, defineComponent, h, inject, nextTick, provide, ref, watch } = await import("vue");
|
||||
const TabsSelectKey = Symbol("tabs:select");
|
||||
const TabsActiveKey = Symbol("tabs:active");
|
||||
const Div = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
const Tabs = defineComponent({
|
||||
name: "MockTabs",
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: { type: String, default: "" } },
|
||||
emits: ["update:modelValue"],
|
||||
setup: (props, { attrs, slots, emit }) => {
|
||||
provide(TabsSelectKey, (value: string) => emit("update:modelValue", value));
|
||||
provide(
|
||||
TabsActiveKey,
|
||||
computed(() => props.modelValue),
|
||||
);
|
||||
return () => h("div", attrs, slots.default?.());
|
||||
},
|
||||
});
|
||||
const TabsContent = defineComponent({
|
||||
name: "MockTabsContent",
|
||||
inheritAttrs: false,
|
||||
props: { value: { type: String, required: true } },
|
||||
setup: (props, { attrs, slots }) => {
|
||||
const active = inject<{ value: string }>(TabsActiveKey, ref(""));
|
||||
const selected = computed(() => active.value === props.value);
|
||||
const present = ref(selected.value);
|
||||
watch(selected, async (isSelected) => {
|
||||
if (!isSelected) {
|
||||
present.value = false;
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
present.value = true;
|
||||
});
|
||||
return () => h("div", attrs, present.value ? slots.default?.() : undefined);
|
||||
},
|
||||
});
|
||||
const TabsTrigger = defineComponent({
|
||||
name: "MockTabsTrigger",
|
||||
inheritAttrs: false,
|
||||
props: { value: { type: String, required: true } },
|
||||
setup: (props, { attrs, slots }) => {
|
||||
const select = inject<(value: string) => void>(TabsSelectKey, () => {});
|
||||
return () => h("button", { ...attrs, type: "button", "data-tab-trigger": props.value, onClick: () => select(props.value) }, slots.default?.());
|
||||
},
|
||||
});
|
||||
return { Tabs, 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, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
const Button = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("button", attrs, slots.default?.()),
|
||||
});
|
||||
return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div };
|
||||
});
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const Div = defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
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, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
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, slots }) =>
|
||||
() =>
|
||||
h("div", attrs, slots.default?.()),
|
||||
});
|
||||
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" },
|
||||
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,
|
||||
buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql,
|
||||
buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql,
|
||||
getTablePartitionStatus: mocks.getTablePartitionStatus,
|
||||
getTableOwner: mocks.getTableOwner,
|
||||
}));
|
||||
|
||||
import TableStructureEditor from "@/components/structure/TableStructureEditor.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function structureDraft(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
return {
|
||||
dirty: false,
|
||||
activeTab: "columns",
|
||||
newTableName: "",
|
||||
tableComment: "",
|
||||
originalTableComment: "",
|
||||
columns: [],
|
||||
indexes: [],
|
||||
foreignKeys: [],
|
||||
constraints: [],
|
||||
triggers: [],
|
||||
initialized: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mountStructureEditor(props: Record<string, unknown> = {}) {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp(TableStructureEditor, {
|
||||
connectionId: mocks.connection.id,
|
||||
database: "test",
|
||||
tableName: "users",
|
||||
...props,
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(root);
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(root.querySelector('[data-tab-trigger="foreignKeys"]')).not.toBeNull();
|
||||
expect(root.textContent).toContain("structureEditor.noChanges");
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await settle();
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Let every already-queued load/preview microtask land before touching the DOM. */
|
||||
async function settle() {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
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.loadObjectDdl.mockResolvedValue({ ddl: TABLE_DDL, cacheStatus: "remote" });
|
||||
mocks.loadObjectMetadataFacet.mockImplementation(async (_request: unknown, facet: string) => ({
|
||||
value:
|
||||
facet === "comment"
|
||||
? ""
|
||||
: facet === "columns"
|
||||
? [
|
||||
{ name: "id", data_type: "bigint", nullable: false, default_value: null, comment: "" },
|
||||
{ name: "email", data_type: "varchar(255)", nullable: true, default_value: null, comment: "" },
|
||||
]
|
||||
: [],
|
||||
cacheStatus: "remote",
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("TableStructureEditor initial tab", () => {
|
||||
it("keeps the draft-restored tab when the initial tab was already consumed", async () => {
|
||||
// Regression for #8419: the tab keeps structureInitialTab="foreignKeys"
|
||||
// after the side panel opened the editor on that facet once. A remount
|
||||
// (switching tabs and coming back) must keep the user's last selection.
|
||||
const root = await mountStructureEditor({
|
||||
initialTab: "foreignKeys",
|
||||
initialTabRequestId: 1,
|
||||
draft: structureDraft({ activeTab: "columns", appliedInitialTabRequestId: 1 }),
|
||||
});
|
||||
|
||||
expect(root.textContent).not.toContain("structureEditor.addForeignKey");
|
||||
expect(root.textContent).toContain("structureEditor.addColumn");
|
||||
});
|
||||
|
||||
it("applies the initial tab when navigation bumped the request id", async () => {
|
||||
const root = await mountStructureEditor({
|
||||
initialTab: "foreignKeys",
|
||||
initialTabRequestId: 2,
|
||||
draft: structureDraft({ activeTab: "columns", appliedInitialTabRequestId: 1 }),
|
||||
});
|
||||
|
||||
expect(root.textContent).toContain("structureEditor.addForeignKey");
|
||||
});
|
||||
|
||||
it("seeds a fresh editor with the initial tab", async () => {
|
||||
const root = await mountStructureEditor({ initialTab: "foreignKeys", initialTabRequestId: 1 });
|
||||
|
||||
expect(root.textContent).toContain("structureEditor.addForeignKey");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user