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()">
|
||||
|
||||
+195
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ function objectGroup(type: "group-triggers" | "group-synonyms" | "group-types",
|
||||
};
|
||||
}
|
||||
|
||||
async function createStore({ listObjects, listTriggers }: { listObjects: ReturnType<typeof vi.fn>; listTriggers: ReturnType<typeof vi.fn> }) {
|
||||
async function createStore({ listObjects, listTriggers, connection = mysqlConnection() }: { listObjects: ReturnType<typeof vi.fn>; listTriggers: ReturnType<typeof vi.fn>; 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<ObjectInfo[]>>().mockResolvedValue([]);
|
||||
const listTriggers = vi.fn<() => Promise<TriggerInfo[]>>();
|
||||
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<ObjectInfo[]>>().mockRejectedValue(new Error("metadata access denied"));
|
||||
const listTriggers = vi.fn<() => Promise<never[]>>().mockResolvedValue([]);
|
||||
|
||||
Reference in new issue
Block a user