mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
fix(database): bound cell previews and edit large values in a dialog
This commit is contained in:
1 parent
49f9c57bcc
commit
560002e0c6
3 files changed
+117
-6
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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}><null></span> : displayed(val, !!numeric[c])}
|
||||
{val === null ? (
|
||||
<span className={styles.null}><null></span>
|
||||
) : (
|
||||
cellPreview(displayed(val, !!numeric[c]))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user