feat(transfer): add tree-based object selection
This commit is contained in:
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] ?? [])],
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user