refactor: convert to bun-workspaces monorepo (desktop/ + www/)

- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CellContextMenu } from "./CellContextMenu";
describe("CellContextMenu", () => {
const base = {
anchorRect: { top: 0, left: 0, width: 10, height: 10 } as DOMRect,
onClose: vi.fn(),
onCopy: vi.fn(), onCopyJson: vi.fn(), onEdit: vi.fn(), onSetNull: vi.fn(), onOpenFk: vi.fn(),
onViewRow: vi.fn(), onSelectRow: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders Copy, Edit, Set NULL for an editable scalar cell", () => {
render(<CellContextMenu {...base} editable isJson={false} isFk={false} nullable />);
expect(screen.getByText("Copy")).toBeInTheDocument();
expect(screen.getByText("Edit")).toBeInTheDocument();
expect(screen.getByText("Set NULL")).toBeInTheDocument();
});
it("renders Copy JSON for a json cell and hides Edit when not editable", () => {
render(<CellContextMenu {...base} editable={false} isJson isFk={false} nullable={false} />);
expect(screen.getByText("Copy JSON")).toBeInTheDocument();
expect(screen.queryByText("Edit")).toBeNull();
});
it("renders Open FK reference for an FK cell", () => {
render(<CellContextMenu {...base} editable={false} isJson={false} isFk nullable={false} />);
expect(screen.getByText("Open FK reference")).toBeInTheDocument();
fireEvent.click(screen.getByText("Open FK reference"));
expect(base.onOpenFk).toHaveBeenCalled();
});
it("always renders View Row and Select Row", () => {
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
expect(screen.getByText("View Row")).toBeInTheDocument();
expect(screen.getByText("Select Row")).toBeInTheDocument();
});
it("calls onViewRow when View Row is clicked", () => {
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
fireEvent.click(screen.getByText("View Row"));
expect(base.onViewRow).toHaveBeenCalledTimes(1);
expect(base.onClose).toHaveBeenCalledTimes(1);
});
it("calls onSelectRow when Select Row is clicked", () => {
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
fireEvent.click(screen.getByText("Select Row"));
expect(base.onSelectRow).toHaveBeenCalledTimes(1);
expect(base.onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,37 @@
import { Copy, Braces, Pencil, CircleSlash, Link2, Eye, MousePointerClick } from "lucide-react";
interface Props {
anchorRect: DOMRect;
editable: boolean;
isJson: boolean;
isFk: boolean;
nullable: boolean;
onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
onViewRow: () => void; onSelectRow: () => void;
}
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true },
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true },
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
];
return (
<div
className="fixed z-50 min-w-[160px] bg-canvas border border-border rounded-md shadow-lg py-1 text-xs"
style={{ top: anchorRect.bottom, left: anchorRect.left }}
onClick={(e) => e.stopPropagation()}
>
{items.filter((i) => i.show).map((i) => (
<button key={i.label} onClick={i.onClick}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface text-left text-text">
{i.icon} {i.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,223 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CellEditor } from "./CellEditor";
describe("CellEditor", () => {
it("renders the initial value and commits on Enter", () => {
const onCommit = vi.fn();
const onCancel = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={onCancel} />);
const input = screen.getByRole("textbox");
expect(input).toHaveValue("Alice");
fireEvent.change(input, { target: { value: "Alicia" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("Alicia");
});
it("commits null when a nullable cell is emptied", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("leaves an empty nullable cell alone when it was already empty (NULL stays NULL)", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("cancels on Escape", () => {
const onCancel = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={vi.fn()} onCancel={onCancel} />);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
expect(onCancel).toHaveBeenCalled();
});
it("uses textarea for large/JSON columns", () => {
render(<CellEditor initialValue="{}" dataType="jsonb" onCommit={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
});
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).not.toHaveBeenCalled();
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
});
it("commits an empty string on non-nullable text columns", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onCommit).toHaveBeenCalledWith("");
});
it("clears the validation error as soon as the user types again", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "5" } });
expect(screen.queryByTestId("cell-editor-error")).toBeNull();
});
it("does not offer a NULL row in the FK dropdown for non-nullable FK columns", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const buttons = screen.getAllByRole("button");
expect(buttons.some((b) => b.textContent === "NULL")).toBe(false);
});
it("offers a NULL row in the FK dropdown for nullable FK columns", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
const nullRow = screen.getAllByRole("button").find((b) => b.textContent === "NULL");
expect(nullRow).toBeDefined();
fireEvent.click(nullRow!);
expect(onCommit).toHaveBeenCalledWith(null);
});
it("commits null when the NULL option is selected in enum mode", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
nullable
/>
);
fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } });
expect(onCommit).toHaveBeenCalledWith(null);
});
it("renders a combobox with enum values and commits on change", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="active"
dataType="text"
enumValues={["active", "inactive", "pending"]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const select = screen.getByRole("combobox");
expect(select).toBeInTheDocument();
const labels = screen.getAllByRole("option").map((o) => o.textContent);
expect(labels).toEqual(expect.arrayContaining(["active", "inactive", "pending"]));
fireEvent.change(select, { target: { value: "pending" } });
expect(onCommit).toHaveBeenCalledWith("pending");
});
it("filters FK options by query and commits the clicked value", () => {
const onCommit = vi.fn();
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[
{ value: "1", label: "1 — Alice" },
{ value: "2", label: "2 — Bob" },
]}
onCommit={onCommit}
onCancel={vi.fn()}
/>
);
const search = screen.getByLabelText(/search foreign key/i);
fireEvent.change(search, { target: { value: "bo" } });
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveTextContent("2 — Bob");
fireEvent.click(buttons[0]);
expect(onCommit).toHaveBeenCalledWith("2");
});
it("shows all FK options when the query is empty", () => {
render(
<CellEditor
initialValue="1"
dataType="integer"
fkOptions={[
{ value: "1", label: "1 — Alice" },
{ value: "2", label: "2 — Bob" },
]}
onCommit={vi.fn()}
onCancel={vi.fn()}
/>
);
expect(screen.getAllByRole("button")).toHaveLength(2);
});
it("renders text columns as a single-line scrolling textarea", () => {
render(<CellEditor initialValue="long text" dataType="text" onCommit={vi.fn()} onCancel={vi.fn()} />);
const input = screen.getByRole("textbox");
expect(input.tagName).toBe("TEXTAREA");
expect(input.className).toContain("overflow-y-auto");
});
it("renders the FK placeholder and a No matches empty state", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }]} fkPlaceholder="Search users…"
onCommit={onCommit} onCancel={vi.fn()} />);
const search = screen.getByLabelText(/search foreign key/i);
expect(search).toHaveAttribute("placeholder", "Search users…");
fireEvent.change(search, { target: { value: "zzz" } });
expect(screen.getByText("No matches")).toBeInTheDocument();
});
it("renders the FK option list as a fixed-position overlay so it is never clipped", () => {
const onCommit = vi.fn();
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1 — Alice" }, { value: "2", label: "2 — Bob" }]}
onCommit={onCommit} onCancel={vi.fn()} />);
const list = screen.getByTestId("fk-options");
expect(list.style.position).toBe("fixed");
expect(document.body.contains(list)).toBe(true);
expect(list).toHaveTextContent("1 — Alice");
expect(list).toHaveTextContent("2 — Bob");
});
it("renders FK options as one-row values only (FK-reference style, cap 4, fixed width)", () => {
const onCommit = vi.fn();
const cells = Array.from({ length: 6 }, (_, i) => ({
name: `col${i}`,
value: `v${i}`,
}));
render(<CellEditor initialValue="" dataType="integer"
fkOptions={[{ value: "1", label: "1", cells }]}
onCommit={onCommit} onCancel={vi.fn()} />);
const list = screen.getByTestId("fk-options");
// first 3 values shown, column names NOT shown, 4th+ capped
for (let i = 0; i < 4; i++) {
expect(list).toHaveTextContent(`v${i}`);
}
expect(list).not.toHaveTextContent("col0");
expect(list).not.toHaveTextContent("v4");
// fixed 360px width, FK-viewer surface styling
expect(list.style.width).toBe("360px");
expect(list.className).toContain("bg-surface");
expect(list.className).toContain("rounded-lg");
expect(list.className).toContain("shadow-xl");
// clicking the row commits the value
fireEvent.click(screen.getByRole("button"));
expect(onCommit).toHaveBeenCalledWith("1");
});
});
+292
View File
@@ -0,0 +1,292 @@
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
export interface FkOption {
value: string; // the referenced column's value (what gets committed)
label: string; // fallback display text (e.g. "42 — Alice")
/** Referenced row cells shown in one row (≤5 columns), FK-reference style. */
cells?: { name: string; value: string }[];
}
const MAX_FK_CELLS = 4;
const FK_DROPDOWN_WIDTH = 360;
interface CellEditorProps {
initialValue: string;
dataType: string;
nullable?: boolean;
enumValues?: string[]; // when present → render <select> of these values
fkOptions?: FkOption[]; // when present → render searchable dropdown
fkPlaceholder?: string; // placeholder for the FK search input
onCommit: (value: string | null) => void;
onCancel: () => void;
}
const inputClass =
"min-w-0 flex-1 bg-transparent px-3 font-heading text-xs text-text outline-none placeholder:text-text-muted";
const controlClass =
"min-w-0 flex-1 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none placeholder:text-text-muted";
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
export function CellEditor({
initialValue,
dataType,
nullable,
enumValues,
fkOptions,
fkPlaceholder,
onCommit,
onCancel,
}: CellEditorProps) {
const [value, setValue] = useState(initialValue);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const enumRef = useRef<HTMLSelectElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [fkDropdownPos, setFkDropdownPos] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const textLike = TEXT_LIKE.some((t) => dataType.toLowerCase().includes(t));
useLayoutEffect(() => {
if (fkOptions && fkOptions.length > 0 && searchRef.current) {
const r = searchRef.current.getBoundingClientRect();
let left = r.left;
if (left + FK_DROPDOWN_WIDTH > window.innerWidth - 16) {
left = Math.max(16, window.innerWidth - FK_DROPDOWN_WIDTH - 16);
}
setFkDropdownPos({
top: r.bottom + 4,
left,
width: FK_DROPDOWN_WIDTH,
});
}
}, [fkOptions]);
useEffect(() => {
if (enumValues && enumValues.length > 0) {
enumRef.current?.focus();
} else if (fkOptions && fkOptions.length > 0) {
searchRef.current?.focus();
searchRef.current?.select();
} else {
ref.current?.focus();
ref.current?.select();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const large = ["json", "jsonb", "text"].some((t) =>
dataType.toLowerCase().includes(t)
);
const Tag = large ? "textarea" : "input";
/**
* Constraint-aware commit resolution:
* - Empty input on a nullable column → NULL (smart "clear = null").
* - Empty input on a NOT NULL column → only text-ish types may fall back to
* an empty string; everything else is blocked with an error.
*/
const resolveCommit = (
raw: string,
): { value: string | null } | { error: string } => {
if (raw.trim() === "") {
if (nullable) return { value: null };
if (textLike) return { value: raw };
return { error: "This column cannot be NULL" };
}
return { value: raw };
};
const commitRaw = (raw: string) => {
const r = resolveCommit(raw);
if ("error" in r) {
setError(r.error);
return;
}
setError(null);
onCommit(r.value);
};
const commit = () => commitRaw(value);
const errorRect = error ? rootRef.current?.getBoundingClientRect() : null;
const errorBubble =
error && errorRect
? createPortal(
<div
data-testid="cell-editor-error"
className="fixed z-50 pointer-events-none rounded-md border border-red-500/50 bg-red-950/95 px-2 py-1 text-[10px] text-red-300 shadow-lg"
style={{ top: errorRect.bottom + 4, left: errorRect.left, maxWidth: 320 }}
>
{error}
</div>,
document.body,
)
: null;
// Priority: enum > FK > default input/textarea
if (enumValues && enumValues.length > 0) {
return (
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
<select
ref={enumRef}
className={controlClass}
value={initialValue}
onChange={(e) => commitRaw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
>
<option value="">{nullable ? "NULL" : "—"}</option>
{enumValues.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
{errorBubble}
</div>
);
}
if (fkOptions && fkOptions.length > 0) {
const q = query.trim().toLowerCase();
const fkSearchText = (o: FkOption) =>
[
o.label,
...(o.cells ?? []).map((c) => `${c.name}:${c.value}`),
]
.join(" ")
.toLowerCase();
const filtered =
q === ""
? fkOptions
: fkOptions.filter((o) => fkSearchText(o).includes(q));
return (
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
<input
ref={searchRef}
aria-label="Search foreign key options"
placeholder={fkPlaceholder ?? "Search…"}
className={controlClass}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (filtered.length > 0) commitRaw(filtered[0].value);
else commitRaw(query);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
{fkDropdownPos &&
createPortal(
<div
data-testid="fk-options"
style={{
position: "fixed",
top: fkDropdownPos.top,
left: fkDropdownPos.left,
width: fkDropdownPos.width,
minWidth: fkDropdownPos.width,
zIndex: 50,
}}
className="max-h-28 overflow-y-auto bg-surface border border-border rounded-lg shadow-xl"
>
{nullable && (
<button
type="button"
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left italic text-text-muted"
onClick={() => commitRaw("")}
>
NULL
</button>
)}
{filtered.length === 0 && (
<div className="px-2 py-1 text-xs text-text-muted">No matches</div>
)}
{filtered.map((o) => (
<button
key={o.value}
type="button"
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left"
onClick={() => commitRaw(o.value)}
>
{o.cells && o.cells.length > 0 ? (
<span className="flex items-center gap-0 min-w-0">
{o.cells.slice(0, MAX_FK_CELLS).map((c, ci) => (
<span key={ci} className="flex items-center min-w-0">
{ci > 0 && (
<span className="mx-1.5 h-3 w-px bg-border shrink-0" />
)}
{c.value === "" ? (
<span className="italic text-text-muted">NULL</span>
) : (
<span className="truncate text-text">{c.value}</span>
)}
</span>
))}
</span>
) : (
o.label
)}
</button>
))}
</div>,
document.body,
)}
{errorBubble}
</div>
);
}
const cls = large
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
: inputClass;
return (
<div
ref={rootRef}
data-testid="cell-editor"
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
>
<Tag
ref={ref as any}
className={cls}
placeholder={nullable && value === "" ? "NULL" : undefined}
value={value}
onChange={(e) => {
setValue(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
/>
{errorBubble}
</div>
);
}
@@ -0,0 +1,25 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { RowDetailDrawer } from "./RowDetailDrawer";
import type { ColumnInfo } from "../../lib/types";
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "data", data_type: "jsonb", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("RowDetailDrawer", () => {
it("renders every column value including hidden ones", () => {
render(<RowDetailDrawer columns={cols} row={[1, { a: 2 }]} onClose={vi.fn()} onCopy={vi.fn()} />);
expect(screen.getByText("id")).toBeInTheDocument();
expect(screen.getByText("data")).toBeInTheDocument();
expect(screen.getByText(/"a": 2/)).toBeInTheDocument();
});
it("copy button calls onCopy with the raw value", () => {
const onCopy = vi.fn();
render(<RowDetailDrawer columns={cols} row={[1, { a: 2 }]} onClose={vi.fn()} onCopy={onCopy} />);
const copyBtns = screen.getAllByRole("button", { name: /copy/i });
fireEvent.click(copyBtns[1]); // copy the 'data' value
expect(onCopy).toHaveBeenCalledWith(JSON.stringify({ a: 2 }, null, 2));
});
});
@@ -0,0 +1,40 @@
import { X, Copy } from "lucide-react";
import type { ColumnInfo } from "../../lib/types";
interface Props {
columns: ColumnInfo[];
row: unknown[];
onClose: () => void;
onCopy: (value: string) => void;
}
function fmt(v: unknown): string {
if (v === null || v === undefined) return "NULL";
if (typeof v === "object") return JSON.stringify(v, null, 2);
return String(v);
}
export function RowDetailDrawer({ columns, row, onClose, onCopy }: Props) {
return (
<div className="fixed top-0 right-0 h-full w-96 bg-canvas border-l border-border z-40 flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-semibold text-text">Row detail</span>
<button onClick={onClose} aria-label="Close" className="text-text-muted hover:text-text"><X size={16} /></button>
</div>
<div className="flex-1 overflow-auto p-3 space-y-2">
{columns.map((c, i) => {
const v = row[i];
const isJson = c.data_type === "json" || c.data_type === "jsonb";
const text = fmt(v);
return (
<div key={c.name} className="border border-border rounded p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-text">{c.name}</span>
<button onClick={() => onCopy(text)} aria-label={`Copy ${c.name}`} className="text-text-muted hover:text-text"><Copy size={12} /></button>
</div>
<pre className={`text-xs mt-1 whitespace-pre-wrap break-all ${isJson ? "font-mono text-accent/80" : "text-text-muted"}`}>{text}</pre>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,617 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { VirtualDataGrid } from "./VirtualDataGrid";
import type { ColumnInfo } from "../../lib/types";
const mockColumns: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
const mockRows: unknown[][] = [
[1, "Alice"],
[2, "Bob"],
];
/**
* Override `useVirtualizer` so virtual items are always rendered
* regardless of container dimensions in jsdom.
*/
const { mockGetVirtualItems, mockGetTotalSize, mockMeasureElement } = vi.hoisted(() => ({
mockGetVirtualItems: vi.fn(),
mockGetTotalSize: vi.fn(),
mockMeasureElement: vi.fn(),
}));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: mockGetVirtualItems,
getTotalSize: mockGetTotalSize,
measureElement: mockMeasureElement,
}),
}));
describe("VirtualDataGrid", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("focuses a cell on click and opens the editor on Enter for an editable cell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
const nameCell = screen.getAllByText("Alice")[0];
fireEvent.click(nameCell);
fireEvent.keyDown(nameCell, { key: "Enter" });
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("shows staged values passed from the parent + a pending outline", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{ "0:name": true }} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
});
it("pending outline requires pendingKeys even when a staged value exists (committed → no outline)", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{}} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
expect(screen.queryByTestId("pending-cell")).toBeNull();
});
it("clears staged values when the stagedValues prop empties (Clear All)", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
const { rerender } = render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} />);
expect(screen.getByText("Alicia")).toBeInTheDocument();
rerender(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" stagedValues={{}} />);
expect(screen.getAllByText("Alice")[0]).toBeInTheDocument();
expect(screen.queryByText("Alicia")).toBeNull();
});
it("Ctrl+C copies the focused cell value to the clipboard", async () => {
const writeText = vi.fn();
Object.assign(navigator, { clipboard: { writeText } });
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
const cell = screen.getAllByText("Alice")[0];
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "c", ctrlKey: true });
expect(writeText).toHaveBeenCalledWith("Alice");
});
it("does not open an editor for a PK cell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
const idCell = screen.getByText("1");
fireEvent.click(idCell);
fireEvent.keyDown(idCell, { key: "Enter" });
expect(screen.queryByRole("textbox")).toBeNull();
});
it("renders all rows when row count is small", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
it("renders column headers with type badges", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
expect(screen.getByText("id")).toBeInTheDocument();
expect(screen.getByText("name")).toBeInTheDocument();
expect(screen.getByText("int")).toBeInTheDocument();
});
it("renders NULL values in italic", () => {
const rows: unknown[][] = [[null, "HasNull"]];
mockGetTotalSize.mockReturnValue(rows.length * 36);
mockGetVirtualItems.mockReturnValue(
rows.map((_, i) => ({
key: i,
index: i,
start: i * 36,
size: 36,
})),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={rows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
expect(screen.getByText("NULL")).toBeInTheDocument();
expect(screen.getByText("NULL").className).toContain("italic");
});
it("renders empty state when no rows", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={[]}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
expect(screen.getByText(/no rows/i)).toBeInTheDocument();
});
it("calls onToggleRow when checkbox clicked", () => {
let toggled = -1;
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
const checkboxes = screen.getAllByRole("checkbox");
fireEvent.click(checkboxes[1]); // first row checkbox
expect(toggled).toBe(0);
});
it("renders FK cells with an FK reference icon button", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.getByLabelText("Open FK reference")).toBeInTheDocument();
});
it("renders JSON cells with preview label", () => {
const jsonCols: ColumnInfo[] = [
{ name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
});
it("keeps header width to content and borders the last column", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
const { container } = render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={[]}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
// Header must span exactly the total column width (40 checkbox + 2 × 200),
// not stretch across the empty area to the right of the last column.
const header = container.querySelector(".sticky > div");
expect(header).not.toBeNull();
expect((header as HTMLElement).style.width).toBe("440px");
expect((header as HTMLElement).style.minWidth).toBe("");
// The last column header keeps a right border, matching body cells.
const headerCells = container.querySelectorAll(".sticky > div:first-child > div");
const lastCol = headerCells[headerCells.length - 1];
expect(lastCol.className).toContain("border-r");
expect(lastCol.className).not.toContain("border-r-0");
});
it("hides the select-all checkbox and header when no columns are present", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
const { container } = render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={[]}
columns={[]}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
// No table open → no select-all checkbox, no header bar, no empty-state message.
expect(screen.queryAllByRole("checkbox").length).toBe(0);
expect(container.querySelector(".sticky")).toBeNull();
expect(screen.queryByText(/no rows/i)).not.toBeInTheDocument();
});
it("has resize handles on column headers", () => {
mockGetTotalSize.mockReturnValue(0);
mockGetVirtualItems.mockReturnValue([]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={[]} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
expect(handles.length).toBe(2); // one per visible column
});
it("renders 10000 rows without crashing (virtualization)", () => {
const bigRows: unknown[][] = Array.from({ length: 10000 }, (_, i) => [i, `Name${i}`]);
mockGetTotalSize.mockReturnValue(10000 * 36);
mockGetVirtualItems.mockReturnValue(
Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={bigRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
);
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all
});
it("shows select-all as checked when all rows selected", () => {
const allSelected = new Set([0, 1]);
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={allSelected}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
);
const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
expect(selectAll.checked).toBe(true);
});
it("hides columns in hiddenColumns set", () => {
const hidden = new Set(["name"]);
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
);
render(
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={hidden} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />,
);
expect(screen.queryByText("name")).not.toBeInTheDocument();
expect(screen.getByText("id")).toBeInTheDocument();
});
it("renders a pending outline on the pending cell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
pendingCell={{ row: 0, col: 1 }}
/>,
);
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
});
it("does not render a pending outline without pendingCell", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
);
render(
<VirtualDataGrid
connectionId="conn-1"
schema="public"
table="users"
rows={mockRows}
columns={mockColumns}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={() => {}}
onToggleAll={() => {}}
dbType="postgresql"
tabType="table"
/>,
);
expect(screen.queryByTestId("pending-cell")).toBeNull();
});
// ── GRID-A: context menu + editing behavior ─────────────────────────
it("opens the context menu on right-click and View Row calls onOpenRowDetail", () => {
let opened = -1;
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" onOpenRowDetail={(i) => { opened = i; }} />);
fireEvent.contextMenu(screen.getByText("Alice"));
expect(screen.getByText("View Row")).toBeInTheDocument();
fireEvent.click(screen.getByText("View Row"));
expect(opened).toBe(0);
});
it("context menu Select Row calls onToggleRow with the row index", () => {
let toggled = -1;
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={(i) => { toggled = i; }} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
fireEvent.contextMenu(screen.getByText("Bob"));
fireEvent.click(screen.getByText("Select Row"));
expect(toggled).toBe(1);
});
it("closes the context menu when clicking the backdrop", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
fireEvent.contextMenu(screen.getByText("Alice"));
expect(screen.getByText("Copy")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("ctx-backdrop"));
expect(screen.queryByText("Copy")).toBeNull();
});
it("cancels in-cell editing with Escape even when the editor input is unfocused", () => {
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
dbType="postgresql" tabType="table" />);
const cell = screen.getAllByText("Alice")[0];
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "Enter" });
expect(screen.getByRole("textbox")).toBeInTheDocument();
// Editor input is not the event target — the document-level listener must cancel.
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("textbox")).toBeNull();
});
it("opens the FK preview popover from the context menu", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
fireEvent.contextMenu(screen.getByText("42"));
fireEvent.click(screen.getByText("Open FK reference"));
// Popover header renders the referenced table synchronously.
expect(screen.getByText("public.users")).toBeInTheDocument();
});
it("clicking an FK cell does NOT open the FK preview (only the icon does)", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
hiddenColumns={new Set()} selectedRows={new Set()}
onToggleRow={() => {}} onToggleAll={() => {}}
dbType="postgresql" tabType="table" />);
fireEvent.click(screen.getByText("42"));
expect(screen.queryByText("public.users")).toBeNull();
// The FK icon button opens the preview popover.
fireEvent.click(screen.getByLabelText("Open FK reference"));
expect(screen.getByText("public.users")).toBeInTheDocument();
});
// ── GRID-C: enum + FK options fed into CellEditor ────────────────
it("renders an enum <select> with the column's labels when editing", () => {
const enumCols: ColumnInfo[] = [
{ name: "status", data_type: "user_role", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(
<VirtualDataGrid
connectionId="c1"
schema="public"
table="users"
rows={[["active"]]}
columns={enumCols}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={vi.fn()}
onToggleAll={vi.fn()}
dbType="postgresql"
tabType="table"
enumValues={{ status: ["active", "inactive"] }}
/>,
);
const cell = screen.getByText("active");
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "Enter" });
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByRole("option", { name: "active" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "inactive" })).toBeInTheDocument();
});
it("renders a searchable FK dropdown with the referenced rows when editing", () => {
const fkCols: ColumnInfo[] = [
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
];
mockGetTotalSize.mockReturnValue(36);
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
render(
<VirtualDataGrid
connectionId="c1"
schema="public"
table="orders"
rows={[[42]]}
columns={fkCols}
hiddenColumns={new Set()}
selectedRows={new Set()}
onToggleRow={vi.fn()}
onToggleAll={vi.fn()}
dbType="postgresql"
tabType="table"
fkOptions={{
user_id: [
{ value: "1", label: "1 — Alice" },
{ value: "2", label: "2 — Bob" },
],
}}
/>,
);
const cell = screen.getByText("42");
fireEvent.click(cell);
fireEvent.keyDown(cell, { key: "Enter" });
expect(screen.getByLabelText(/search foreign key/i)).toBeInTheDocument();
expect(screen.getByText("1 — Alice")).toBeInTheDocument();
expect(screen.getByText("2 — Bob")).toBeInTheDocument();
});
});
@@ -0,0 +1,654 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Key, Braces, ArrowUpRight } from "lucide-react";
import type { ColumnInfo } from "../../lib/types";
import { abbreviateType } from "../../lib/utils";
import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
import { CellEditor, type FkOption } from "./CellEditor";
import { CellContextMenu } from "./CellContextMenu";
import { cellToUpdateChange, isCellEditable, type TabKind } from "./gridEditability";
import { nextCell, type CellPos } from "./keyboardNav";
interface VirtualDataGridProps {
connectionId: string;
schema: string;
table?: string;
rows: unknown[][];
columns: ColumnInfo[];
hiddenColumns: Set<string>;
selectedRows: Set<number>;
onToggleRow: (rowIndex: number) => void;
onToggleAll: () => void;
dbType?: string;
tabType?: TabKind;
onStageEdit?: (payload: {
type: "update";
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
}) => void;
onOpenRowDetail?: (rowIndex: number) => void;
getLocator?: (row: unknown[]) => Record<string, unknown>;
readOnly?: boolean;
/** When set, renders a pending-edit indicator on the staged cell at (row, col). */
pendingCell?: { row: number; col: number } | null;
/** Enum labels keyed by column NAME → renders a <select> in the CellEditor. */
enumValues?: Record<string, string[]>;
/** Foreign-key reference rows keyed by column NAME → renders a searchable dropdown in the CellEditor. */
fkOptions?: Record<string, FkOption[]>;
/** Placeholder text for the FK search input, keyed by column NAME. */
fkPlaceholders?: Record<string, string>;
/** Optimistic staged cell values keyed `${rowIndex}:${colName}` → value (null = NULL), from the changes queue. */
stagedValues?: Record<string, string | null>;
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
pendingKeys?: Record<string, boolean>;
}
const ROW_HEIGHT = 36;
const DEFAULT_COL_WIDTH = 200;
const MIN_COL_WIDTH = 60;
const MAX_COL_WIDTH = 800;
export function VirtualDataGrid({
connectionId,
schema,
table = "",
rows,
columns,
hiddenColumns,
selectedRows,
onToggleRow,
onToggleAll,
dbType = "postgresql",
tabType = "table",
onStageEdit,
onOpenRowDetail,
getLocator,
readOnly = false,
pendingCell = null,
enumValues,
fkOptions,
fkPlaceholders,
stagedValues,
pendingKeys,
}: VirtualDataGridProps) {
const parentRef = useRef<HTMLDivElement>(null);
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
const hasColumns = columns.length > 0;
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
const selectAllRef = useRef<HTMLInputElement>(null);
// Indeterminate state for partial selection
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
}
}, [selectedRows.size, rows.length]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 5,
});
// ── focus / editing / context menu / row detail state ──
const [activeCell, setActiveCell] = useState<CellPos | null>(null);
const [editingCell, setEditingCell] = useState<CellPos | null>(null);
// Optimistic staged cell values come from the parent via `stagedValues`
// (derived from the changes queue), so clearing the queue clears them.
const [pendingCellKey, setPendingCellKey] = useState<string | null>(null);
useEffect(() => {
setPendingCellKey(null);
}, [stagedValues]);
const [ctxMenu, setCtxMenu] = useState<{ pos: DOMRect; row: number; col: number } | null>(null);
// Reset transient focus state when the data shape changes.
useEffect(() => {
setActiveCell(null);
setEditingCell(null);
setCtxMenu(null);
}, [rows.length, columns.length, hiddenColumns.size]);
// Document-level Escape: cancels in-cell editing even when the editor input
// has lost focus, and closes the context menu when open.
useEffect(() => {
const editing = editingCell != null;
const menuOpen = ctxMenu != null;
if (!editing && !menuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (editing) {
setEditingCell(null);
setActiveCell(null);
}
if (menuOpen) setCtxMenu(null);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [editingCell != null, ctxMenu != null]);
// ── column widths ─────────────────────────────────────
const [colWidths, setColWidths] = useState<Record<string, number>>({});
const getWidth = useCallback(
(colName: string) => colWidths[colName] ?? DEFAULT_COL_WIDTH,
[colWidths],
);
// Total width for horizontal scroll support
const totalWidth =
(hasColumns ? 40 : 0) +
visibleColumns.reduce((sum, c) => sum + getWidth(c.name), 0);
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
const startResize = useCallback(
(colName: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { col: colName, startX: e.clientX, startWidth: getWidth(colName) };
const onMove = (ev: MouseEvent) => {
const current = resizeRef.current;
if (!current) return;
const delta = ev.clientX - current.startX;
const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, current.startWidth + delta));
setColWidths((prev) => ({ ...prev, [current.col]: next }));
};
const onUp = () => {
resizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[getWidth],
);
const resetWidth = useCallback((colName: string) => {
setColWidths((prev) => {
const next = { ...prev };
delete next[colName];
return next;
});
}, []);
// ── FK preview popover state ──────────────────────────
const [fkPreview, setFkPreview] = useState<{
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
} | null>(null);
const handleFkClick = useCallback(
(col: ColumnInfo, cellValue: unknown, e: React.MouseEvent) => {
if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
const [refTable] = col.fk_ref;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setFkPreview({
connectionId,
schema,
table: refTable,
column: col.fk_ref[1],
value: String(cellValue),
anchorRect: rect,
});
},
[connectionId, schema],
);
// ── JSON popover state ────────────────────────────────
const [jsonPopover, setJsonPopover] = useState<{
value: unknown;
anchorRect: DOMRect | null;
} | null>(null);
// ── keyboard navigation ───────────────────────────────
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (editingCell) return;
if (!activeCell) return;
const keyLabel = e.key === "Tab" ? (e.shiftKey ? "Shift+Tab" : "Tab") : e.key;
if (keyLabel.startsWith("Arrow") || keyLabel === "Tab" || keyLabel === "Shift+Tab") {
e.preventDefault();
const next = nextCell(activeCell, keyLabel, rows.length, visibleColumns.length);
setActiveCell(next);
virtualizer.scrollToIndex(next.row);
return;
}
if (e.key === "Enter") {
const col = visibleColumns[activeCell.col];
if (col && isCellEditable(col, tabType, dbType, readOnly)) {
e.preventDefault();
setEditingCell(activeCell);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
setActiveCell(null);
return;
}
if (e.key === "c" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
const col = visibleColumns[activeCell.col];
if (!col) return;
const ci = columns.findIndex((c) => c.name === col.name);
const value = rows[activeCell.row]?.[ci];
navigator.clipboard.writeText(String(value));
}
},
[activeCell, columns, dbType, editingCell, rows, tabType, visibleColumns, virtualizer],
);
// ── cell renderer (shared between header sizing and body) ──
const renderCell = useCallback(
(col: ColumnInfo, row: unknown[], rowIndex: number, colIndex: number) => {
const ci = columns.findIndex((c) => c.name === col.name);
const cell = ci >= 0 ? row[ci] : undefined;
const cellKey = `${rowIndex}:${col.name}`;
const stagedDefined = stagedValues ? cellKey in stagedValues : false;
const displayCell = stagedDefined
? stagedValues![cellKey]
: cell;
const displayIsNull =
displayCell === null || displayCell === undefined;
const isNull = displayIsNull;
const isFk = col.is_fk && col.fk_ref && !isNull;
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
const editable = isCellEditable(col, tabType, dbType, readOnly);
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
const isPending =
(pendingCell?.row === rowIndex && pendingCell?.col === colIndex) ||
pendingCellKey === cellKey ||
(pendingKeys ? cellKey in pendingKeys : false);
const handleJsonClick = (e: React.MouseEvent) => {
if (isJson) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: displayCell, anchorRect: rect });
}
};
const commitEdit = (committed: string | null) => {
// Constraint guard: never stage NULL on a NOT NULL column
// (the editor blocks this with UX; this is defense in depth)
if (committed === null && col.is_nullable === false) {
setEditingCell(null);
return;
}
// oldData must be the DB value (the un-staged cell), so the queue's
// revert/display stays correct even after repeated edits of the same cell.
const dbValue = ci >= 0 ? row[ci] : undefined;
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
const locator = getLocator?.(row) ?? {};
onStageEdit?.(
cellToUpdateChange({
schema,
table,
primaryKey: locator,
oldData: { [col.name]: dbValue },
newData: { [col.name]: committed },
}) as {
type: "update";
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
},
);
}
setPendingCellKey(cellKey);
setEditingCell(null);
};
return (
<div
key={col.name}
data-testid={isPending ? "pending-cell" : undefined}
className={`relative px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""} ${
isActive && !isEditing ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
} ${isEditing ? "outline outline-2 outline-amber-400 outline-offset-[-2px]" : ""} ${
isPending && !isEditing ? "animate-pending-ring" : ""
}`}
role={isJson ? "button" : undefined}
tabIndex={isJson ? 0 : -1}
onKeyDown={
isJson
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setJsonPopover({ value: cell, anchorRect: rect });
}
}
: undefined
}
style={{ width: getWidth(col.name), flexShrink: 0 }}
title={
isNull
? "NULL"
: isFk
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
: isJson
? "Click to view JSON"
: String(displayCell)
}
onClick={(e) => {
setActiveCell({ row: rowIndex, col: colIndex });
if (isJson) handleJsonClick(e);
}}
onDoubleClick={() => {
if (editable) setEditingCell({ row: rowIndex, col: colIndex });
}}
onContextMenu={(e) => {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setCtxMenu({ pos: rect, row: rowIndex, col: colIndex });
setActiveCell({ row: rowIndex, col: colIndex });
}}
>
{isEditing ? (
<div className="absolute inset-0 z-20" onClick={(e) => e.stopPropagation()}>
<CellEditor
initialValue={isNull ? "" : String(displayCell)}
dataType={col.data_type}
nullable={col.is_nullable}
enumValues={enumValues?.[col.name]}
fkOptions={fkOptions?.[col.name]}
fkPlaceholder={fkPlaceholders?.[col.name]}
onCommit={commitEdit}
onCancel={() => setEditingCell(null)}
/>
</div>
) : isNull ? (
<span className="italic text-text-muted">NULL</span>
) : isJson ? (
<span className="inline-flex items-center gap-0.5">
<Braces size={10} className="shrink-0" />
{jp.label}
</span>
) : isFk && displayCell !== null && displayCell !== undefined ? (
<span className="inline-flex items-center gap-1 min-w-0">
<button
type="button"
aria-label="Open FK reference"
title={`FK → ${col.fk_ref![0]}.${col.fk_ref![1]}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleFkClick(col, displayCell, e);
}}
className="shrink-0 text-text-muted hover:text-accent"
>
<ArrowUpRight size={11} />
</button>
<span className="truncate">{String(displayCell)}</span>
</span>
) : (
String(displayCell)
)}
</div>
);
},
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey],
);
// ── context menu helpers ──────────────────────────────
const ctxCol = ctxMenu ? visibleColumns[ctxMenu.col] : null;
const copyCellValue = useCallback(
async (row: number, col: number) => {
const column = visibleColumns[col];
if (!column) return;
const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci];
await navigator.clipboard.writeText(String(value));
},
[columns, rows, visibleColumns],
);
const stageNull = useCallback(
(row: number, col: number) => {
const column = visibleColumns[col];
if (!column || !isCellEditable(column, tabType, dbType, readOnly)) return;
// NOT NULL columns can't be nulled (matches the context-menu gating)
if (!column.is_nullable) return;
const ci = columns.findIndex((c) => c.name === column.name);
const value = rows[row]?.[ci];
if (value === null || value === undefined) return;
const locator = getLocator?.(rows[row]) ?? {};
onStageEdit?.(
cellToUpdateChange({
schema,
table,
primaryKey: locator,
oldData: { [column.name]: value },
newData: { [column.name]: null },
}) as {
type: "update";
schema: string;
table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
},
);
},
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
);
return (
<div
ref={parentRef}
className="overflow-auto h-full outline-none"
style={{ overscrollBehavior: "none" }}
tabIndex={-1}
role="grid"
onKeyDown={handleKeyDown}
>
{/* ── sticky header (hidden when no columns/table open) ── */}
{hasColumns && (
<div className="sticky top-0 z-10">
<div className="flex items-center border-b border-border bg-canvas" style={{ width: totalWidth }}>
<div style={{ width: 40, minWidth: 40 }} className="px-2 py-2 flex items-center justify-center border-r border-border self-stretch">
<input
ref={selectAllRef}
type="checkbox"
checked={allSelected}
onChange={onToggleAll}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
{visibleColumns.map((col) => (
<div
key={col.name}
className="group relative px-3 py-2 font-heading text-text-muted border-r border-border self-stretch"
style={{ width: getWidth(col.name), flexShrink: 0 }}
>
<div className="truncate flex items-center gap-1">
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
<span className="text-text text-xs">{col.name}</span>
<span className="text-[10px] text-text-muted/50 shrink-0" title={col.data_type}>
{abbreviateType(col.data_type)}
</span>
</div>
<div
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
onMouseDown={(e) => startResize(col.name, e)}
onDoubleClick={() => resetWidth(col.name)}
/>
</div>
))}
</div>
</div>
)}
{/* ── virtual body (hidden when no columns/table open) ── */}
{!hasColumns ? null : rows.length === 0 ? (
<div className="py-12 text-center text-sm text-text-muted">
No rows in result set
</div>
) : (
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
const isSelected = selectedRows.has(virtualRow.index);
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
className={`flex items-center border-b border-border ${
isSelected ? "bg-accent/5" : ""
} hover:bg-surface/50`}
style={{
position: "absolute",
top: 0,
left: 0,
minWidth: totalWidth,
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{hasColumns && (
<div
style={{ width: 40, minWidth: 40 }}
className="flex items-center justify-center border-r border-border self-stretch"
>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleRow(virtualRow.index)}
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
/>
</div>
)}
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
</div>
);
})}
</div>
)}
{/* FK preview popover */}
{fkPreview && (
<FkPreviewPopover
connectionId={fkPreview.connectionId}
schema={fkPreview.schema}
table={fkPreview.table}
column={fkPreview.column}
value={fkPreview.value}
anchorRect={fkPreview.anchorRect}
onClose={() => setFkPreview(null)}
/>
)}
{/* JSON cell popover */}
{jsonPopover && (
<JsonCellPopover
value={jsonPopover.value}
anchorRect={jsonPopover.anchorRect}
onClose={() => setJsonPopover(null)}
/>
)}
{/* Cell context menu */}
{ctxMenu && ctxCol && (
<>
{/* Click-outside-to-close backdrop (below the z-50 menu) */}
<div
className="fixed inset-0 z-40"
data-testid="ctx-backdrop"
onClick={() => setCtxMenu(null)}
/>
<CellContextMenu
anchorRect={ctxMenu.pos}
editable={isCellEditable(ctxCol, tabType, dbType, readOnly)}
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
nullable={ctxCol.is_nullable}
onCopy={() => {
void copyCellValue(ctxMenu.row, ctxMenu.col);
setCtxMenu(null);
}}
onCopyJson={() => {
void copyCellValue(ctxMenu.row, ctxMenu.col);
setCtxMenu(null);
}}
onViewRow={() => {
onOpenRowDetail?.(ctxMenu.row);
setCtxMenu(null);
}}
onSelectRow={() => {
onToggleRow(ctxMenu.row);
setCtxMenu(null);
}}
onEdit={() => {
setActiveCell({ row: ctxMenu.row, col: ctxMenu.col });
setEditingCell({ row: ctxMenu.row, col: ctxMenu.col });
setCtxMenu(null);
}}
onSetNull={() => {
stageNull(ctxMenu.row, ctxMenu.col);
setCtxMenu(null);
}}
onOpenFk={() => {
if (ctxCol?.is_fk && ctxCol.fk_ref) {
// Resolve the column index into `rows` (ctxMenu.col indexes visibleColumns,
// which can differ when columns are hidden).
const ci = columns.findIndex((c) => c.name === ctxCol.name);
const cellValue = rows[ctxMenu.row]?.[ci];
if (cellValue !== null && cellValue !== undefined) {
setFkPreview({
connectionId,
schema,
table: ctxCol.fk_ref[0],
column: ctxCol.fk_ref[1],
value: String(cellValue),
anchorRect: ctxMenu.pos,
});
}
}
setCtxMenu(null);
}}
onClose={() => setCtxMenu(null)}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { isCellEditable, defaultFilterOperator, cellToUpdateChange } from "./gridEditability";
import type { ColumnInfo } from "../../lib/types";
const col = (over: Partial<ColumnInfo> = {}): ColumnInfo => ({
name: "c", data_type: "text", is_nullable: true, is_pk: false, is_fk: false,
fk_ref: null, default_value: null, editable: true, is_generated: false, ...over,
});
describe("gridEditability", () => {
it("isCellEditable is false for PK, generated, non-editable flag, or view/tabType!=table", () => {
expect(isCellEditable(col({ is_pk: true }), "table", "postgresql")).toBe(false);
expect(isCellEditable(col({ is_generated: true }), "table", "postgresql")).toBe(false);
expect(isCellEditable(col({ editable: false }), "table", "postgresql")).toBe(false);
expect(isCellEditable(col(), "query", "postgresql")).toBe(false);
expect(isCellEditable(col(), "table", "mysql")).toBe(false);
expect(isCellEditable(col(), "table", "postgresql")).toBe(true);
expect(isCellEditable(col(), "table", "sqlite")).toBe(true);
});
it("defaultFilterOperator picks by data type", () => {
expect(defaultFilterOperator("text")).toBe("contains");
expect(defaultFilterOperator("uuid")).toBe("contains");
expect(defaultFilterOperator("integer")).toBe("eq");
expect(defaultFilterOperator("timestamptz")).toBe("eq");
expect(defaultFilterOperator("boolean")).toBe("eq");
expect(defaultFilterOperator("USER-DEFINED")).toBe("eq");
});
it("cellToUpdateChange builds the update payload using a locator when no PK", () => {
const out = cellToUpdateChange({
schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "A" }, newData: { name: "B" },
});
expect(out).toEqual({ type: "update", schema: "public", table: "users",
primaryKey: { id: 1 }, oldData: { name: "A" }, newData: { name: "B" } });
});
it("cellToUpdateChange uses row locator as primaryKey when PK absent", () => {
const out = cellToUpdateChange({
schema: "public", table: "no_pk",
primaryKey: { ctid: "(0,1)" }, oldData: { name: "A" }, newData: { name: "B" },
});
expect(out.primaryKey).toEqual({ ctid: "(0,1)" });
});
});
@@ -0,0 +1,36 @@
import type { ColumnInfo, ChangeItemType } from "../../lib/types";
export type TabKind = "table" | "query" | "object" | "objectForm";
export type EditableDbType = "postgresql" | "sqlite";
/** A cell is editable iff: table tab, PG/SQLite, column flagged editable, not PK, not generated, and not read-only. */
export function isCellEditable(col: ColumnInfo, tabType: TabKind, dbType: string, readOnly?: boolean): boolean {
if (readOnly) return false;
if (tabType !== "table") return false;
if (dbType !== "postgresql" && dbType !== "sqlite") return false;
if (!col.editable) return false;
if (col.is_pk) return false;
if (col.is_generated) return false;
return true;
}
/** Default filter operator inferred from column data type. */
export function defaultFilterOperator(dataType: string): "contains" | "eq" {
const t = dataType.toLowerCase();
const textish = ["text", "varchar", "char", "bpchar", "name", "uuid"];
if (textish.some((x) => t.includes(x))) return "contains";
return "eq";
}
/** Build the update change payload for a single edited cell. */
export function cellToUpdateChange(input: {
schema: string; table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown>;
}): { type: ChangeItemType; schema: string; table: string;
primaryKey: Record<string, unknown>;
oldData: Record<string, unknown>;
newData: Record<string, unknown> } {
return { type: "update", ...input };
}
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { nextCell } from "./keyboardNav";
describe("keyboardNav", () => {
it("ArrowRight moves right, clamps at last col", () => {
expect(nextCell({ row: 0, col: 0 }, "ArrowRight", 5, 3)).toEqual({ row: 0, col: 1 });
expect(nextCell({ row: 0, col: 2 }, "ArrowRight", 5, 3)).toEqual({ row: 0, col: 2 });
});
it("ArrowLeft moves left, clamps at 0", () => {
expect(nextCell({ row: 1, col: 1 }, "ArrowLeft", 5, 3)).toEqual({ row: 1, col: 0 });
expect(nextCell({ row: 1, col: 0 }, "ArrowLeft", 5, 3)).toEqual({ row: 1, col: 0 });
});
it("ArrowDown/ArrowUp move row, clamp", () => {
expect(nextCell({ row: 0, col: 1 }, "ArrowDown", 5, 3)).toEqual({ row: 1, col: 1 });
expect(nextCell({ row: 4, col: 1 }, "ArrowDown", 5, 3)).toEqual({ row: 4, col: 1 });
expect(nextCell({ row: 4, col: 1 }, "ArrowUp", 5, 3)).toEqual({ row: 3, col: 1 });
});
it("Tab wraps to next row; Shift+Tab wraps back", () => {
expect(nextCell({ row: 0, col: 2 }, "Tab", 5, 3)).toEqual({ row: 1, col: 0 });
expect(nextCell({ row: 1, col: 0 }, "Shift+Tab", 5, 3)).toEqual({ row: 0, col: 2 });
});
it("unknown key returns same cell", () => {
expect(nextCell({ row: 1, col: 1 }, "x", 5, 3)).toEqual({ row: 1, col: 1 });
});
});
@@ -0,0 +1,26 @@
export interface CellPos { row: number; col: number; }
export function nextCell(pos: CellPos, key: string, rowCount: number, colCount: number): CellPos {
let { row, col } = pos;
switch (key) {
case "ArrowRight":
col = Math.min(col + 1, colCount - 1); break;
case "ArrowLeft":
col = Math.max(col - 1, 0); break;
case "ArrowDown":
row = Math.min(row + 1, rowCount - 1); break;
case "ArrowUp":
row = Math.max(row - 1, 0); break;
case "Tab":
if (col + 1 < colCount) col += 1;
else { col = 0; row = Math.min(row + 1, rowCount - 1); }
break;
case "Shift+Tab":
if (col - 1 >= 0) col -= 1;
else { col = colCount - 1; row = Math.max(row - 1, 0); }
break;
default:
return pos;
}
return { row, col };
}