fix(import): pick existing tables from database scope
This commit is contained in:
10 files changed
+464
-16
No files matched your search
@@ -7,6 +7,7 @@ import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogScrollContent }
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileCode, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
type TableImportWizardStep,
|
||||
} from "@/lib/table/tableImport";
|
||||
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
|
||||
import { tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { metadataSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import type { ColumnInfo } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
@@ -64,6 +65,9 @@ interface BatchImportTask {
|
||||
|
||||
const SKIP_VALUE = "__skip__";
|
||||
const targetColumns = ref<ColumnInfo[]>([]);
|
||||
const loadedTargetTableName = ref("");
|
||||
const existingTableNames = ref<string[]>([]);
|
||||
const selectedExistingTable = ref(props.prefillTable || "");
|
||||
const targetMode = ref<ImportTargetMode>(props.prefillTable ? "existing" : "create");
|
||||
const newTableName = ref("");
|
||||
const selectedSource = ref<string | File | null>(null);
|
||||
@@ -75,6 +79,7 @@ const columnMapping = ref<Record<string, string>>({});
|
||||
const columnDataTypes = ref<Record<string, string>>({});
|
||||
const dynamicDataTypeOptions = ref<string[]>([]);
|
||||
const loadingDataTypeOptions = ref(false);
|
||||
const loadingExistingTables = ref(false);
|
||||
const loadingTarget = ref(false);
|
||||
const loadingPreview = ref(false);
|
||||
const importMode = ref<api.TableImportMode>("append");
|
||||
@@ -101,6 +106,8 @@ let previewReloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let importElapsedTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let importStartedAt = 0;
|
||||
let dataTypeOptionsRequestId = 0;
|
||||
let existingTablesRequestId = 0;
|
||||
let targetColumnsRequestId = 0;
|
||||
let previewRequestId = 0;
|
||||
let batchEncodingRequestId = 0;
|
||||
|
||||
@@ -131,9 +138,11 @@ const wizardSteps: Array<{ value: TableImportWizardStep; labelKey: string }> = [
|
||||
|
||||
const selectedConnection = computed(() => (props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined));
|
||||
const structureDatabaseType = computed(() => tableStructureDatabaseTypeForConnection(selectedConnection.value));
|
||||
const targetSchema = computed(() => metadataSchemaForConnection(selectedConnection.value, props.prefillDatabase || "", props.prefillSchema));
|
||||
const dataTypeOptions = computed(() => mergeDataTypeOptions(dynamicDataTypeOptions.value, getDataTypeOptions(structureDatabaseType.value), Object.values(columnDataTypes.value)));
|
||||
const hasExistingTarget = computed(() => !!props.prefillTable);
|
||||
const targetTableName = computed(() => (targetMode.value === "create" ? newTableName.value.trim() : props.prefillTable || ""));
|
||||
const hasExistingTarget = computed(() => !!props.prefillTable || loadingExistingTables.value || existingTableNames.value.length > 0);
|
||||
const targetTableName = computed(() => (targetMode.value === "create" ? newTableName.value.trim() : selectedExistingTable.value));
|
||||
const existingTargetMetadataReady = computed(() => targetMode.value !== "existing" || (!loadingTarget.value && !!selectedExistingTable.value && loadedTargetTableName.value === selectedExistingTable.value));
|
||||
const targetColumnNames = computed(() => targetColumns.value.map((column) => column.name));
|
||||
const mappedColumns = computed<api.TableImportColumnMapping[]>(() => {
|
||||
const currentPreview = preview.value;
|
||||
@@ -159,7 +168,7 @@ const requiredUnmappedColumns = computed(() =>
|
||||
);
|
||||
const isBatchImport = computed(() => targetMode.value === "create" && batchTasks.value.length > 1);
|
||||
const canImport = computed(() => {
|
||||
if (running.value || !props.prefillConnectionId) return false;
|
||||
if (running.value || !props.prefillConnectionId || !existingTargetMetadataReady.value) return false;
|
||||
if (!isBatchImport.value) return !!preview.value && !!targetTableName.value && mappingValidation.value.valid;
|
||||
const tableNames = batchTasks.value.map((task) => task.tableName.trim().toLowerCase());
|
||||
if (new Set(tableNames).size !== tableNames.length) return false;
|
||||
@@ -171,8 +180,8 @@ const canImport = computed(() => {
|
||||
const canGoBack = computed(() => wizardStep.value !== "source" && wizardStep.value !== "execution" && !running.value);
|
||||
const canGoNext = computed(() => {
|
||||
if (wizardStep.value === "source") return !!selectedSource.value && !!sourceFormat.value;
|
||||
if (wizardStep.value === "options") return !!preview.value && !!targetTableName.value;
|
||||
if (wizardStep.value === "mapping") return mappingValidation.value.valid;
|
||||
if (wizardStep.value === "options") return !!preview.value && !!targetTableName.value && existingTargetMetadataReady.value;
|
||||
if (wizardStep.value === "mapping") return existingTargetMetadataReady.value && mappingValidation.value.valid;
|
||||
return false;
|
||||
});
|
||||
const rawProgressPercent = computed(() => tableImportProgressPercent(progress.value));
|
||||
@@ -236,11 +245,16 @@ function resetState() {
|
||||
liveElapsedMs.value = 0;
|
||||
previewRequestId++;
|
||||
batchEncodingRequestId++;
|
||||
existingTablesRequestId++;
|
||||
targetColumnsRequestId++;
|
||||
if (previewReloadTimer) {
|
||||
clearTimeout(previewReloadTimer);
|
||||
previewReloadTimer = null;
|
||||
}
|
||||
targetColumns.value = [];
|
||||
loadedTargetTableName.value = "";
|
||||
existingTableNames.value = [];
|
||||
selectedExistingTable.value = props.prefillTable || "";
|
||||
targetMode.value = props.prefillTable ? "existing" : "create";
|
||||
newTableName.value = "";
|
||||
selectedSource.value = null;
|
||||
@@ -263,6 +277,8 @@ function resetState() {
|
||||
importMode.value = "append";
|
||||
batchSize.value = 500;
|
||||
loadingPreview.value = false;
|
||||
loadingExistingTables.value = false;
|
||||
loadingTarget.value = false;
|
||||
running.value = false;
|
||||
cancelling.value = false;
|
||||
importId.value = "";
|
||||
@@ -465,18 +481,58 @@ async function loadDataTypeOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExistingTables() {
|
||||
if (props.prefillTable || loadingExistingTables.value || existingTableNames.value.length || !props.prefillConnectionId || !props.prefillDatabase) return;
|
||||
const requestId = ++existingTablesRequestId;
|
||||
loadingExistingTables.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await store.ensureConnected(props.prefillConnectionId);
|
||||
const tables = await api.listTables(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, undefined, undefined, undefined, ["TABLE"]);
|
||||
if (requestId !== existingTablesRequestId) return;
|
||||
existingTableNames.value = tables.map((table) => table.name);
|
||||
} catch (e: any) {
|
||||
if (requestId === existingTablesRequestId) {
|
||||
existingTableNames.value = [];
|
||||
errorMessage.value = String(e?.message || e);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === existingTablesRequestId) loadingExistingTables.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTargetColumns() {
|
||||
if (targetMode.value !== "existing" || !props.prefillConnectionId || !props.prefillDatabase || !props.prefillTable) return;
|
||||
const tableName = selectedExistingTable.value;
|
||||
if (targetMode.value !== "existing" || !props.prefillConnectionId || !props.prefillDatabase || !tableName) {
|
||||
targetColumnsRequestId++;
|
||||
targetColumns.value = [];
|
||||
loadedTargetTableName.value = "";
|
||||
columnMapping.value = {};
|
||||
loadingTarget.value = false;
|
||||
return;
|
||||
}
|
||||
const requestId = ++targetColumnsRequestId;
|
||||
targetColumns.value = [];
|
||||
loadedTargetTableName.value = "";
|
||||
columnMapping.value = {};
|
||||
loadingTarget.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await store.ensureConnected(props.prefillConnectionId);
|
||||
targetColumns.value = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, props.prefillSchema || props.prefillDatabase, props.prefillTable);
|
||||
const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, targetSchema.value, tableName);
|
||||
if (requestId !== targetColumnsRequestId) return;
|
||||
targetColumns.value = columns;
|
||||
loadedTargetTableName.value = tableName;
|
||||
applyAutoMapping();
|
||||
} catch (e: any) {
|
||||
errorMessage.value = String(e?.message || e);
|
||||
if (requestId === targetColumnsRequestId) {
|
||||
targetColumns.value = [];
|
||||
loadedTargetTableName.value = "";
|
||||
columnMapping.value = {};
|
||||
errorMessage.value = String(e?.message || e);
|
||||
}
|
||||
} finally {
|
||||
loadingTarget.value = false;
|
||||
if (requestId === targetColumnsRequestId) loadingTarget.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,8 +727,8 @@ function canOpenStep(step: TableImportWizardStep) {
|
||||
if (running.value || step === "execution") return false;
|
||||
if (step === "source") return true;
|
||||
if (step === "options") return !!selectedSource.value;
|
||||
if (step === "mapping") return !!preview.value;
|
||||
if (step === "review") return !!preview.value && mappingValidation.value.valid;
|
||||
if (step === "mapping") return !!preview.value && existingTargetMetadataReady.value;
|
||||
if (step === "review") return !!preview.value && existingTargetMetadataReady.value && mappingValidation.value.valid;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -749,7 +805,7 @@ async function startImport() {
|
||||
importId: importId.value,
|
||||
connectionId: props.prefillConnectionId,
|
||||
database: props.prefillDatabase || "",
|
||||
schema: props.prefillSchema || "",
|
||||
schema: targetSchema.value,
|
||||
table: tableName,
|
||||
filePath: currentPreview.filePath,
|
||||
sourceRef: currentPreview.sourceRef || null,
|
||||
@@ -835,7 +891,7 @@ async function startBatchImport() {
|
||||
importId: importId.value,
|
||||
connectionId: props.prefillConnectionId,
|
||||
database: props.prefillDatabase || "",
|
||||
schema: props.prefillSchema || "",
|
||||
schema: targetSchema.value,
|
||||
table: task.tableName,
|
||||
filePath: task.preview.filePath,
|
||||
sourceRef: task.preview.sourceRef || null,
|
||||
@@ -992,6 +1048,7 @@ watch(textEncoding, schedulePreviewReloadAfterEncodingChange);
|
||||
watch([newTableName, columnMapping, columnDataTypes], saveActiveBatchTask, { deep: true });
|
||||
watch(wizardStep, (step) => {
|
||||
if (step !== "mapping") closeDataTypePicker();
|
||||
if (step === "options") void loadExistingTables();
|
||||
});
|
||||
watch(targetMode, (mode) => {
|
||||
if (mode === "existing") {
|
||||
@@ -999,13 +1056,19 @@ watch(targetMode, (mode) => {
|
||||
dynamicDataTypeOptions.value = [];
|
||||
void loadTargetColumns();
|
||||
} else {
|
||||
targetColumnsRequestId++;
|
||||
targetColumns.value = [];
|
||||
loadedTargetTableName.value = "";
|
||||
loadingTarget.value = false;
|
||||
importMode.value = "append";
|
||||
applyAutoMapping();
|
||||
applySuggestedColumnDataTypes();
|
||||
void loadDataTypeOptions();
|
||||
}
|
||||
});
|
||||
watch(selectedExistingTable, () => {
|
||||
if (targetMode.value === "existing") void loadTargetColumns();
|
||||
});
|
||||
|
||||
watch(rawProgressPercent, (percent) => {
|
||||
if (progress.value?.status === "running") {
|
||||
@@ -1144,7 +1207,7 @@ watch(rawProgressPercent, (percent) => {
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button type="button" class="rounded-md border px-3 py-2 text-left text-xs" :class="targetMode === 'existing' ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'" :disabled="!hasExistingTarget" @click="targetMode = 'existing'">
|
||||
<div class="font-medium">{{ t("tableImport.existingTable") }}</div>
|
||||
<div class="mt-1 truncate text-[11px] text-muted-foreground">{{ props.prefillTable || t("tableImport.noExistingTarget") }}</div>
|
||||
<div class="mt-1 truncate text-[11px] text-muted-foreground">{{ selectedExistingTable || t("tableImport.noExistingTarget") }}</div>
|
||||
</button>
|
||||
<button type="button" class="rounded-md border px-3 py-2 text-left text-xs" :class="targetMode === 'create' ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'" @click="targetMode = 'create'">
|
||||
<div class="font-medium">{{ t("tableImport.createTable") }}</div>
|
||||
@@ -1155,9 +1218,22 @@ watch(rawProgressPercent, (percent) => {
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.targetTableName") }}</Label>
|
||||
<Input v-if="targetMode === 'create'" v-model="newTableName" class="h-8 text-xs font-mono" />
|
||||
<div v-else class="flex h-8 items-center rounded-md border px-2 text-xs">
|
||||
<div v-else-if="props.prefillTable" class="flex h-8 items-center rounded-md border px-2 text-xs">
|
||||
<span class="truncate">{{ props.prefillTable }}</span>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
v-else
|
||||
:model-value="selectedExistingTable"
|
||||
:options="existingTableNames"
|
||||
:placeholder="t('tableImport.noExistingTarget')"
|
||||
:search-placeholder="t('tableImport.searchExistingTables')"
|
||||
:empty-text="t('tableImport.noTables')"
|
||||
:loading-text="t('tableImport.loadingTables')"
|
||||
:loading="loadingExistingTables"
|
||||
:disabled="loadingExistingTables || !existingTableNames.length"
|
||||
trigger-class="h-8 font-mono text-xs"
|
||||
@update:model-value="(value) => (selectedExistingTable = value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
listTables: vi.fn().mockResolvedValue([
|
||||
{ name: "existing_target", table_type: "TABLE" },
|
||||
{ name: "archived_target", table_type: "TABLE" },
|
||||
]),
|
||||
getColumns: vi.fn().mockResolvedValue([
|
||||
{ name: "id", data_type: "INTEGER", nullable: false },
|
||||
{ name: "name", data_type: "TEXT", nullable: true },
|
||||
]),
|
||||
listDataTypes: vi.fn().mockResolvedValue(["INTEGER", "TEXT"]),
|
||||
previewTableImportFile: vi.fn().mockResolvedValue({
|
||||
fileName: "rows.xlsx",
|
||||
filePath: "/tmp/rows.xlsx",
|
||||
fileType: "excel",
|
||||
sizeBytes: 24,
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "Alice"]],
|
||||
totalRows: 1,
|
||||
sourceFingerprint: "rows-xlsx",
|
||||
sheets: ["Data"],
|
||||
}),
|
||||
releaseTableImportSource: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
getConfig: (id: string) => {
|
||||
if (id === "connection-1") return { id, name: "SQLite", db_type: "sqlite" };
|
||||
if (id === "postgres-1") return { id, name: "PostgreSQL", db_type: "postgres" };
|
||||
return undefined;
|
||||
},
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
invalidateMetadataCache: vi.fn(),
|
||||
refreshObjectListTreeNode: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({ editorSettings: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listTables: mocks.listTables,
|
||||
getColumns: mocks.getColumns,
|
||||
listDataTypes: mocks.listDataTypes,
|
||||
previewTableImportFile: mocks.previewTableImportFile,
|
||||
releaseTableImportSource: mocks.releaseTableImportSource,
|
||||
importTableFile: vi.fn(),
|
||||
cancelTableImport: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h("div", attrs, slots.default?.());
|
||||
},
|
||||
});
|
||||
return {
|
||||
Dialog: passthrough,
|
||||
DialogHeader: passthrough,
|
||||
DialogTitle: passthrough,
|
||||
DialogFooter: passthrough,
|
||||
DialogScrollContent: passthrough,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
props: { disabled: Boolean },
|
||||
emits: ["click"],
|
||||
setup(props, { attrs, slots, emit }) {
|
||||
return () => h("button", { ...attrs, disabled: props.disabled, onClick: () => emit("click") }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/input", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Input: defineComponent({
|
||||
props: { modelValue: [String, Number], disabled: Boolean },
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () =>
|
||||
h("input", {
|
||||
...attrs,
|
||||
value: props.modelValue,
|
||||
disabled: props.disabled,
|
||||
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/label", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Label: defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("label", slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/select", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup(_props, { slots }) {
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
return {
|
||||
Select: passthrough,
|
||||
SelectContent: passthrough,
|
||||
SelectItem: passthrough,
|
||||
SelectTrigger: passthrough,
|
||||
SelectValue: passthrough,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/searchable-select", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
SearchableSelect: defineComponent({
|
||||
props: {
|
||||
modelValue: { type: String, default: "" },
|
||||
options: { type: Array<string>, default: () => [] },
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
"select",
|
||||
{
|
||||
class: "existing-table-select-stub",
|
||||
value: props.modelValue,
|
||||
disabled: props.disabled,
|
||||
onChange: (event: Event) => emit("update:modelValue", (event.target as HTMLSelectElement).value),
|
||||
},
|
||||
[h("option", { value: "" }), ...(props.options as string[]).map((option) => h("option", { value: option }, option))],
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import TableImportDialog from "@/components/import/TableImportDialog.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function flushAsyncUpdates() {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
async function mountDialog(
|
||||
options: {
|
||||
prefillTable?: string;
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
} = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(TableImportDialog, {
|
||||
open: true,
|
||||
prefillConnectionId: options.connectionId || "connection-1",
|
||||
prefillDatabase: options.database || "main",
|
||||
prefillSchema: options.schema,
|
||||
prefillTable: options.prefillTable,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await flushAsyncUpdates();
|
||||
}
|
||||
|
||||
async function selectWorkbook() {
|
||||
const fileInput = document.body.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(fileInput).toBeTruthy();
|
||||
Object.defineProperty(fileInput, "files", {
|
||||
configurable: true,
|
||||
value: [new File(["test workbook"], "rows.xlsx", { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" })],
|
||||
});
|
||||
fileInput?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(mocks.previewTableImportFile).toHaveBeenCalled());
|
||||
}
|
||||
|
||||
async function selectExistingTarget(tableName: string) {
|
||||
const tableSelect = document.body.querySelector<HTMLSelectElement>("select.existing-table-select-stub");
|
||||
expect(tableSelect).toBeTruthy();
|
||||
if (tableSelect) {
|
||||
tableSelect.value = tableName;
|
||||
tableSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function buttonContaining(text: string) {
|
||||
return [...document.body.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes(text));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.ensureConnected.mockReset().mockResolvedValue(undefined);
|
||||
mocks.listTables.mockReset().mockResolvedValue([
|
||||
{ name: "existing_target", table_type: "TABLE" },
|
||||
{ name: "archived_target", table_type: "TABLE" },
|
||||
]);
|
||||
mocks.getColumns.mockReset().mockResolvedValue([
|
||||
{ name: "id", data_type: "INTEGER", nullable: false },
|
||||
{ name: "name", data_type: "TEXT", nullable: true },
|
||||
]);
|
||||
mocks.listDataTypes.mockClear();
|
||||
mocks.previewTableImportFile.mockClear();
|
||||
mocks.releaseTableImportSource.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.textContent = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("TableImportDialog existing targets", () => {
|
||||
it("lets a database-level import choose an existing table and loads its columns", async () => {
|
||||
i18n.global.locale.value = "en";
|
||||
await mountDialog();
|
||||
|
||||
expect(mocks.listTables).not.toHaveBeenCalled();
|
||||
await selectWorkbook();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.listTables).toHaveBeenCalledWith("connection-1", "main", "main", undefined, undefined, undefined, ["TABLE"]);
|
||||
});
|
||||
|
||||
const existingTableButton = buttonContaining("Existing table");
|
||||
expect(existingTableButton).toBeTruthy();
|
||||
expect(existingTableButton?.disabled).toBe(false);
|
||||
existingTableButton?.click();
|
||||
await flushAsyncUpdates();
|
||||
await selectExistingTarget("existing_target");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.getColumns).toHaveBeenCalledWith("connection-1", "main", "main", "existing_target");
|
||||
});
|
||||
|
||||
buttonContaining("Next")?.click();
|
||||
await flushAsyncUpdates();
|
||||
const mappings = [...document.body.querySelectorAll<HTMLSelectElement>("select")];
|
||||
expect(mappings.map((select) => select.value)).toEqual(["id", "name"]);
|
||||
expect(mappings.map((select) => [...select.options].map((option) => option.value))).toEqual([
|
||||
["__skip__", "id", "name"],
|
||||
["__skip__", "id", "name"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for the selected table metadata and ignores an older table response", async () => {
|
||||
let resolveExisting!: (columns: Array<Record<string, unknown>>) => void;
|
||||
let resolveArchived!: (columns: Array<Record<string, unknown>>) => void;
|
||||
const existingColumns = new Promise<Array<Record<string, unknown>>>((resolve) => (resolveExisting = resolve));
|
||||
const archivedColumns = new Promise<Array<Record<string, unknown>>>((resolve) => (resolveArchived = resolve));
|
||||
mocks.getColumns.mockImplementation((_connectionId, _database, _schema, tableName) => (tableName === "existing_target" ? existingColumns : archivedColumns));
|
||||
|
||||
i18n.global.locale.value = "en";
|
||||
await mountDialog({ schema: "main" });
|
||||
await selectWorkbook();
|
||||
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalled());
|
||||
buttonContaining("Existing table")?.click();
|
||||
await flushAsyncUpdates();
|
||||
|
||||
await selectExistingTarget("existing_target");
|
||||
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledWith("connection-1", "main", "main", "existing_target"));
|
||||
expect(buttonContaining("Next")?.disabled).toBe(true);
|
||||
|
||||
await selectExistingTarget("archived_target");
|
||||
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledWith("connection-1", "main", "main", "archived_target"));
|
||||
resolveArchived([{ name: "name", data_type: "TEXT", nullable: true }]);
|
||||
await flushAsyncUpdates();
|
||||
expect(buttonContaining("Next")?.disabled).toBe(false);
|
||||
|
||||
resolveExisting([{ name: "id", data_type: "INTEGER", nullable: false }]);
|
||||
await flushAsyncUpdates();
|
||||
buttonContaining("Next")?.click();
|
||||
await flushAsyncUpdates();
|
||||
|
||||
const mappings = [...document.body.querySelectorAll<HTMLSelectElement>("select")];
|
||||
expect(mappings.map((select) => select.value)).toEqual(["__skip__", "name"]);
|
||||
expect(mappings.every((select) => [...select.options].every((option) => option.value !== "id"))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps navigation disabled when loading existing-table columns fails", async () => {
|
||||
mocks.getColumns.mockRejectedValueOnce(new Error("metadata unavailable"));
|
||||
i18n.global.locale.value = "en";
|
||||
await mountDialog({ schema: "main" });
|
||||
await selectWorkbook();
|
||||
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalled());
|
||||
buttonContaining("Existing table")?.click();
|
||||
await flushAsyncUpdates();
|
||||
await selectExistingTarget("existing_target");
|
||||
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain("metadata unavailable"));
|
||||
expect(buttonContaining("Next")?.disabled).toBe(true);
|
||||
expect(buttonContaining("Mapping")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the connection-aware default schema for a database-level import", async () => {
|
||||
i18n.global.locale.value = "en";
|
||||
await mountDialog({ connectionId: "postgres-1", database: "dbx_test" });
|
||||
await selectWorkbook();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.listTables).toHaveBeenCalledWith("postgres-1", "dbx_test", "", undefined, undefined, undefined, ["TABLE"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a table-level import prefilled without loading the table list", async () => {
|
||||
i18n.global.locale.value = "en";
|
||||
await mountDialog({ prefillTable: "existing_target", schema: "main" });
|
||||
|
||||
expect(mocks.listTables).not.toHaveBeenCalled();
|
||||
expect(mocks.getColumns).toHaveBeenCalledWith("connection-1", "main", "main", "existing_target");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user