mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
desktop: true,
|
||||
@@ -24,6 +25,8 @@ const mocks = vi.hoisted(() => ({
|
||||
progressHandler: undefined as undefined | ((progress: Record<string, unknown>) => void),
|
||||
changeDialogOpen: undefined as undefined | ((open: boolean) => void),
|
||||
connections: [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }] as Array<{ id: string; name: string; db_type: string; driver_profile: string; database: string }>,
|
||||
treeNodes: [] as TreeNode[],
|
||||
editorSettings: { sidebarConnectionSortMode: "manual" as "manual" | "asc" | "desc" },
|
||||
getConfig: vi.fn(),
|
||||
refreshDatabaseTreeNode: vi.fn(),
|
||||
refreshObjectListTreeNode: vi.fn(),
|
||||
@@ -48,6 +51,14 @@ function passthrough(tag: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function connectionNode(id: string, label: string): TreeNode {
|
||||
return { id, label, type: "connection", connectionId: id, children: [] };
|
||||
}
|
||||
|
||||
function connectionGroupNode(id: string, label: string, children: TreeNode[]): TreeNode {
|
||||
return { id, label, type: "connection-group", isExpanded: true, children };
|
||||
}
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/lib/common/utils", () => ({ uuid: mocks.uuid }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
|
||||
@@ -78,6 +89,9 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
get connections() {
|
||||
return mocks.connections;
|
||||
},
|
||||
get treeNodes() {
|
||||
return mocks.treeNodes;
|
||||
},
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
getConfig: mocks.getConfig,
|
||||
refreshDatabaseTreeNode: mocks.refreshDatabaseTreeNode,
|
||||
@@ -85,6 +99,7 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => mocks.queryStore }));
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: mocks.editorSettings }) }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
beginManualTransaction: mocks.beginManualTransaction,
|
||||
commitManualTransaction: mocks.commitManualTransaction,
|
||||
@@ -130,9 +145,21 @@ vi.mock("@/components/ui/input", () => ({
|
||||
}));
|
||||
vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: passthrough("div"),
|
||||
Select: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: ["modelValue"],
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h("div", { ...attrs, "data-select-value": props.modelValue ?? "" }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
SelectContent: passthrough("div"),
|
||||
SelectItem: passthrough("div"),
|
||||
SelectItem: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: ["value"],
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h("div", { ...attrs, "data-select-item": props.value }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
SelectTrigger: passthrough("div"),
|
||||
SelectValue: passthrough("span"),
|
||||
}));
|
||||
@@ -247,6 +274,8 @@ beforeEach(() => {
|
||||
]);
|
||||
mocks.fetchSqlFileTargetOptions.mockResolvedValue([]);
|
||||
mocks.connections = [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }];
|
||||
mocks.treeNodes = reactive<TreeNode[]>([]);
|
||||
mocks.editorSettings = reactive({ sidebarConnectionSortMode: "manual" as "manual" | "asc" | "desc" });
|
||||
mocks.getConfig.mockImplementation((connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId));
|
||||
mocks.openFileDialog.mockResolvedValue(["/tmp/first.sql", "/tmp/second.sql"]);
|
||||
mocks.previewSqlFile.mockImplementation(async (filePath: string) => ({
|
||||
@@ -844,6 +873,44 @@ describe("SqlFileExecutionDialog retries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqlFileExecutionDialog connection order", () => {
|
||||
function connectionOptionIds(): string[] {
|
||||
return Array.from(root!.querySelectorAll<HTMLElement>("[data-select-item]"), (item) => item.dataset.selectItem!);
|
||||
}
|
||||
|
||||
it("tracks nested sidebar order and sort mode while keeping missing SQL connections and the selection", async () => {
|
||||
mocks.connections = [
|
||||
{ id: "alpha", name: "Alpha", db_type: "mysql", driver_profile: "mysql", database: "" },
|
||||
{ id: "message-queue", name: "Messages", db_type: "mq", driver_profile: "kafka", database: "" },
|
||||
{ id: "zebra", name: "Zebra", db_type: "postgres", driver_profile: "postgres", database: "" },
|
||||
{ id: "beta", name: "Beta", db_type: "mysql", driver_profile: "mysql", database: "" },
|
||||
{ id: "new", name: "New connection", db_type: "mysql", driver_profile: "mysql", database: "" },
|
||||
];
|
||||
mocks.treeNodes = reactive<TreeNode[]>([connectionGroupNode("parent", "Parent", [connectionNode("zebra", "Zebra"), connectionGroupNode("nested", "Nested", [connectionNode("beta", "Beta")]), connectionNode("alpha", "Alpha")])]);
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(SqlFileExecutionDialog, { open: true, prefillConnectionId: "beta" });
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
|
||||
expect(connectionOptionIds()).toEqual(["zebra", "beta", "alpha", "new"]);
|
||||
expect(root.querySelector('[data-select-value="beta"]')).not.toBeNull();
|
||||
|
||||
mocks.treeNodes.splice(0, 1, connectionGroupNode("parent", "Parent", [connectionNode("alpha", "Alpha"), connectionGroupNode("nested", "Nested", [connectionNode("beta", "Beta")]), connectionNode("zebra", "Zebra")]));
|
||||
await nextTick();
|
||||
|
||||
expect(connectionOptionIds()).toEqual(["alpha", "beta", "zebra", "new"]);
|
||||
expect(root.querySelector('[data-select-value="beta"]')).not.toBeNull();
|
||||
|
||||
mocks.editorSettings.sidebarConnectionSortMode = "desc";
|
||||
await nextTick();
|
||||
|
||||
expect(connectionOptionIds()).toEqual(["zebra", "beta", "alpha", "new"]);
|
||||
expect(root.querySelector('[data-select-value="beta"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqlFileExecutionDialog selected-table restore", () => {
|
||||
async function mountBackup() {
|
||||
root = document.createElement("div");
|
||||
|
||||
@@ -15,6 +15,7 @@ import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.v
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
|
||||
@@ -27,6 +28,7 @@ import { activeTabExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTa
|
||||
import { isSqlFilePath } from "@/lib/sql/sqlFileOpen";
|
||||
import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel";
|
||||
import { parseSqlFilePathInput } from "./sqlFilePathInput";
|
||||
import { orderConnectionsForSidebarDisplay } from "@/lib/sidebar/connectionListSort";
|
||||
import SqlFileProgressIndicator from "./SqlFileProgressIndicator.vue";
|
||||
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
@@ -48,6 +50,7 @@ const props = defineProps<{
|
||||
|
||||
const store = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
// Tauri = real filesystem paths; Web = browser File.name (no path) + server temp paths.
|
||||
const isDesktopRuntime = isTauriRuntime();
|
||||
@@ -177,7 +180,7 @@ function resetPerFileState() {
|
||||
currentFileName.value = "";
|
||||
}
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => supportsSqlFileExecution(connection.db_type)));
|
||||
const sqlConnections = computed(() => orderConnectionsForSidebarDisplay(store.connections, store.treeNodes, settingsStore.editorSettings.sidebarConnectionSortMode).filter((connection) => supportsSqlFileExecution(connection.db_type)));
|
||||
// Mirrors the core executor gate (`relational_constraint_bypass_kind` in
|
||||
// sql_file_import.rs): MySQL-family types use the session-scoped
|
||||
// FOREIGN_KEY_CHECKS toggle, PostgreSQL-family types use DISABLE/ENABLE
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
|
||||
import { orderConnectionsForSidebarDisplay, sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
function connection(id: string, label: string): TreeNode {
|
||||
@@ -36,4 +36,18 @@ describe("connection list display sort", () => {
|
||||
expect(manualTree[1]?.children?.map((node) => node.id)).toEqual(["beta", "alpha", "alpha-two"]);
|
||||
expect(sorted[1]).not.toBe(manualTree[1]);
|
||||
});
|
||||
|
||||
it("flattens nested manual order, appends missing connections, and removes duplicates", () => {
|
||||
const connections = [{ id: "alpha" }, { id: "beta" }, { id: "zebra" }, { id: "new" }];
|
||||
const tree = [connection("zebra", "Zebra"), group("parent", "Parent", [connection("beta", "Beta"), group("nested", "Nested", [connection("alpha", "Alpha"), connection("zebra", "Zebra duplicate")])])];
|
||||
|
||||
expect(orderConnectionsForSidebarDisplay(connections, tree, "manual").map((connection) => connection.id)).toEqual(["zebra", "beta", "alpha", "new"]);
|
||||
});
|
||||
|
||||
it("projects configs through the sidebar's alphabetical display mode", () => {
|
||||
const connections = [{ id: "zebra" }, { id: "alpha" }, { id: "beta" }];
|
||||
const tree = [group("parent", "Parent", [connection("zebra", "Zebra"), group("nested", "Nested", [connection("beta", "Beta")]), connection("alpha", "Alpha")])];
|
||||
|
||||
expect(orderConnectionsForSidebarDisplay(connections, tree, "asc").map((connection) => connection.id)).toEqual(["alpha", "beta", "zebra"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import type { ConnectionConfig, TreeNode } from "@/types/database";
|
||||
|
||||
export type ConnectionListSortMode = "manual" | "asc" | "desc";
|
||||
|
||||
@@ -38,3 +38,34 @@ export function sortConnectionListForDisplay(nodes: readonly TreeNode[], mode: C
|
||||
if (mode === "manual") return nodes as TreeNode[];
|
||||
return sortConnectionSiblingsForDisplay(nodes, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects connection configs into the order shown by the sidebar. Connections
|
||||
* missing from the current tree are appended in store order so a stale layout
|
||||
* or newly added connection cannot make them unavailable to other surfaces.
|
||||
*/
|
||||
export function orderConnectionsForSidebarDisplay<T extends Pick<ConnectionConfig, "id">>(connections: readonly T[], nodes: readonly TreeNode[], mode: ConnectionListSortMode): T[] {
|
||||
const connectionsById = new Map<string, T>();
|
||||
for (const connection of connections) {
|
||||
if (!connectionsById.has(connection.id)) connectionsById.set(connection.id, connection);
|
||||
}
|
||||
|
||||
const ordered: T[] = [];
|
||||
const seen = new Set<string>();
|
||||
const appendConnection = (id: string) => {
|
||||
const connection = connectionsById.get(id);
|
||||
if (!connection || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
ordered.push(connection);
|
||||
};
|
||||
const visit = (treeNodes: readonly TreeNode[]) => {
|
||||
for (const node of treeNodes) {
|
||||
if (node.type === "connection") appendConnection(node.connectionId ?? node.id);
|
||||
else if (node.type === "connection-group" && node.children) visit(node.children);
|
||||
}
|
||||
};
|
||||
|
||||
visit(sortConnectionListForDisplay(nodes, mode));
|
||||
for (const connection of connections) appendConnection(connection.id);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user