fix(database): bound cell previews and edit large values in a dialog

This commit is contained in:
l0ng-ai committed 2026-11-03 21:05:10 +08:00
1 parent 49f9c57bcc
commit 560002e0c6
3 files changed
+117 -6

No files matched your search

+15
View File
@@ -1259,3 +1259,18 @@
flex: none;
width: 2px;
}
/* Wrap large values instead of laying megabytes out as one line. */
.largeCellEditor {
display: block;
box-sizing: border-box;
width: 100%;
height: min(60vh, 480px);
padding: 8px;
font: inherit;
color: inherit;
background: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
resize: vertical;
}
+58 -1
View File
@@ -2,7 +2,8 @@ import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { registerActions } from "@/platform/actions";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Grid } from "./Grid";
import { CELL_PREVIEW_LIMIT, cellPreview, Grid } from "./Grid";
import { exportData } from "./export";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
@@ -72,3 +73,59 @@ describe("database grid", () => {
]);
});
});
describe("large database cells", () => {
const full = JSON.stringify({ text: "\u8bf4\u660e text \u6df7\u5408 ".repeat(200_000), tail: "complete" });
const columns = [{ name: "payload", type: "jsonb" }];
it("preserves short values and truncates only beyond the preview limit", () => {
for (const value of ["", "hello", "\u4e2d".repeat(CELL_PREVIEW_LIMIT)]) expect(cellPreview(value)).toBe(value);
expect(cellPreview(full)).toBe(full.slice(0, CELL_PREVIEW_LIMIT) + "\u2026");
expect(cellPreview("a".repeat(999) + "\ud83d\ude00tail")).toBe("a".repeat(999) + "\u2026");
});
it("bounds rendered text while copying and exporting the complete value", () => {
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
render(<Grid columns={columns} rowCount={1} value={() => full} />);
const cell = screen.getByRole("gridcell");
expect(cell.textContent).toBe(cellPreview(full));
expect(cell.title).toBe(full.slice(0, 2000));
fireEvent.mouseDown(cell, { button: 0 });
fireEvent.keyDown(screen.getByRole("grid"), { key: "c", ctrlKey: true });
expect(writeText).toHaveBeenCalledWith(full);
expect(JSON.parse(exportData("json", { columns, rows: [[full]], table: "big", driver: "postgres" }))).toEqual([
{ payload: full },
]);
});
it("edits the full large value in a dialog and supports cancel and apply", () => {
const onEdit = vi.fn();
render(<Grid columns={columns} rowCount={1} value={() => full} editable onEdit={onEdit} />);
fireEvent.doubleClick(screen.getByRole("gridcell"));
expect(screen.getByRole("dialog")).toBeTruthy();
const editor = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(editor.tagName).toBe("TEXTAREA");
expect(editor.value).toBe(full);
fireEvent.change(editor, { target: { value: full + "changed" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onEdit).not.toHaveBeenCalled();
fireEvent.doubleClick(screen.getByRole("gridcell"));
fireEvent.change(screen.getByRole("textbox"), { target: { value: full + "changed" } });
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(onEdit).toHaveBeenCalledWith(0, 0, full + "changed");
expect(screen.queryByRole("dialog")).toBeNull();
});
it("keeps short values in the inline editor and preserves null display", () => {
const onEdit = vi.fn();
const { rerender } = render(<Grid columns={columns} rowCount={1} value={() => "short"} editable onEdit={onEdit} />);
fireEvent.doubleClick(screen.getByRole("gridcell"));
expect(screen.getByRole("textbox").tagName).toBe("INPUT");
fireEvent.change(screen.getByRole("textbox"), { target: { value: "updated" } });
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(onEdit).toHaveBeenCalledWith(0, 0, "updated");
rerender(<Grid columns={columns} rowCount={1} value={() => null} />);
expect(screen.getByRole("gridcell").textContent).toBe("<null>");
});
});
+44 -5
View File
@@ -6,7 +6,7 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, type MouseEvent } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import clsx from "clsx";
import { Icon, showContextMenu, type MenuItem } from "@/ui";
import { Dialog, Icon, showContextMenu, type MenuItem } from "@/ui";
import { parseDelimited, toCsv, toTsv, type Cell } from "./model";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
@@ -69,6 +69,17 @@ interface Sel {
c1: number;
}
export const CELL_PREVIEW_LIMIT = 1000;
/** Bound text layout without changing the underlying value or splitting a surrogate pair. */
export function cellPreview(value: string): string {
if (value.length <= CELL_PREVIEW_LIMIT) return value;
let end = CELL_PREVIEW_LIMIT;
const last = value.charCodeAt(end - 1);
if (last >= 0xd800 && last <= 0xdbff) end--;
return value.slice(0, end) + "\u2026";
}
const ROW_H = 24;
const NUM_W = 48;
@@ -103,7 +114,7 @@ export function Grid(props: GridProps) {
window.addEventListener("mouseup", stop);
return () => window.removeEventListener("mouseup", stop);
}, []);
const [editing, setEditing] = useState<{ row: number; col: number; text: string } | null>(null);
const [editing, setEditing] = useState<{ row: number; col: number; text: string; dialog: boolean } | null>(null);
const columnKey = columns.map((c) => c.name).join("\u0000");
const [widths, setWidths] = useState<number[]>([]);
const [numeric, setNumeric] = useState<boolean[]>([]);
@@ -171,7 +182,8 @@ export function Grid(props: GridProps) {
const startEdit = (row: number, col: number) => {
if (!editable || props.rowState?.(row) === "deleted") return;
setEditing({ row, col, text: value(row, col) ?? "" });
const text = value(row, col) ?? "";
setEditing({ row, col, text, dialog: text.length > CELL_PREVIEW_LIMIT });
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
@@ -302,6 +314,29 @@ export function Grid(props: GridProps) {
onPaste={onPaste}
onContextMenu={onContextMenu}
>
{editing?.dialog && (
<Dialog
open
title={`Edit ${columns[editing.col]!.name}`}
width={720}
onClose={() => setEditing(null)}
buttons={[
{ label: "Cancel", role: "cancel", onClick: () => setEditing(null) },
{ label: "Apply", role: "default", onClick: commit },
]}
>
<textarea
className={styles.largeCellEditor}
aria-label={`Edit ${columns[editing.col]!.name}`}
value={editing.text}
spellCheck={false}
wrap="soft"
onContextMenu={(e) => e.stopPropagation()}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
onKeyDown={(e) => e.stopPropagation()}
/>
</Dialog>
)}
<div className={styles.gridInner} style={{ width: totalWidth, height: virtualizer.getTotalSize() + ROW_H }}>
<div className={clsx(styles.gridRow, styles.gridHeader)} style={{ gridTemplateColumns: template }} role="row">
<div className={styles.rowNumber} />
@@ -368,7 +403,7 @@ export function Grid(props: GridProps) {
{state === "inserted" ? "+" : (props.rowNumber?.(r) ?? firstRowNumber + r)}
</div>
{columns.map((_, c) => {
if (editing && editing.row === r && editing.col === c) {
if (editing && !editing.dialog && editing.row === r && editing.col === c) {
return (
<input
key={c}
@@ -407,7 +442,11 @@ export function Grid(props: GridProps) {
onDoubleClick={() => startEdit(r, c)}
title={val != null && val.length > 40 ? val.slice(0, 2000) : undefined}
>
{val === null ? <span className={styles.null}>&lt;null&gt;</span> : displayed(val, !!numeric[c])}
{val === null ? (
<span className={styles.null}>&lt;null&gt;</span>
) : (
cellPreview(displayed(val, !!numeric[c]))
)}
</div>
);
})}