diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 78251775a..d6e064b8d 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -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) { diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 7cff02f48..6aad0dc4c 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -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) { diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts new file mode 100644 index 000000000..275ef9df2 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts @@ -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 | 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); + }); +}); diff --git a/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts b/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts index d705f9bb6..d027d1e90 100644 --- a/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/crossDatabaseTablePaste.spec.ts @@ -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)"); diff --git a/packages/app-tests/sidebarTreeAffordances.test.ts b/packages/app-tests/sidebarTreeAffordances.test.ts index b65a2fc4e..9793b1c6b 100644 --- a/packages/app-tests/sidebarTreeAffordances.test.ts +++ b/packages/app-tests/sidebarTreeAffordances.test.ts @@ -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\)\)/); });