mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(mcp): restore custom connection scope after switching to all
This commit is contained in:
@@ -53,6 +53,7 @@ const { t } = useI18n();
|
||||
const search = ref("");
|
||||
const expandedGroupIds = ref(new Set<string>());
|
||||
const initializedExpansion = ref(false);
|
||||
const lastCustomScope = ref<{ allowedGroupIds: string[]; allowedConnectionIds: string[] } | null>(null);
|
||||
|
||||
const scopeMode = computed<ScopeMode>(() => (props.allowedConnectionIds === null ? "all" : "custom"));
|
||||
const connectionById = computed(() => new Map(props.connections.map((connection) => [connection.id, connection])));
|
||||
@@ -205,10 +206,19 @@ const visibleRows = computed(() => {
|
||||
return rows;
|
||||
});
|
||||
|
||||
function copyCustomScope(groupIds: readonly string[], connectionIds: readonly string[] | null): { allowedGroupIds: string[]; allowedConnectionIds: string[] } {
|
||||
return { allowedGroupIds: [...groupIds], allowedConnectionIds: [...(connectionIds ?? [])] };
|
||||
}
|
||||
|
||||
function setScopeMode(mode: ScopeMode) {
|
||||
if (props.disabled || mode === scopeMode.value) return;
|
||||
if (mode === "all" && (props.allowedGroupIds.length > 0 || (props.allowedConnectionIds?.length ?? 0) > 0) && !window.confirm(t("settings.mcpResourceScopeAllConfirm"))) return;
|
||||
emit("update:scope", mode === "all" ? { allowedGroupIds: [], allowedConnectionIds: null } : { allowedGroupIds: [], allowedConnectionIds: [] });
|
||||
if (props.disabled || props.busy || mode === scopeMode.value) return;
|
||||
if (mode === "all") {
|
||||
if ((props.allowedGroupIds.length > 0 || (props.allowedConnectionIds?.length ?? 0) > 0) && !window.confirm(t("settings.mcpResourceScopeAllConfirm"))) return;
|
||||
lastCustomScope.value = copyCustomScope(props.allowedGroupIds, props.allowedConnectionIds);
|
||||
emit("update:scope", { allowedGroupIds: [], allowedConnectionIds: null });
|
||||
return;
|
||||
}
|
||||
emit("update:scope", copyCustomScope(lastCustomScope.value?.allowedGroupIds ?? [], lastCustomScope.value?.allowedConnectionIds ?? []));
|
||||
}
|
||||
|
||||
function groupHasSelectedDescendant(group: GroupNode): boolean {
|
||||
@@ -266,10 +276,10 @@ function connectionPolicyMode(connectionId: string): ExecutionMode | "inherit" {
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-1 border-t bg-muted/40 p-1" role="radiogroup" :aria-label="t('settings.mcpResourceScopeTitle')">
|
||||
<Button type="button" role="radio" variant="ghost" class="h-9" :class="scopeMode === 'all' ? 'bg-background shadow-sm' : 'text-muted-foreground'" :aria-checked="scopeMode === 'all'" :disabled="disabled" @click="setScopeMode('all')">
|
||||
<Button type="button" role="radio" variant="ghost" class="h-9" data-scope-mode="all" :class="scopeMode === 'all' ? 'bg-background shadow-sm' : 'text-muted-foreground'" :aria-checked="scopeMode === 'all'" :disabled="disabled || busy" @click="setScopeMode('all')">
|
||||
{{ t("settings.mcpScopeModeAll") }}
|
||||
</Button>
|
||||
<Button type="button" role="radio" variant="ghost" class="h-9" :class="scopeMode === 'custom' ? 'bg-background shadow-sm' : 'text-muted-foreground'" :aria-checked="scopeMode === 'custom'" :disabled="disabled" @click="setScopeMode('custom')">
|
||||
<Button type="button" role="radio" variant="ghost" class="h-9" data-scope-mode="custom" :class="scopeMode === 'custom' ? 'bg-background shadow-sm' : 'text-muted-foreground'" :aria-checked="scopeMode === 'custom'" :disabled="disabled || busy" @click="setScopeMode('custom')">
|
||||
{{ t("settings.mcpResourceScopeModeCustom") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dispatch, findOne, mountComponent } from "@/components/grid/__tests__/vueHostHarness";
|
||||
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => `${key}${values ? ` ${JSON.stringify(values)}` : ""}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@lucide/vue", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
const icon = createPassthroughStub("Icon", "i");
|
||||
return { ChevronDown: icon, ChevronRight: icon, Database: icon, Folder: icon, Search: icon };
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/badge", async () => ({ Badge: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Badge", "span") }));
|
||||
vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") }));
|
||||
vi.mock("@/components/ui/input", async () => ({ Input: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Input", "input") }));
|
||||
|
||||
import McpResourceScopePicker from "@/components/settings/McpResourceScopePicker.vue";
|
||||
|
||||
function connection(id: string): ConnectionConfig {
|
||||
return {
|
||||
id,
|
||||
name: `${id}-name`,
|
||||
db_type: "mysql",
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
username: "test",
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "Group 1", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] },
|
||||
{ type: "connection", id: "c2" },
|
||||
],
|
||||
};
|
||||
|
||||
const connections = [connection("c1"), connection("c2")];
|
||||
|
||||
function mountPicker(props: Record<string, unknown> = {}) {
|
||||
const update = vi.fn();
|
||||
const mounted = mountComponent(McpResourceScopePicker, {
|
||||
layout,
|
||||
connections,
|
||||
allowedGroupIds: [],
|
||||
allowedConnectionIds: [],
|
||||
"onUpdate:scope": update,
|
||||
...props,
|
||||
});
|
||||
return { mounted, update };
|
||||
}
|
||||
|
||||
function modeButton(root: ReturnType<typeof mountComponent>["root"], mode: "all" | "custom") {
|
||||
return findOne(root, (node) => node.type === "button" && node.props["data-scope-mode"] === mode);
|
||||
}
|
||||
|
||||
describe("McpResourceScopePicker", () => {
|
||||
let confirmMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
confirmMock = vi.fn().mockReturnValue(true);
|
||||
vi.stubGlobal("confirm", confirmMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("emits allow-all when switching from a custom group and connection selection", () => {
|
||||
const { mounted, update } = mountPicker({
|
||||
allowedGroupIds: ["g1"],
|
||||
allowedConnectionIds: ["c2"],
|
||||
});
|
||||
|
||||
dispatch(modeButton(mounted.root, "all"), "click");
|
||||
|
||||
expect(update).toHaveBeenCalledWith({ allowedGroupIds: [], allowedConnectionIds: null });
|
||||
});
|
||||
|
||||
it("restores the previous custom selection after switching to all connections and back", async () => {
|
||||
const customScope = { allowedGroupIds: ["g1"], allowedConnectionIds: ["c2"] };
|
||||
const { mounted, update } = mountPicker(customScope);
|
||||
|
||||
dispatch(modeButton(mounted.root, "all"), "click");
|
||||
expect(confirmMock).toHaveBeenCalledWith("settings.mcpResourceScopeAllConfirm");
|
||||
await mounted.setProps({ allowedGroupIds: [], allowedConnectionIds: null });
|
||||
dispatch(modeButton(mounted.root, "custom"), "click");
|
||||
|
||||
expect(update).toHaveBeenLastCalledWith(customScope);
|
||||
});
|
||||
|
||||
it("keeps the custom scope without emitting when the all-connections confirm is cancelled", () => {
|
||||
confirmMock.mockReturnValue(false);
|
||||
const { mounted, update } = mountPicker({
|
||||
allowedGroupIds: ["g1"],
|
||||
allowedConnectionIds: ["c2"],
|
||||
});
|
||||
|
||||
dispatch(modeButton(mounted.root, "all"), "click");
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledWith("settings.mcpResourceScopeAllConfirm");
|
||||
expect(modeButton(mounted.root, "all").props["aria-checked"]).toBe(false);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts custom scope empty when there is no previous custom snapshot", () => {
|
||||
const { mounted, update } = mountPicker({
|
||||
allowedGroupIds: [],
|
||||
allowedConnectionIds: null,
|
||||
});
|
||||
|
||||
dispatch(modeButton(mounted.root, "custom"), "click");
|
||||
|
||||
expect(update).toHaveBeenCalledWith({ allowedGroupIds: [], allowedConnectionIds: [] });
|
||||
});
|
||||
|
||||
it("restores an empty custom selection after switching away from it", async () => {
|
||||
const { mounted, update } = mountPicker({
|
||||
allowedGroupIds: [],
|
||||
allowedConnectionIds: [],
|
||||
});
|
||||
|
||||
dispatch(modeButton(mounted.root, "all"), "click");
|
||||
expect(confirmMock).not.toHaveBeenCalled();
|
||||
await mounted.setProps({ allowedGroupIds: [], allowedConnectionIds: null });
|
||||
dispatch(modeButton(mounted.root, "custom"), "click");
|
||||
|
||||
expect(update).toHaveBeenLastCalledWith({ allowedGroupIds: [], allowedConnectionIds: [] });
|
||||
});
|
||||
|
||||
it("does not emit while disabled", () => {
|
||||
const { mounted, update } = mountPicker({
|
||||
allowedGroupIds: ["g1"],
|
||||
allowedConnectionIds: ["c2"],
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
dispatch(modeButton(mounted.root, "all"), "click");
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit while busy", () => {
|
||||
const { mounted, update } = mountPicker({
|
||||
allowedGroupIds: ["g1"],
|
||||
allowedConnectionIds: ["c2"],
|
||||
busy: true,
|
||||
});
|
||||
|
||||
dispatch(modeButton(mounted.root, "all"), "click");
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user