feat(config): support selective connection import and export

This commit is contained in:
jearhe authored and GitHub committed 2026-08-20 15:15:05 +08:00
1 parent 89e0f1734b
commit e4577abeed
21 files changed
+1171 -175

No files matched your search

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Loader2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { connectionDriverLabel, connectionEndpointLabel, connectionIconType } from "@/lib/connection/connectionPresentation";
import type { ConnectionConfig } from "@/types/database";
const props = defineProps<{
open: boolean;
mode: "export" | "import";
busy?: boolean;
connections: ConnectionConfig[];
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
confirm: [connectionIds: string[]];
}>();
const { t } = useI18n();
const selectedIds = ref<string[]>([]);
const dialogOpen = computed({
get: () => props.open,
set: (value) => {
if (props.busy && !value) return;
emit("update:open", value);
},
});
const connectionIds = computed(() => props.connections.map((connection) => connection.id).filter((id) => id.length > 0));
const selectedCount = computed(() => selectedIds.value.length);
const canConfirm = computed(() => selectedCount.value > 0);
watch(
() => [props.open, props.connections] as const,
([open]) => {
if (open) selectedIds.value = [...connectionIds.value];
},
{ immediate: true },
);
function isSelected(id: string) {
return selectedIds.value.includes(id);
}
function toggle(id: string, checked: boolean) {
if (checked) {
if (!selectedIds.value.includes(id)) selectedIds.value = [...selectedIds.value, id];
return;
}
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id);
}
function selectAll() {
selectedIds.value = [...connectionIds.value];
}
function deselectAll() {
selectedIds.value = [];
}
function confirm() {
if (!canConfirm.value || props.busy) return;
emit("confirm", [...selectedIds.value]);
}
function connectionMeta(connection: ConnectionConfig) {
const parts = [connectionDriverLabel(connection), connectionEndpointLabel(connection), connection.database].filter((part) => !!part && String(part).trim().length > 0);
return parts.join(" · ");
}
</script>
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle class="flex items-center justify-between gap-3 pr-8">
<span>{{ mode === "export" ? t("configExport.selectExportTitle") : t("configExport.selectImportTitle") }}</span>
<span class="text-sm font-normal text-muted-foreground">{{ t("configExport.selectedCount", { selected: selectedCount, total: connections.length }) }}</span>
</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-2">
<div class="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" :disabled="busy" @click="selectAll">{{ t("configExport.selectAll") }}</Button>
<Button type="button" variant="outline" size="sm" :disabled="busy" @click="deselectAll">{{ t("configExport.deselectAll") }}</Button>
</div>
<ScrollArea class="h-72 rounded-md border">
<div v-if="connections.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">
{{ t("configExport.noConnections") }}
</div>
<label v-for="connection in connections" :key="connection.id" class="flex cursor-pointer items-start gap-3 border-b px-3 py-2 last:border-b-0 hover:bg-muted/50">
<input type="checkbox" class="mt-1 accent-primary shrink-0" :checked="isSelected(connection.id)" :disabled="busy" @change="toggle(connection.id, ($event.target as HTMLInputElement).checked)" />
<DatabaseIcon :db-type="connectionIconType(connection)" class="mt-0.5 h-4 w-4 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ connection.name }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ connectionMeta(connection) }}</span>
</span>
</label>
</ScrollArea>
</div>
<DialogFooter>
<Button type="button" :disabled="!canConfirm || busy" @click="confirm">
<Loader2 v-if="busy" class="mr-1.5 h-4 w-4 animate-spin" />
{{ mode === "export" ? t("configExport.next") : t("configExport.importSelected", { count: selectedCount }) }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,141 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { ConnectionConfig } from "@/types/database";
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return { Dialog: passthrough, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
props: { disabled: Boolean },
setup(props, { slots, emit }) {
return () =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("click"),
},
slots.default?.(),
);
},
}),
};
});
vi.mock("@/components/ui/scroll-area", async () => {
const { defineComponent, h } = await import("vue");
return {
ScrollArea: defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
}),
};
});
vi.mock("@/components/icons/DatabaseIcon.vue", async () => {
const { defineComponent, h } = await import("vue");
return {
default: defineComponent({
setup() {
return () => h("span");
},
}),
};
});
import ConfigConnectionSelectDialog from "@/components/config/ConfigConnectionSelectDialog.vue";
const mountedApps: App[] = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
});
function conn(id: string, name: string): ConnectionConfig {
return { id, name, db_type: "mysql", host: "127.0.0.1", port: 3306, username: "root", password: "secret" };
}
async function mountDialog(props: Record<string, unknown>, onConfirm = vi.fn()) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () => h(ConfigConnectionSelectDialog, { ...props, onConfirm });
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
return { onConfirm };
}
describe("ConfigConnectionSelectDialog", () => {
it("defaults to all selected and confirms the current selection", async () => {
i18n.global.locale.value = "en";
const { onConfirm } = await mountDialog({
open: true,
mode: "export",
connections: [conn("a", "Alpha"), conn("b", "Beta")],
});
expect(document.body.textContent).toContain("2 / 2");
const confirm = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Next"));
expect(confirm).toBeTruthy();
confirm?.click();
expect(onConfirm).toHaveBeenCalledWith(["a", "b"]);
});
it("disables confirm when nothing is selected", async () => {
i18n.global.locale.value = "en";
await mountDialog({
open: true,
mode: "import",
connections: [conn("a", "Alpha")],
});
const deselect = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Deselect all"));
deselect?.click();
await nextTick();
const confirm = [...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Import"));
expect(confirm?.disabled).toBe(true);
});
it("disables selection controls while an import is being applied", async () => {
i18n.global.locale.value = "en";
const { onConfirm } = await mountDialog({
open: true,
mode: "import",
busy: true,
connections: [conn("a", "Alpha")],
});
const buttons = [...document.body.querySelectorAll("button")];
const selectAll = buttons.find((button) => button.textContent?.includes("Select all"));
const deselectAll = buttons.find((button) => button.textContent?.includes("Deselect all"));
const confirm = buttons.find((button) => button.textContent?.includes("Import"));
expect(selectAll?.disabled).toBe(true);
expect(deselectAll?.disabled).toBe(true);
expect(confirm?.disabled).toBe(true);
expect(document.body.querySelector("input[type='checkbox']")?.hasAttribute("disabled")).toBe(true);
confirm?.click();
expect(onConfirm).not.toHaveBeenCalled();
});
});
@@ -15,6 +15,7 @@ const DatabaseDocsDialog = defineAsyncComponent(() => import("@/components/docs/
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
const ConfigConnectionSelectDialog = defineAsyncComponent(() => import("@/components/config/ConfigConnectionSelectDialog.vue"));
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
const SshHostKeyPromptDialog = defineAsyncComponent(() => import("@/components/ssh/SshHostKeyPromptDialog.vue"));
const ConnectionPasswordPromptDialog = defineAsyncComponent(() => import("@/components/connection/ConnectionPasswordPromptDialog.vue"));
@@ -275,11 +276,21 @@ watch(
:prefill-tables="dialogs.databaseExportPrefillTables.value"
:prefill-all-databases="dialogs.databaseExportAllDatabases.value"
/>
<ConfigConnectionSelectDialog
v-if="dialogs.showConfigConnectionSelectDialog.value"
:open="dialogs.showConfigConnectionSelectDialog.value"
:mode="dialogs.configConnectionSelectMode.value"
:busy="dialogs.applyingImportSelection.value"
:connections="dialogs.configConnectionSelectList.value"
@update:open="dialogs.onConfigConnectionSelectOpenChange"
@confirm="dialogs.onConfigConnectionSelectConfirm"
/>
<ConfigPassphraseDialog
v-if="dialogs.showConfigPassphraseDialog.value"
v-model:open="dialogs.showConfigPassphraseDialog.value"
:open="dialogs.showConfigPassphraseDialog.value"
:mode="dialogs.configPassphraseMode.value"
:external-error="dialogs.configPassphraseError.value"
@update:open="dialogs.onConfigPassphraseOpenChange"
@confirm="dialogs.configPassphraseMode.value === 'export' ? dialogs.onExportConfirm($event) : dialogs.onImportConfirm($event)"
/>
<Dialog v-model:open="dialogs.showImportLayoutConfirm.value">