feat(import): select worksheets for batch table imports

This commit is contained in:
cowking
2026-09-22 01:21:46 +08:00
committed by GitHub
parent e1f46cf9a8
commit 0edd6554d5
4 changed files with 484 additions and 32 deletions
@@ -53,6 +53,7 @@ type ImportSource = string | File;
interface BatchImportTask {
id: string;
selected: boolean;
source: ImportSource;
format: api.TableImportSourceFormat;
sheetName: string;
@@ -182,12 +183,16 @@ const requiredUnmappedColumns = computed(() =>
),
);
const isBatchImport = computed(() => targetMode.value === "create" && batchTasks.value.length > 1);
const selectedBatchTasks = computed(() => batchTasks.value.filter((task) => task.selected));
const batchTargetNamesValid = computed(() => {
const tableNames = selectedBatchTasks.value.map((task) => task.tableName.trim().toLowerCase());
return tableNames.length > 0 && tableNames.every(Boolean) && new Set(tableNames).size === tableNames.length;
});
const canImport = computed(() => {
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;
return batchTasks.value.every((task) => {
if (!batchTargetNamesValid.value) return false;
return selectedBatchTasks.value.every((task) => {
const mappings = task.preview.columns.map((sourceColumn) => ({ sourceColumn, targetColumn: task.columnMapping[sourceColumn] ?? "", targetDataType: task.columnDataTypes[sourceColumn] ?? "" })).filter((mapping) => mapping.targetColumn);
return !!task.tableName.trim() && validateImportMappings(mappings).valid;
});
@@ -195,6 +200,10 @@ 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 (isBatchImport.value) {
if (wizardStep.value === "options") return batchTargetNamesValid.value;
if (wizardStep.value === "mapping") return canImport.value;
}
if (wizardStep.value === "options") return !!preview.value && !!targetTableName.value && existingTargetMetadataReady.value;
if (wizardStep.value === "mapping") return existingTargetMetadataReady.value && mappingValidation.value.valid;
return false;
@@ -657,6 +666,7 @@ async function prepareBatchSources(sources: ImportSource[]) {
const tableBase = sheetName ? `${suggestedTableName(sourceName(source))}_${sheetName}` : sourceName(source);
tasks.push({
id: uuid(),
selected: true,
source,
format,
sheetName: effectiveSheetName,
@@ -742,6 +752,10 @@ function canOpenStep(step: TableImportWizardStep) {
if (running.value || step === "execution") return false;
if (step === "source") return true;
if (step === "options") return !!selectedSource.value;
if (isBatchImport.value) {
if (step === "mapping") return selectedBatchTasks.value.length > 0;
if (step === "review") return canImport.value;
}
if (step === "mapping") return !!preview.value && existingTargetMetadataReady.value;
if (step === "review") return !!preview.value && existingTargetMetadataReady.value && mappingValidation.value.valid;
return false;
@@ -785,6 +799,7 @@ async function goNext() {
async function startImport() {
saveActiveBatchTask();
if (!canImport.value) return;
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(props.prefillConnectionId ?? ""), source: t("readOnlyUnlock.sourceImport"), treatAsMutation: true }))) {
return;
}
@@ -866,15 +881,18 @@ async function startImport() {
}
async function startBatchImport() {
if (!props.prefillConnectionId || !batchTasks.value.length || running.value) return;
if (!props.prefillConnectionId || !canImport.value) return;
// Keep original indices for the active preview and error state, even when
// the import queue excludes worksheets between selected tasks.
const tasks = batchTasks.value.map((task, index) => ({ task, index })).filter(({ task }) => task.selected);
running.value = true;
progressPercentFloor.value = 0;
cancelling.value = false;
errorMessage.value = "";
wizardStep.value = "execution";
const totalRowsExact = batchTasks.value.every((task) => task.preview.totalRowsExact !== false);
const totalRows = totalRowsExact ? batchTasks.value.reduce((sum, task) => sum + task.preview.totalRows, 0) : 0;
const totalBytes = batchTasks.value.reduce((sum, task) => sum + task.preview.sizeBytes, 0);
const totalRowsExact = tasks.every(({ task }) => task.preview.totalRowsExact !== false);
const totalRows = totalRowsExact ? tasks.reduce((sum, { task }) => sum + task.preview.totalRows, 0) : 0;
const totalBytes = tasks.reduce((sum, { task }) => sum + task.preview.sizeBytes, 0);
let completedRows = 0;
let completedBytes = 0;
importId.value = uuid();
@@ -882,10 +900,9 @@ async function startBatchImport() {
progress.value = { importId: importId.value, status: "running", phase: "preparing", rowsImported: 0, totalRows, totalRowsExact, bytesRead: 0, totalBytes, elapsedMs: 0 };
try {
const tableNames = batchTasks.value.map((task) => task.tableName.trim().toLowerCase());
const tableNames = tasks.map(({ task }) => task.tableName.trim().toLowerCase());
if (new Set(tableNames).size !== tableNames.length) throw new Error("Target table names must be unique");
for (let index = 0; index < batchTasks.value.length; index++) {
const task = batchTasks.value[index];
for (const [queueIndex, { task, index }] of tasks.entries()) {
activeTaskIndex.value = index;
task.status = "running";
importId.value = uuid();
@@ -922,7 +939,7 @@ async function startBatchImport() {
},
(nextProgress) => {
task.rowsImported = nextProgress.rowsImported;
const hasRemainingTasks = index < batchTasks.value.length - 1;
const hasRemainingTasks = queueIndex < tasks.length - 1;
const aggregateStatus = nextProgress.status === "done" && hasRemainingTasks ? "running" : nextProgress.status;
const aggregatePhase = nextProgress.status === "done" && hasRemainingTasks ? "preparing" : nextProgress.phase;
progress.value = {
@@ -1122,30 +1139,31 @@ watch(rawProgressPercent, (percent) => {
<div v-if="isBatchImport" class="rounded-md border">
<div class="flex items-center justify-between border-b px-3 py-2 text-xs font-medium">
<span>{{ batchTasks.length }} {{ t("tableImport.file") }}</span>
<span>{{ t("tableImport.selectedTasks", { selected: selectedBatchTasks.length, total: batchTasks.length }) }}</span>
<span class="text-muted-foreground">{{ selectedSourceName }}</span>
</div>
<div class="flex max-h-32 flex-wrap gap-1.5 overflow-auto p-2">
<button
v-for="(task, index) in batchTasks"
:key="task.id"
type="button"
class="flex max-w-[260px] items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors"
:class="index === activeTaskIndex ? 'border-primary bg-primary/10 text-primary' : 'hover:bg-muted/60'"
:disabled="running"
@click="
saveActiveBatchTask();
activateBatchTask(index);
"
>
<CheckCircle2 v-if="task.status === 'done'" class="h-3.5 w-3.5 shrink-0 text-emerald-500" />
<Loader2 v-else-if="task.status === 'running'" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<AlertTriangle v-else-if="task.status === 'error'" class="h-3.5 w-3.5 shrink-0 text-destructive" />
<FileSpreadsheet v-else-if="task.format === 'excel'" class="h-3.5 w-3.5 shrink-0" />
<FileText v-else class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ task.tableName }}</span>
</button>
<div v-for="(task, index) in batchTasks" :key="task.id" class="flex max-w-[260px] items-center gap-1.5 rounded-md border pl-2 text-xs transition-colors" :class="index === activeTaskIndex ? 'border-primary bg-primary/10 text-primary' : 'hover:bg-muted/60'">
<input v-model="task.selected" type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :aria-label="t('tableImport.selectTask', { name: task.tableName })" :disabled="running" />
<button
type="button"
class="flex min-w-0 items-center gap-1.5 py-1 pr-2"
:disabled="running"
@click="
saveActiveBatchTask();
activateBatchTask(index);
"
>
<CheckCircle2 v-if="task.status === 'done'" class="h-3.5 w-3.5 shrink-0 text-emerald-500" />
<Loader2 v-else-if="task.status === 'running'" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<AlertTriangle v-else-if="task.status === 'error'" class="h-3.5 w-3.5 shrink-0 text-destructive" />
<FileSpreadsheet v-else-if="task.format === 'excel'" class="h-3.5 w-3.5 shrink-0" />
<FileText v-else class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ task.tableName }}</span>
</button>
</div>
</div>
<p v-if="!selectedBatchTasks.length" class="px-3 pb-2 text-xs text-muted-foreground">{{ t("tableImport.noTasksSelected") }}</p>
</div>
<nav class="rounded-md border bg-muted/20 px-3 py-2" :aria-label="t('tableImport.progress')">
@@ -1333,7 +1351,8 @@ watch(rawProgressPercent, (percent) => {
<div v-else-if="sourceFormat === 'excel'" class="grid grid-cols-4 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.sheet") }}</Label>
<Select :model-value="selectedSheet" :disabled="!preview?.sheets?.length" @update:model-value="(value: any) => (selectedSheet = value)">
<Input v-if="isBatchImport" :model-value="selectedSheet || preview?.sheets?.[0] || t('tableImport.firstSheet')" :aria-label="t('tableImport.sheet')" readonly class="h-8 text-xs" />
<Select v-else :model-value="selectedSheet" :disabled="!preview?.sheets?.length" @update:model-value="(value: any) => (selectedSheet = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('tableImport.firstSheet')" />
</SelectTrigger>
@@ -0,0 +1,427 @@
// @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";
import type { TableImportPreview, TableImportRequest, TableImportProgress, TableImportSummary } from "@/lib/backend/api";
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(),
importTableFile: vi.fn<(request: TableImportRequest, onProgress: (progress: TableImportProgress) => void) => Promise<TableImportSummary>>(),
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: mocks.importTableFile,
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[] = [];
const sheets = ["First", "Second", "Third"];
function workbookPreview(sheetName: string): TableImportPreview {
const index = sheets.indexOf(sheetName);
return {
fileName: "rows.xlsx",
filePath: "/tmp/rows.xlsx",
sourceRef: "workbook-source",
fileType: "excel",
sizeBytes: [10, 20, 30][index]!,
columns: ["id", "name"],
rows: [[index + 1, sheetName]],
totalRows: [2, 3, 5][index]!,
totalRowsExact: true,
sourceFingerprint: `rows-${sheetName}`,
sheets,
};
}
async function flushAsyncUpdates() {
for (let index = 0; index < 4; index += 1) {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function mountDialog(files = [new File(["workbook"], "rows.xlsx")]) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(defineComponent({ setup: () => () => h(TableImportDialog, { open: true, prefillConnectionId: "connection-1", prefillDatabase: "main" }) }));
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await flushAsyncUpdates();
const input = document.body.querySelector<HTMLInputElement>('input[type="file"]')!;
Object.defineProperty(input, "files", { configurable: true, value: files });
input.dispatchEvent(new Event("change", { bubbles: true }));
await flushAsyncUpdates();
}
function button(text: string) {
const result = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === text);
expect(result, `button ${text}`).toBeTruthy();
return result!;
}
function taskCheckbox(name: string) {
const result = document.body.querySelector<HTMLInputElement>(`input[type="checkbox"][aria-label="Import ${name}"]`);
expect(result, `import selection for ${name}`).toBeTruthy();
return result!;
}
async function selectTask(name: string, selected: boolean) {
const checkbox = taskCheckbox(name);
checkbox.checked = selected;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
await flushAsyncUpdates();
}
async function click(text: string) {
button(text).click();
await flushAsyncUpdates();
}
async function editInputWithValue(value: string, replacement: string) {
const input = [...document.body.querySelectorAll<HTMLInputElement>("input")].find((item) => item.value === value);
expect(input, `input ${value}`).toBeTruthy();
input!.value = replacement;
input!.dispatchEvent(new Event("input", { bubbles: true }));
await flushAsyncUpdates();
}
async function startImport() {
await click("Next");
await click("Next");
expect(button("Start Import").disabled).toBe(false);
await click("Start Import");
}
beforeEach(() => {
i18n.global.locale.value = "en";
mocks.listTables.mockReset().mockResolvedValue([]);
mocks.previewTableImportFile.mockReset().mockImplementation((_source, options) => Promise.resolve(workbookPreview(options.parseOptions.sheetName || "First")));
mocks.importTableFile.mockReset().mockImplementation(async (request, onProgress) => {
const rowsImported = request.preparedSource!.totalRows;
onProgress({ importId: request.importId, status: "done", phase: "done", rowsImported, totalRows: rowsImported, elapsedMs: 1 });
return { importId: request.importId, rowsImported, totalRows: rowsImported, elapsedMs: 1 };
});
mocks.releaseTableImportSource.mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.textContent = "";
vi.clearAllMocks();
});
describe("TableImportDialog batch selection", () => {
it("keeps every worksheet selected by default and imports them in workbook order", async () => {
await mountDialog();
for (const name of ["rows_First", "rows_Second", "rows_Third"]) expect(taskCheckbox(name).checked).toBe(true);
await startImport();
expect(mocks.importTableFile.mock.calls.map(([request]) => request.table)).toEqual(["rows_First", "rows_Second", "rows_Third"]);
expect(mocks.importTableFile.mock.calls.map(([request]) => request.parseOptions?.sheetName)).toEqual([null, "Second", "Third"]);
});
it("imports only the selected worksheet even when the active preview is excluded", async () => {
await mountDialog();
await selectTask("rows_First", false);
await selectTask("rows_Third", false);
await startImport();
expect(mocks.importTableFile).toHaveBeenCalledTimes(1);
expect(mocks.importTableFile.mock.calls[0]![0]).toMatchObject({ table: "rows_Second", parseOptions: { sheetName: "Second" }, preparedSource: { rows: [[2, "Second"]] } });
expect(mocks.releaseTableImportSource.mock.calls).toEqual([["workbook-source"]]);
});
it("blocks empty selection and lets selecting one item restore import", async () => {
await mountDialog();
for (const name of ["rows_First", "rows_Second", "rows_Third"]) await selectTask(name, false);
expect(button("Next").disabled).toBe(true);
const reviewStep = [...document.body.querySelectorAll<HTMLButtonElement>("nav button")].find((item) => item.textContent?.includes("Review"));
expect(reviewStep?.disabled).toBe(true);
expect(mocks.importTableFile).not.toHaveBeenCalled();
await selectTask("rows_Third", true);
await startImport();
expect(mocks.importTableFile.mock.calls.map(([request]) => request.table)).toEqual(["rows_Third"]);
});
it("shows the actual worksheet name as read-only while switching batch previews", async () => {
await mountDialog();
const sheetInput = () => document.body.querySelector<HTMLInputElement>('input[aria-label="Sheet"]');
expect(sheetInput()?.value).toBe("First");
expect(sheetInput()?.readOnly).toBe(true);
await click("rows_Third");
expect(sheetInput()?.value).toBe("Third");
});
it("preserves table names and mappings while an active task is excluded and reselected", async () => {
await mountDialog();
await editInputWithValue("rows_First", "custom_first");
await click("Next");
await editInputWithValue("name", "display_name");
await selectTask("custom_first", false);
await click("rows_Second");
await click("custom_first");
expect([...document.body.querySelectorAll<HTMLInputElement>("input")].some((input) => input.value === "display_name")).toBe(true);
await selectTask("custom_first", true);
await selectTask("rows_Second", false);
await selectTask("rows_Third", false);
await click("Next");
await click("Start Import");
expect(mocks.importTableFile).toHaveBeenCalledTimes(1);
expect(mocks.importTableFile.mock.calls[0]![0]).toMatchObject({
table: "custom_first",
mappings: [
{ sourceColumn: "id", targetColumn: "id" },
{ sourceColumn: "name", targetColumn: "display_name" },
],
});
});
it("ignores excluded invalid mappings but rejects invalid selected tasks", async () => {
await mountDialog();
await click("Next");
await editInputWithValue("name", "id");
expect(button("Next").disabled).toBe(true);
await selectTask("rows_First", false);
expect(button("Next").disabled).toBe(false);
await click("Next");
expect(button("Start Import").disabled).toBe(false);
await selectTask("rows_First", true);
expect(button("Start Import").disabled).toBe(true);
await selectTask("rows_First", false);
await click("Start Import");
expect(mocks.importTableFile.mock.calls.map(([request]) => request.table)).toEqual(["rows_Second", "rows_Third"]);
});
it("checks duplicate target names only among selected tasks", async () => {
await mountDialog();
await editInputWithValue("rows_First", "rows_Second");
expect(button("Next").disabled).toBe(true);
await selectTask("rows_Second", false);
await startImport();
expect(mocks.importTableFile.mock.calls.map(([request]) => request.table)).toEqual(["rows_Second", "rows_Third"]);
});
it("disables the start button when the last item is deselected on the review step", async () => {
await mountDialog();
await selectTask("rows_First", false);
await selectTask("rows_Third", false);
await click("Next");
await click("Next");
await selectTask("rows_Second", false);
expect(button("Start Import").disabled).toBe(true);
button("Start Import").click();
await flushAsyncUpdates();
expect(mocks.importTableFile).not.toHaveBeenCalled();
await selectTask("rows_Second", true);
expect(button("Start Import").disabled).toBe(false);
});
it("counts only selected rows and keeps the batch running until its last selected task", async () => {
const pending: Array<{ request: TableImportRequest; progress: (progress: TableImportProgress) => void; resolve: (summary: TableImportSummary) => void }> = [];
mocks.previewTableImportFile.mockImplementation((_source, options) => {
const preview = workbookPreview(options.parseOptions.sheetName || "First");
if (options.parseOptions.sheetName === "Second") preview.totalRowsExact = false;
return Promise.resolve(preview);
});
mocks.importTableFile.mockImplementation((request, progress) => new Promise((resolve) => pending.push({ request, progress, resolve })));
await mountDialog();
await selectTask("rows_Second", false);
await startImport();
expect(pending).toHaveLength(1);
expect(taskCheckbox("rows_First").disabled).toBe(true);
pending[0]!.progress({ importId: pending[0]!.request.importId, status: "done", phase: "done", rowsImported: 2, totalRows: 2, elapsedMs: 1 });
await flushAsyncUpdates();
expect(document.body.textContent).toContain("2 / 7");
expect(document.body.textContent).not.toContain("Import complete");
pending[0]!.resolve({ importId: pending[0]!.request.importId, rowsImported: 2, totalRows: 2, elapsedMs: 1 });
await flushAsyncUpdates();
expect(pending[1]!.request.table).toBe("rows_Third");
pending[1]!.resolve({ importId: pending[1]!.request.importId, rowsImported: 5, totalRows: 5, elapsedMs: 1 });
await flushAsyncUpdates();
expect(document.body.textContent).toContain("7 / 7");
expect(document.body.textContent).toContain("Import complete");
});
it("uses selected source bytes when the selected worksheet has no exact row count", async () => {
mocks.previewTableImportFile.mockImplementation((_source, options) => Promise.resolve({ ...workbookPreview(options.parseOptions.sheetName || "First"), totalRowsExact: false }));
let finish!: () => void;
mocks.importTableFile.mockImplementation((request, onProgress) => {
onProgress({ importId: request.importId, status: "running", phase: "reading", rowsImported: 0, totalRows: 0, totalRowsExact: false, bytesRead: 15, totalBytes: 30, elapsedMs: 1 });
return new Promise((resolve) => {
finish = () => resolve({ importId: request.importId, rowsImported: 5, totalRows: 5, elapsedMs: 1 });
});
});
await mountDialog();
await selectTask("rows_First", false);
await selectTask("rows_Second", false);
await startImport();
expect(document.body.textContent).toContain("50%");
finish();
await flushAsyncUpdates();
});
it("marks the actual failed task after skipping a worksheet", async () => {
mocks.importTableFile.mockRejectedValueOnce(new Error("write failed"));
await mountDialog();
await selectTask("rows_First", false);
await selectTask("rows_Second", false);
await startImport();
expect(mocks.importTableFile.mock.calls[0]![0].table).toBe("rows_Third");
expect(taskCheckbox("rows_Third").parentElement?.querySelector("svg.text-destructive")).toBeTruthy();
expect(taskCheckbox("rows_First").parentElement?.querySelector("svg.text-destructive")).toBeNull();
expect(document.body.textContent).toContain("write failed");
});
it("retains multi-file CSV selection and releases excluded uploaded sources", async () => {
mocks.previewTableImportFile.mockImplementation((file: File) => Promise.resolve({ ...workbookPreview("First"), fileName: file.name, filePath: `/tmp/${file.name}`, fileType: "csv", sourceRef: `${file.name}-source`, sheets: [], effectiveEncoding: "utf8" }));
await mountDialog([new File(["id,name"], "first.csv"), new File(["id,name"], "second.csv")]);
expect(taskCheckbox("first").checked).toBe(true);
expect(taskCheckbox("second").checked).toBe(true);
await selectTask("first", false);
await startImport();
expect(mocks.importTableFile).toHaveBeenCalledTimes(1);
expect(mocks.importTableFile.mock.calls[0]![0]).toMatchObject({ table: "second", sourceFormat: "csv", parseOptions: { encoding: "utf8", sheetName: null } });
expect(mocks.releaseTableImportSource.mock.calls.map(([source]) => source).sort()).toEqual(["first.csv-source", "second.csv-source"]);
});
});
+3
View File
@@ -6460,6 +6460,9 @@ export default {
},
tableImport: {
title: "Import Table Data",
selectedTasks: "{selected} / {total} import items selected",
selectTask: "Import {name}",
noTasksSelected: "Select at least one item to import.",
target: "Target table",
selectFile: "Select File",
changeFile: "Change File",
+3
View File
@@ -6433,6 +6433,9 @@ export default withEnglishFallback({
},
tableImport: {
title: "导入表数据",
selectedTasks: "已选择 {selected} / {total} 个导入项",
selectTask: "导入 {name}",
noTasksSelected: "请至少选择一个导入项。",
target: "目标表",
selectFile: "选择文件",
changeFile: "更换文件",