Release 0.7.15 #25
@@ -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,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<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
},
|
||||
);
|
||||
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<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
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)}
|
||||
/>
|
||||
</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,20 +572,28 @@ export function VirtualDataGrid({
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{hasColumns && (
|
||||
<div
|
||||
style={{ width: 40, minWidth: 40 }}
|
||||
className="flex items-center justify-center border-r border-border self-stretch"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleRow(virtualRow.index)}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
|
||||
{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"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
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, 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