feat(config): support selective connection import and export
This commit is contained in:
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>
|
||||
+141
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user