From f480d0f864aedb12f193b98c6f18914f06b18923 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 6 Sep 2026 12:49:20 +0800 Subject: [PATCH] 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. --- AGENTS.md | 4 +- desktop/src-tauri/src/commands/db_viewer.rs | 17 +++ .../components/db-viewer/DbViewerScreen.tsx | 40 +++++- .../db-viewer/TableControls.test.tsx | 15 ++ .../components/db-viewer/TableControls.tsx | 8 +- .../src/components/grid/CellContextMenu.tsx | 8 +- .../src/components/grid/CellEditor.test.tsx | 40 +++++- desktop/src/components/grid/CellEditor.tsx | 104 +++++++++++++- .../components/grid/VirtualDataGrid.test.tsx | 15 ++ .../src/components/grid/VirtualDataGrid.tsx | 133 +++++++++++------- desktop/src/lib/changePayload.test.ts | 4 + desktop/src/lib/changePayload.ts | 1 + desktop/src/stores/dbViewerStore.test.ts | 10 ++ desktop/src/stores/dbViewerStore.ts | 8 ++ www/src/data/changelog.ts | 2 + 15 files changed, 349 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 037b6de..6b80a91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` 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 | diff --git a/desktop/src-tauri/src/commands/db_viewer.rs b/desktop/src-tauri/src/commands/db_viewer.rs index fc976ea..59fc72a 100644 --- a/desktop/src-tauri/src/commands/db_viewer.rs +++ b/desktop/src-tauri/src/commands/db_viewer.rs @@ -566,6 +566,14 @@ pub fn build_pg_insert_sql( table: &str, columns: &[(String, serde_json::Value)], ) -> (String, Vec) { + // 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 = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect(); let mut params: Vec = Vec::new(); let placeholders: Vec = 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] diff --git a/desktop/src/components/db-viewer/DbViewerScreen.tsx b/desktop/src/components/db-viewer/DbViewerScreen.tsx index c87a363..cd2a518 100644 --- a/desktop/src/components/db-viewer/DbViewerScreen.tsx +++ b/desktop/src/components/db-viewer/DbViewerScreen.tsx @@ -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( diff --git a/desktop/src/components/db-viewer/TableControls.test.tsx b/desktop/src/components/db-viewer/TableControls.test.tsx index 744e00e..b2b1b29 100644 --- a/desktop/src/components/db-viewer/TableControls.test.tsx +++ b/desktop/src/components/db-viewer/TableControls.test.tsx @@ -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(); diff --git a/desktop/src/components/db-viewer/TableControls.tsx b/desktop/src/components/db-viewer/TableControls.tsx index 759317d..6f087c7 100644 --- a/desktop/src/components/db-viewer/TableControls.tsx +++ b/desktop/src/components/db-viewer/TableControls.tsx @@ -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 = {}; - 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, diff --git a/desktop/src/components/grid/CellContextMenu.tsx b/desktop/src/components/grid/CellContextMenu.tsx index 17b9b6a..a702eb8 100644 --- a/desktop/src/components/grid/CellContextMenu.tsx +++ b/desktop/src/components/grid/CellContextMenu.tsx @@ -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: , onClick: () => { onCopy(); onClose(); }, show: true }, { label: "Copy JSON", icon: , onClick: () => { onCopyJson(); onClose(); }, show: isJson }, - { label: "View Row", icon: , onClick: () => { onViewRow(); onClose(); }, show: true }, - { label: "Select Row", icon: , onClick: () => { onSelectRow(); onClose(); }, show: true }, + { label: "View Row", icon: , onClick: () => { onViewRow(); onClose(); }, show: !isInsertRow }, + { label: "Select Row", icon: , onClick: () => { onSelectRow(); onClose(); }, show: !isInsertRow }, { label: "Edit", icon: , onClick: () => { onEdit(); onClose(); }, show: editable }, { label: "Set NULL", icon: , onClick: () => { onSetNull(); onClose(); }, show: editable && nullable }, { label: "Open FK reference", icon: , onClick: () => { onOpenFk(); onClose(); }, show: isFk }, diff --git a/desktop/src/components/grid/CellEditor.test.tsx b/desktop/src/components/grid/CellEditor.test.tsx index d2be8da..848f23e 100644 --- a/desktop/src/components/grid/CellEditor.test.tsx +++ b/desktop/src/components/grid/CellEditor.test.tsx @@ -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(); + const input = screen.getByDisplayValue("2024-01-15"); + expect(input).toHaveAttribute("type", "date"); + }); + + it("renders a datetime-local input for timestamp columns", () => { + render(); + 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(); + 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(); + 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(); + 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(); @@ -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(); - 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(); - 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(); diff --git a/desktop/src/components/grid/CellEditor.tsx b/desktop/src/components/grid/CellEditor.tsx index 12ffbb1..b714bbc 100644 --- a/desktop/src/components/grid/CellEditor.tsx +++ b/desktop/src/components/grid/CellEditor.tsx @@ -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(null); const rootRef = useRef(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 ( +
+ + {errorBubble} +
+ ); + } + + if (kind === "date" || kind === "datetime" || kind === "time" || kind === "number") { + const inputType = kind === "datetime" ? "datetime-local" : kind; + return ( +
+ { + 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} +
+ ); + } + const cls = large ? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5` : inputClass; diff --git a/desktop/src/components/grid/VirtualDataGrid.test.tsx b/desktop/src/components/grid/VirtualDataGrid.test.tsx index 7e3270a..0aa1063 100644 --- a/desktop/src/components/grid/VirtualDataGrid.test.tsx +++ b/desktop/src/components/grid/VirtualDataGrid.test.tsx @@ -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(); + // 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 }))); diff --git a/desktop/src/components/grid/VirtualDataGrid.tsx b/desktop/src/components/grid/VirtualDataGrid.tsx index 3f71f77..ce1e288 100644 --- a/desktop/src/components/grid/VirtualDataGrid.tsx +++ b/desktop/src/components/grid/VirtualDataGrid.tsx @@ -45,6 +45,10 @@ interface VirtualDataGridProps { stagedValues?: Record; /** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */ pendingKeys?: Record; + /** 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(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(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,23 +317,27 @@ 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)) { - 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; - oldData: Record; - newData: Record; - }, - ); + if (isInsertRow && insertChangeId) { + onStageInsertCell?.(insertChangeId, col.name, committed); + } else { + 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; + oldData: Record; + newData: Record; + }, + ); + } } setPendingCellKey(cellKey); setEditingCell(null); @@ -355,13 +369,15 @@ export function VirtualDataGrid({ } 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) + isInsertRow && !editable + ? "auto (default)" + : 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 }); @@ -390,6 +406,8 @@ export function VirtualDataGrid({ onCancel={() => setEditingCell(null)} /> + ) : isInsertRow && !editable ? ( + auto ) : isNull ? ( NULL ) : isJson ? ( @@ -420,7 +438,7 @@ export function VirtualDataGrid({ ); }, - [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 (
- {hasColumns && ( -
- onToggleRow(virtualRow.index)} - className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" - /> -
- )} - {visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))} + {hasColumns && + (isInsertRow ? ( +
+ + +
+ ) : ( +
+ onToggleRow(realIndex)} + className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent" + /> +
+ ))} + {visibleColumns.map((col, i) => renderCell(col, row, gridIndex, i))}
); })} @@ -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={() => { diff --git a/desktop/src/lib/changePayload.test.ts b/desktop/src/lib/changePayload.test.ts index c133bca..ac5b8e4 100644 --- a/desktop/src/lib/changePayload.test.ts +++ b/desktop/src/lib/changePayload.test.ts @@ -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'); diff --git a/desktop/src/lib/changePayload.ts b/desktop/src/lib/changePayload.ts index 2f14fe8..9e9202e 100644 --- a/desktop/src/lib/changePayload.ts +++ b/desktop/src/lib/changePayload.ts @@ -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": { diff --git a/desktop/src/stores/dbViewerStore.test.ts b/desktop/src/stores/dbViewerStore.test.ts index 947b87c..4aa6ab7 100644 --- a/desktop/src/stores/dbViewerStore.test.ts +++ b/desktop/src/stores/dbViewerStore.test.ts @@ -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); diff --git a/desktop/src/stores/dbViewerStore.ts b/desktop/src/stores/dbViewerStore.ts index 8dc28b7..70d6fda 100644 --- a/desktop/src/stores/dbViewerStore.ts +++ b/desktop/src/stores/dbViewerStore.ts @@ -167,6 +167,7 @@ interface DbViewerState { }) => void; cancelChange: (changeId: string) => void; removeChange: (changeId: string) => void; + updateChange: (changeId: string, patch: Partial) => void; clearChanges: () => void; markChangeCommitted: (changeId: string) => void; markChangeFailed: (changeId: string, error: string) => void; @@ -547,6 +548,13 @@ export const useDbViewerStore = create((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) => diff --git a/www/src/data/changelog.ts b/www/src/data/changelog.ts index 39aac62..ba38e89 100644 --- a/www/src/data/changelog.ts +++ b/www/src/data/changelog.ts @@ -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.", ],