fix(sidebar): sync tree node expansion state after async load

This commit is contained in:
二丫讲梵 2026-08-06 14:45:25 +08:00 committed by GitHub
parent 74f7ce31b6
commit c0b2cfc2ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 91 additions and 9 deletions

View File

@ -1184,9 +1184,9 @@ function onSearchToggle(node: TreeNode) {
searchCollapsedIds.value = next;
}
function onNodeToggled(node: TreeNode, wasExpanded: boolean) {
function onNodeToggled(node: TreeNode, expanded: boolean) {
if (isTreeSearchFiltering.value) return;
syncSidebarTreeNodeExpansion(store.treeNodes, node, !wasExpanded);
syncSidebarTreeNodeExpansion(store.treeNodes, node, expanded);
}
function openSidebarContextMenu(event: MouseEvent, node: TreeNode, openContextMenu: (event: MouseEvent, itemsOverride?: ContextMenuItem[]) => void) {

View File

@ -325,7 +325,7 @@ const emit = defineEmits<{
"rename-started": [];
"group-created": [groupId: string];
"request-group-rename": [groupId: string];
"node-toggled": [node: TreeNode, wasExpanded: boolean];
"node-toggled": [node: TreeNode, expanded: boolean];
"search-toggle": [node: TreeNode];
"context-menu": [event: MouseEvent, node: TreeNode, items: ContextMenuItem[]];
"open-ddl": [node: TreeNode];
@ -4754,13 +4754,13 @@ function activateRuntimeNode(node: TreeNode) {
activeNode.value = node;
}
// Async loaders can rebuild a connection node while awaiting the backend.
// Publish the live tree node so a stale rendered row cannot reset expansion.
// Async loaders can rebuild a connection node while awaiting the backend. Keep
// the live node active for later actions, but publish the rendered node so the
// tree owner can synchronize display projections without losing the toggle.
function emitNodeToggled(node: TreeNode, wasExpanded: boolean, expandedOverride?: boolean) {
const liveNode = findSidebarActionTarget(connectionStore.treeNodes, createSidebarActionTarget(node)) ?? node;
if (expandedOverride !== undefined) liveNode.isExpanded = expandedOverride;
activeNode.value = liveNode;
emit("node-toggled", liveNode, wasExpanded);
emit("node-toggled", node, expandedOverride ?? !wasExpanded);
}
function activateActionTarget(target: SidebarActionTarget) {

View File

@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { TreeNode } from "@/types/database";
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue";
const connectionStore = {
treeNodes: [] as TreeNode[],
sidebarSearchQuery: "",
canUseLoadedTreeNodeToggle: vi.fn(() => true),
releaseCollapsedTreeNodeChildren: vi.fn(),
getConfig: vi.fn(() => ({ db_type: "mysql" })),
};
vi.mock("@/stores/connectionStore", () => ({
CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "connection attempt cancelled",
useConnectionStore: () => connectionStore,
}));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: {} }) }));
vi.mock("@/stores/savedSqlStore", () => ({ useSavedSqlStore: () => ({}) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: vi.fn() }) }));
vi.mock("@/composables/useSidebarDataOpenRuntime", () => ({ useSidebarDataOpenRuntime: () => ({ openData: vi.fn() }) }));
vi.mock("@/composables/useDatabaseOptions", () => ({ useDatabaseOptions: () => ({ getDatabaseOptions: vi.fn() }) }));
vi.mock("@/composables/useSidebarConnectionMutationRuntime", () => ({ useSidebarConnectionMutationRuntime: () => ({}) }));
vi.mock("@/composables/useSidebarDatabaseSpecificMutationRuntime", () => ({ useSidebarDatabaseSpecificMutationRuntime: () => ({}) }));
vi.mock("@/composables/useSidebarTableMutationRuntime", () => ({ useSidebarTableMutationRuntime: () => ({}) }));
vi.mock("@/composables/useSidebarTreeExportRuntime", () => ({ useSidebarTreeExportRuntime: () => ({}) }));
vi.mock("@/composables/useSidebarTreeToolRuntime", () => ({ useSidebarTreeToolRuntime: () => ({}) }));
const mountedApps: App[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
connectionStore.treeNodes = [];
connectionStore.sidebarSearchQuery = "";
vi.clearAllMocks();
});
describe("SidebarTreeRuntimeHost expansion", () => {
it("publishes a rendered group collapse and synchronizes the live tree", async () => {
const liveGroup: TreeNode = {
id: "connection:database:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "connection",
database: "database",
isExpanded: true,
children: [],
};
const renderedGroup: TreeNode = { ...liveGroup };
connectionStore.treeNodes = [liveGroup];
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const toggled = vi.fn((node: TreeNode, expanded: boolean) => {
syncSidebarTreeNodeExpansion(connectionStore.treeNodes, node, expanded);
});
const app = createApp(
defineComponent({
setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: renderedGroup, depth: 0, onNodeToggled: toggled }),
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
host.value?.toggleNode(renderedGroup);
await nextTick();
expect(toggled).toHaveBeenCalledWith(renderedGroup, false);
expect(liveGroup.isExpanded).toBe(false);
});
});

View File

@ -4,9 +4,10 @@ import { describe, expect, it } from "vitest";
const runtimeSource = readFileSync(new URL("../SidebarTreeRuntimeHost.vue", import.meta.url), "utf8");
describe("cross-database table paste", () => {
it("publishes the live node after async tree loads", () => {
it("keeps the live node active after async tree loads", () => {
expect(runtimeSource).toContain("function emitNodeToggled(node: TreeNode, wasExpanded: boolean, expandedOverride?: boolean)");
expect(runtimeSource).toContain("findSidebarActionTarget(connectionStore.treeNodes, createSidebarActionTarget(node)) ?? node");
expect(runtimeSource).toContain("activeNode.value = liveNode");
expect(runtimeSource).toContain("emitNodeToggled(node, wasExpanded, false)");
expect(runtimeSource).toMatch(/await connectionStore\.loadMongoDatabases\(node\.connectionId\);[\s\S]*?emitNodeToggled\(node, wasExpanded\)/);
expect(runtimeSource).toContain("connectionStore.cancelTreeNodeLoad(node.id)");

View File

@ -77,7 +77,7 @@ test("tree filters retain a temporary expansion state", () => {
assert.match(connectionTree, /return \{ \.\.\.node, children: matchingChildren \};/);
assert.doesNotMatch(connectionTree, /children: matchingChildren,\s*isExpanded:\s*true/);
assert.match(connectionTree, /function onSearchToggle\(node: TreeNode\) \{\s*if \(!isTreeSearchFiltering\.value \|\| !node\.children\) return;/);
assert.match(connectionTree, /function onNodeToggled\(node: TreeNode, wasExpanded: boolean\) \{\s*if \(isTreeSearchFiltering\.value\) return;\s*syncSidebarTreeNodeExpansion\(store\.treeNodes, node, !wasExpanded\)/);
assert.match(connectionTree, /function onNodeToggled\(node: TreeNode, expanded: boolean\) \{\s*if \(isTreeSearchFiltering\.value\) return;\s*syncSidebarTreeNodeExpansion\(store\.treeNodes, node, expanded\)/);
assert.match(runtimeHost, /shouldRunTreeNodeRowAction\(action, clickDetail, isGroupLabel\(node\)\)/);
});