import {
IconChevronDown,
IconCircleX,
IconMaximize,
} from "@tabler/icons-react";
import { useEffect, useRef, useState } from "react";
import type { DbAdminColumn } from "../../db-admin/types.js";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "../components/ui/popover.js";
import { cn } from "../utils.js";
import {
type EditorKind,
formatCellValue,
inferEnumValues,
valueToEditString,
parseEditValue,
ParseError,
cycleTriStateBoolean,
formatJsonPretty,
} from "./cell-format.js";
export interface EditableCellProps {
column: DbAdminColumn;
kind: EditorKind;
value: unknown;
/** Whether this cell holds a staged (uncommitted) edit. */
dirty?: boolean;
/** Whether editing is allowed (false when the table has no PK). */
editable?: boolean;
/** Whether this cell is the keyboard-focused/active cell in the grid. */
active?: boolean;
/** True if the editor should open immediately (e.g. typing began). */
editing?: boolean;
/** Commit a new value into the changeset. */
onCommit: (value: unknown) => void;
/** Request entering edit mode. */
onStartEdit?: () => void;
/** Request leaving edit mode without committing. */
onCancelEdit?: () => void;
/** Move focus after Enter ("down") or Tab ("right"). */
onNavigate?: (dir: "up" | "down" | "left" | "right") => void;
className?: string;
}
const NULL_TOKEN = (
NULL
);
export function EditableCell({
column,
kind,
value,
dirty,
editable = true,
active,
editing,
onCommit,
onStartEdit,
onCancelEdit,
onNavigate,
className,
}: EditableCellProps) {
const display = formatCellValue(value, kind);
// Boolean cells toggle in place rather than opening a text editor.
if (kind === "boolean") {
return (
);
}
const baseCell = cn(
"relative h-full w-full px-2 py-1 text-xs truncate outline-none",
"font-mono",
active && "ring-1 ring-inset ring-ring",
dirty && "bg-amber-500/10 ring-1 ring-inset ring-amber-500/50",
editable && "cursor-text",
className,
);
if (editing && editable) {
if (kind === "enum") {
return (
);
}
if (kind === "json") {
return (
);
}
return (
);
}
return (
editable && onStartEdit?.()}
onKeyDown={(e) => {
if (!editable) return;
if (e.key === "Enter" || e.key === "F2") {
e.preventDefault();
onStartEdit?.();
}
}}
title={display.isNull ? "NULL" : display.text}
>
{display.isNull ? NULL_TOKEN : display.text}
{editable && (kind === "json" || kind === "text") && (
)}
);
}
// ─── Boolean (tri-state) ─────────────────────────────────────────────────────
function BooleanCell({
value,
dirty,
editable,
active,
onCommit,
onNavigate,
className,
}: {
value: unknown;
dirty?: boolean;
editable: boolean;
active?: boolean;
onCommit: (v: unknown) => void;
onNavigate?: (dir: "up" | "down" | "left" | "right") => void;
className?: string;
}) {
const label = value === true ? "true" : value === false ? "false" : null;
return (
editable && onCommit(cycleTriStateBoolean(value))}
onKeyDown={(e) => {
if (!editable) return;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
onCommit(cycleTriStateBoolean(value));
} else if (e.key === "ArrowDown") {
e.preventDefault();
onNavigate?.("down");
} else if (e.key === "ArrowUp") {
e.preventDefault();
onNavigate?.("up");
}
}}
>
{label === null ? NULL_TOKEN : label}
);
}
// ─── Inline text / number / timestamp / uuid editor ──────────────────────────
function InlineTextEditor({
kind,
value,
nullable,
onCommit,
onCancel,
onNavigate,
}: {
kind: EditorKind;
value: unknown;
nullable: boolean;
onCommit: (v: unknown) => void;
onCancel?: () => void;
onNavigate?: (dir: "up" | "down" | "left" | "right") => void;
}) {
const ref = useRef(null);
const [text, setText] = useState(() => valueToEditString(value, kind));
const [error, setError] = useState(null);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
const commit = (nav?: "down" | "right") => {
try {
const parsed = parseEditValue(text, kind);
setError(null);
onCommit(parsed);
if (nav) onNavigate?.(nav);
} catch (err) {
setError(err instanceof ParseError ? err.message : String(err));
}
};
return (
setText(e.target.value)}
onBlur={() => commit()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit("down");
} else if (e.key === "Tab") {
e.preventDefault();
commit("right");
} else if (e.key === "Escape") {
e.preventDefault();
onCancel?.();
}
}}
className={cn(
"h-full w-full bg-background px-2 py-1 text-xs font-mono outline-none",
"ring-2 ring-inset ring-ring",
error && "ring-destructive",
)}
/>
{nullable && (
)}
{error && (
{error}
)}
);
}
// ─── Enum (select) editor ────────────────────────────────────────────────────
function EnumEditor({
column,
value,
onCommit,
onCancel,
onNavigate,
}: {
column: DbAdminColumn;
value: unknown;
onCommit: (v: unknown) => void;
onCancel?: () => void;
onNavigate?: (dir: "up" | "down" | "left" | "right") => void;
}) {
const ref = useRef(null);
const options = inferEnumValues(column) ?? [];
useEffect(() => {
ref.current?.focus();
}, []);
return (
);
}
// ─── JSON / long-text expanding editor ───────────────────────────────────────
function JsonEditor({
value,
onCommit,
onCancel,
}: {
value: unknown;
onCommit: (v: unknown) => void;
onCancel?: () => void;
}) {
const [open, setOpen] = useState(true);
const [text, setText] = useState(() => formatJsonPretty(value));
const [error, setError] = useState(null);
const commit = () => {
if (text.trim() === "") {
onCommit(null);
setOpen(false);
return;
}
try {
const parsed = parseEditValue(text, "json");
setError(null);
onCommit(parsed);
setOpen(false);
} catch (err) {
setError(err instanceof ParseError ? err.message : String(err));
}
};
return (
{
if (!o) onCancel?.();
setOpen(o);
}}
>
e.preventDefault()}
>
);
}