feat: working Insert Row + smart cell editors
Insert Row now stages an editable pending row at the top of the data grid (with an accent outline) instead of an invisible all-null insert that failed on commit. Committing inserts only the filled columns so serial/identity/generated defaults apply; empty-column inserts fall back to DEFAULT VALUES. Cell editors are now type-aware: date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and FKs already had dropdowns). Applies to both new rows and inline editing. Datetime values commit in the canonical space-separated format accepted by both PostgreSQL and MySQL.
This commit is contained in:
@@ -298,13 +298,13 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
||||
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) |
|
||||
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
|
||||
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
||||
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table |
|
||||
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table. **Insert Row** adds an editable pending row at the top of the grid (accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply |
|
||||
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
||||
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
||||
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
||||
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
||||
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
||||
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `date`/`time`/`timestamp` columns use native date/time/datetime pickers, `boolean` a true/false select, and numeric types a number input; `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
||||
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
||||
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
||||
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
|
||||
|
||||
@@ -566,6 +566,14 @@ pub fn build_pg_insert_sql(
|
||||
table: &str,
|
||||
columns: &[(String, serde_json::Value)],
|
||||
) -> (String, Vec<serde_json::Value>) {
|
||||
// A table with no editable columns (e.g. only a serial/identity PK) inserts
|
||||
// with all defaults — `INSERT ... () VALUES ()` is invalid SQL.
|
||||
if columns.is_empty() {
|
||||
return (
|
||||
format!("INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", schema, table),
|
||||
vec![],
|
||||
);
|
||||
}
|
||||
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
|
||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||
let placeholders: Vec<String> = columns
|
||||
@@ -3102,6 +3110,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A table with no editable columns (only a serial/identity PK) must insert
|
||||
/// with `DEFAULT VALUES`, not the invalid `INSERT ... () VALUES ()`.
|
||||
#[test]
|
||||
fn build_pg_insert_sql_empty_columns_uses_default_values() {
|
||||
let (sql, params) = build_pg_insert_sql("public", "t", &[]);
|
||||
assert_eq!(sql, "INSERT INTO \"public\".\"t\" DEFAULT VALUES");
|
||||
assert!(params.is_empty());
|
||||
}
|
||||
|
||||
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
||||
/// from `sqlite_master`.
|
||||
#[test]
|
||||
|
||||
@@ -231,6 +231,7 @@ export function DbViewerScreen({
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||
const updateChange = useDbViewerStore((s) => s.updateChange);
|
||||
|
||||
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
||||
|
||||
@@ -339,6 +340,19 @@ export function DbViewerScreen({
|
||||
[activeTab, stageCellEdit],
|
||||
);
|
||||
|
||||
const handleStageInsertCell = useCallback(
|
||||
(changeId: string, column: string, value: string | null) => {
|
||||
const change = useDbViewerStore
|
||||
.getState()
|
||||
.changesQueue.find((c) => c.id === changeId);
|
||||
if (!change) return;
|
||||
const newData = { ...(change.newData ?? {}) };
|
||||
newData[column] = value;
|
||||
updateChange(changeId, { newData });
|
||||
},
|
||||
[updateChange],
|
||||
);
|
||||
|
||||
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
||||
setRowDetailIdx(rowIndex);
|
||||
}, []);
|
||||
@@ -832,6 +846,24 @@ export function DbViewerScreen({
|
||||
// Data is already filtered and sorted server-side; no client-side transform needed.
|
||||
const processedRows = rawRows;
|
||||
|
||||
// Pending insert rows (from the changes queue) are prepended to the grid as
|
||||
// editable rows. Only pending inserts for the active table tab count.
|
||||
const pendingInserts =
|
||||
activeTab?.tabType === "table"
|
||||
? changesQueue.filter(
|
||||
(c) =>
|
||||
c.type === "insert" &&
|
||||
c.status === "pending" &&
|
||||
c.schema === activeTab.schema &&
|
||||
c.table === activeTab.table,
|
||||
)
|
||||
: [];
|
||||
const pendingInsertChangeIds = pendingInserts.map((c) => c.id);
|
||||
const pendingInsertRows = pendingInserts.map((c) =>
|
||||
columns.map((col) => c.newData?.[col.name] ?? null),
|
||||
);
|
||||
const displayRows = [...pendingInsertRows, ...processedRows];
|
||||
|
||||
const onPanelResizeStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1184,7 +1216,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={processedRows}
|
||||
rows={displayRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
@@ -1192,6 +1224,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={readOnlyTable}
|
||||
enumValues={editorOptions?.enums}
|
||||
@@ -1199,6 +1232,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
pendingInsertChangeIds={pendingInsertChangeIds}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows(
|
||||
(prev) => {
|
||||
@@ -1348,7 +1382,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={processedRows}
|
||||
rows={displayRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
@@ -1356,6 +1390,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={readOnlyTable}
|
||||
enumValues={editorOptions?.enums}
|
||||
@@ -1363,6 +1398,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
pendingInsertChangeIds={pendingInsertChangeIds}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(
|
||||
|
||||
@@ -325,6 +325,21 @@ describe("TableControls", () => {
|
||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("stages an insert with only editable columns when Insert Row is clicked", () => {
|
||||
const cols = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: "nextval('users_id_seq')", editable: false, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
{ name: "created_at", data_type: "timestamp", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: "now()", editable: true, is_generated: false },
|
||||
];
|
||||
seed([makeTab({ data: { columns: cols, rows: [], total_rows: 0, page: 1, page_size: 50 } })], "tab-1");
|
||||
renderControls({ columns: cols, rows: [] });
|
||||
fireEvent.click(screen.getByLabelText(/insert row/i));
|
||||
const queue = useDbViewerStore.getState().changesQueue;
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].type).toBe("insert");
|
||||
expect(queue[0].newData).toEqual({ name: null, created_at: null });
|
||||
});
|
||||
|
||||
it("renders the column filter popover with the + Add filter action", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
|
||||
@@ -547,8 +547,14 @@ export function TableControls({
|
||||
};
|
||||
|
||||
const handleInsertRow = () => {
|
||||
// Build the insert template from editable columns only. PK / generated /
|
||||
// identity columns are omitted so their defaults (serial, identity,
|
||||
// generated) apply on commit — an explicit NULL would bypass them and
|
||||
// fail with a NOT NULL violation.
|
||||
const newData: Record<string, unknown> = {};
|
||||
columns.forEach((c) => { newData[c.name] = null; });
|
||||
columns.forEach((c) => {
|
||||
if (c.editable && !c.is_pk && !c.is_generated) newData[c.name] = null;
|
||||
});
|
||||
addChange({
|
||||
type: "insert",
|
||||
schema,
|
||||
|
||||
@@ -6,16 +6,18 @@ interface Props {
|
||||
isJson: boolean;
|
||||
isFk: boolean;
|
||||
nullable: boolean;
|
||||
/** When true, hide row-scoped actions (View Row / Select Row) — pending insert rows have no backing row. */
|
||||
isInsertRow?: 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) {
|
||||
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, isInsertRow = false, 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: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: !isInsertRow },
|
||||
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: !isInsertRow },
|
||||
{ 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 },
|
||||
|
||||
@@ -13,6 +13,42 @@ describe("CellEditor", () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
||||
});
|
||||
|
||||
it("renders a native date input for date columns", () => {
|
||||
render(<CellEditor initialValue="2024-01-15" dataType="date" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const input = screen.getByDisplayValue("2024-01-15");
|
||||
expect(input).toHaveAttribute("type", "date");
|
||||
});
|
||||
|
||||
it("renders a datetime-local input for timestamp columns", () => {
|
||||
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const input = screen.getByDisplayValue("2024-01-15T10:30");
|
||||
expect(input).toHaveAttribute("type", "datetime-local");
|
||||
});
|
||||
|
||||
it("commits a datetime-local value in canonical DB format (space separator + seconds)", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const input = screen.getByDisplayValue("2024-01-15T10:30");
|
||||
fireEvent.change(input, { target: { value: "2024-01-15T11:45" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith("2024-01-15 11:45:00");
|
||||
});
|
||||
|
||||
it("renders a boolean select with true/false options", () => {
|
||||
render(<CellEditor initialValue="true" dataType="boolean" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select).toHaveValue("true");
|
||||
expect(screen.getByRole("option", { name: "true" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "false" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a number input for integer columns", () => {
|
||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("spinbutton");
|
||||
expect(input).toHaveAttribute("type", "number");
|
||||
expect(input).toHaveAttribute("step", "1");
|
||||
});
|
||||
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 />);
|
||||
@@ -40,7 +76,7 @@ describe("CellEditor", () => {
|
||||
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");
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
@@ -57,7 +93,7 @@ describe("CellEditor", () => {
|
||||
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");
|
||||
const input = screen.getByRole("spinbutton");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
|
||||
|
||||
@@ -30,6 +30,45 @@ const controlClass =
|
||||
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
|
||||
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
|
||||
|
||||
/** Map a column data type to a "smart" input kind. */
|
||||
type InputKind = "date" | "datetime" | "time" | "boolean" | "number" | "text";
|
||||
|
||||
const NUMERIC_TYPES = new Set([
|
||||
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
|
||||
"tinyint", "mediumint", "numeric", "decimal", "real", "double",
|
||||
"double precision", "float", "float4", "float8", "money",
|
||||
"serial", "bigserial", "smallserial",
|
||||
]);
|
||||
|
||||
const INTEGER_TYPES = new Set([
|
||||
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
|
||||
"tinyint", "mediumint", "serial", "bigserial", "smallserial",
|
||||
]);
|
||||
|
||||
function inputKindForType(dataType: string): InputKind {
|
||||
const t = dataType.toLowerCase().trim();
|
||||
if (t === "date") return "date";
|
||||
if (t.includes("timestamp") || t === "datetime") return "datetime";
|
||||
if (t === "time" || t === "timetz" || t.startsWith("time ")) return "time";
|
||||
if (t === "boolean" || t === "bool") return "boolean";
|
||||
if (NUMERIC_TYPES.has(t)) return "number";
|
||||
return "text";
|
||||
}
|
||||
|
||||
/** Convert a DB timestamp ("2024-01-15 10:30:00") to datetime-local ("2024-01-15T10:30"). */
|
||||
function toDatetimeLocal(v: string): string {
|
||||
const m = v.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||
return m ? `${m[1]}T${m[2]}` : v;
|
||||
}
|
||||
|
||||
/** Convert a datetime-local value ("2024-01-15T10:30") to the canonical DB
|
||||
* format ("2024-01-15 10:30:00") accepted by both PostgreSQL and MySQL. */
|
||||
function fromDatetimeLocal(v: string): string {
|
||||
const m = v.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?/);
|
||||
if (m) return `${m[1]} ${m[2]}:${m[3] ?? "00"}`;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function CellEditor({
|
||||
initialValue,
|
||||
dataType,
|
||||
@@ -40,7 +79,11 @@ export function CellEditor({
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellEditorProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const kind = inputKindForType(dataType);
|
||||
const isInteger = INTEGER_TYPES.has(dataType.toLowerCase().trim());
|
||||
const [value, setValue] = useState(
|
||||
kind === "datetime" ? toDatetimeLocal(initialValue) : initialValue,
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -73,6 +116,8 @@ export function CellEditor({
|
||||
useEffect(() => {
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
enumRef.current?.focus();
|
||||
} else if (kind === "boolean") {
|
||||
enumRef.current?.focus();
|
||||
} else if (fkOptions && fkOptions.length > 0) {
|
||||
searchRef.current?.focus();
|
||||
searchRef.current?.select();
|
||||
@@ -258,6 +303,63 @@ export function CellEditor({
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "boolean") {
|
||||
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>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
{errorBubble}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "date" || kind === "datetime" || kind === "time" || kind === "number") {
|
||||
const inputType = kind === "datetime" ? "datetime-local" : kind;
|
||||
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" : ""}`}
|
||||
>
|
||||
<input
|
||||
ref={ref as any}
|
||||
type={inputType}
|
||||
className={inputClass}
|
||||
value={value}
|
||||
step={kind === "number" ? (isInteger ? "1" : "any") : undefined}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitRaw(kind === "datetime" ? fromDatetimeLocal(value) : value);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errorBubble}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cls = large
|
||||
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
|
||||
: inputClass;
|
||||
|
||||
@@ -48,6 +48,21 @@ describe("VirtualDataGrid", () => {
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a pending insert row with an 'auto' placeholder and no selection checkbox", () => {
|
||||
const rows = [[null, null], ...mockRows];
|
||||
mockGetTotalSize.mockReturnValue(rows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(rows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={rows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" pendingInsertChangeIds={["ch-1"]} />);
|
||||
// Non-editable PK column in the insert row shows "auto" instead of NULL.
|
||||
expect(screen.getByText("auto")).toBeInTheDocument();
|
||||
// The insert row shows a "+" marker instead of a selection checkbox.
|
||||
expect(screen.getByTitle("New row")).toBeInTheDocument();
|
||||
// Real rows still render their checkboxes (2 real rows).
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(3); // 1 select-all + 2 row checkboxes
|
||||
});
|
||||
|
||||
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 })));
|
||||
|
||||
@@ -45,6 +45,10 @@ interface VirtualDataGridProps {
|
||||
stagedValues?: Record<string, string | null>;
|
||||
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
|
||||
pendingKeys?: Record<string, boolean>;
|
||||
/** Ordered change ids of pending insert rows prepended to `rows`. */
|
||||
pendingInsertChangeIds?: string[];
|
||||
/** Stage a cell edit on a pending insert row (updates the insert's newData). */
|
||||
onStageInsertCell?: (changeId: string, column: string, value: string | null) => void;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -74,20 +78,24 @@ export function VirtualDataGrid({
|
||||
fkPlaceholders,
|
||||
stagedValues,
|
||||
pendingKeys,
|
||||
pendingInsertChangeIds,
|
||||
onStageInsertCell,
|
||||
}: 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 pendingInsertCount = pendingInsertChangeIds?.length ?? 0;
|
||||
const realRowCount = rows.length - pendingInsertCount;
|
||||
const allSelected = realRowCount > 0 && selectedRows.size === realRowCount;
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Indeterminate state for partial selection
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current) {
|
||||
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
|
||||
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < realRowCount;
|
||||
}
|
||||
}, [selectedRows.size, rows.length]);
|
||||
}, [selectedRows.size, realRowCount]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
@@ -282,6 +290,8 @@ export function VirtualDataGrid({
|
||||
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 isInsertRow = rowIndex < pendingInsertCount;
|
||||
const insertChangeId = isInsertRow ? pendingInsertChangeIds?.[rowIndex] : undefined;
|
||||
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
|
||||
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
||||
const isPending =
|
||||
@@ -307,6 +317,9 @@ export function VirtualDataGrid({
|
||||
// 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)) {
|
||||
if (isInsertRow && insertChangeId) {
|
||||
onStageInsertCell?.(insertChangeId, col.name, committed);
|
||||
} else {
|
||||
const locator = getLocator?.(row) ?? {};
|
||||
onStageEdit?.(
|
||||
cellToUpdateChange({
|
||||
@@ -325,6 +338,7 @@ export function VirtualDataGrid({
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
setPendingCellKey(cellKey);
|
||||
setEditingCell(null);
|
||||
};
|
||||
@@ -355,7 +369,9 @@ export function VirtualDataGrid({
|
||||
}
|
||||
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
||||
title={
|
||||
isNull
|
||||
isInsertRow && !editable
|
||||
? "auto (default)"
|
||||
: isNull
|
||||
? "NULL"
|
||||
: isFk
|
||||
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
|
||||
@@ -390,6 +406,8 @@ export function VirtualDataGrid({
|
||||
onCancel={() => setEditingCell(null)}
|
||||
/>
|
||||
</div>
|
||||
) : isInsertRow && !editable ? (
|
||||
<span className="italic text-text-muted/60">auto</span>
|
||||
) : isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : isJson ? (
|
||||
@@ -420,7 +438,7 @@ export function VirtualDataGrid({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey],
|
||||
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, onStageInsertCell, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey, pendingInsertCount, pendingInsertChangeIds],
|
||||
);
|
||||
|
||||
// ── context menu helpers ──────────────────────────────
|
||||
@@ -446,6 +464,11 @@ export function VirtualDataGrid({
|
||||
const ci = columns.findIndex((c) => c.name === column.name);
|
||||
const value = rows[row]?.[ci];
|
||||
if (value === null || value === undefined) return;
|
||||
if (row < pendingInsertCount) {
|
||||
const changeId = pendingInsertChangeIds?.[row];
|
||||
if (changeId) onStageInsertCell?.(changeId, column.name, null);
|
||||
return;
|
||||
}
|
||||
const locator = getLocator?.(rows[row]) ?? {};
|
||||
onStageEdit?.(
|
||||
cellToUpdateChange({
|
||||
@@ -464,7 +487,7 @@ export function VirtualDataGrid({
|
||||
},
|
||||
);
|
||||
},
|
||||
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
|
||||
[columns, dbType, getLocator, onStageEdit, onStageInsertCell, readOnly, rows, schema, table, tabType, visibleColumns, pendingInsertCount, pendingInsertChangeIds],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -528,15 +551,18 @@ export function VirtualDataGrid({
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
const isSelected = selectedRows.has(virtualRow.index);
|
||||
const gridIndex = virtualRow.index;
|
||||
const isInsertRow = gridIndex < pendingInsertCount;
|
||||
const realIndex = isInsertRow ? -1 : gridIndex - pendingInsertCount;
|
||||
const row = rows[gridIndex];
|
||||
const isSelected = !isInsertRow && selectedRows.has(realIndex);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
data-index={gridIndex}
|
||||
className={`flex items-center border-b border-border ${
|
||||
isSelected ? "bg-accent/5" : ""
|
||||
} hover:bg-surface/50`}
|
||||
} ${isInsertRow ? "bg-accent/[0.03] ring-1 ring-inset ring-accent/60" : ""} hover:bg-surface/50`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -546,7 +572,15 @@ export function VirtualDataGrid({
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{hasColumns && (
|
||||
{hasColumns &&
|
||||
(isInsertRow ? (
|
||||
<div
|
||||
style={{ width: 40, minWidth: 40 }}
|
||||
className="flex items-center justify-center border-r border-border self-stretch"
|
||||
>
|
||||
<span className="text-[10px] text-accent/70" title="New row">+</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ width: 40, minWidth: 40 }}
|
||||
className="flex items-center justify-center border-r border-border self-stretch"
|
||||
@@ -554,12 +588,12 @@ export function VirtualDataGrid({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleRow(virtualRow.index)}
|
||||
onChange={() => onToggleRow(realIndex)}
|
||||
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))}
|
||||
))}
|
||||
{visibleColumns.map((col, i) => renderCell(col, row, gridIndex, i))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -601,6 +635,7 @@ export function VirtualDataGrid({
|
||||
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
|
||||
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
|
||||
nullable={ctxCol.is_nullable}
|
||||
isInsertRow={ctxMenu.row < pendingInsertCount}
|
||||
onCopy={() => {
|
||||
void copyCellValue(ctxMenu.row, ctxMenu.col);
|
||||
setCtxMenu(null);
|
||||
@@ -610,11 +645,11 @@ export function VirtualDataGrid({
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onViewRow={() => {
|
||||
onOpenRowDetail?.(ctxMenu.row);
|
||||
onOpenRowDetail?.(ctxMenu.row - pendingInsertCount);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onSelectRow={() => {
|
||||
onToggleRow(ctxMenu.row);
|
||||
onToggleRow(ctxMenu.row - pendingInsertCount);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onEdit={() => {
|
||||
|
||||
@@ -45,6 +45,10 @@ describe("buildChangeSql", () => {
|
||||
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
|
||||
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
|
||||
});
|
||||
it("insert with no columns falls back to DEFAULT VALUES", () => {
|
||||
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: {} } as any;
|
||||
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" DEFAULT VALUES');
|
||||
});
|
||||
it("update", () => {
|
||||
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
|
||||
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
|
||||
|
||||
@@ -33,6 +33,7 @@ export function buildChangeSql(item: QueueItem): string {
|
||||
case "insert": {
|
||||
const data = item.newData ?? {};
|
||||
const cols = Object.keys(data);
|
||||
if (cols.length === 0) return `INSERT INTO ${t} DEFAULT VALUES`;
|
||||
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
|
||||
}
|
||||
case "update": {
|
||||
|
||||
@@ -247,6 +247,16 @@ describe("dbViewerStore", () => {
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("updateChange patches a single change in place", () => {
|
||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: null }, description: "x" } as any);
|
||||
const id = useDbViewerStore.getState().changesQueue[0].id;
|
||||
useDbViewerStore.getState().updateChange(id, { newData: { a: "hello" } });
|
||||
const item = useDbViewerStore.getState().changesQueue[0];
|
||||
expect(item.newData).toEqual({ a: "hello" });
|
||||
expect(item.type).toBe("insert");
|
||||
expect(item.description).toBe("x");
|
||||
});
|
||||
|
||||
it("clearChanges empties the queue", () => {
|
||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
||||
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
|
||||
|
||||
@@ -167,6 +167,7 @@ interface DbViewerState {
|
||||
}) => void;
|
||||
cancelChange: (changeId: string) => void;
|
||||
removeChange: (changeId: string) => void;
|
||||
updateChange: (changeId: string, patch: Partial<QueueItem>) => void;
|
||||
clearChanges: () => void;
|
||||
markChangeCommitted: (changeId: string) => void;
|
||||
markChangeFailed: (changeId: string, error: string) => void;
|
||||
@@ -547,6 +548,13 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
|
||||
})),
|
||||
|
||||
updateChange: (changeId, patch) =>
|
||||
set((state) => ({
|
||||
changesQueue: state.changesQueue.map((c) =>
|
||||
c.id === changeId ? { ...c, ...patch } : c,
|
||||
),
|
||||
})),
|
||||
|
||||
clearChanges: () => set({ changesQueue: [] }),
|
||||
|
||||
markChangeCommitted: (changeId) =>
|
||||
|
||||
@@ -9,6 +9,8 @@ export const changelogEntries: ChangelogEntry[] = [
|
||||
version: "0.7.15",
|
||||
date: "2026-09-08",
|
||||
highlights: [
|
||||
"Insert Row now works — it adds an editable pending row at the top of the data grid (with a visible accent outline), and committing inserts only the columns you filled in so serial/identity/generated defaults apply.",
|
||||
"Smart cell editors — date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and foreign keys already had dropdowns), applied to both new rows and inline editing.",
|
||||
"Landing page adds a support channel: coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature.",
|
||||
"Landing page copy refreshed: em dashes dropped, OSS section simplified, FAQ added for how the project is funded.",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user