fix(sidebar): show disabled trigger indicators

Closes #8697
This commit is contained in:
t8y2 committed 2026-09-18 01:32:31 +08:00
1 parent 717cc318ed
commit 7b164d0084
3 files changed
+251 -3

No files matched your search

@@ -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<TreeNode>(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) {
</button>
</template>
<span v-else class="w-3.5 h-3.5 shrink-0" />
<span class="relative flex h-3.5 w-3.5 shrink-0" :class="{ 'overflow-visible': node.valid === false }">
<span class="relative flex h-3.5 w-3.5 shrink-0" :class="{ 'overflow-visible': node.valid === false || isDisabledTrigger }">
<PluginIcon v-if="node.type === 'connection' && pluginConnectionIcon" :plugin-id="pluginConnectionIcon.pluginId" :contribution-id="pluginConnectionIcon.contributionId" class="h-3.5 w-3.5 shrink-0" />
<DatabaseIcon v-else-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="h-3.5 w-3.5 shrink-0" />
<Loader2 v-else-if="node.type === 'load-more' && node.isLoading" class="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
<component v-else :is="getIconInfo(node)?.icon || Database" class="h-3.5 w-3.5 shrink-0" :class="databaseOpenVisual.iconClass" />
<CircleX v-if="node.valid === false" data-invalid-object-indicator="true" class="pointer-events-none absolute -right-1 -bottom-1 h-2.5 w-2.5 rounded-full bg-background text-destructive stroke-[3]" aria-hidden="true" />
<span
v-if="isDisabledTrigger"
data-disabled-trigger-indicator="true"
class="absolute -bottom-1 h-2.5 w-2.5 rounded-full bg-background text-muted-foreground"
:class="node.valid === false ? '-left-1' : '-right-1'"
role="img"
:aria-label="t('objects.disabled')"
:title="t('objects.disabled')"
>
<Ban class="h-2.5 w-2.5 stroke-[3]" aria-hidden="true" />
</span>
</span>
<div ref="trailingCommentLayoutRef" :class="hasTrailingMetadata() ? 'flex flex-1 min-w-0 items-center' : 'contents'">
<div ref="trailingCommentLeadingRef" :class="trailingComment ? 'flex max-w-full min-w-0 shrink-0 items-center gap-2' : formattedObjectStorage() ? 'flex min-w-0 flex-1 items-center gap-2' : 'contents'" :style="alignedCommentLeadingStyle()">
@@ -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<string>(),
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<string>(),
sidebarTableSearchQueries: {},
tableNameFilterForScope: () => undefined,
treeNodes: [],
treeSelectionAnchorId: null as string | null,
};
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => connectionStore }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openDatabaseKeys: new Set<string>() }) }));
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<HTMLElement>(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<HTMLElement>("[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();
});
});