feat(transfer): add tree-based object selection

This commit is contained in:
GIWTO authored and GitHub committed 2026-08-02 16:34:50 +08:00
1 parent fb7b2878fd
commit 473ef910b6
28 files changed
+5395 -205

No files matched your search

@@ -4,6 +4,7 @@ import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { buildTransferObjectSelections } from "./transferSelections";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -13,7 +14,9 @@ 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 { TransferMode, TransferTableNameCase } from "@/lib/backend/api";
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
import ObjectSelectionTree from "@/components/transfer/ObjectSelectionTree.vue";
import type { DatabaseType } from "@/types/database";
import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities";
import { isDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
@@ -21,7 +24,7 @@ import { isSameTransferDatabase, normalizeTransferCatalog } from "@/lib/database
import { databaseOptionsForConnection, fetchCatalogNamespaceOptions, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions";
import { useExportTracker } from "@/composables/useExportTracker";
import type { CatalogInfo } from "@/types/database";
import { ArrowRightLeft, ArrowLeftRight, Loader2, Square, CheckSquare } from "@lucide/vue";
import { ArrowRightLeft, ArrowLeftRight, Loader2 } from "@lucide/vue";
const { t } = useI18n();
const { startDataTransferTask } = useExportTracker();
@@ -50,10 +53,73 @@ const sourceDatabase = ref("");
const sourceDatabases = ref<string[]>([]);
const sourceSchemas = ref<string[]>([]);
const sourceSchema = ref("");
const sourceTables = ref<string[]>([]);
const selectedTables = ref<Set<string>>(new Set());
const tableSearch = ref("");
const loadingTables = ref(false);
const objectGroups = ref<Partial<Record<TransferObjectKind, string[]>>>({});
const selectedObjects = ref<Partial<Record<TransferObjectKind, Set<string>>>>({});
const objectSearch = ref("");
const loadingObjects = ref(false);
const transferContent = ref<TransferContent>("structureAndData");
const selectedTables = computed(() => new Set(selectedObjects.value.TABLE ?? []));
const OBJECT_KIND_LABEL_KEY: Record<TransferObjectKind, string> = {
TABLE: "objectTypeTable",
VIEW: "objectTypeView",
MATERIALIZED_VIEW: "objectTypeMaterializedView",
PROCEDURE: "objectTypeProcedure",
FUNCTION: "objectTypeFunction",
TRIGGER: "objectTypeTrigger",
SEQUENCE: "objectTypeSequence",
EVENT: "objectTypeEvent",
};
const treeSelection = computed<Record<string, string[]>>({
get: () => Object.fromEntries(Object.entries(selectedObjects.value).map(([k, v]) => [k, [...(v ?? [])]])),
set: (value) => {
const next: Partial<Record<TransferObjectKind, Set<string>>> = {};
for (const [k, names] of Object.entries(value)) {
if (names.length > 0) next[k as TransferObjectKind] = new Set(names);
}
selectedObjects.value = next;
},
});
const treeGroups = computed(() =>
(Object.keys(objectGroups.value) as TransferObjectKind[]).map((kind) => ({
kind,
label: t(`transfer.${OBJECT_KIND_LABEL_KEY[kind]}`),
items: objectGroups.value[kind] ?? [],
})),
);
const treeDisabledGroups = computed<TransferObjectKind[]>(() => {
const presentKinds = Object.keys(objectGroups.value) as TransferObjectKind[];
if (transferContent.value === "dataOnly") {
return presentKinds.filter((k) => k !== "TABLE");
}
const sourceConfig = store.getConfig(sourceConnectionId.value);
const targetConfig = store.getConfig(targetConnectionId.value);
const allowed = crossFamilyTransferableKinds(sourceConfig?.db_type, targetConfig?.db_type);
return presentKinds.filter((k) => k !== "TABLE" && !allowed.includes(k));
});
const treeDisabledHints = computed<Record<string, string>>(() => {
const hints: Record<string, string> = {};
const dataOnly = transferContent.value === "dataOnly";
for (const kind of treeDisabledGroups.value) {
hints[kind] = dataOnly ? t("transfer.objectDataOnlyDisabled") : t("transfer.objectCrossFamilyDisabled");
}
return hints;
});
const showCrossFamilyViewHint = computed(() => {
const sourceConfig = store.getConfig(sourceConnectionId.value);
const targetConfig = store.getConfig(targetConnectionId.value);
if (transferContent.value === "dataOnly") return false;
if (!sourceConfig || !targetConfig) return false;
const allowed = crossFamilyTransferableKinds(sourceConfig.db_type, targetConfig.db_type);
if (!allowed.includes("VIEW")) return false;
return !isSameTransferFamily(sourceConfig.db_type, targetConfig.db_type) && (selectedObjects.value.VIEW?.size ?? 0) > 0;
});
const pendingSourceSchemaPrefill = ref("");
const pendingSelectedTablesPrefill = ref<string[] | null>(null);
@@ -68,7 +134,6 @@ const targetSchema = ref("");
const pendingTargetSchemaPrefill = ref("");
// Options
const createTable = ref(true);
const transferMode = ref<TransferMode>("append");
const targetTableNameCase = ref<TransferTableNameCase>("preserve");
const batchSize = ref(1000);
@@ -79,13 +144,6 @@ const ownershipTargetOwner = ref("");
const pendingOwnershipRequest = ref<api.TransferRequest | null>(null);
const pendingOwnershipRefresh = ref<{ shouldRefreshTargetTree: boolean } | null>(null);
const filteredTables = computed(() => {
const q = tableSearch.value.toLowerCase();
return q ? sourceTables.value.filter((t) => t.toLowerCase().includes(q)) : sourceTables.value;
});
const allSelected = computed(() => filteredTables.value.length > 0 && filteredTables.value.every((t) => selectedTables.value.has(t)));
function connectionType(id: string): DatabaseType | undefined {
return store.connections.find((c) => c.id === id)?.db_type;
}
@@ -108,26 +166,17 @@ const canStart = computed(() => {
);
const sameSourceAndTarget = sameCatalogAndDatabase && effectiveSourceSchema === effectiveTargetSchema;
return (
!!sourceConnectionId.value && !!sourceDatabase.value && !!targetConnectionId.value && !!targetDatabase.value && (sourceCatalogs.value.length <= 1 || !!sourceCatalog.value) && (targetCatalogs.value.length <= 1 || !!targetCatalog.value) && selectedTables.value.size > 0 && !sameSourceAndTarget
!!sourceConnectionId.value &&
!!sourceDatabase.value &&
!!targetConnectionId.value &&
!!targetDatabase.value &&
(sourceCatalogs.value.length <= 1 || !!sourceCatalog.value) &&
(targetCatalogs.value.length <= 1 || !!targetCatalog.value) &&
(selectedTables.value.size > 0 || Object.values(selectedObjects.value).some((names) => names.size > 0)) &&
!sameSourceAndTarget
);
});
function toggleSelectAll() {
if (allSelected.value) {
filteredTables.value.forEach((t) => selectedTables.value.delete(t));
} else {
filteredTables.value.forEach((t) => selectedTables.value.add(t));
}
}
function toggleTable(table: string) {
if (selectedTables.value.has(table)) {
selectedTables.value.delete(table);
} else {
selectedTables.value.add(table);
}
}
async function loadCatalogs(connectionId: string, side: "source" | "target") {
if (!connectionId || !isCatalogCapable(connectionId)) {
if (side === "source") {
@@ -234,19 +283,26 @@ async function loadSchemas(connectionId: string, database: string, side: "source
function applyPendingTableSelection() {
const pending = pendingSelectedTablesPrefill.value;
selectedTables.value = pending ? new Set(sourceTables.value.filter((table) => pending.includes(table))) : new Set(sourceTables.value);
const tables = objectGroups.value.TABLE ?? [];
if (pending) {
const chosen = new Set(tables.filter((table) => pending.includes(table)));
if (chosen.size > 0) {
selectedObjects.value = { ...selectedObjects.value, TABLE: chosen };
}
}
pendingSelectedTablesPrefill.value = null;
}
async function loadTables() {
async function loadObjects() {
if (!sourceConnectionId.value || !sourceDatabase.value) {
sourceTables.value = [];
objectGroups.value = {};
return;
}
loadingTables.value = true;
loadingObjects.value = true;
try {
if (isMongoConnection(sourceConnectionId.value)) {
sourceTables.value = (await api.mongoListCollections(sourceConnectionId.value, sourceDatabase.value)).map((c) => c.name);
const collections = await api.mongoListCollections(sourceConnectionId.value, sourceDatabase.value);
objectGroups.value = { TABLE: collections.map((c) => c.name) };
applyPendingTableSelection();
return;
}
@@ -254,13 +310,27 @@ async function loadTables() {
const needsSchema = isSchemaAware(config?.db_type);
const schema = needsSchema && sourceSchema.value ? sourceSchema.value : sourceDatabase.value;
const catalog = sourceCatalog.value || undefined;
const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema, undefined, undefined, undefined, undefined, catalog);
sourceTables.value = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name);
const kinds = transferObjectKindsForDatabase(config?.db_type);
const groups: Partial<Record<TransferObjectKind, string[]>> = {};
for (const kind of kinds) {
try {
if (kind === "TABLE") {
const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema, undefined, undefined, undefined, undefined, catalog);
groups.TABLE = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name);
} else {
const objects = await api.listObjects(sourceConnectionId.value, sourceDatabase.value, schema, [kind], undefined, undefined, undefined, catalog);
groups[kind] = objects.map((o) => o.name);
}
} catch {
groups[kind] = [];
}
}
objectGroups.value = groups;
applyPendingTableSelection();
} catch {
sourceTables.value = [];
objectGroups.value = {};
} finally {
loadingTables.value = false;
loadingObjects.value = false;
}
}
@@ -275,8 +345,8 @@ watch(sourceConnectionId, async (id) => {
sourceCatalog.value = "";
sourceCatalogs.value = [];
sourceDatabase.value = "";
sourceTables.value = [];
selectedTables.value.clear();
objectGroups.value = {};
selectedObjects.value = {};
pendingSourceSchemaPrefill.value = "";
pendingSelectedTablesPrefill.value = null;
if (isCatalogCapable(id)) {
@@ -292,8 +362,8 @@ watch(sourceConnectionId, async (id) => {
watch(sourceCatalog, async (catalog) => {
if (!sourceConnectionId.value) return;
sourceDatabase.value = "";
sourceTables.value = [];
selectedTables.value.clear();
objectGroups.value = {};
selectedObjects.value = {};
if (catalog) {
await loadDatabasesForCatalog(sourceConnectionId.value, catalog, "source");
}
@@ -316,7 +386,7 @@ watch(sourceDatabase, async (db) => {
}
});
watch(sourceSchema, () => loadTables());
watch(sourceSchema, () => loadObjects());
watch(targetConnectionId, async (id) => {
if (skipTargetWatch.value) {
@@ -414,11 +484,11 @@ function resetState() {
sourceDatabases.value = [];
sourceSchemas.value = [];
sourceSchema.value = "";
sourceTables.value = [];
selectedTables.value.clear();
objectGroups.value = {};
selectedObjects.value = {};
pendingSourceSchemaPrefill.value = "";
pendingSelectedTablesPrefill.value = null;
tableSearch.value = "";
objectSearch.value = "";
targetConnectionId.value = "";
targetCatalog.value = "";
targetCatalogs.value = [];
@@ -427,7 +497,7 @@ function resetState() {
targetSchemas.value = [];
targetSchema.value = "";
pendingTargetSchemaPrefill.value = "";
createTable.value = true;
transferContent.value = "structureAndData";
transferMode.value = "append";
targetTableNameCase.value = "preserve";
batchSize.value = 1000;
@@ -448,7 +518,7 @@ async function startTransfer() {
const sourceDatabaseName = sourceDatabase.value;
const targetConnection = targetConnectionId.value;
const targetDatabaseName = targetDatabase.value;
const shouldRefreshTargetTree = createTable.value;
const shouldRefreshTargetTree = transferContent.value !== "dataOnly";
const request: api.TransferRequest = {
transferId: uuid(),
@@ -461,14 +531,16 @@ async function startTransfer() {
targetSchema: effectiveTargetSchema,
targetCatalog: normalizeTransferCatalog(targetCatalog.value, targetCatalogs.value) || undefined,
tables: [...selectedTables.value],
createTable: createTable.value,
createTable: transferContent.value !== "dataOnly",
content: transferContent.value,
objects: buildTransferObjectSelections(selectedObjects.value, treeDisabledGroups.value),
mode: transferMode.value,
targetTableNameCase: targetTableNameCase.value,
ownershipPolicy: "preserve",
batchSize: batchSize.value,
};
if (createTable.value) {
if (transferContent.value !== "dataOnly") {
try {
const preview = await api.previewTransferOwnership(request);
if (preview.missingOwners.length > 0) {
@@ -535,8 +607,8 @@ function getConnectionName(id: string) {
</DialogTitle>
</DialogHeader>
<div class="min-h-0 flex-1 overflow-hidden">
<div class="grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 py-3">
<div class="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-thin">
<div class="flex flex-col gap-5 py-3">
<!-- Source / Target Side by Side -->
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
<!-- Source Section -->
@@ -693,48 +765,47 @@ function getConnectionName(id: string) {
</div>
</div>
<!-- Tables Section -->
<!-- Objects Section -->
<div class="flex min-h-0 flex-col gap-2">
<div class="flex items-center justify-between">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t("transfer.tables") }}
<span v-if="sourceTables.length" class="text-muted-foreground/60">({{ selectedTables.size }}/{{ sourceTables.length }})</span>
{{ t("transfer.objects") }}
<span v-if="objectGroups.TABLE?.length" class="text-muted-foreground/60">({{ selectedTables.size }}/{{ objectGroups.TABLE.length }})</span>
</div>
<Button v-if="sourceTables.length" variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleSelectAll">
{{ allSelected ? t("transfer.deselectAll") : t("transfer.selectAll") }}
</Button>
</div>
<Input v-if="sourceTables.length > 5" v-model="tableSearch" :placeholder="t('transfer.searchTables')" class="h-7 text-xs" />
<div v-if="loadingTables" class="flex items-center gap-2 text-xs text-muted-foreground py-4 justify-center">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="!sourceConnectionId || !sourceDatabase" class="text-xs text-muted-foreground py-4 text-center">
<div v-if="(!loadingObjects && !sourceConnectionId) || !sourceDatabase" class="text-xs text-muted-foreground py-4 text-center">
{{ t("transfer.selectSourceFirst") }}
</div>
<div v-else-if="sourceTables.length === 0" class="text-xs text-muted-foreground py-4 text-center">
{{ t("transfer.noTables") }}
</div>
<div v-else class="min-h-0 max-h-[200px] overflow-y-auto rounded-md border">
<div v-for="table in filteredTables" :key="table" class="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 cursor-pointer text-xs" @click="toggleTable(table)">
<CheckSquare v-if="selectedTables.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
<span class="truncate">{{ table }}</span>
</div>
<ObjectSelectionTree v-model="treeSelection" :groups="treeGroups" :disabled-groups="treeDisabledGroups" :disabled-hints="treeDisabledHints" v-model:search="objectSearch" :loading="loadingObjects" class="min-h-0 flex-1" />
<div v-if="showCrossFamilyViewHint" class="mt-1.5 rounded-md border border-amber-300/40 bg-amber-50 px-2 py-1.5 text-xs text-amber-700">
{{ t("transfer.crossFamilyViewHint") }}
</div>
</div>
<!-- Options -->
<div class="space-y-2.5">
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="createTable = !createTable">
<CheckSquare v-if="createTable" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("transfer.createTable") }}
<div class="space-y-1">
<Label class="text-xs">{{ t("transfer.content") }}</Label>
<div class="flex flex-col gap-1">
<label class="flex cursor-pointer items-center gap-2 text-xs">
<input type="radio" value="structureAndData" v-model="transferContent" class="h-3.5 w-3.5" />
{{ t("transfer.contentStructureAndData") }}
</label>
<label class="flex cursor-pointer items-center gap-2 text-xs">
<input type="radio" value="structureOnly" v-model="transferContent" class="h-3.5 w-3.5" />
{{ t("transfer.contentStructureOnly") }}
<span class="text-muted-foreground/70">{{ t("transfer.contentStructureOnlyHint") }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-xs">
<input type="radio" value="dataOnly" v-model="transferContent" class="h-3.5 w-3.5" />
{{ t("transfer.contentDataOnly") }}
<span class="text-muted-foreground/70">{{ t("transfer.contentDataOnlyHint") }}</span>
</label>
</div>
</div>
<div class="flex items-center gap-3">
<Label class="text-xs shrink-0">{{ t("transfer.transferMode") }}</Label>
<div v-if="transferContent !== 'structureOnly'" class="flex items-center gap-3">
<Label class="text-xs shrink-0">{{ t("transfer.dataWriteMode") }}</Label>
<Select v-model="transferMode">
<SelectTrigger class="h-7 text-xs">
<SelectValue />
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { TransferObjectKind } from "@/lib/backend/api";
import { Square, CheckSquare, MinusSquare, Search, ChevronRight } from "@lucide/vue";
const { t } = useI18n();
export interface ObjectTreeGroup {
kind: TransferObjectKind;
label: string;
items: string[];
}
const props = defineProps<{
groups: ObjectTreeGroup[];
disabledGroups: TransferObjectKind[];
disabledHints: Record<string, string>;
modelValue: Record<string, string[]>;
search?: string;
loading?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: Record<string, string[]>];
"update:search": [value: string];
}>();
const expanded = ref<Set<string>>(new Set(props.groups.map((g) => g.kind)));
// Groups may arrive after the tree mounts (async object loading); keep newly
// appearing groups expanded so search results are visible without a manual tap.
watch(
() => props.groups.map((g) => g.kind),
(kinds) => {
let changed = false;
const next = new Set(expanded.value);
for (const kind of kinds) {
if (!next.has(kind)) {
next.add(kind);
changed = true;
}
}
if (changed) expanded.value = next;
},
);
// Local search state: the input edits this ref directly and changes are
// forwarded up via update:search (v-model:search on the parent).
const localSearch = ref(props.search ?? "");
watch(
() => props.search,
(v) => {
if (v !== localSearch.value) localSearch.value = v ?? "";
},
);
watch(localSearch, (v) => {
if (v !== (props.search ?? "")) emit("update:search", v);
});
const searchQuery = computed(() => localSearch.value.trim().toLowerCase());
function toggleGroup(kind: string) {
const next = new Set(expanded.value);
if (next.has(kind)) {
next.delete(kind);
} else {
next.add(kind);
}
expanded.value = next;
}
function isGroupDisabled(kind: TransferObjectKind): boolean {
return props.disabledGroups.includes(kind);
}
function selectedNames(kind: string): string[] {
return props.modelValue[kind] ?? [];
}
function updateSelection(kind: string, names: string[]) {
const next: Record<string, string[]> = { ...props.modelValue };
if (names.length === 0) {
delete next[kind];
} else {
next[kind] = names;
}
emit("update:modelValue", next);
}
function toggleItem(kind: string, item: string) {
const current = selectedNames(kind);
const next = current.includes(item) ? current.filter((n) => n !== item) : [...current, item];
updateSelection(kind, next);
}
function toggleGroupAll(kind: TransferObjectKind, items: string[]) {
const current = selectedNames(kind);
const allVisibleSelected = items.length > 0 && items.every((n) => current.includes(n));
if (allVisibleSelected) {
// uncheck only the visible items, keep selections hidden by the search
updateSelection(
kind,
current.filter((n) => !items.includes(n)),
);
} else {
// check the visible items, merging with existing (possibly hidden) ones
updateSelection(kind, [...new Set([...current, ...items])]);
}
}
const filteredGroups = computed<ObjectTreeGroup[]>(() => {
const query = searchQuery.value;
if (!query) {
return props.groups;
}
return props.groups.map((g) => ({
...g,
items: g.items.filter((name) => name.toLowerCase().includes(query)),
}));
});
function selectAllEnabled() {
const next: Record<string, string[]> = { ...props.modelValue };
for (const group of filteredGroups.value) {
if (isGroupDisabled(group.kind)) continue;
next[group.kind] = [...new Set([...(next[group.kind] ?? []), ...group.items])];
}
emit("update:modelValue", next);
}
function deselectAll() {
// Clear only the visible, enabled selections; selections hidden by the
// current search and selections inside disabled groups are preserved so
// bulk actions never silently drop what the user cannot see.
const next: Record<string, string[]> = { ...props.modelValue };
for (const group of filteredGroups.value) {
if (isGroupDisabled(group.kind)) continue;
const kept = (next[group.kind] ?? []).filter((n) => !group.items.includes(n));
if (kept.length === 0) {
delete next[group.kind];
} else {
next[group.kind] = kept;
}
}
emit("update:modelValue", next);
}
// The bulk button flips between “select all” and “deselect all”. It shows
// “deselect all” only when every visible, enabled item is already selected
// (selecting more is then a no-op); otherwise “select all” is shown so the
// user can fill the remaining visible items. Groups with no visible items
// (fully filtered out by the search) and disabled groups are ignored.
const allVisibleEnabledSelected = computed(() => {
const visibleEnabled = filteredGroups.value.filter((g) => !isGroupDisabled(g.kind) && g.items.length > 0);
return visibleEnabled.length > 0 && visibleEnabled.every((g) => g.items.every((item) => (props.modelValue[g.kind] ?? []).includes(item)));
});
// Tri-state header checkbox: "all" when every visible item of the group is
// selected, "partial" when only some are, "none" otherwise. Mirrors
// toggleGroupAll, which only ever touches the visible items.
function groupSelectionState(kind: TransferObjectKind, items: string[]): "none" | "partial" | "all" {
if (items.length === 0) return "none";
const selected = props.modelValue[kind] ?? [];
const visibleSelected = items.filter((item) => selected.includes(item)).length;
if (visibleSelected === 0) return "none";
if (visibleSelected === items.length) return "all";
return "partial";
}
</script>
<template>
<div class="flex flex-col flex-1 min-h-0 gap-2.5">
<!-- Sticky top bar: search input and action buttons -->
<div class="flex items-center gap-2 shrink-0">
<div class="relative flex-1 min-w-0">
<Search class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60 pointer-events-none" />
<input
data-test="search"
:placeholder="t('transfer.searchObjects')"
v-model="localSearch"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
spellcheck="false"
class="pl-8 pr-2.5 h-8 w-full rounded-md border border-input bg-transparent text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-ring transition-colors placeholder:text-muted-foreground/70"
/>
</div>
<Button v-if="allVisibleEnabledSelected" variant="outline" size="sm" @click="deselectAll">
{{ t("transfer.deselectAll") }}
</Button>
<Button v-else variant="outline" size="sm" @click="selectAllEnabled">
{{ t("transfer.selectAll") }}
</Button>
</div>
<div v-if="loading" class="py-4 text-center text-sm text-muted-foreground">
{{ t("common.loading") }}
</div>
<div v-else-if="groups.length === 0" class="py-4 text-center text-sm text-muted-foreground">
{{ t("transfer.noObjects") }}
</div>
<!-- Independent scroll area for tree groups -->
<div v-else class="flex-1 min-h-0 max-h-[240px] flex flex-col gap-1.5 overflow-y-auto rounded-md border border-border/80 bg-muted/10 p-1.5 scrollbar-thin scrollbar-thumb-muted-foreground/20">
<div v-for="group in filteredGroups" :key="group.kind" :data-test="`group-${group.kind}`" class="rounded-lg border border-border/60 bg-card/60 transition-all hover:bg-card flex flex-col p-1.5" :class="{ 'opacity-50 bg-muted/5 border-dashed border-muted': isGroupDisabled(group.kind) }">
<div class="flex items-center gap-2 px-1 py-0.5 select-none">
<button :data-test="'group-toggle'" :data-state="groupSelectionState(group.kind, group.items)" type="button" class="flex items-center gap-2 text-left shrink-0" :disabled="isGroupDisabled(group.kind)" @click="!isGroupDisabled(group.kind) && toggleGroupAll(group.kind, group.items)">
<CheckSquare v-if="groupSelectionState(group.kind, group.items) === 'all'" class="h-4 w-4 text-primary" />
<MinusSquare v-else-if="groupSelectionState(group.kind, group.items) === 'partial'" class="h-4 w-4 text-primary" />
<Square v-else class="h-4 w-4 text-muted-foreground" />
</button>
<span class="flex-1 text-xs font-semibold text-foreground/90 cursor-pointer" @click="toggleGroup(group.kind)">
{{ group.label }}
</span>
<span class="text-[10px] px-1 py-0.5 rounded-full bg-muted/60 text-muted-foreground font-mono shrink-0"> {{ (modelValue[group.kind] ?? []).length }}/{{ group.items.length }} </span>
<span v-if="disabledHints[group.kind]" class="rounded bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 text-[10px] text-amber-700 font-medium shrink-0">
{{ disabledHints[group.kind] }}
</span>
<button type="button" class="text-muted-foreground hover:text-foreground shrink-0 p-0.5" @click="toggleGroup(group.kind)">
<ChevronRight class="h-3.5 w-3.5 text-muted-foreground/80 transition-transform duration-200" :class="{ 'rotate-90': expanded.has(group.kind) }" />
</button>
</div>
<div v-if="expanded.has(group.kind)" class="flex flex-col gap-0.5 pl-6 pr-1 mt-1">
<label v-for="item in group.items" :key="item" class="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/70 transition-colors" :class="{ 'pointer-events-none opacity-50': isGroupDisabled(group.kind) }" :data-test="`item-${group.kind}-${item}`">
<input type="checkbox" class="h-3.5 w-3.5" :checked="(modelValue[group.kind] ?? []).includes(item)" :disabled="isGroupDisabled(group.kind)" @change="toggleItem(group.kind, item)" />
<span class="truncate text-foreground/80">{{ item }}</span>
</label>
<div v-if="group.items.length === 0" class="px-1 py-1 text-xs text-muted-foreground">无匹配</div>
</div>
</div>
</div>
</div>
</template>
@@ -7,17 +7,16 @@ describe("DataTransferDialog layout", () => {
it("keeps the header and footer outside the shrinking content region", () => {
expect(dialogSource).toContain('<DialogHeader class="shrink-0">');
expect(dialogSource).toContain('<DialogFooter class="shrink-0">');
expect(dialogSource).toContain('class="min-h-0 flex-1 overflow-hidden"');
expect(dialogSource).toContain('class="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-thin"');
});
it("shrinks the table row on short viewports without adding dialog scrolling", () => {
expect(dialogSource).toContain('class="grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 py-3"');
it("allows layout scrolling on short viewports to prevent content clipping", () => {
expect(dialogSource).toContain('class="flex flex-col gap-5 py-3"');
expect(dialogSource).toContain('class="flex min-h-0 flex-col gap-2"');
expect(dialogSource).not.toContain('class="flex-1 min-h-0 overflow-auto"');
});
it("keeps long table lists independently scrollable", () => {
expect(dialogSource).toContain('class="min-h-0 max-h-[200px] overflow-y-auto rounded-md border"');
expect(dialogSource).toContain('class="min-h-0 flex-1"');
});
});
@@ -32,7 +31,27 @@ describe("DataTransferDialog transfer prefill", () => {
it("keeps only copied tables selected after loading the source list", () => {
expect(dialogSource).toContain("function applyPendingTableSelection()");
expect(dialogSource).toContain("new Set(sourceTables.value.filter((table) => pending.includes(table)))");
expect(dialogSource).toContain("new Set(tables.filter((table) => pending.includes(table)))");
});
it("sends content and objects in the transfer request", () => {
expect(dialogSource).toContain("buildTransferObjectSelections(selectedObjects.value, treeDisabledGroups.value)");
expect(dialogSource).toContain('import { buildTransferObjectSelections } from "./transferSelections"');
expect(dialogSource).toContain('createTable: transferContent.value !== "dataOnly"');
expect(dialogSource).toContain('createTable: transferContent.value !== "dataOnly"');
});
it("loads non-table object groups per source database kind", () => {
expect(dialogSource).toContain("transferObjectKindsForDatabase");
expect(dialogSource).toContain("api.listObjects(sourceConnectionId.value, sourceDatabase.value, schema, [kind]");
expect(dialogSource).toContain("groups[kind] = objects.map((o) => o.name)");
});
it("disables non-table groups for data-only and cross-family transfers", () => {
expect(dialogSource).toContain("treeDisabledGroups");
expect(dialogSource).toContain('transferContent.value === "dataOnly"');
expect(dialogSource).toContain("crossFamilyTransferableKinds");
expect(dialogSource).toContain("objectDataOnlyDisabled");
});
it("allows a transfer between different schemas in the same database", () => {
@@ -0,0 +1,272 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Component } from "vue";
function passthrough(tag: string): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h(tag, attrs, slots.default?.());
},
});
}
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
return {
Square: Icon,
CheckSquare: Icon,
MinusSquare: Icon,
Search: Icon,
ChevronRight: Icon,
};
});
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
import ObjectSelectionTree from "../ObjectSelectionTree.vue";
type Kind = "TABLE" | "VIEW" | "FUNCTION";
interface TreeGroup {
kind: Kind;
label: string;
items: string[];
}
const VIEWS: TreeGroup = { kind: "VIEW", label: "Views", items: ["v1", "v2", "v3"] };
const FUNCTIONS: TreeGroup = { kind: "FUNCTION", label: "Fns", items: ["f1", "f2"] };
const TABLES: TreeGroup = { kind: "TABLE", label: "Tables", items: ["t1", "t2"] };
function mountTree(init: { groups?: TreeGroup[]; disabledGroups?: Kind[]; disabledHints?: Record<string, string>; selection?: Record<string, string[]>; search?: string }) {
const { groups = [VIEWS, FUNCTIONS], disabledGroups = [], disabledHints = {}, selection = {}, search = "" } = init;
const state = reactive({ selection, search });
const Wrapper = defineComponent({
setup() {
return () =>
h(ObjectSelectionTree, {
groups,
disabledGroups,
disabledHints,
modelValue: state.selection,
"onUpdate:modelValue": (v: Record<string, string[]>) => {
state.selection = v;
},
search: state.search,
"onUpdate:search": (v: string) => {
state.search = v;
},
});
},
});
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(Wrapper);
app.mount(container);
return { state, container, app };
}
function groupToggle(container: HTMLElement, index = 0): HTMLButtonElement {
return container.querySelectorAll<HTMLButtonElement>('button[data-test="group-toggle"]')[index];
}
function itemCheckbox(container: HTMLElement, kind: string, item: string): HTMLInputElement {
return container.querySelector<HTMLInputElement>(`label[data-test="item-${kind}-${item}"] input`)!;
}
function toolbarButton(container: HTMLElement, label: string): HTMLButtonElement | undefined {
return Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find((b) => b.textContent?.includes(label));
}
async function typeSearch(container: HTMLElement, value: string) {
const input = container.querySelector<HTMLInputElement>('input[data-test="search"]')!;
input.value = value;
input.dispatchEvent(new Event("input"));
await nextTick();
}
let cleanup: (() => void) | undefined;
afterEach(() => {
cleanup?.();
cleanup = undefined;
document.body.innerHTML = "";
vi.clearAllMocks();
});
describe("ObjectSelectionTree interaction", () => {
it("renders group headers, items and the search input", async () => {
const { container, app } = mountTree({});
cleanup = () => app.unmount();
expect(container.textContent).toContain("Views");
expect(container.querySelector('label[data-test="item-VIEW-v1"]')).not.toBeNull();
expect(container.querySelector('label[data-test="item-FUNCTION-f2"]')).not.toBeNull();
expect(container.querySelector('input[data-test="search"]')).not.toBeNull();
});
it("toggles all items of a group through the group header checkbox", async () => {
const { state, container, app } = mountTree({});
cleanup = () => app.unmount();
groupToggle(container).click();
await nextTick();
expect(state.selection.VIEW).toEqual(["v1", "v2", "v3"]);
// clicking again with every visible item selected unchecks them all
groupToggle(container).click();
await nextTick();
expect(state.selection.VIEW ?? []).toEqual([]);
});
it("group toggle under a search only touches the visible items", async () => {
const { state, container, app } = mountTree({ selection: { VIEW: ["v1", "v2", "v3"] } });
cleanup = () => app.unmount();
await typeSearch(container, "v2");
// all visible items (only v2) are selected -> toggle unchecks only v2
groupToggle(container).click();
await nextTick();
expect(state.selection.VIEW).toEqual(["v1", "v3"]);
});
it("deselect all only clears visible enabled selections, keeping hidden ones", async () => {
const { state, container, app } = mountTree({
selection: { VIEW: ["v1", "v2", "v3"], FUNCTION: ["f1", "f2"] },
});
cleanup = () => app.unmount();
await typeSearch(container, "v1");
toolbarButton(container, "transfer.deselectAll")!.click();
await nextTick();
expect(state.selection.VIEW).toEqual(["v2", "v3"]); // hidden selections survive
expect(state.selection.FUNCTION).toEqual(["f1", "f2"]);
});
it("deselect all skips disabled groups entirely", async () => {
const { state, container, app } = mountTree({
groups: [TABLES, VIEWS],
disabledGroups: ["VIEW"],
selection: { TABLE: ["t1", "t2"], VIEW: ["v1", "v2"] },
});
cleanup = () => app.unmount();
toolbarButton(container, "transfer.deselectAll")!.click();
await nextTick();
expect(state.selection.TABLE ?? []).toEqual([]);
expect(state.selection.VIEW).toEqual(["v1", "v2"]);
});
it("select all only selects visible enabled groups, merging hidden selections", async () => {
const { state, container, app } = mountTree({
selection: { VIEW: ["v1"], FUNCTION: [] },
});
cleanup = () => app.unmount();
await typeSearch(container, "v2");
// VIEW is the only group with visible items; FUNCTION items are filtered out
toolbarButton(container, "transfer.selectAll")!.click();
await nextTick();
expect(state.selection.VIEW).toEqual(["v1", "v2"]);
expect(state.selection.FUNCTION ?? []).toEqual([]);
});
it("select all skips disabled groups", async () => {
const { state, container, app } = mountTree({
groups: [TABLES, VIEWS],
disabledGroups: ["VIEW"],
});
cleanup = () => app.unmount();
toolbarButton(container, "transfer.selectAll")!.click();
await nextTick();
expect(state.selection.TABLE).toEqual(["t1", "t2"]);
expect(state.selection.VIEW ?? []).toEqual([]);
});
it("shows select-all while some visible enabled items are still unselected", async () => {
// user scenario: every TABLE selected but VIEW untouched -> select-all
const { container, app } = mountTree({
groups: [TABLES, VIEWS],
selection: { TABLE: ["t1", "t2"], VIEW: ["v1"] },
});
cleanup = () => app.unmount();
expect(toolbarButton(container, "transfer.selectAll")).toBeDefined();
expect(toolbarButton(container, "transfer.deselectAll")).toBeUndefined();
});
it("shows deselect-all only when every visible enabled item is selected", async () => {
const { state, container, app } = mountTree({
selection: { VIEW: ["v1", "v2", "v3"], FUNCTION: ["f1", "f2"] },
});
cleanup = () => app.unmount();
expect(toolbarButton(container, "transfer.deselectAll")).toBeDefined();
expect(toolbarButton(container, "transfer.selectAll")).toBeUndefined();
// deselecting everything flips the button back to select-all
toolbarButton(container, "transfer.deselectAll")!.click();
await nextTick();
expect(state.selection).toEqual({});
expect(toolbarButton(container, "transfer.selectAll")).toBeDefined();
});
it("search-filtered view: all visible items selected still shows deselect-all", async () => {
const { container, app } = mountTree({
selection: { VIEW: ["v1", "v2", "v3"], FUNCTION: ["f1"] },
});
cleanup = () => app.unmount();
// only v1 and f1 remain visible; both are selected -> deselect-all
await typeSearch(container, "1");
expect(toolbarButton(container, "transfer.deselectAll")).toBeDefined();
// filtering to nothing leaves only hidden selections -> select-all is shown again
await typeSearch(container, "zzz-no-match");
expect(toolbarButton(container, "transfer.selectAll")).toBeDefined();
expect(toolbarButton(container, "transfer.deselectAll")).toBeUndefined();
});
it("hidden selections in disabled groups do not surface the deselect button", async () => {
const { container, app } = mountTree({
groups: [TABLES, VIEWS],
disabledGroups: ["VIEW"],
selection: { VIEW: ["v1", "v2"] },
});
cleanup = () => app.unmount();
expect(toolbarButton(container, "transfer.selectAll")).toBeDefined();
expect(toolbarButton(container, "transfer.deselectAll")).toBeUndefined();
});
it("item checkbox toggles a single selection", async () => {
const { state, container, app } = mountTree({});
cleanup = () => app.unmount();
itemCheckbox(container, "VIEW", "v2").click();
await nextTick();
expect(state.selection.VIEW).toEqual(["v2"]);
itemCheckbox(container, "VIEW", "v2").click();
await nextTick();
expect(state.selection.VIEW ?? []).toEqual([]);
});
it("group header checkbox shows none / partial / all tri-state", async () => {
// none: no selection in the group
const none = mountTree({ groups: [VIEWS], selection: {} });
cleanup = () => none.app.unmount();
expect(groupToggle(none.container).dataset.state).toBe("none");
none.app.unmount();
// partial: only some visible items selected
const partial = mountTree({ groups: [VIEWS], selection: { VIEW: ["v1"] } });
cleanup = () => partial.app.unmount();
expect(groupToggle(partial.container).dataset.state).toBe("partial");
partial.app.unmount();
// all: every visible item selected
const all = mountTree({ groups: [VIEWS], selection: { VIEW: ["v1", "v2", "v3"] } });
cleanup = () => all.app.unmount();
expect(groupToggle(all.container).dataset.state).toBe("all");
all.app.unmount();
});
it("group header state is computed over visible items only", async () => {
// every item selected, but the search narrows the visible set to v1
const { container, app } = mountTree({ groups: [VIEWS], selection: { VIEW: ["v1", "v2", "v3"] } });
cleanup = () => app.unmount();
await typeSearch(container, "v1");
// v1 is the only visible item and it is selected -> all
expect(groupToggle(container).dataset.state).toBe("all");
// clearing the search shows every item selected again -> all
await typeSearch(container, "");
expect(groupToggle(container).dataset.state).toBe("all");
});
});
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { buildTransferObjectSelections } from "../transferSelections";
function setOf(names: string[]): Set<string> {
return new Set(names);
}
describe("buildTransferObjectSelections", () => {
it("serializes non-table selections in request order", () => {
const result = buildTransferObjectSelections(
{
VIEW: setOf(["v1", "v2"]),
SEQUENCE: setOf(["s1"]),
TABLE: setOf(["t1"]),
},
[],
);
expect(result).toEqual([
{ objectType: "VIEW", names: ["v1", "v2"] },
{ objectType: "SEQUENCE", names: ["s1"] },
]);
});
it("drops TABLE selections (handled by the tables field)", () => {
const result = buildTransferObjectSelections({ TABLE: setOf(["t1", "t2"]) }, []);
expect(result).toEqual([]);
});
it("filters disabled object types even when stale selections remain", () => {
const result = buildTransferObjectSelections(
{
VIEW: setOf(["v1"]),
SEQUENCE: setOf(["s1"]),
PROCEDURE: setOf(["p1"]),
},
["VIEW", "SEQUENCE"],
);
expect(result).toEqual([{ objectType: "PROCEDURE", names: ["p1"] }]);
});
it("returns an empty payload when nothing is selected", () => {
expect(buildTransferObjectSelections({}, [])).toEqual([]);
});
});
@@ -0,0 +1,22 @@
import type { TransferObjectKind } from "@/lib/backend/api";
export interface TransferObjectSelectionPayload {
objectType: TransferObjectKind;
names: string[];
}
/**
* Builds the `objects` payload of a transfer request from the tree
* selection state. TABLE is handled by `tables`, and object types that
* are currently disabled for this transfer (data-only mode, or kinds the
* cross-family matrix does not allow) are dropped at the request boundary
* even if stale selections are still present in the tree.
*/
export function buildTransferObjectSelections(selectedObjects: Partial<Record<TransferObjectKind, Set<string>>>, disabledGroups: TransferObjectKind[]): TransferObjectSelectionPayload[] {
return (Object.keys(selectedObjects) as TransferObjectKind[])
.filter((kind) => kind !== "TABLE" && !disabledGroups.includes(kind))
.map((kind) => ({
objectType: kind,
names: [...(selectedObjects[kind] ?? [])],
}));
}