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
6 changes: 3 additions & 3 deletions public/r/data-grid.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/components/data-grid/data-grid-cell-variants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function ShortTextCell<TData>({
const [value, setValue] = React.useState(initialValue);
const cellRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const prevIsEditingRef = React.useRef(isEditing);
const prevIsEditingRef = React.useRef(false);

const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
Expand Down Expand Up @@ -428,7 +428,7 @@ export function NumberCell<TData>({
const max = numberCellOpts?.max;
const step = numberCellOpts?.step;

const prevIsEditingRef = React.useRef(isEditing);
const prevIsEditingRef = React.useRef(false);

const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
Expand Down Expand Up @@ -550,7 +550,7 @@ export function UrlCell<TData>({
const [value, setValue] = React.useState(initialValue ?? "");
const cellRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const prevIsEditingRef = React.useRef(isEditing);
const prevIsEditingRef = React.useRef(false);

const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
Expand Down
45 changes: 45 additions & 0 deletions src/hooks/test/use-data-grid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,51 @@ describe("useDataGrid", () => {
});

it("should preserve multiline content within cells when pasting", async () => {
const onPaste = vi.fn().mockResolvedValue(undefined);
mockClipboard.readText.mockResolvedValue(
'Alice\tKickflip\t95\nBob\t"Trick with\nmultiple\nlines"\t98',
);

const { result } = renderHook(
() =>
useDataGrid({
data: testData,
columns: testColumns,
onPaste,
}),
{ wrapper: createWrapper() },
);

act(() => {
result.current.tableMeta.onCellClick?.(0, "name");
});

await act(async () => {
await result.current.tableMeta.onCellsPaste?.();
});

expect(onPaste).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
rowIndex: 0,
columnId: "score",
value: 95,
}),
expect.objectContaining({
rowIndex: 1,
columnId: "trick",
value: "Trick with\nmultiple\nlines",
}),
expect.objectContaining({
rowIndex: 1,
columnId: "score",
value: 98,
}),
]),
);
});

it("should preserve unquoted multiline content when pasting (Excel format)", async () => {
const onPaste = vi.fn().mockResolvedValue(undefined);
mockClipboard.readText.mockResolvedValue(
"Alice\tKickflip\t95\nBob\tTrick with\nmultiple\nlines\t98",
Expand Down
19 changes: 9 additions & 10 deletions src/hooks/use-data-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ function useDataGrid<TData>({
const existingRow = currentData[i];

if (!existingRow) {
newData[i] = {} as TData;
newData[i] = existingRow as TData;
continue;
}

Expand Down Expand Up @@ -1950,14 +1950,18 @@ function useDataGrid<TData>({
const currentState = store.getState();
const rows = tableRef.current?.getRowModel().rows ?? [];
const currentRowIndex = rows.findIndex((r) => r.id === rowId);
const currentRow = currentRowIndex >= 0 ? rows[currentRowIndex] : null;
if (!currentRow) return;
if (currentRowIndex === -1) return;

if (shiftKey && currentState.lastClickedRowId !== null) {
const lastClickedRowIndex = rows.findIndex(
(r) => r.id === currentState.lastClickedRowId,
);
if (lastClickedRowIndex >= 0) {
if (lastClickedRowIndex === -1) {
onRowSelectionChange({
...currentState.rowSelection,
[rowId]: selected,
});
} else {
const startIndex = Math.min(lastClickedRowIndex, currentRowIndex);
const endIndex = Math.max(lastClickedRowIndex, currentRowIndex);

Expand All @@ -1973,16 +1977,11 @@ function useDataGrid<TData>({
}

onRowSelectionChange(newRowSelection);
} else {
onRowSelectionChange({
...currentState.rowSelection,
[currentRow.id]: selected,
});
}
} else {
onRowSelectionChange({
...currentState.rowSelection,
[currentRow.id]: selected,
[rowId]: selected,
});
}

Expand Down
79 changes: 71 additions & 8 deletions src/lib/data-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,67 @@ export function parseTsv(
text: string,
fallbackColumnCount: number,
): string[][] {
if (text.startsWith('"') || text.includes('\t"')) {
const rows: string[][] = [];
let currentRow: string[] = [];
let currentField = "";
let inQuotes = false;
let i = 0;

while (i < text.length) {
const char = text[i];
const nextChar = text[i + 1];

if (inQuotes) {
if (char === '"' && nextChar === '"') {
currentField += '"';
i += 2;
} else if (char === '"') {
inQuotes = false;
i++;
} else {
currentField += char;
i++;
}
} else {
if (char === '"' && currentField === "") {
inQuotes = true;
i++;
} else if (char === "\t") {
currentRow.push(currentField);
currentField = "";
i++;
} else if (char === "\n") {
currentRow.push(currentField);
if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {
rows.push(currentRow);
}
currentRow = [];
currentField = "";
i++;
} else if (char === "\r" && nextChar === "\n") {
currentRow.push(currentField);
if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {
rows.push(currentRow);
}
currentRow = [];
currentField = "";
i += 2;
} else {
currentField += char;
i++;
}
}
}

currentRow.push(currentField);
if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {
rows.push(currentRow);
}

return rows;
}

const lines = text.split("\n");
let maxTabs = 0;
for (const line of lines) {
Expand All @@ -277,26 +338,28 @@ export function parseTsv(

const rows: string[][] = [];
let buf = "";
let bufTabs = 0;

for (const line of lines) {
const tc = countTabs(line);

if (tc === cols - 1) {
if (buf && countTabs(buf) === cols - 1) rows.push(buf.split("\t"));
if (buf && bufTabs === cols - 1) rows.push(buf.split("\t"));
buf = "";
bufTabs = 0;
rows.push(line.split("\t"));
} else if (tc === 0 && rows.length > 0 && !buf) {
const last = rows[rows.length - 1];
if (last) {
const cell = last[cols - 1];
if (cell !== undefined) last[cols - 1] = `${cell}\n${line}`;
}
} else {
buf = buf ? `${buf}\n${line}` : line;
bufTabs += tc;
if (bufTabs === cols - 1) {
rows.push(buf.split("\t"));
buf = "";
bufTabs = 0;
}
}
}

if (buf && countTabs(buf) === cols - 1) rows.push(buf.split("\t"));
if (buf && bufTabs === cols - 1) rows.push(buf.split("\t"));

return rows.length > 0
? rows
Expand Down
140 changes: 140 additions & 0 deletions src/lib/test/data-grid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { parseTsv } from "@/lib/data-grid";

describe("parseTsv", () => {
describe("basic parsing", () => {
it("should parse simple single-row TSV", () => {
expect(parseTsv("Alice\tKickflip\t95", 3)).toEqual([
["Alice", "Kickflip", "95"],
]);
});

it("should parse multiple rows", () => {
expect(parseTsv("Alice\tKickflip\t95\nBob\tOllie\t88", 3)).toEqual([
["Alice", "Kickflip", "95"],
["Bob", "Ollie", "88"],
]);
});

it("should handle single-column paste", () => {
expect(parseTsv("Alice\nBob\nCharlie", 1)).toEqual([
["Alice"],
["Bob"],
["Charlie"],
]);
});

it("should skip empty rows", () => {
expect(parseTsv("Alice\tKickflip\t95\n\nBob\tOllie\t88", 3)).toEqual([
["Alice", "Kickflip", "95"],
["Bob", "Ollie", "88"],
]);
});
});

describe("quoted fields (standard TSV)", () => {
it("should handle quoted multiline content", () => {
const text =
'Alice\tKickflip\t95\nBob\t"Trick with\nmultiple\nlines"\t98';
expect(parseTsv(text, 3)).toEqual([
["Alice", "Kickflip", "95"],
["Bob", "Trick with\nmultiple\nlines", "98"],
]);
});

it("should handle escaped quotes", () => {
const text = '"She said ""hello"""\t42';
expect(parseTsv(text, 2)).toEqual([['She said "hello"', "42"]]);
});

it("should handle Windows line endings", () => {
const text = '"Line 1\r\nLine 2"\tvalue';
expect(parseTsv(text, 2)).toEqual([["Line 1\r\nLine 2", "value"]]);
});

it("should handle mixed quoted and unquoted fields", () => {
const text = 'plain\t"quoted\nfield"\t123';
expect(parseTsv(text, 3)).toEqual([["plain", "quoted\nfield", "123"]]);
});
});

describe("unquoted multiline (tab counting)", () => {
it("should handle multiline in last column", () => {
const text = "Alice\tKickflip\t95\nBob\tTrick with\nmultiple\nlines\t98";
expect(parseTsv(text, 3)).toEqual([
["Alice", "Kickflip", "95"],
["Bob", "Trick with\nmultiple\nlines", "98"],
]);
});

it("should handle multiline in middle column", () => {
const text =
"Alice\tShort note\t95\nBob\tLine 1\nLine 2\nLine 3\t88\nCharlie\tSimple\t77";
expect(parseTsv(text, 3)).toEqual([
["Alice", "Short note", "95"],
["Bob", "Line 1\nLine 2\nLine 3", "88"],
["Charlie", "Simple", "77"],
]);
});

it("should handle multiple rows with multiline in middle columns", () => {
const text = [
"Alice\tShort\t1",
"Bob\tMulti",
"line",
"content\t2",
"Charlie\tAnother",
"multi\t3",
"Dave\tPlain\t4",
].join("\n");
expect(parseTsv(text, 3)).toEqual([
["Alice", "Short", "1"],
["Bob", "Multi\nline\ncontent", "2"],
["Charlie", "Another\nmulti", "3"],
["Dave", "Plain", "4"],
]);
});
});

describe("data with JSON values (no false positives)", () => {
it("should use tab counting when quotes are inside field values not delimiters", () => {
const text = 'Alice\t["React","Node.js"]\t95\nBob\t["Python"]\t88';
expect(parseTsv(text, 3)).toEqual([
["Alice", '["React","Node.js"]', "95"],
["Bob", '["Python"]', "88"],
]);
});

it("should handle JSON values with unquoted multiline", () => {
const text = [
'Alice\tShort note\t["React"]\t1',
"Bob\tLine 1",
'Line 2\t["Python"]\t2',
'Charlie\tPlain\t["SQL"]\t3',
].join("\n");
expect(parseTsv(text, 4)).toEqual([
["Alice", "Short note", '["React"]', "1"],
["Bob", "Line 1\nLine 2", '["Python"]', "2"],
["Charlie", "Plain", '["SQL"]', "3"],
]);
});
});

describe("edge cases", () => {
it("should return empty array for empty string", () => {
expect(parseTsv("", 0)).toEqual([]);
});

it("should handle single cell", () => {
expect(parseTsv("hello", 1)).toEqual([["hello"]]);
});

it("should fallback to simple split when no tabs detected", () => {
expect(parseTsv("line1\nline2\nline3", 1)).toEqual([
["line1"],
["line2"],
["line3"],
]);
});
});
});