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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user