feat(sql-file): follow sidebar connection order

Closes #4210
This commit is contained in:
t8y2
2026-10-03 20:19:02 +00:00
parent 5f0ad9217a
commit 01320286e8
4 changed files with 120 additions and 5 deletions
@@ -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;
}