diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 8d81d1d2e..94b337218 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -65,6 +65,7 @@ import * as api from "@/lib/backend/api"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout"; import { objectTypesForGroupNode } from "@/lib/table/tableTree"; +import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"; import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates"; import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint"; import { @@ -513,6 +514,11 @@ async function toggle() { } try { + if (await loadSidebarObjectGroup(node, connectionStore)) { + emit("node-toggled", node, wasExpanded); + return; + } + if (node.type === "connection" && node.connectionId) { const config = connectionStore.getConfig(node.connectionId); if (config?.db_type === "redis") { @@ -623,10 +629,6 @@ async function toggle() { await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); } else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); - } else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { - await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); - } else if (databaseObjectGroup) { - await connectionStore.loadObjectGroupChildren(node); } emit("node-toggled", node, wasExpanded); } catch (e: any) { diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts new file mode 100644 index 000000000..9f80d6e16 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts @@ -0,0 +1,125 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting"; +import type { ConnectionConfig, ObjectInfo, TreeNode } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function mysqlConnection(): ConnectionConfig { + return { + id: "mysql-1", + name: "MySQL", + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: "", + database: "app", + } as ConnectionConfig; +} + +function objectGroup(type: "group-triggers" | "group-types", id: string): TreeNode { + return { + id, + label: type === "group-triggers" ? "tree.triggers" : "tree.types", + type, + connectionId: "mysql-1", + database: "app", + schema: "app", + isExpanded: false, + children: [], + }; +} + +async function createStore({ listObjects, listTriggers }: { listObjects: ReturnType; listTriggers: ReturnType }) { + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listInstalledAgents: vi.fn().mockResolvedValue([]), + listObjects, + listTriggers, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const connection = mysqlConnection(); + store.connections = [connection]; + store.connectedIds.add(connection.id); + useSettingsStore().desktopSettings.sidebar_table_page_size = 10; + return { connection, store }; +} + +describe("sidebar object-group routing", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("uses listObjects for schema-level trigger and type groups, including empty results", async () => { + const listObjects = vi.fn<() => Promise>().mockResolvedValue([]); + const listTriggers = vi.fn<() => Promise>().mockResolvedValue([]); + const { connection, store } = await createStore({ listObjects, listTriggers }); + const triggerGroup = objectGroup("group-triggers", `${connection.id}:app:app:__triggers`); + const typeGroup = objectGroup("group-types", `${connection.id}:app:app:__types`); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [triggerGroup, typeGroup] }]; + const storedTriggerGroup = store.treeNodes[0].children![0]; + const storedTypeGroup = store.treeNodes[0].children![1]; + + await loadSidebarObjectGroup(storedTriggerGroup, store); + await loadSidebarObjectGroup(storedTypeGroup, store); + + expect(listObjects).toHaveBeenNthCalledWith(1, connection.id, "app", "app", ["TRIGGER"], undefined, 11, 0); + expect(listObjects).toHaveBeenNthCalledWith(2, connection.id, "app", "app", ["TYPE", "TYPE_BODY"], undefined, 11, 0); + expect(listTriggers).not.toHaveBeenCalled(); + expect(storedTriggerGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] }); + expect(storedTypeGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] }); + }); + + it("keeps table-level trigger groups on listTriggers", async () => { + const listObjects = vi.fn<() => Promise>().mockResolvedValue([]); + const listTriggers = vi.fn<() => Promise>().mockResolvedValue([]); + const { connection, store } = await createStore({ listObjects, listTriggers }); + const tableTriggerGroup: TreeNode = { + ...objectGroup("group-triggers", `${connection.id}:app:app:orders:__triggers`), + tableName: "orders", + }; + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tableTriggerGroup] }]; + const storedTriggerGroup = store.treeNodes[0].children![0]; + + await loadSidebarObjectGroup(storedTriggerGroup, store); + + expect(listTriggers).toHaveBeenCalledWith(connection.id, "app", "app", "orders", undefined); + expect(listObjects).not.toHaveBeenCalled(); + expect(storedTriggerGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] }); + }); + + it("propagates rejected schema-level metadata while clearing the loading state", async () => { + const listObjects = vi.fn<() => Promise>().mockRejectedValue(new Error("metadata access denied")); + const listTriggers = vi.fn<() => Promise>().mockResolvedValue([]); + const { connection, store } = await createStore({ listObjects, listTriggers }); + const triggerGroup = objectGroup("group-triggers", `${connection.id}:app:app:__triggers`); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [triggerGroup] }]; + const storedTriggerGroup = store.treeNodes[0].children![0]; + + await expect(loadSidebarObjectGroup(storedTriggerGroup, store)).rejects.toThrow("metadata access denied"); + + expect(listObjects).toHaveBeenCalledWith(connection.id, "app", "app", ["TRIGGER"], undefined, 11, 0); + expect(listTriggers).not.toHaveBeenCalled(); + expect(storedTriggerGroup).toMatchObject({ isExpanded: false, isLoading: false, children: [] }); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarObjectGroupRouting.ts b/apps/desktop/src/lib/sidebar/sidebarObjectGroupRouting.ts new file mode 100644 index 000000000..cc18ce1a9 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarObjectGroupRouting.ts @@ -0,0 +1,24 @@ +import type { TreeNode } from "@/types/database"; +import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; +import { objectTypesForGroupNode } from "@/lib/table/tableTree"; + +export interface SidebarObjectGroupLoaders { + loadTriggers(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string): Promise; + loadObjectGroupChildren(node: TreeNode): Promise; +} + +/** + * Loads object-group metadata from the same path used by the sidebar toggle. + * Table trigger groups are special because they are scoped to one table; + * schema-level trigger groups are regular database object groups. + */ +export async function loadSidebarObjectGroup(node: TreeNode, loaders: SidebarObjectGroupLoaders): Promise { + if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { + await loaders.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); + return true; + } + + if (!objectTypesForGroupNode(node.type)) return false; + await loaders.loadObjectGroupChildren(node); + return true; +}