diff --git a/src/components/DataImportModal.tsx b/src/components/DataImportModal.tsx index 89f032b9..5457b1d0 100644 --- a/src/components/DataImportModal.tsx +++ b/src/components/DataImportModal.tsx @@ -37,7 +37,7 @@ export interface ParsedData { type ImportStep = "upload" | "preview" | "configure" | "ready"; -export function parseCSV(text: string): ParsedData { +export function parseCSV(text: string, firstRowIsHeader = true): ParsedData { const lines = text.split(/\r?\n/).filter((line) => line.trim()); if (lines.length === 0) return { headers: [], rows: [], totalRows: 0 }; @@ -69,8 +69,9 @@ export function parseCSV(text: string): ParsedData { return result; }; - const headers = parseLine(lines[0]); - const rows = lines.slice(1).map((line) => parseLine(line)); + const firstRow = parseLine(lines[0]); + const headers = firstRowIsHeader ? firstRow : firstRow.map((_, index) => `column_${index + 1}`); + const rows = lines.slice(firstRowIsHeader ? 1 : 0).map((line) => parseLine(line)); return { headers, rows, totalRows: rows.length }; } @@ -201,6 +202,7 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp const [parsedData, setParsedData] = useState(null); const [fileName, setFileName] = useState(""); const [fileType, setFileType] = useState<"csv" | "json">("csv"); + const [firstRowIsHeader, setFirstRowIsHeader] = useState(true); const [targetTable, setTargetTable] = useState(""); const [createNewTable, setCreateNewTable] = useState(false); const [newTableName, setNewTableName] = useState(""); @@ -208,11 +210,14 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp const [error, setError] = useState(null); const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); + const csvTextRef = useRef(""); const resetState = useCallback(() => { setStep("upload"); setParsedData(null); setFileName(""); + setFirstRowIsHeader(true); + csvTextRef.current = ""; setTargetTable(""); setCreateNewTable(false); setNewTableName(""); @@ -245,6 +250,7 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp return; } + csvTextRef.current = isJSON ? "" : text; setParsedData(data); // Auto-map columns 1:1 const mapping: Record = {}; @@ -418,6 +424,30 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp + {fileType === "csv" && ( + + )} + {/* Preview Table */}
diff --git a/tests/components/DataImportModal.test.tsx b/tests/components/DataImportModal.test.tsx index cd07bba6..8b5ae7bb 100644 --- a/tests/components/DataImportModal.test.tsx +++ b/tests/components/DataImportModal.test.tsx @@ -142,6 +142,117 @@ describe("DataImportModal", () => { expect(body.queryByText("loves, commas")).not.toBeNull(); }); + test("headerless CSV preserves the first row through preview, mapping, and SQL import", () => { + const onImport = mock((sql: string) => sql); + const { baseElement } = render(); + act(() => simulateFileUpload(baseElement, "Alice,30\nBob,25", "headerless.csv")); + + const body = within(baseElement); + const checkbox = body.getByRole("checkbox", { name: "First row is header" }) as HTMLInputElement; + expect(checkbox.checked).toBe(true); + expect(body.getByText("1 rows, 2 columns")).not.toBeNull(); + act(() => fireEvent.click(checkbox)); + + expect(checkbox.checked).toBe(false); + expect(body.getByRole("columnheader", { name: "column_1" })).not.toBeNull(); + expect(body.getByRole("columnheader", { name: "column_2" })).not.toBeNull(); + expect(body.getByRole("cell", { name: "Alice" })).not.toBeNull(); + expect(body.getByRole("cell", { name: "Bob" })).not.toBeNull(); + expect(body.getByText("2 rows, 2 columns")).not.toBeNull(); + + act(() => fireEvent.click(body.getByText("Configure Import"))); + expect((body.getByLabelText("Target column for column_1") as HTMLInputElement).value).toBe("column_1"); + act(() => { + fireEvent.click(body.getByText("New Table")); + fireEvent.change(body.getByLabelText("Target column for column_1"), { target: { value: "name" } }); + }); + act(() => fireEvent.click(body.getByText("Review SQL"))); + act(() => fireEvent.click(body.getByText("Execute Import"))); + + expect(onImport).toHaveBeenCalledTimes(1); + const sql = onImport.mock.calls[0][0]; + expect(sql).toContain("CREATE TABLE imported_data"); + expect(sql).toContain("INSERT INTO imported_data (name, column_2)"); + expect(sql).toContain("('Alice', 30)"); + expect(sql).toContain("('Bob', 25)"); + }); + + test("the header option can be toggled repeatedly for a single-row CSV", () => { + const { baseElement } = render(); + act(() => simulateFileUpload(baseElement, "Alice,30", "single.csv")); + const body = within(baseElement); + const checkbox = body.getByRole("checkbox", { name: "First row is header" }); + + act(() => fireEvent.click(checkbox)); + expect(body.getByText("1 rows, 2 columns")).not.toBeNull(); + expect(body.getByRole("cell", { name: "Alice" })).not.toBeNull(); + act(() => fireEvent.click(checkbox)); + expect(body.getByText("0 rows, 2 columns")).not.toBeNull(); + expect(body.getByRole("columnheader", { name: "Alice" })).not.toBeNull(); + act(() => fireEvent.click(checkbox)); + expect(body.getByText("1 rows, 2 columns")).not.toBeNull(); + expect(body.getAllByRole("cell")).toHaveLength(2); + }); + + test.each([ + ["name", "age"], + ["column_1", "column_2"], + ["name", "column_2"], + ])("preserves target column mappings through header toggles for %s,%s", (firstHeader, secondHeader) => { + const onImport = mock((sql: string) => sql); + const { baseElement } = render(); + act(() => simulateFileUpload(baseElement, `${firstHeader},${secondHeader}\nAlice,30`, "mapped.csv")); + const body = within(baseElement); + + act(() => fireEvent.click(body.getByText("Configure Import"))); + act(() => { + fireEvent.click(body.getByText("New Table")); + fireEvent.change(body.getByLabelText(`Target column for ${firstHeader}`), { target: { value: "full_name" } }); + fireEvent.change(body.getByLabelText(`Target column for ${secondHeader}`), { target: { value: "user_age" } }); + }); + act(() => fireEvent.click(body.getByText("Back"))); + act(() => fireEvent.click(body.getByRole("checkbox", { name: "First row is header" }))); + act(() => fireEvent.click(body.getByText("Configure Import"))); + + expect((body.getByLabelText("Target column for column_1") as HTMLInputElement).value).toBe( + firstHeader === "column_1" ? "full_name" : "column_1", + ); + expect((body.getByLabelText("Target column for column_2") as HTMLInputElement).value).toBe( + secondHeader === "column_2" ? "user_age" : "column_2", + ); + + act(() => fireEvent.click(body.getByText("Back"))); + act(() => fireEvent.click(body.getByRole("checkbox", { name: "First row is header" }))); + act(() => fireEvent.click(body.getByText("Configure Import"))); + expect((body.getByLabelText(`Target column for ${firstHeader}`) as HTMLInputElement).value).toBe("full_name"); + expect((body.getByLabelText(`Target column for ${secondHeader}`) as HTMLInputElement).value).toBe("user_age"); + act(() => fireEvent.click(body.getByText("Review SQL"))); + act(() => fireEvent.click(body.getByText("Execute Import"))); + + expect(onImport).toHaveBeenCalledTimes(1); + expect(onImport.mock.calls[0][0]).toContain("INSERT INTO imported_data (full_name, user_age)"); + expect(onImport.mock.calls[0][0]).toContain("('Alice', 30)"); + }); + + test("Reset restores header handling for the next CSV upload", () => { + const { baseElement } = render(); + act(() => simulateFileUpload(baseElement, "name,age\nAlice,30", "first.csv")); + const body = within(baseElement); + act(() => fireEvent.click(body.getByText("Configure Import"))); + act(() => fireEvent.change(body.getByLabelText("Target column for name"), { target: { value: "full_name" } })); + act(() => fireEvent.click(body.getByText("Back"))); + act(() => fireEvent.click(body.getByRole("checkbox", { name: "First row is header" }))); + act(() => fireEvent.click(body.getByText("Reset"))); + act(() => simulateFileUpload(baseElement, "name,age\nBob,25", "second.csv")); + + expect((body.getByRole("checkbox", { name: "First row is header" }) as HTMLInputElement).checked).toBe(true); + expect(body.getByRole("columnheader", { name: "name" })).not.toBeNull(); + expect(body.getByRole("cell", { name: "Bob" })).not.toBeNull(); + expect(body.getByText("1 rows, 2 columns")).not.toBeNull(); + act(() => fireEvent.click(body.getByText("Configure Import"))); + expect((body.getByLabelText("Target column for name") as HTMLInputElement).value).toBe("name"); + }); + test("shows preview table headers from CSV", () => { const { baseElement } = render(); @@ -223,6 +334,7 @@ describe("DataImportModal", () => { // File name appears in header + preview body expect(body.queryAllByText("data.json").length).toBeGreaterThanOrEqual(1); expect(body.queryByText(/1 row/)).not.toBeNull(); + expect(body.queryByRole("checkbox", { name: "First row is header" })).toBeNull(); }); // ── Error handling ───────────────────────────────────────────────────────── diff --git a/tests/unit/data-import-functions.test.ts b/tests/unit/data-import-functions.test.ts index 77a715fd..a560d8eb 100644 --- a/tests/unit/data-import-functions.test.ts +++ b/tests/unit/data-import-functions.test.ts @@ -31,6 +31,29 @@ describe("parseCSV", () => { expect(result.totalRows).toBe(0); }); + test("preserves every row and generates column names for headerless CSV", () => { + expect(parseCSV("Alice,30\r\n\r\nBob,25\r\n", false)).toEqual({ + headers: ["column_1", "column_2"], + rows: [ + ["Alice", "30"], + ["Bob", "25"], + ], + totalRows: 2, + }); + }); + + test("preserves a single headerless row with quoted and empty fields", () => { + expect(parseCSV('"Alice, Smith","She said ""hi""",,Alice', false)).toEqual({ + headers: ["column_1", "column_2", "column_3", "column_4"], + rows: [["Alice, Smith", 'She said "hi"', "", "Alice"]], + totalRows: 1, + }); + }); + + test("returns empty data for a blank headerless CSV", () => { + expect(parseCSV(" \r\n\r\n", false)).toEqual({ headers: [], rows: [], totalRows: 0 }); + }); + test("returns empty for whitespace-only input", () => { const result = parseCSV(" \n \n "); expect(result.headers).toEqual([]);