From 7b164d0084da71c95d3c61a5b2e950b1d5a68d2c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 18 Sep 2026 01:23:54 +0800 Subject: [PATCH] fix(sidebar): show disabled trigger indicators Closes #8697 --- .../src/components/sidebar/TreeItem.vue | 16 +- .../__tests__/TreeItem.triggerStatus.spec.ts | 195 ++++++++++++++++++ .../sidebar/sidebarObjectGroupRouting.spec.ts | 43 +++- 3 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/components/sidebar/__tests__/TreeItem.triggerStatus.spec.ts diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 333781f298..1da4ce6bed 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -37,6 +37,7 @@ import { Minus, X, CircleX, + Ban, RefreshCw, } from "@lucide/vue"; import OracleDatabaseLinksDialog from "@/components/objects/OracleDatabaseLinksDialog.vue"; @@ -225,6 +226,8 @@ const stopPasteHandlerRegistration = watch( const activeNode = shallowRef(props.node); +const isDisabledTrigger = computed(() => activeNode.value.type === "trigger" && (activeNode.value.meta as TriggerInfo | undefined)?.enabled === false); + const showProductionBadge = computed(() => { const connectionId = activeNode.value.connectionId; const context = productionContextForDatabase(connectionId ? connectionStore.getConfig(connectionId) : undefined, activeNode.value.database); @@ -1597,12 +1600,23 @@ function onKeydown(event: KeyboardEvent) { - +
diff --git a/apps/desktop/src/components/sidebar/__tests__/TreeItem.triggerStatus.spec.ts b/apps/desktop/src/components/sidebar/__tests__/TreeItem.triggerStatus.spec.ts new file mode 100644 index 0000000000..ec3cf10559 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/TreeItem.triggerStatus.spec.ts @@ -0,0 +1,195 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, ref, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import i18n, { setLocale } from "@/i18n"; +import TreeItem from "@/components/sidebar/TreeItem.vue"; +import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime"; +import type { DatabaseType, TreeNode, TriggerInfo } from "@/types/database"; + +let databaseType: DatabaseType = "sqlserver"; +const connectionStore = { + activeConnectionId: "connection-1", + connectedIds: new Set(["connection-1"]), + connectingIds: new Set(), + connectionMultiSelectActive: false, + connections: [], + getConfig: () => ({ id: "connection-1", db_type: databaseType }), + isDefaultDatabase: () => false, + isDefaultSchema: () => false, + isPinnedTreeNodeReorderTarget: () => false, + isTreeNodeChildrenLoaded: () => false, + isTreeNodePinned: () => false, + selectedTreeNodeId: null as string | null, + selectedTreeNodeIds: [] as string[], + selectedTreeNodeIdsSet: new Set(), + sidebarTableSearchQueries: {}, + tableNameFilterForScope: () => undefined, + treeNodes: [], + treeSelectionAnchorId: null as string | null, +}; + +vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => connectionStore })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openDatabaseKeys: new Set() }) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + shortcuts: { openDataInNewTab: "" }, + sidebarActivation: "double", + sidebarAllowHorizontalScroll: false, + sidebarHiddenTablePrefixes: [], + sidebarObjectInfoMode: "none", + sidebarShowTooltips: true, + }, + }), +})); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +const mountedApps: App[] = []; +const disabledSelector = '[data-disabled-trigger-indicator="true"]'; +const invalidSelector = '[data-invalid-object-indicator="true"]'; +const statuses = [false, true, undefined, null] as const; + +function triggerNode(enabled: TriggerInfo["enabled"], valid?: TreeNode["valid"]): TreeNode { + return { + id: "connection-1:app:dbo:orders:__triggers:trg_orders_audit", + label: "trg_orders_audit (AFTER UPDATE)", + type: "trigger", + connectionId: "connection-1", + database: "app", + schema: "dbo", + tableName: "orders", + objectName: "trg_orders_audit", + valid, + meta: { name: "trg_orders_audit", timing: "AFTER", event: "UPDATE", enabled }, + }; +} + +async function mountTreeItem(initialNode: TreeNode) { + const container = document.createElement("div"); + document.body.append(container); + const node = ref(initialNode); + const host: SidebarTreeRuntimeHost = { + buildContextMenu: vi.fn(() => []), + handleRowClick: vi.fn(), + handleRowDoubleClick: vi.fn(), + handleRowKeydown: vi.fn(), + openPrimaryVisibleFilter: vi.fn(), + openDataInNewTab: vi.fn(), + requestPaste: vi.fn(() => false), + toggleNode: vi.fn(), + }; + const runtime = createSidebarTreeRuntime(); + runtime.bindHost(host); + const app = createApp(defineComponent({ setup: () => () => h(TreeItem, { node: node.value, depth: 4 }) })); + mountedApps.push(app); + app.use(i18n); + app.provide(sidebarTreeRuntimeKey, runtime); + app.mount(container); + await nextTick(); + return { container, node, host }; +} + +beforeEach(async () => { + databaseType = "sqlserver"; + await setLocale("en"); +}); + +afterEach(async () => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.replaceChildren(); + await setLocale("en"); +}); + +describe("TreeItem trigger status", () => { + it.each(statuses.flatMap((enabled) => statuses.map((valid) => ({ enabled, valid }))))("renders independent disabled and invalid indicators for enabled=$enabled, valid=$valid", async ({ enabled, valid }) => { + const initialNode = triggerNode(enabled, valid); + const { container } = await mountTreeItem(initialNode); + const disabled = container.querySelector(disabledSelector); + const invalid = container.querySelector(invalidSelector); + + expect(disabled !== null).toBe(enabled === false); + expect(invalid !== null).toBe(valid === false); + expect(container.querySelector(".tree-object-label")?.textContent).toBe(`${initialNode.label}${valid === false ? " · INVALID" : ""}`); + if (disabled && invalid) { + expect(disabled.classList.contains("-left-1")).toBe(true); + expect(invalid.classList.contains("-right-1")).toBe(true); + } + }); + + it("does not infer disabled status when trigger metadata is missing", async () => { + const initialNode = triggerNode(undefined); + delete initialNode.meta; + const { container } = await mountTreeItem(initialNode); + + expect(container.querySelector(disabledSelector)).toBeNull(); + expect(container.querySelector(invalidSelector)).toBeNull(); + }); + + it.each(["postgres", "oracle", "xugu"] as const)("respects explicit disabled metadata for %s without changing its label", async (dbType) => { + databaseType = dbType; + const initialNode = triggerNode(false); + if (dbType === "xugu") initialNode.label = "trg_orders_audit (AFTER · UPDATE · Disabled)"; + const { container } = await mountTreeItem(initialNode); + + expect(container.querySelector(disabledSelector)).not.toBeNull(); + expect(container.querySelector(".tree-object-label")?.textContent).toBe(initialNode.label); + }); + + it.each(["table", "view", "index", "function", "group-triggers"] as const)("does not mark unrelated %s nodes as disabled", async (type) => { + const { container } = await mountTreeItem({ ...triggerNode(false, false), type }); + + expect(container.querySelector(disabledSelector)).toBeNull(); + expect(container.querySelector(invalidSelector)).not.toBeNull(); + }); + + it("updates the badge after metadata refresh and virtual row reuse", async () => { + const { container, node } = await mountTreeItem(triggerNode(true)); + expect(container.querySelector(disabledSelector)).toBeNull(); + + node.value = triggerNode(false); + await nextTick(); + expect(container.querySelector(disabledSelector)).not.toBeNull(); + + (node.value.meta as TriggerInfo).enabled = true; + await nextTick(); + expect(container.querySelector(disabledSelector)).toBeNull(); + + (node.value.meta as TriggerInfo).enabled = false; + await nextTick(); + expect(container.querySelector(disabledSelector)).not.toBeNull(); + + node.value = triggerNode(null); + await nextTick(); + expect(container.querySelector(disabledSelector)).toBeNull(); + + node.value = triggerNode(false); + await nextTick(); + node.value = { ...triggerNode(false), id: "connection-1:app:dbo:orders", type: "table" }; + await nextTick(); + expect(container.querySelector(disabledSelector)).toBeNull(); + }); + + it("localizes the badge tooltip and accessible name without disabling row activation", async () => { + const initialNode = triggerNode(false); + const { container, host } = await mountTreeItem(initialNode); + const disabled = container.querySelector(disabledSelector); + expect(disabled).not.toBeNull(); + expect(disabled?.getAttribute("role")).toBe("img"); + expect(disabled?.getAttribute("aria-label")).toBe(i18n.global.t("objects.disabled")); + expect(disabled?.getAttribute("title")).toBe(i18n.global.t("objects.disabled")); + expect(disabled?.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true"); + + await setLocale("zh-CN"); + await nextTick(); + expect(disabled?.getAttribute("aria-label")).toBe("禁用"); + expect(disabled?.getAttribute("title")).toBe("禁用"); + expect(container.querySelector(".tree-object-label")?.textContent).toBe(initialNode.label); + + const row = container.querySelector("[data-node-id]")!; + const event = new MouseEvent("dblclick", { bubbles: true }); + row.dispatchEvent(event); + expect(host.handleRowDoubleClick).toHaveBeenCalledWith(initialNode, event); + expect(row.getAttribute("aria-disabled")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts index 844a562f41..a36cd3575c 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarObjectGroupRouting.spec.ts @@ -38,7 +38,7 @@ function objectGroup(type: "group-triggers" | "group-synonyms" | "group-types", }; } -async function createStore({ listObjects, listTriggers }: { listObjects: ReturnType; listTriggers: ReturnType }) { +async function createStore({ listObjects, listTriggers, connection = mysqlConnection() }: { listObjects: ReturnType; listTriggers: ReturnType; connection?: ConnectionConfig }) { vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), @@ -55,7 +55,6 @@ async function createStore({ listObjects, listTriggers }: { listObjects: ReturnT 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; @@ -130,6 +129,46 @@ describe("sidebar object-group routing", () => { }); }); + it("preserves SQL Server table trigger status metadata across refreshes", async () => { + const listObjects = vi.fn<() => Promise>().mockResolvedValue([]); + const listTriggers = vi.fn<() => Promise>(); + const { connection, store } = await createStore({ + listObjects, + listTriggers, + connection: { ...mysqlConnection(), id: "sqlserver-1", name: "SQL Server", db_type: "sqlserver", port: 1433 }, + }); + const tableTriggerGroup: TreeNode = { + ...objectGroup("group-triggers", `${connection.id}:app:dbo:orders:__triggers`), + connectionId: connection.id, + schema: "dbo", + tableName: "orders", + }; + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tableTriggerGroup] }]; + const storedTriggerGroup = store.treeNodes[0].children![0]; + + for (const enabled of [false, true, undefined, null, false]) { + const trigger: TriggerInfo = { name: "trg_orders_audit", timing: "AFTER", event: "UPDATE", enabled }; + listTriggers.mockResolvedValueOnce([trigger]); + await loadSidebarObjectGroup(storedTriggerGroup, store); + + expect(storedTriggerGroup.children).toHaveLength(1); + expect(storedTriggerGroup.children![0]).toMatchObject({ + id: `${tableTriggerGroup.id}:trg_orders_audit`, + label: "trg_orders_audit (AFTER UPDATE)", + objectName: trigger.name, + type: "trigger", + tableName: "orders", + meta: trigger, + }); + expect((storedTriggerGroup.children![0].meta as TriggerInfo).enabled).toBe(enabled); + expect(storedTriggerGroup.children![0].valid).toBeUndefined(); + } + + expect(listTriggers).toHaveBeenCalledTimes(5); + expect(listTriggers).toHaveBeenLastCalledWith(connection.id, "app", "dbo", "orders", undefined); + expect(listObjects).not.toHaveBeenCalled(); + }); + 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([]);