Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions src/components/DataImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -201,18 +202,22 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp
const [parsedData, setParsedData] = useState<ParsedData | null>(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("");
const [columnMapping, setColumnMapping] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const csvTextRef = useRef("");

const resetState = useCallback(() => {
setStep("upload");
setParsedData(null);
setFileName("");
setFirstRowIsHeader(true);
csvTextRef.current = "";
setTargetTable("");
setCreateNewTable(false);
setNewTableName("");
Expand Down Expand Up @@ -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<string, string> = {};
Expand Down Expand Up @@ -418,6 +424,30 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp
</Button>
</div>

{fileType === "csv" && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={firstRowIsHeader}
onChange={(e) => {
const hasHeader = e.target.checked;
const data = parseCSV(csvTextRef.current, hasHeader);
setFirstRowIsHeader(hasHeader);
setParsedData(data);
if (data.headers.some((header, index) => header !== parsedData.headers[index])) {
// Retain both header interpretations so a round trip preserves the user's edits.
setColumnMapping((mapping) => ({
...Object.fromEntries(data.headers.map((header) => [header, header])),
...mapping,
}));
}
}}
className="rounded border-edge bg-panel"
/>
<span className="text-xs text-fg-secondary">First row is header</span>
</label>
)}

{/* Preview Table */}
<div className="border border-hairline rounded-lg overflow-auto max-h-60">
<table className="w-full text-xs">
Expand Down
112 changes: 112 additions & 0 deletions tests/components/DataImportModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DataImportModal isOpen onClose={noop} onImport={onImport} tables={sampleTables} />);
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(<DataImportModal isOpen onClose={noop} onImport={noop} tables={[]} />);
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(<DataImportModal isOpen onClose={noop} onImport={onImport} tables={[]} />);
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(<DataImportModal isOpen onClose={noop} onImport={noop} tables={[]} />);
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(<DataImportModal isOpen onClose={noop} onImport={noop} tables={sampleTables} />);

Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/data-import-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
Loading