feat(import): auto-map headerless data by target order

This commit is contained in:
CSXFanMeng authored and GitHub committed 2026-10-02 15:28:47 +08:00
1 parent b57b3873d9
commit 0b360aac7d
5 files changed
+57 -4

No files matched your search

@@ -523,7 +523,8 @@ function applyAutoMapping() {
columnMapping.value = Object.fromEntries(currentPreview.columns.map((source) => [source, source]));
return;
}
columnMapping.value = autoMapImportColumns(currentPreview.columns, targetColumnNames.value);
const headerless = titleRow.value === 0 && (isDelimitedFormat(sourceFormat.value) || sourceFormat.value === "excel");
columnMapping.value = autoMapImportColumns(currentPreview.columns, targetColumnNames.value, headerless ? "position" : "name");
}
function applySuggestedColumnDataTypes(currentPreview = preview.value) {
@@ -202,12 +202,12 @@ async function mountDialog(
await flushAsyncUpdates();
}
async function selectWorkbook() {
async function selectWorkbook(fileName = "rows.xlsx") {
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" })],
value: [new File(["test workbook"], fileName)],
});
fileInput?.dispatchEvent(new Event("change", { bubbles: true }));
await flushAsyncUpdates();
@@ -258,6 +258,45 @@ afterEach(() => {
});
describe("TableImportDialog existing targets", () => {
it.each(["rows.csv", "rows.tsv", "rows.txt", "rows.xlsx"])("maps headerless %s by ordinal position and imports a manual adjustment", async (fileName) => {
i18n.global.locale.value = "en";
await mountDialog({ prefillTable: "existing_target" });
await selectWorkbook(fileName);
mocks.previewTableImportFile.mockResolvedValueOnce({
fileName,
filePath: `/tmp/${fileName}`,
fileType: fileName.endsWith("xlsx") ? "excel" : "csv",
sizeBytes: 24,
columns: ["column_1", "column_2", "column_3"],
rows: [[1, "Alice", "extra"]],
totalRows: 1,
sourceFingerprint: "headerless",
});
const titleInput = [...document.body.querySelectorAll<HTMLInputElement>('input[type="number"]')].find((input) => input.parentElement?.textContent?.includes("Title row"));
expect(titleInput).toBeTruthy();
titleInput!.value = "0";
titleInput!.dispatchEvent(new Event("input", { bubbles: true }));
await vi.waitFor(() => expect(mocks.previewTableImportFile.mock.calls.at(-1)?.[1]).toMatchObject({ parseOptions: { titleRow: 0 } }));
await flushAsyncUpdates();
buttonContaining("Next")?.click();
await flushAsyncUpdates();
const mappings = [...document.body.querySelectorAll<HTMLSelectElement>("select")];
expect(mappings.map((select) => select.value)).toEqual(["id", "name", "__skip__"]);
mappings[1]!.value = "__skip__";
mappings[1]!.dispatchEvent(new Event("change", { bubbles: true }));
await flushAsyncUpdates();
buttonContaining("Next")?.click();
await flushAsyncUpdates();
buttonContaining("Next")?.click();
await flushAsyncUpdates();
buttonContaining("Start Import")?.click();
await flushAsyncUpdates();
expect(mocks.importTableFile).toHaveBeenCalledTimes(1);
expect(mocks.importTableFile.mock.calls[0]![0]).toMatchObject({ parseOptions: { titleRow: 0 }, mappings: [{ sourceColumn: "column_1", targetColumn: "id" }] });
});
it("lets a database-level import choose an existing table and loads its columns", async () => {
i18n.global.locale.value = "en";
await mountDialog();
@@ -95,6 +95,16 @@ describe("tableImport", () => {
});
});
it("maps headerless columns by position even when generated names match a different target", () => {
expect(autoMapImportColumns(["column_1", "column_2"], ["column_2", "column_1"], "position")).toEqual({ column_1: "column_2", column_2: "column_1" });
});
it("skips excess source columns and leaves excess target columns unmapped", () => {
expect(autoMapImportColumns(["column_1", "column_2", "column_3"], ["id", "name"], "position")).toEqual({ column_1: "id", column_2: "name", column_3: "" });
expect(autoMapImportColumns(["column_1"], ["id", "name"], "position")).toEqual({ column_1: "id" });
expect(autoMapImportColumns(["column_1"], [], "position")).toEqual({ column_1: "" });
});
it("rejects empty mappings and duplicate target columns", () => {
expect(validateImportMappings([])).toEqual({
valid: false,
+4 -1
View File
@@ -136,7 +136,10 @@ export function normalizeImportColumnName(name: string): string {
return name.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
}
export function autoMapImportColumns(sourceColumns: string[], targetColumns: string[]): Record<string, string> {
export function autoMapImportColumns(sourceColumns: string[], targetColumns: string[], mode: "name" | "position" = "name"): Record<string, string> {
if (mode === "position") {
return Object.fromEntries(sourceColumns.map((source, index) => [source, targetColumns[index] ?? IMPORT_SKIP_TARGET]));
}
const exactTargets = new Map(targetColumns.map((column) => [column, column]));
const normalizedTargets = new Map(targetColumns.map((column) => [normalizeImportColumnName(column), column]));
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB