diff --git a/AGENTS.md b/AGENTS.md index f49a3e6..8cb7e7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,15 +207,16 @@ cargo test # Rust tests | Folder tag matching | ✅ | When any filter is active, folder cards show only if the folder matches a selected tag OR contains matching connections (directly or in subfolders) | | DB type filter (Postgres/MySQL/SQLite/Redis) | ✅ | Dropdown with checkboxes + Clear all; folder cards hidden when their contents don't match the DB type | | Environment filter | ✅ | Select in Filters dropdown: All / Production / Staging / Development / None (unassigned); counts toward active badge | -| Global search (Cmd+K) | ✅ | Connection URL detection auto-fills new-connection form; shows results from ALL folders as if at root (folder scope bypassed while searching); breadcrumb shows "Showing Search Results" with Clear button | +| Global search (Cmd+K) | ✅ | Connection URL detection auto-fills new-connection form; shows results from ALL folders as if at root (folder scope bypassed while searching); breadcrumb shows "Showing Search Results" with Clear button; **Esc while the search is focused clears the query, exits search mode and blurs** | | Connection name editing | ✅ | Name field in GeneralTab edit form | | Import/Export connections (JSON) | ✅ | Bulk import with validation, skipped-record reporting | | Bulk select + delete connections/folders | ✅ | Checkbox selection with confirmation dialog | | Drag-and-drop connections to folders | ✅ | Optimistic update with atomic snapshot rollback (race-condition hardened) | | Inline tag creation | ✅ | "Create first tag" inline form (name + color) in SearchableTagPicker empty state | -| Move-to-folder bulk action | ❌ | | -| Favorites / Recent connections | ❌ | | -| Connection status indicator on cards | ❌ | | +| Move-to-folder bulk action | ✅ | Selection toolbar → Move to Folder dialog (folder picker, move confirmed via dialog) | +| Favorites / Recent connections | ✅ | Star toggle in the connection card ⋮ menu (persisted `favorite` flag); Recent connections row (top 8 via `getRecentConnections`) | +| Connection status indicator on cards | ✅ | Kebab menu → Test connection with inline idle/checking/online/offline result, on-demand via keychain + `testConnection`. **Reports real `server_version` + `latency_ms`** (PG/MySQL/SQLite queries + connect timing in the Rust backend); shows `Online · 16.4 · 42ms` or the error, re-check debounced 2s | +| Connection card actions menu (⋮) | ✅ | Kebab dropdown: Favorite toggle, Test connection (inline status), Manage submenu (Edit… / Duplicate / Delete…) | ### Database Viewer | Feature | Status | Details | @@ -231,6 +232,7 @@ cargo test # Rust tests | Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version | | Data grid pagination | ✅ | Page nav, page size selector persisted in settings | | Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE | +| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` | | Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY | | Column show/hide | ✅ | Toggle visibility per column | | Column resize (drag handle) | ✅ | Double-click to auto-fit | @@ -243,11 +245,12 @@ cargo test # Rust tests | 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 | ❌ | Cells are read-only; changes via queue Insert button only | +| 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 ` + update(r.id, { operator: e.target.value as FilterOperator }) + } + className="bg-surface border border-border rounded px-1" + > + {OPERATORS.map((o) => ( + + ))} + + {!["null", "notnull"].includes(r.operator) && ( + { + setVal({ ...val, [r.id]: e.target.value }); + update(r.id, { value: e.target.value }); + }} + className="bg-surface border border-border rounded px-1 flex-1" + placeholder="value" + /> + )} + + + ))} + + + ); +} \ No newline at end of file diff --git a/src/components/db-viewer/ObjectExplorerPage.test.tsx b/src/components/db-viewer/ObjectExplorerPage.test.tsx new file mode 100644 index 0000000..fcfcf02 --- /dev/null +++ b/src/components/db-viewer/ObjectExplorerPage.test.tsx @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ObjectExplorerPage } from "./ObjectExplorerPage"; +import { useDbViewerStore } from "../../stores/dbViewerStore"; +import * as commands from "../../lib/commands"; + +describe("ObjectExplorerPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + useDbViewerStore.getState().reset(); + useDbViewerStore.setState({ + schemas: ["public"], + currentSchema: "public", + }); + vi.spyOn(commands, "getFunctions").mockResolvedValue([]); + vi.spyOn(commands, "getIndexes").mockResolvedValue([]); + vi.spyOn(commands, "getConstraints").mockResolvedValue([]); + vi.spyOn(commands, "getTriggers").mockResolvedValue([]); + vi.spyOn(commands, "getSequences").mockResolvedValue([]); + vi.spyOn(commands, "getEnums").mockResolvedValue([]); + vi.spyOn(commands, "getExtensions").mockResolvedValue([]); + }); + + + + it("renders functions by default", async () => { + vi.spyOn(commands, "getFunctions").mockResolvedValue([ + { + name: "add_one", + schema: "public", + return_type: "int", + argument_types: ["int"], + argument_names: ["x"], + argument_modes: ["IN"], + language: "sql", + source: "SELECT $1 + 1", + kind: "f", + }, + ]); + render(); + await waitFor(() => + expect(screen.getByText("add_one(int)")).toBeInTheDocument(), + ); + }); + + it("functions type filters to kind === 'f'", async () => { + vi.spyOn(commands, "getFunctions").mockResolvedValue([ + { + name: "do_thing", + schema: "public", + return_type: "void", + argument_types: [], + argument_names: [], + argument_modes: [], + language: "plpgsql", + source: "BEGIN END", + kind: "p", + }, + { + name: "calc", + schema: "public", + return_type: "int", + argument_types: [], + argument_names: [], + argument_modes: [], + language: "sql", + source: "SELECT 1", + kind: "f", + }, + ]); + render(); + await waitFor(() => expect(screen.getByText("calc")).toBeInTheDocument()); + expect(screen.queryByText("do_thing")).not.toBeInTheDocument(); + }); + + it("indexes type fetches getIndexes and renders the index name + detail", async () => { + const user = userEvent.setup(); + const getIndexes = vi.spyOn(commands, "getIndexes").mockResolvedValue([ + { + name: "idx_users_email", + schema: "public", + table: "users", + definition: "CREATE INDEX idx_users_email ON users USING btree (email);", + is_unique: true, + method: "btree", + columns: ["email"], + size_bytes: 8192, + tablespace: null, + }, + ]); + render(); + await user.click(screen.getByLabelText("Object type")); + await user.click(screen.getByText("Indexes")); + await waitFor(() => + expect(screen.getByText("idx_users_email")).toBeInTheDocument(), + ); + expect(getIndexes).toHaveBeenCalledWith("c1", "public"); + await user.click(screen.getByText("idx_users_email")); + await waitFor(() => { + expect(screen.getByText("Index")).toBeInTheDocument(); + expect(screen.getAllByText("btree").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("8192")).toBeInTheDocument(); + }); + }); + + it("constraints type fetches getConstraints and renders the constraint name + detail", async () => { + const user = userEvent.setup(); + const getConstraints = vi.spyOn(commands, "getConstraints").mockResolvedValue([ + { + name: "chk_users_positive", + schema: "public", + table: "users", + contype: "CHECK", + definition: "CHECK (age > 0)", + deferrable: false, + validated: true, + columns: ["age"], + }, + ]); + render(); + await user.click(screen.getByLabelText("Object type")); + await user.click(screen.getByText("Constraints")); + await waitFor(() => + expect(screen.getByText("chk_users_positive")).toBeInTheDocument(), + ); + expect(getConstraints).toHaveBeenCalledWith("c1", "public"); + await user.click(screen.getByText("chk_users_positive")); + await waitFor(() => { + expect(screen.getByText("Constraint")).toBeInTheDocument(); + expect(screen.getAllByText("CHECK").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Deferrable")).toBeInTheDocument(); + }); + }); + + it("shows 'No indexes found' when getIndexes returns []", async () => { + const user = userEvent.setup(); + vi.spyOn(commands, "getIndexes").mockResolvedValue([]); + render(); + await user.click(screen.getByLabelText("Object type")); + await user.click(screen.getByText("Indexes")); + await waitFor(() => + expect(screen.getByText("No indexes found")).toBeInTheDocument(), + ); + }); + + it("shows an error message when getIndexes rejects", async () => { + const user = userEvent.setup(); + vi.spyOn(commands, "getIndexes").mockRejectedValue(new Error("boom")); + render(); + await user.click(screen.getByLabelText("Object type")); + await user.click(screen.getByText("Indexes")); + await waitFor(() => + expect(screen.getByText("boom")).toBeInTheDocument(), + ); + }); + + it("procedures type filters getFunctions to kind === 'p'", async () => { + const user = userEvent.setup(); + vi.spyOn(commands, "getFunctions").mockResolvedValue([ + { + name: "do_thing", + schema: "public", + return_type: "void", + argument_types: [], + argument_names: [], + argument_modes: [], + language: "plpgsql", + source: "BEGIN END", + kind: "p", + }, + { + name: "calc", + schema: "public", + return_type: "int", + argument_types: [], + argument_names: [], + argument_modes: [], + language: "sql", + source: "SELECT 1", + kind: "f", + }, + ]); + render(); + await user.click(screen.getByLabelText("Object type")); + await user.click(screen.getByText("Procedures")); + await waitFor(() => + expect(screen.getByText("do_thing")).toBeInTheDocument(), + ); + expect(screen.queryByText("calc")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/ObjectExplorerPage.tsx b/src/components/db-viewer/ObjectExplorerPage.tsx index 5385cea..510b4d7 100644 --- a/src/components/db-viewer/ObjectExplorerPage.tsx +++ b/src/components/db-viewer/ObjectExplorerPage.tsx @@ -1,9 +1,12 @@ import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react"; import { + BookMarked, ChevronRight, FunctionSquare, GitBranch, + ListChecks, ListOrdered, + SquareFunction, Tag, Puzzle, Search, @@ -19,6 +22,8 @@ import type { SequenceInfo, EnumInfo, ExtensionInfo, + IndexInfo, + ConstraintInfo, } from "../../lib/types"; export type ObjectType = @@ -26,7 +31,10 @@ export type ObjectType = | "triggers" | "sequences" | "enums" - | "extensions"; + | "extensions" + | "indexes" + | "constraints" + | "procedures"; interface ObjectExplorerPageProps { connectionId: string; @@ -38,6 +46,9 @@ const TYPE_LABELS: Record = { sequences: "Sequences", enums: "Enums", extensions: "Extensions", + indexes: "Indexes", + constraints: "Constraints", + procedures: "Procedures", }; const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map( @@ -50,8 +61,21 @@ const SINGULAR_LABELS: Record = { sequences: "sequence", enums: "enum", extensions: "extension", + indexes: "index", + constraints: "constraint", + procedures: "procedure", }; +/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */ +function emptyPlural(type: ObjectType): string { + const singular = SINGULAR_LABELS[type]; + const irregulars: Record = { + index: "indexes", + constraint: "constraints", + }; + return irregulars[singular] ?? `${singular}s`; +} + const ICONS: Record = { functions: ( @@ -60,6 +84,9 @@ const ICONS: Record = { sequences: , enums: , extensions: , + indexes: , + constraints: , + procedures: , }; type AnyObject = @@ -67,7 +94,9 @@ type AnyObject = | TriggerInfo | SequenceInfo | EnumInfo - | ExtensionInfo; + | ExtensionInfo + | IndexInfo + | ConstraintInfo; /** Build a unique key per item. Functions use their signature to disambiguate overloads. */ function itemKey(item: AnyObject): string { @@ -487,105 +516,287 @@ function SyntaxCode({ ); } +function renderFunctionDetail(f: FunctionInfo) { + return ( +
+
+ + Signature + +
+
+
+ + Returns + + + {f.return_type || "void"} + +
+
+ + Language + + + {f.language} + +
+
+
+
+ + Kind + + + {f.kind === "f" ? "Function" : "Procedure"} + +
+
+ + Schema + + + {f.schema} + +
+
+ {f.argument_names.length > 0 && ( + <> +
+ + Arguments + + + {f.argument_names.length} total + +
+ {f.argument_names.map((name, i) => ( +
+
+ + {f.argument_modes?.[i] && + f.argument_modes[i] !== + "IN" && ( + + {f.argument_modes[i]} + + )} + #{i + 1} + +
+ + {name} + + : + + {f.argument_types?.[i] || "unknown"} + +
+ ))} + + )} + {f.source && ( + <> +
+ + Source + + + {f.language} + +
+ + + )} +
+ ); +} + function renderDetail(type: ObjectType, item: AnyObject) { switch (type) { - case "functions": { - const f = item as FunctionInfo; + case "functions": + return renderFunctionDetail(item as FunctionInfo); + case "procedures": + return renderFunctionDetail(item as FunctionInfo); + case "indexes": { + const idx = item as IndexInfo; return (
- Signature + Index
- Returns + Table - - {f.return_type || "void"} + + {idx.table}
- Language + Method - - {f.language} + + {idx.method}
- Kind + Unique - - {f.kind === "f" ? "Function" : "Procedure"} + + {idx.is_unique ? "Yes" : "No"}
- Schema + Size - {f.schema} + {idx.size_bytes ?? "-"}
- {f.argument_names.length > 0 && ( + {idx.columns.length > 0 && ( <>
- Arguments + Columns - {f.argument_names.length} total + {idx.columns.length}
- {f.argument_names.map((name, i) => ( + {idx.columns.map((col, i) => (
-
- - {f.argument_modes?.[i] && - f.argument_modes[i] !== - "IN" && ( - - {f.argument_modes[i]} - - )} - #{i + 1} - -
- - {name} + + #{i + 1} - : - - {f.argument_types?.[i] || "unknown"} + + {col}
))} )} - {f.source && ( + {idx.definition && ( <>
- Source + Definition - {f.language} + SQL
- + + + )} +
+ ); + } + case "constraints": { + const c = item as ConstraintInfo; + return ( +
+
+ + Constraint + +
+
+
+ + Type + + + {c.contype} + +
+
+ + Table + + + {c.table} + +
+
+
+
+ + Deferrable + + + {c.deferrable ? "Yes" : "No"} + +
+
+ + Validated + + + {c.validated ? "Yes" : "No"} + +
+
+ {c.columns.length > 0 && ( + <> +
+ + Columns + + + {c.columns.length} + +
+ {c.columns.map((col, i) => ( +
+ + #{i + 1} + + + {col} + +
+ ))} + + )} + {c.definition && ( + <> +
+ + Definition + + + SQL + +
+ )}
@@ -867,10 +1078,15 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { if (type === "extensions") { result = await cmd.getExtensions(connectionId); } else if (type === "functions") { - result = await cmd.getFunctions( + result = (await cmd.getFunctions( connectionId, currentSchema ?? undefined, - ); + )).filter((f) => f.kind === "f"); + } else if (type === "procedures") { + result = (await cmd.getFunctions( + connectionId, + currentSchema ?? undefined, + )).filter((f) => f.kind === "p"); } else if (type === "triggers") { result = await cmd.getTriggers( connectionId, @@ -886,6 +1102,16 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { connectionId, currentSchema ?? undefined, ); + } else if (type === "indexes") { + result = await cmd.getIndexes( + connectionId, + currentSchema ?? undefined, + ); + } else if (type === "constraints") { + result = await cmd.getConstraints( + connectionId, + currentSchema ?? undefined, + ); } else { result = []; } @@ -1087,11 +1313,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) { {!loading && !error && filtered.length === 0 && (
- {items === null - ? `No ${label.toLowerCase()} found` - : searchQuery - ? `No ${label.toLowerCase()} matching "${searchQuery}"` - : `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`} + {searchQuery + ? `No ${emptyPlural(type)} matching "${searchQuery}"` + : `No ${emptyPlural(type)} found`}
)} diff --git a/src/components/db-viewer/TabBar.tsx b/src/components/db-viewer/TabBar.tsx index e25ea48..07e4ecd 100644 --- a/src/components/db-viewer/TabBar.tsx +++ b/src/components/db-viewer/TabBar.tsx @@ -3,7 +3,7 @@ import { ListChecks, Play, Table2, Terminal, X } from "lucide-react"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import { ChangesQueuePanel } from "./ChangesQueuePanel"; -export function TabBar() { +export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) { const tabs = useDbViewerStore((state) => state.tabs); const activeTabId = useDbViewerStore((state) => state.activeTabId); const closeTab = useDbViewerStore((state) => state.closeTab); @@ -129,7 +129,7 @@ export function TabBar() { {changesPanelExpanded && (
- +
)} diff --git a/src/components/db-viewer/TableControls.test.tsx b/src/components/db-viewer/TableControls.test.tsx index 9947d25..061f79e 100644 --- a/src/components/db-viewer/TableControls.test.tsx +++ b/src/components/db-viewer/TableControls.test.tsx @@ -15,6 +15,8 @@ const columns = [ is_fk: false, fk_ref: null, default_value: null, + editable: true, + is_generated: false, }, ]; @@ -314,4 +316,19 @@ describe("TableControls", () => { }); expect(onRefresh).toHaveBeenCalledTimes(1); }); + + it("hides the Insert Row button for a materialized view", () => { + seed([makeTab()], "tab-1"); + renderControls({ isMatview: true }); + expect(screen.queryByLabelText(/insert row/i)).toBeNull(); + }); + + it("renders the FilterBuilder inside the filter popover", () => { + seed([makeTab()], "tab-1"); + renderControls(); + fireEvent.click(screen.getByLabelText(/column filters/i)); + expect( + screen.getByText("Drop columns here to add filters"), + ).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/src/components/db-viewer/TableControls.tsx b/src/components/db-viewer/TableControls.tsx index 8daf868..cadaf8d 100644 --- a/src/components/db-viewer/TableControls.tsx +++ b/src/components/db-viewer/TableControls.tsx @@ -4,7 +4,8 @@ import { Columns, Check, ChevronLeft, ChevronRight, X, Trash2, ChevronDown, FileJson, FileText, Terminal, } from "lucide-react"; -import { useDbViewerStore } from "../../stores/dbViewerStore"; +import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore"; +import { FilterBuilder } from "./FilterBuilder"; import { Tooltip } from "../ui/Tooltip"; import { exportData } from "../../lib/exportData"; import type { ColumnInfo } from "../../lib/types"; @@ -27,19 +28,6 @@ const EXPORT_FORMATS = [ { label: "Markdown", ext: "md" }, ] as const; -type FilterRule = { - id: string; - column: string; - operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull"; - value: string; -}; - -type SortRule = { - id: string; - column: string; - order: "asc" | "desc"; -}; - // ─── helpers ──────────────────────────────────────────── /** @@ -136,6 +124,7 @@ function FilterModal({ + {rules.map((rule) => (
of these values + fkOptions?: FkOption[]; // when present → render searchable dropdown + fkPlaceholder?: string; // placeholder for the FK search input + onCommit: (value: string | null) => void; + onCancel: () => void; +} + +const inputClass = + "w-full px-1 py-0.5 text-xs bg-surface border border-border rounded font-mono"; + +export function CellEditor({ + initialValue, + dataType, + nullable, + enumValues, + fkOptions, + fkPlaceholder, + onCommit, + onCancel, +}: CellEditorProps) { + const [value, setValue] = useState(initialValue); + const [setNull, setSetNull] = useState(initialValue === "" && nullable); + const [query, setQuery] = useState(""); + const ref = useRef(null); + const enumRef = useRef(null); + const searchRef = useRef(null); + const [fkDropdownPos, setFkDropdownPos] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + + useLayoutEffect(() => { + if (fkOptions && fkOptions.length > 0 && searchRef.current) { + const r = searchRef.current.getBoundingClientRect(); + let left = r.left; + if (left + FK_DROPDOWN_WIDTH > window.innerWidth - 16) { + left = Math.max(16, window.innerWidth - FK_DROPDOWN_WIDTH - 16); + } + setFkDropdownPos({ + top: r.bottom + 4, + left, + width: FK_DROPDOWN_WIDTH, + }); + } + }, [fkOptions]); + + useEffect(() => { + if (enumValues && enumValues.length > 0) { + enumRef.current?.focus(); + } else if (fkOptions && fkOptions.length > 0) { + searchRef.current?.focus(); + searchRef.current?.select(); + } else { + ref.current?.focus(); + ref.current?.select(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const large = ["json", "jsonb", "text"].some((t) => + dataType.toLowerCase().includes(t) + ); + const Tag = large ? "textarea" : "input"; + + const commit = () => onCommit(setNull ? null : value); + + const handleSetNull = (checked: boolean) => { + setSetNull(checked); + if (checked) onCommit(null); + }; + + // Priority: enum > FK > default input/textarea + if (enumValues && enumValues.length > 0) { + return ( +
+ + {nullable && ( + + )} +
+ ); + } + + if (fkOptions && fkOptions.length > 0) { + const q = query.trim().toLowerCase(); + const fkSearchText = (o: FkOption) => + [ + o.label, + ...(o.cells ?? []).map((c) => `${c.name}:${c.value}`), + ] + .join(" ") + .toLowerCase(); + + const filtered = + q === "" + ? fkOptions + : fkOptions.filter((o) => fkSearchText(o).includes(q)); + return ( +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (filtered.length > 0) onCommit(filtered[0].value); + else onCommit(query); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + /> + {fkDropdownPos && + createPortal( +
+ {filtered.length === 0 && ( +
No matches
+ )} + {filtered.map((o) => ( + + ))} +
, + document.body, + )} + {nullable && ( + + )} +
+ ); + } + + const cls = large ? `${inputClass} h-6 resize-none overflow-y-auto leading-none` : inputClass; + return ( +
+ { + setValue(e.target.value); + setSetNull(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + commit(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + /> + {nullable && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/grid/RowDetailDrawer.test.tsx b/src/components/grid/RowDetailDrawer.test.tsx new file mode 100644 index 0000000..94b58e0 --- /dev/null +++ b/src/components/grid/RowDetailDrawer.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { RowDetailDrawer } from "./RowDetailDrawer"; +import type { ColumnInfo } from "../../lib/types"; + +const cols: ColumnInfo[] = [ + { name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false }, + { name: "data", data_type: "jsonb", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }, +]; + +describe("RowDetailDrawer", () => { + it("renders every column value including hidden ones", () => { + render(); + expect(screen.getByText("id")).toBeInTheDocument(); + expect(screen.getByText("data")).toBeInTheDocument(); + expect(screen.getByText(/"a": 2/)).toBeInTheDocument(); + }); + it("copy button calls onCopy with the raw value", () => { + const onCopy = vi.fn(); + render(); + const copyBtns = screen.getAllByRole("button", { name: /copy/i }); + fireEvent.click(copyBtns[1]); // copy the 'data' value + expect(onCopy).toHaveBeenCalledWith(JSON.stringify({ a: 2 }, null, 2)); + }); +}); \ No newline at end of file diff --git a/src/components/grid/RowDetailDrawer.tsx b/src/components/grid/RowDetailDrawer.tsx new file mode 100644 index 0000000..4aabf35 --- /dev/null +++ b/src/components/grid/RowDetailDrawer.tsx @@ -0,0 +1,40 @@ +import { X, Copy } from "lucide-react"; +import type { ColumnInfo } from "../../lib/types"; + +interface Props { + columns: ColumnInfo[]; + row: unknown[]; + onClose: () => void; + onCopy: (value: string) => void; +} +function fmt(v: unknown): string { + if (v === null || v === undefined) return "NULL"; + if (typeof v === "object") return JSON.stringify(v, null, 2); + return String(v); +} +export function RowDetailDrawer({ columns, row, onClose, onCopy }: Props) { + return ( +
+
+ Row detail + +
+
+ {columns.map((c, i) => { + const v = row[i]; + const isJson = c.data_type === "json" || c.data_type === "jsonb"; + const text = fmt(v); + return ( +
+
+ {c.name} + +
+
{text}
+
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/grid/VirtualDataGrid.test.tsx b/src/components/grid/VirtualDataGrid.test.tsx index 8186401..bb26675 100644 --- a/src/components/grid/VirtualDataGrid.test.tsx +++ b/src/components/grid/VirtualDataGrid.test.tsx @@ -4,8 +4,8 @@ import { VirtualDataGrid } from "./VirtualDataGrid"; import type { ColumnInfo } from "../../lib/types"; const mockColumns: ColumnInfo[] = [ - { name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null }, - { name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null }, + { name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false }, + { name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }, ]; const mockRows: unknown[][] = [ @@ -36,6 +36,78 @@ describe("VirtualDataGrid", () => { vi.clearAllMocks(); }); + it("focuses a cell on click and opens the editor on Enter for an editable cell", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render(); + const nameCell = screen.getAllByText("Alice")[0]; + fireEvent.click(nameCell); + fireEvent.keyDown(nameCell, { key: "Enter" }); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("shows staged values passed from the parent + a pending dot", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render(); + expect(screen.getByText("Alicia")).toBeInTheDocument(); + expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument(); + }); + + it("pending dot requires pendingKeys even when a staged value exists (committed → no dot)", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render(); + expect(screen.getByText("Alicia")).toBeInTheDocument(); + expect(screen.queryByTestId("pending-edit-dot")).toBeNull(); + }); + + it("clears staged values when the stagedValues prop empties (Clear All)", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + const { rerender } = render(); + expect(screen.getByText("Alicia")).toBeInTheDocument(); + rerender(); + expect(screen.getAllByText("Alice")[0]).toBeInTheDocument(); + expect(screen.queryByText("Alicia")).toBeNull(); + }); + + it("Ctrl+C copies the focused cell value to the clipboard", async () => { + const writeText = vi.fn(); + Object.assign(navigator, { clipboard: { writeText } }); + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render(); + const cell = screen.getAllByText("Alice")[0]; + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "c", ctrlKey: true }); + expect(writeText).toHaveBeenCalledWith("Alice"); + }); + + it("does not open an editor for a PK cell", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render(); + const idCell = screen.getByText("1"); + fireEvent.click(idCell); + fireEvent.keyDown(idCell, { key: "Enter" }); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + it("renders all rows when row count is small", () => { mockGetTotalSize.mockReturnValue(mockRows.length * 36); mockGetVirtualItems.mockReturnValue( @@ -51,12 +123,15 @@ describe("VirtualDataGrid", () => { {}} onToggleAll={() => {}} + dbType="postgresql" + tabType="table" />, ); @@ -79,12 +154,15 @@ describe("VirtualDataGrid", () => { {}} onToggleAll={() => {}} + dbType="postgresql" + tabType="table" />, ); @@ -109,12 +187,15 @@ describe("VirtualDataGrid", () => { {}} onToggleAll={() => {}} + dbType="postgresql" + tabType="table" />, ); @@ -130,12 +211,15 @@ describe("VirtualDataGrid", () => { {}} onToggleAll={() => {}} + dbType="postgresql" + tabType="table" />, ); @@ -147,41 +231,43 @@ describe("VirtualDataGrid", () => { mockGetTotalSize.mockReturnValue(mockRows.length * 36); mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); - render( { toggled = i; }} onToggleAll={() => {}} />); + onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />); const checkboxes = screen.getAllByRole("checkbox"); fireEvent.click(checkboxes[1]); // first row checkbox expect(toggled).toBe(0); }); - it("renders FK cells with clickable underline styling", () => { + it("renders FK cells with an FK reference icon button", () => { const fkCols: ColumnInfo[] = [ - { name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null }, + { name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false }, ]; mockGetTotalSize.mockReturnValue(36); mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]); - render( {}} onToggleAll={() => {}} />); + onToggleRow={() => {}} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />); - const fkCell = screen.getByText("42"); - expect(fkCell.className).toContain("cursor-pointer"); - expect(fkCell.className).toContain("underline"); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByLabelText("Open FK reference")).toBeInTheDocument(); }); it("renders JSON cells with preview label", () => { const jsonCols: ColumnInfo[] = [ - { name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null }, + { name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }, ]; mockGetTotalSize.mockReturnValue(36); mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]); - render( {}} onToggleAll={() => {}} />); + onToggleRow={() => {}} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />); expect(screen.getByText(/2 keys/)).toBeInTheDocument(); }); @@ -194,12 +280,15 @@ describe("VirtualDataGrid", () => { {}} onToggleAll={() => {}} + dbType="postgresql" + tabType="table" />, ); @@ -225,12 +314,15 @@ describe("VirtualDataGrid", () => { {}} onToggleAll={() => {}} + dbType="postgresql" + tabType="table" />, ); @@ -244,9 +336,10 @@ describe("VirtualDataGrid", () => { mockGetTotalSize.mockReturnValue(0); mockGetVirtualItems.mockReturnValue([]); - render( {}} onToggleAll={() => {}} />); + onToggleRow={() => {}} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />); const handles = document.querySelectorAll('[class*="cursor-col-resize"]'); expect(handles.length).toBe(2); // one per visible column @@ -259,9 +352,10 @@ describe("VirtualDataGrid", () => { Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 })) ); render( - {}} onToggleAll={() => {}} />, + onToggleRow={() => {}} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />, ); const checkboxes = screen.getAllByRole("checkbox"); expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all @@ -274,9 +368,10 @@ describe("VirtualDataGrid", () => { mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })) ); render( - {}} onToggleAll={() => {}} />, + onToggleRow={() => {}} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />, ); const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement; expect(selectAll.checked).toBe(true); @@ -289,11 +384,234 @@ describe("VirtualDataGrid", () => { mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })) ); render( - {}} onToggleAll={() => {}} />, + onToggleRow={() => {}} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />, ); expect(screen.queryByText("name")).not.toBeInTheDocument(); expect(screen.getByText("id")).toBeInTheDocument(); }); + + it("renders a pending-edit dot on the pending cell", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue( + mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })), + ); + + render( + {}} + onToggleAll={() => {}} + dbType="postgresql" + tabType="table" + pendingCell={{ row: 0, col: 1 }} + />, + ); + + expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument(); + }); + + it("does not render a pending-edit dot without pendingCell", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue( + mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })), + ); + + render( + {}} + onToggleAll={() => {}} + dbType="postgresql" + tabType="table" + />, + ); + + expect(screen.queryByTestId("pending-edit-dot")).toBeNull(); + }); + + // ── GRID-A: context menu + editing behavior ───────────────────────── + + it("opens the context menu on right-click and View Row calls onOpenRowDetail", () => { + let opened = -1; + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render( { opened = i; }} />); + + fireEvent.contextMenu(screen.getByText("Alice")); + expect(screen.getByText("View Row")).toBeInTheDocument(); + fireEvent.click(screen.getByText("View Row")); + expect(opened).toBe(0); + }); + + it("context menu Select Row calls onToggleRow with the row index", () => { + let toggled = -1; + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render( { toggled = i; }} onToggleAll={vi.fn()} + dbType="postgresql" tabType="table" />); + + fireEvent.contextMenu(screen.getByText("Bob")); + fireEvent.click(screen.getByText("Select Row")); + expect(toggled).toBe(1); + }); + + it("closes the context menu when clicking the backdrop", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render(); + + fireEvent.contextMenu(screen.getByText("Alice")); + expect(screen.getByText("Copy")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("ctx-backdrop")); + expect(screen.queryByText("Copy")).toBeNull(); + }); + + it("cancels in-cell editing with Escape even when the editor input is unfocused", () => { + mockGetTotalSize.mockReturnValue(mockRows.length * 36); + mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))); + render(); + + const cell = screen.getAllByText("Alice")[0]; + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + + // Editor input is not the event target — the document-level listener must cancel. + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("opens the FK preview popover from the context menu", () => { + const fkCols: ColumnInfo[] = [ + { name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false }, + ]; + mockGetTotalSize.mockReturnValue(36); + mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]); + + render( {}} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />); + + fireEvent.contextMenu(screen.getByText("42")); + fireEvent.click(screen.getByText("Open FK reference")); + + // Popover header renders the referenced table synchronously. + expect(screen.getByText("public.users")).toBeInTheDocument(); + }); + + it("clicking an FK cell does NOT open the FK preview (only the icon does)", () => { + const fkCols: ColumnInfo[] = [ + { name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false }, + ]; + mockGetTotalSize.mockReturnValue(36); + mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]); + + render( {}} onToggleAll={() => {}} + dbType="postgresql" tabType="table" />); + + fireEvent.click(screen.getByText("42")); + expect(screen.queryByText("public.users")).toBeNull(); + + // The FK icon button opens the preview popover. + fireEvent.click(screen.getByLabelText("Open FK reference")); + expect(screen.getByText("public.users")).toBeInTheDocument(); + }); + + // ── GRID-C: enum + FK options fed into CellEditor ──────────────── + + it("renders an enum in the CellEditor. */ + enumValues?: Record; + /** Foreign-key reference rows keyed by column NAME → renders a searchable dropdown in the CellEditor. */ + fkOptions?: Record; + /** Placeholder text for the FK search input, keyed by column NAME. */ + fkPlaceholders?: Record; + /** Optimistic staged cell values keyed `${rowIndex}:${colName}` → value (null = NULL), from the changes queue. */ + stagedValues?: Record; + /** Keys of cells with a PENDING (not yet committed) update → drives the amber dot. */ + pendingKeys?: Record; } const ROW_HEIGHT = 36; @@ -25,12 +55,25 @@ const MAX_COL_WIDTH = 800; export function VirtualDataGrid({ connectionId, schema, + table = "", rows, columns, hiddenColumns, selectedRows, onToggleRow, onToggleAll, + dbType = "postgresql", + tabType = "table", + onStageEdit, + onOpenRowDetail, + getLocator, + readOnly = false, + pendingCell = null, + enumValues, + fkOptions, + fkPlaceholders, + stagedValues, + pendingKeys, }: VirtualDataGridProps) { const parentRef = useRef(null); @@ -53,6 +96,45 @@ export function VirtualDataGrid({ overscan: 5, }); + // ── focus / editing / context menu / row detail state ── + + const [activeCell, setActiveCell] = useState(null); + const [editingCell, setEditingCell] = useState(null); + + // Optimistic staged cell values come from the parent via `stagedValues` + // (derived from the changes queue), so clearing the queue clears them. + const [pendingCellKey, setPendingCellKey] = useState(null); + + useEffect(() => { + setPendingCellKey(null); + }, [stagedValues]); + const [ctxMenu, setCtxMenu] = useState<{ pos: DOMRect; row: number; col: number } | null>(null); + + // Reset transient focus state when the data shape changes. + useEffect(() => { + setActiveCell(null); + setEditingCell(null); + setCtxMenu(null); + }, [rows.length, columns.length, hiddenColumns.size]); + + // Document-level Escape: cancels in-cell editing even when the editor input + // has lost focus, and closes the context menu when open. + useEffect(() => { + const editing = editingCell != null; + const menuOpen = ctxMenu != null; + if (!editing && !menuOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (editing) { + setEditingCell(null); + setActiveCell(null); + } + if (menuOpen) setCtxMenu(null); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [editingCell != null, ctxMenu != null]); + // ── column widths ───────────────────────────────────── const [colWidths, setColWidths] = useState>({}); @@ -138,42 +220,126 @@ export function VirtualDataGrid({ anchorRect: DOMRect | null; } | null>(null); + // ── keyboard navigation ─────────────────────────────── + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (editingCell) return; + if (!activeCell) return; + + const keyLabel = e.key === "Tab" ? (e.shiftKey ? "Shift+Tab" : "Tab") : e.key; + + if (keyLabel.startsWith("Arrow") || keyLabel === "Tab" || keyLabel === "Shift+Tab") { + e.preventDefault(); + const next = nextCell(activeCell, keyLabel, rows.length, visibleColumns.length); + setActiveCell(next); + virtualizer.scrollToIndex(next.row); + return; + } + + if (e.key === "Enter") { + const col = visibleColumns[activeCell.col]; + if (col && isCellEditable(col, tabType, dbType, readOnly)) { + e.preventDefault(); + setEditingCell(activeCell); + } + return; + } + + if (e.key === "Escape") { + e.preventDefault(); + setActiveCell(null); + return; + } + + if (e.key === "c" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + const col = visibleColumns[activeCell.col]; + if (!col) return; + const ci = columns.findIndex((c) => c.name === col.name); + const value = rows[activeCell.row]?.[ci]; + navigator.clipboard.writeText(String(value)); + } + }, + [activeCell, columns, dbType, editingCell, rows, tabType, visibleColumns, virtualizer], + ); + // ── cell renderer (shared between header sizing and body) ── const renderCell = useCallback( - (col: ColumnInfo, row: unknown[], _rowIndex: number) => { + (col: ColumnInfo, row: unknown[], rowIndex: number, colIndex: number) => { const ci = columns.findIndex((c) => c.name === col.name); const cell = ci >= 0 ? row[ci] : undefined; - const isNull = cell === null || cell === undefined; + const cellKey = `${rowIndex}:${col.name}`; + const stagedDefined = stagedValues ? cellKey in stagedValues : false; + const displayCell = stagedDefined + ? stagedValues![cellKey] + : cell; + const displayIsNull = + displayCell === null || displayCell === undefined; + const isNull = displayIsNull; const isFk = col.is_fk && col.fk_ref && !isNull; const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json"); - const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false }; + const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false }; + const editable = isCellEditable(col, tabType, dbType, readOnly); + const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex; + const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex; + const isPending = + (pendingCell?.row === rowIndex && pendingCell?.col === colIndex) || + pendingCellKey === cellKey || + (pendingKeys ? cellKey in pendingKeys : false); const handleJsonClick = (e: React.MouseEvent) => { if (isJson) { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setJsonPopover({ value: cell, anchorRect: rect }); + setJsonPopover({ value: displayCell, anchorRect: rect }); } }; + const commitEdit = (committed: string | null) => { + // oldData must be the DB value (the un-staged cell), so the queue's + // 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; + }, + ); + } + setPendingCellKey(cellKey); + setEditingCell(null); + }; + return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - if (isFk) handleFkClick(col, cell, e as any); - else if (isJson) { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setJsonPopover({ value: cell, anchorRect: rect }); - } + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setJsonPopover({ value: cell, anchorRect: rect }); } } : undefined @@ -183,37 +349,128 @@ export function VirtualDataGrid({ isNull ? "NULL" : isFk - ? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(cell)}` + ? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}` : isJson ? "Click to view JSON" - : String(cell) - } - onClick={ - isFk - ? (e) => handleFkClick(col, cell, e) - : isJson - ? handleJsonClick - : undefined + : String(displayCell) } + onClick={(e) => { + setActiveCell({ row: rowIndex, col: colIndex }); + if (isJson) handleJsonClick(e); + }} + onDoubleClick={() => { + if (editable) setEditingCell({ row: rowIndex, col: colIndex }); + }} + onContextMenu={(e) => { + e.preventDefault(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setCtxMenu({ pos: rect, row: rowIndex, col: colIndex }); + setActiveCell({ row: rowIndex, col: colIndex }); + }} > - {isNull ? ( + {isEditing ? ( +
e.stopPropagation()}> + setEditingCell(null)} + /> +
+ ) : isNull ? ( NULL ) : isJson ? ( {jp.label} + ) : isFk && displayCell !== null && displayCell !== undefined ? ( + + + {String(displayCell)} + ) : ( - String(cell) + String(displayCell) + )} + {isPending && ( + )}
); }, - [columns, getWidth, handleFkClick], + [activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey], + ); + + // ── context menu helpers ────────────────────────────── + + const ctxCol = ctxMenu ? visibleColumns[ctxMenu.col] : null; + const copyCellValue = useCallback( + async (row: number, col: number) => { + const column = visibleColumns[col]; + if (!column) return; + const ci = columns.findIndex((c) => c.name === column.name); + const value = rows[row]?.[ci]; + await navigator.clipboard.writeText(String(value)); + }, + [columns, rows, visibleColumns], + ); + + const stageNull = useCallback( + (row: number, col: number) => { + const column = visibleColumns[col]; + if (!column || !isCellEditable(column, tabType, dbType, readOnly)) return; + const ci = columns.findIndex((c) => c.name === column.name); + const value = rows[row]?.[ci]; + if (value === null || value === undefined) return; + const locator = getLocator?.(rows[row]) ?? {}; + onStageEdit?.( + cellToUpdateChange({ + schema, + table, + primaryKey: locator, + oldData: { [column.name]: value }, + newData: { [column.name]: null }, + }) as { + type: "update"; + schema: string; + table: string; + primaryKey: Record; + oldData: Record; + newData: Record; + }, + ); + }, + [columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns], ); return ( -
+
{/* ── sticky header (hidden when no columns/table open) ── */} {hasColumns && (
@@ -285,7 +542,10 @@ export function VirtualDataGrid({ }} > {hasColumns && ( -
+
)} - {visibleColumns.map((col) => renderCell(col, row, virtualRow.index))} + {visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
); })} @@ -321,6 +581,69 @@ export function VirtualDataGrid({ onClose={() => setJsonPopover(null)} /> )} + {/* Cell context menu */} + {ctxMenu && ctxCol && ( + <> + {/* Click-outside-to-close backdrop (below the z-50 menu) */} +
setCtxMenu(null)} + /> + { + void copyCellValue(ctxMenu.row, ctxMenu.col); + setCtxMenu(null); + }} + onCopyJson={() => { + void copyCellValue(ctxMenu.row, ctxMenu.col); + setCtxMenu(null); + }} + onViewRow={() => { + onOpenRowDetail?.(ctxMenu.row); + setCtxMenu(null); + }} + onSelectRow={() => { + onToggleRow(ctxMenu.row); + setCtxMenu(null); + }} + onEdit={() => { + setActiveCell({ row: ctxMenu.row, col: ctxMenu.col }); + setEditingCell({ row: ctxMenu.row, col: ctxMenu.col }); + setCtxMenu(null); + }} + onSetNull={() => { + stageNull(ctxMenu.row, ctxMenu.col); + setCtxMenu(null); + }} + onOpenFk={() => { + if (ctxCol?.is_fk && ctxCol.fk_ref) { + // Resolve the column index into `rows` (ctxMenu.col indexes visibleColumns, + // which can differ when columns are hidden). + const ci = columns.findIndex((c) => c.name === ctxCol.name); + const cellValue = rows[ctxMenu.row]?.[ci]; + if (cellValue !== null && cellValue !== undefined) { + setFkPreview({ + connectionId, + schema, + table: ctxCol.fk_ref[0], + column: ctxCol.fk_ref[1], + value: String(cellValue), + anchorRect: ctxMenu.pos, + }); + } + } + setCtxMenu(null); + }} + onClose={() => setCtxMenu(null)} + /> + + )}
); } \ No newline at end of file diff --git a/src/components/grid/gridEditability.test.ts b/src/components/grid/gridEditability.test.ts new file mode 100644 index 0000000..35b5768 --- /dev/null +++ b/src/components/grid/gridEditability.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { isCellEditable, defaultFilterOperator, cellToUpdateChange } from "./gridEditability"; +import type { ColumnInfo } from "../../lib/types"; + +const col = (over: Partial = {}): ColumnInfo => ({ + name: "c", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, + fk_ref: null, default_value: null, editable: true, is_generated: false, ...over, +}); + +describe("gridEditability", () => { + it("isCellEditable is false for PK, generated, non-editable flag, or view/tabType!=table", () => { + expect(isCellEditable(col({ is_pk: true }), "table", "postgresql")).toBe(false); + expect(isCellEditable(col({ is_generated: true }), "table", "postgresql")).toBe(false); + expect(isCellEditable(col({ editable: false }), "table", "postgresql")).toBe(false); + expect(isCellEditable(col(), "query", "postgresql")).toBe(false); + expect(isCellEditable(col(), "table", "mysql")).toBe(false); + expect(isCellEditable(col(), "table", "postgresql")).toBe(true); + expect(isCellEditable(col(), "table", "sqlite")).toBe(true); + }); + + it("defaultFilterOperator picks by data type", () => { + expect(defaultFilterOperator("text")).toBe("contains"); + expect(defaultFilterOperator("uuid")).toBe("contains"); + expect(defaultFilterOperator("integer")).toBe("eq"); + expect(defaultFilterOperator("timestamptz")).toBe("eq"); + expect(defaultFilterOperator("boolean")).toBe("eq"); + expect(defaultFilterOperator("USER-DEFINED")).toBe("eq"); + }); + + it("cellToUpdateChange builds the update payload using a locator when no PK", () => { + const out = cellToUpdateChange({ + schema: "public", table: "users", + primaryKey: { id: 1 }, oldData: { name: "A" }, newData: { name: "B" }, + }); + expect(out).toEqual({ type: "update", schema: "public", table: "users", + primaryKey: { id: 1 }, oldData: { name: "A" }, newData: { name: "B" } }); + }); + it("cellToUpdateChange uses row locator as primaryKey when PK absent", () => { + const out = cellToUpdateChange({ + schema: "public", table: "no_pk", + primaryKey: { ctid: "(0,1)" }, oldData: { name: "A" }, newData: { name: "B" }, + }); + expect(out.primaryKey).toEqual({ ctid: "(0,1)" }); + }); +}); \ No newline at end of file diff --git a/src/components/grid/gridEditability.ts b/src/components/grid/gridEditability.ts new file mode 100644 index 0000000..e14fc00 --- /dev/null +++ b/src/components/grid/gridEditability.ts @@ -0,0 +1,36 @@ +import type { ColumnInfo, ChangeItemType } from "../../lib/types"; + +export type TabKind = "table" | "query"; +export type EditableDbType = "postgresql" | "sqlite"; + +/** A cell is editable iff: table tab, PG/SQLite, column flagged editable, not PK, not generated, and not read-only. */ +export function isCellEditable(col: ColumnInfo, tabType: TabKind, dbType: string, readOnly?: boolean): boolean { + if (readOnly) return false; + if (tabType !== "table") return false; + if (dbType !== "postgresql" && dbType !== "sqlite") return false; + if (!col.editable) return false; + if (col.is_pk) return false; + if (col.is_generated) return false; + return true; +} + +/** Default filter operator inferred from column data type. */ +export function defaultFilterOperator(dataType: string): "contains" | "eq" { + const t = dataType.toLowerCase(); + const textish = ["text", "varchar", "char", "bpchar", "name", "uuid"]; + if (textish.some((x) => t.includes(x))) return "contains"; + return "eq"; +} + +/** Build the update change payload for a single edited cell. */ +export function cellToUpdateChange(input: { + schema: string; table: string; + primaryKey: Record; + oldData: Record; + newData: Record; +}): { type: ChangeItemType; schema: string; table: string; + primaryKey: Record; + oldData: Record; + newData: Record } { + return { type: "update", ...input }; +} \ No newline at end of file diff --git a/src/components/grid/keyboardNav.test.ts b/src/components/grid/keyboardNav.test.ts new file mode 100644 index 0000000..b5798f6 --- /dev/null +++ b/src/components/grid/keyboardNav.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { nextCell } from "./keyboardNav"; + +describe("keyboardNav", () => { + it("ArrowRight moves right, clamps at last col", () => { + expect(nextCell({ row: 0, col: 0 }, "ArrowRight", 5, 3)).toEqual({ row: 0, col: 1 }); + expect(nextCell({ row: 0, col: 2 }, "ArrowRight", 5, 3)).toEqual({ row: 0, col: 2 }); + }); + it("ArrowLeft moves left, clamps at 0", () => { + expect(nextCell({ row: 1, col: 1 }, "ArrowLeft", 5, 3)).toEqual({ row: 1, col: 0 }); + expect(nextCell({ row: 1, col: 0 }, "ArrowLeft", 5, 3)).toEqual({ row: 1, col: 0 }); + }); + it("ArrowDown/ArrowUp move row, clamp", () => { + expect(nextCell({ row: 0, col: 1 }, "ArrowDown", 5, 3)).toEqual({ row: 1, col: 1 }); + expect(nextCell({ row: 4, col: 1 }, "ArrowDown", 5, 3)).toEqual({ row: 4, col: 1 }); + expect(nextCell({ row: 4, col: 1 }, "ArrowUp", 5, 3)).toEqual({ row: 3, col: 1 }); + }); + it("Tab wraps to next row; Shift+Tab wraps back", () => { + expect(nextCell({ row: 0, col: 2 }, "Tab", 5, 3)).toEqual({ row: 1, col: 0 }); + expect(nextCell({ row: 1, col: 0 }, "Shift+Tab", 5, 3)).toEqual({ row: 0, col: 2 }); + }); + it("unknown key returns same cell", () => { + expect(nextCell({ row: 1, col: 1 }, "x", 5, 3)).toEqual({ row: 1, col: 1 }); + }); +}); \ No newline at end of file diff --git a/src/components/grid/keyboardNav.ts b/src/components/grid/keyboardNav.ts new file mode 100644 index 0000000..3d45359 --- /dev/null +++ b/src/components/grid/keyboardNav.ts @@ -0,0 +1,26 @@ +export interface CellPos { row: number; col: number; } + +export function nextCell(pos: CellPos, key: string, rowCount: number, colCount: number): CellPos { + let { row, col } = pos; + switch (key) { + case "ArrowRight": + col = Math.min(col + 1, colCount - 1); break; + case "ArrowLeft": + col = Math.max(col - 1, 0); break; + case "ArrowDown": + row = Math.min(row + 1, rowCount - 1); break; + case "ArrowUp": + row = Math.max(row - 1, 0); break; + case "Tab": + if (col + 1 < colCount) col += 1; + else { col = 0; row = Math.min(row + 1, rowCount - 1); } + break; + case "Shift+Tab": + if (col - 1 >= 0) col -= 1; + else { col = colCount - 1; row = Math.max(row - 1, 0); } + break; + default: + return pos; + } + return { row, col }; +} \ No newline at end of file diff --git a/src/components/layout/ActionRow.test.tsx b/src/components/layout/ActionRow.test.tsx index f850767..c32bf82 100644 --- a/src/components/layout/ActionRow.test.tsx +++ b/src/components/layout/ActionRow.test.tsx @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ActionRow } from "./ActionRow"; import { useUiStore } from "../../stores/uiStore"; -beforeEach(() => useUiStore.setState({ activeView: "home" })); +beforeEach(() => useUiStore.setState({ activeView: "home", selectedItemIds: [] })); describe("ActionRow", () => { it("renders Saved Connections title", () => { @@ -21,4 +21,11 @@ describe("ActionRow", () => { await userEvent.click(screen.getByText(/settings/i)); expect(useUiStore.getState().activeView).toBe("settings"); }); + it("shows a Move to folder button when selection exists and calls onMoveToFolder", async () => { + useUiStore.setState({ selectedItemIds: ["c1"] }); + const onMoveToFolder = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: /move to folder/i })); + expect(onMoveToFolder).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/src/components/layout/ActionRow.tsx b/src/components/layout/ActionRow.tsx index 753eed4..d9f02cf 100644 --- a/src/components/layout/ActionRow.tsx +++ b/src/components/layout/ActionRow.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Plus, Settings as SettingsIcon, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react"; +import { Plus, Settings as SettingsIcon, FolderPlus, FolderInput, Trash2, Check, X, ChevronDown } from "lucide-react"; import { Button } from "../ui/Button"; import { useUiStore } from "../../stores/uiStore"; import { ImportExportMenu } from "./ImportExportMenu"; @@ -12,10 +12,11 @@ interface ActionRowProps { onNewFolder?: () => void; onFilters?: () => void; onDeleteSelected?: () => void; + onMoveToFolder?: () => void; visibleItemIds?: string[]; } -export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) { +export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, onMoveToFolder, visibleItemIds = [] }: ActionRowProps) { const setActiveView = useUiStore((s) => s.setActiveView); const openSettings = useUiStore((s) => s.openSettings); const selectedItemIds = useUiStore((s) => s.selectedItemIds); @@ -52,6 +53,11 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilte + {hasSelection && ( + + )} {hasSelection && (