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.", ],