feat(transfer): show source/target pickers as group trees

This commit is contained in:
Allen authored and GitHub committed 2026-08-24 13:26:08 +08:00
1 parent 80240d0ba6
commit 29b527fd3a
4 files changed
+43 -38

No files matched your search

@@ -10,10 +10,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import * as api from "@/lib/backend/api";
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
@@ -946,25 +944,16 @@ async function saveConfigTask() {
<div class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.sourceConnection") }}</Label>
<SearchableSelect
<ConnectionTreeSelect
v-model="sourceConnectionId"
:options="sqlConnections.map((c) => c.id)"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('transfer.selectConnection')"
:search-placeholder="t('transfer.searchConnection')"
:empty-text="t('common.noResults')"
:display-name="getConnectionName"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
>
<template #option-label="{ option, label }">
<div class="flex min-w-0 items-center gap-1.5">
<DatabaseIcon :db-type="connectionIconType(sqlConnections.find((c) => c.id === option))" class="h-3.5 w-3.5 shrink-0" />
<ConnectionGroupBadge :connection-id="option" />
<span class="min-w-0 flex-1 truncate">{{ label }}</span>
</div>
</template>
</SearchableSelect>
trigger-class="h-8 w-full max-w-none justify-between gap-1.5 border border-input rounded-md bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
list-class="w-[var(--reka-popover-trigger-width)]"
/>
</div>
<!-- Source Catalog (Doris/StarRocks multi-catalog) -->
@@ -1025,25 +1014,16 @@ async function saveConfigTask() {
<div class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.targetConnection") }}</Label>
<SearchableSelect
<ConnectionTreeSelect
v-model="targetConnectionId"
:options="sqlConnections.map((c) => c.id)"
:connections="sqlConnections"
:layout="store.sidebarLayout"
:placeholder="t('transfer.selectConnection')"
:search-placeholder="t('transfer.searchConnection')"
:empty-text="t('common.noResults')"
:display-name="getConnectionName"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
>
<template #option-label="{ option, label }">
<div class="flex min-w-0 items-center gap-1.5">
<DatabaseIcon :db-type="connectionIconType(sqlConnections.find((c) => c.id === option))" class="h-3.5 w-3.5 shrink-0" />
<ConnectionGroupBadge :connection-id="option" />
<span class="min-w-0 flex-1 truncate">{{ label }}</span>
</div>
</template>
</SearchableSelect>
trigger-class="h-8 w-full max-w-none justify-between gap-1.5 border border-input rounded-md bg-transparent px-2.5 text-xs shadow-none hover:bg-muted/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
list-class="w-[var(--reka-popover-trigger-width)]"
/>
</div>
<!-- Target Catalog (Doris/StarRocks multi-catalog) -->
@@ -81,6 +81,17 @@ describe("buildConnectionPickerRows", () => {
expect(buildConnectionPickerRows(layout(), connections, new Set(), "不存在")).toEqual([]);
});
it("prunes groups that hold none of the given connections", () => {
const onlyC3 = connections.filter((connection) => connection.id === "c3");
expect(kinds(buildConnectionPickerRows(layout(), onlyC3, new Set(), ""))).toEqual(["connection:c3@0"]);
});
it("prunes nested groups without usable descendants even when collapsed", () => {
const onlyC2 = connections.filter((connection) => connection.id === "c2");
const rows = buildConnectionPickerRows(layout(), onlyC2, new Set(["g2"]), "");
expect(kinds(rows)).toEqual(["group:g1@0", "connection:c2@1"]);
});
it("falls back to the connection id when the name is empty", () => {
const rows = buildConnectionPickerRows({ groups: [], order: [] }, [{ id: "c9", name: "" }], new Set(), "");
expect(rows[0]?.label).toBe("c9");
@@ -18,12 +18,12 @@ describe("SearchableSelect layout", () => {
expect(searchableSelectSource).toContain("props.trimCustom ? searchText.value.trim() : searchText.value");
});
it("truncates long data transfer connection labels without shrinking their icons", () => {
const truncatedConnectionLabels = dataTransferDialogSource.match(/<span class="min-w-0 flex-1 truncate">\{\{ label \}\}<\/span>/g) ?? [];
const fixedConnectionIcons = dataTransferDialogSource.match(/class="h-3\.5 w-3\.5 shrink-0"/g) ?? [];
it("renders data transfer connection pickers as sidebar-like trees", () => {
const treeSelects = dataTransferDialogSource.match(/<ConnectionTreeSelect/g) ?? [];
expect(truncatedConnectionLabels).toHaveLength(2);
expect(fixedConnectionIcons).toHaveLength(2);
expect(treeSelects).toHaveLength(2);
expect(dataTransferDialogSource).toContain(':layout="store.sidebarLayout"');
expect(dataTransferDialogSource).not.toContain("ConnectionGroupBadge");
});
it("renders the toolbar connection picker as a sidebar-like tree", () => {
@@ -86,6 +86,17 @@ export function buildConnectionPickerRows(layout: SidebarLayout, connections: re
}
const rows: ConnectionPickerRow[] = [];
const subtreeHasConnection = (entries: SidebarOrderEntry[]): boolean => {
for (const entry of entries) {
if (entry.type === "connection") {
if (connectionById.has(entry.id)) return true;
continue;
}
if (!groupById.has(entry.id)) continue;
if (subtreeHasConnection(groupEntryChildren(entry))) return true;
}
return false;
};
const visitTree = (entries: SidebarOrderEntry[], depth: number) => {
for (const entry of entries) {
if (entry.type === "connection") {
@@ -96,6 +107,9 @@ export function buildConnectionPickerRows(layout: SidebarLayout, connections: re
}
const group = groupById.get(entry.id);
if (!group) continue;
// Skip groups that hold none of the picker's connections (e.g. when the
// caller passes a filtered list such as transfer-capable connections).
if (!subtreeHasConnection(groupEntryChildren(entry))) continue;
const collapsed = collapsedGroupIds.has(group.id);
rows.push({ key: `group:${group.id}`, kind: "group", id: group.id, label: group.name, depth, collapsed });
if (!collapsed) visitTree(groupEntryChildren(entry), depth + 1);