v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL (#8)
* chore: bump version to 0.5.0 (Task 1) * feat(store): v7 migration — favorites + recent_connections (Task 2) * feat(models): add favorite to Connection + Store CRUD (Task 3) * feat(types): ColumnInfo editability + IndexInfo/ConstraintInfo/RecentConnection (Task 4) * chore: bump version to 0.5.0 (Task 1) — lockfile * feat(commands): typed wrappers for favorites/recents/indexes/constraints (Task 5) * feat(db): PG indexes/constraints queries + matview UNION in tables (Task 6) * feat(db): get_table_data editability flags + ctid/rowid locator (Task 7) * feat(db): execute_change no-PK locator guard + affected-count check (Task 8) * feat(db): preserve bigint precision as string on PG read path (Task 9) * feat(db): get_indexes / get_constraints commands (Task 10) * feat(store): favorites + recents Store methods (Task 11) * feat(commands): favorites/recents IPC + register indexes/constraints (Task 12) * feat(store): connectionStore favorites/recents/move-selection (Task 13) * feat(lib): recent-connections pure helpers (Task 14) * feat(store): dbViewerStore indexes/constraints + stageCellEdit (Task 15) * feat(grid): pure editability + filter-operator + cell transform (Task 16) * feat(grid): pure keyboard-nav helper (Task 17) * feat(grid): CellEditor inline editor (Task 18) * feat(grid): CellContextMenu + RowDetailDrawer (Task 19) * feat(grid): focus model + keyboard nav + inline edit + copy + context menu (Task 20) * feat(db-viewer): FilterBuilder drag-and-drop + type-aware operators (Task 21) * feat(db-viewer): ObjectExplorer indexes/constraints/procedures + matview icon (Task 22) * feat(home): ConnectionCard favorite star + on-demand StatusDot (Task 23) * feat(home): move-to-folder + recents strip + status wiring (Task 24) * feat(db-viewer): grid wiring + matview read-only + post-commit refetch (Task 25) * docs: v0.5.0 status + roadmap updates (Task 26) * polish: empty/error/loading states for v0.5.0 surfaces (Task 28) * feat(home): duplicateConnection + useConnectionStatus hook, drop StatusDot (FEAT-A) * feat(home): connection card kebab menu — favorite/test/manage (FEAT-B) * fix(home): populate server_version/latency_ms in test_connection + clean online display * style(home): swap grab handle and kebab positions on connection card * style(home): nudge kebab menu to right-1 * style(home): nudge kebab menu to right-0.5 * feat(home): Escape clears + exits focused search * feat(grid): context-menu View/Select Row, outside-click close, Esc cancels edit, FK reference (GRID-A) * feat(grid): smart CellEditor — enum select, FK searchable dropdown, textarea height (GRID-B) * feat(grid): enums + FK options fed into CellEditor (GRID-C) * feat(grid): FK dropdown display-column labels + placeholder + empty state * fix(grid): FK dropdown renders as fixed overlay to avoid clipping * fix(grid): portal FK dropdown to body + FK reference icon instead of click-to-open * style(grid): move FK reference icon to the start of the cell * feat(grid): optimistic staged cell values + pending dot, cleared on refetch * fix(grid): queue is source of truth for staged values — value diff, Clear All clears dots, same-cell edits replace * fix(db-viewer): type getLocator for staged-value matching * test(db-viewer): unit-test deriveStagedValues; fix activeTab null guard * fix(db-viewer): pass table prop to VirtualDataGrid — staged edits now carry the table name * test(db-viewer): use index access instead of .at() for TS lib target * feat(grid): FK dropdown options as one-row column cells (FK-reference style, cap 5) * style(grid): FK dropdown — values only, fixed 360px width, FK-viewer surface styling * style(grid): harden FK dropdown minWidth to 360px * style(grid): cap FK dropdown cells at 3 * style(grid): cap FK dropdown cells at 4 * fix(grid): pending dot clears on commit — values stay until refetch * fix(db): deserialize pg_attribute char columns as i8 — no more panic on get_table_data * docs: reflect grid interactivity, smart editors, optimistic queue, FK reference, kebab status
This commit is contained in:
@@ -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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Connection name editing | ✅ | Name field in GeneralTab edit form |
|
||||||
| Import/Export connections (JSON) | ✅ | Bulk import with validation, skipped-record reporting |
|
| Import/Export connections (JSON) | ✅ | Bulk import with validation, skipped-record reporting |
|
||||||
| Bulk select + delete connections/folders | ✅ | Checkbox selection with confirmation dialog |
|
| 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) |
|
| 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 |
|
| Inline tag creation | ✅ | "Create first tag" inline form (name + color) in SearchableTagPicker empty state |
|
||||||
| Move-to-folder bulk action | ❌ | |
|
| Move-to-folder bulk action | ✅ | Selection toolbar → Move to Folder dialog (folder picker, move confirmed via dialog) |
|
||||||
| Favorites / Recent connections | ❌ | |
|
| 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 | ❌ | |
|
| 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
|
### Database Viewer
|
||||||
| Feature | Status | Details |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
||||||
| Column show/hide | ✅ | Toggle visibility per column |
|
| Column show/hide | ✅ | Toggle visibility per column |
|
||||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
| 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) |
|
| 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 |
|
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||||
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
| 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 `<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. |
|
||||||
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
||||||
| Row detail / expandable row view | ❌ | |
|
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
||||||
| Keyboard cell navigation (arrow keys, Tab) | ❌ | |
|
| 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 |
|
||||||
| Cell-level copy (right-click or Ctrl+C) | ❌ | Only bulk copy via toolbar |
|
| Cell-level copy (right-click or Ctrl+C) | ✅ | CellContextMenu: Copy / Copy JSON (jsonb) / Edit / Set NULL / Open FK reference + **View Row** and **Select Row** items; right-click or Ctrl/Cmd+C on the focused cell; menu closes on outside click/Esc |
|
||||||
|
| FK reference | ✅ | Small ↗ icon at the start of FK cells opens the FK preview popover (also via context menu Open FK reference); plain click on the cell selects/edits and does not open it |
|
||||||
|
|
||||||
### Object Explorer (non-table objects)
|
### Object Explorer (non-table objects)
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -258,10 +261,10 @@ cargo test # Rust tests
|
|||||||
| Sequences | ✅ | Full detail view: current value, increment, start, min/max, cycle flag. Schema-filtered via information_schema.sequences. |
|
| Sequences | ✅ | Full detail view: current value, increment, start, min/max, cycle flag. Schema-filtered via information_schema.sequences. |
|
||||||
| Enums | ✅ | Full detail view: numbered bordered list matching Arguments style. Schema-filtered via pg_type WHERE typtype='e'. |
|
| Enums | ✅ | Full detail view: numbered bordered list matching Arguments style. Schema-filtered via pg_type WHERE typtype='e'. |
|
||||||
| Extensions | ✅ | Full detail view: version, schema, comment. Queried from pg_extension (no schema filter — extensions are DB-scoped). |
|
| Extensions | ✅ | Full detail view: version, schema, comment. Queried from pg_extension (no schema filter — extensions are DB-scoped). |
|
||||||
| Indexes (per table) | ❌ | |
|
| Indexes (per table) | ✅ | Per-table index list with columns, method, unique/partial flags (via pg_indexes) |
|
||||||
| Constraints (CHECK, UNIQUE beyond PK/FK) | ❌ | |
|
| Constraints (CHECK, UNIQUE beyond PK/FK) | ✅ | CHECK/UNIQUE constraints beyond PK/FK, introspected via information_schema |
|
||||||
| Materialized views | ❌ | Not distinguished from regular views |
|
| Materialized views | ✅ | Distinct icon in table tree, browsable, read-only (via pg_matviews) |
|
||||||
| Stored procedures | 🟡 | Included in Functions via p.prokind IN ('f','p'); no separate view yet |
|
| Stored procedures | ✅ | Procedures object type filters prokind='p'; Functions now filters kind='f' |
|
||||||
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
|
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
|
||||||
|
|
||||||
### Query Editor
|
### Query Editor
|
||||||
|
|||||||
@@ -20,11 +20,19 @@ Most database GUI clients either lock essential productivity features behind pay
|
|||||||
| pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** |
|
| pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** |
|
||||||
| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** |
|
| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** |
|
||||||
| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** |
|
| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** |
|
||||||
|
| Indexes, constraints, matviews, procedures | 🔒 paid | ❌ | **✅ Full object views** |
|
||||||
| ER diagram / schema visualizer | ❌ (planned) | ❌ (paid only) | **✅ Interactive React Flow** |
|
| ER diagram / schema visualizer | ❌ (planned) | ❌ (paid only) | **✅ Interactive React Flow** |
|
||||||
| Inline cell editing | ✅ | ✅ | 🟡 *Upcoming* |
|
| Inline cell editing | ✅ | ✅ | **✅ Inline editing** (double-click / Enter, ctid/rowid locator, stale-write guard; optimistic staged values + pending dot from the changes queue; smart editors — PG enum dropdowns and searchable FK dropdowns of referenced rows) |
|
||||||
|
| Keyboard cell navigation | ✅ | ✅ | **✅** (arrows + Tab wrap, Esc cancels editing) |
|
||||||
|
| Cell copy (right-click / Ctrl+C) | ✅ | ✅ | **✅** (context menu: Copy / Copy JSON / Edit / Set NULL / Open FK / View Row / Select Row) |
|
||||||
|
| Row detail drawer | ✅ | ✅ | **✅** (via context menu View Row) |
|
||||||
|
| Visual filter builder | ✅ | ✅ | **✅** (drag-and-drop, type-aware operators) |
|
||||||
| SSH tunneling | 🟡 (likely paid) | ✅ | **✅ Full tunnel (password + key auth, keychain)** |
|
| SSH tunneling | 🟡 (likely paid) | ✅ | **✅ Full tunnel (password + key auth, keychain)** |
|
||||||
| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** |
|
| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** |
|
||||||
| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** |
|
| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** |
|
||||||
|
| Favorites / Recent connections | ✅ | ✅ | **✅ Star + recents row** |
|
||||||
|
| Connection status indicator | ❌ | ✅ | **✅ On-demand click-to-test dot** |
|
||||||
|
| Bulk move-to-folder | ✅ | ✅ | **✅ Selection toolbar → folder picker** |
|
||||||
| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles a popover: Visual/SQL preview, per-change revert, Clear All, ⌘S commit) |
|
| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** (tab-bar **Changes** button with count badge toggles a popover: Visual/SQL preview, per-change revert, Clear All, ⌘S commit) |
|
||||||
| Query history | ✅ (auto-saved) | ✅ | **✅ Toolbar dropdown, favorites, Queries view** |
|
| Query history | ✅ (auto-saved) | ✅ | **✅ Toolbar dropdown, favorites, Queries view** |
|
||||||
| AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* |
|
| AI assistant | ✅ (BYO key) | ❌ (paid only) | 🔮 *Planned — BYOK* |
|
||||||
@@ -84,6 +92,9 @@ Most database GUI clients either lock essential productivity features behind pay
|
|||||||
- **URI Parser** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` connection strings and have all fields auto-populated
|
- **URI Parser** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` connection strings and have all fields auto-populated
|
||||||
- **Workspace Tree** — multi-level hierarchy: `Workspace → Folder → Connection`, with color-coded tags (red = Production, green = Local)
|
- **Workspace Tree** — multi-level hierarchy: `Workspace → Folder → Connection`, with color-coded tags (red = Production, green = Local)
|
||||||
- **Credential Security** — passwords stored in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager), never as plaintext
|
- **Credential Security** — passwords stored in the OS keychain (macOS Keychain / Linux Secret Service / Windows Credential Manager), never as plaintext
|
||||||
|
- **Favorites & Recent Connections** — star your go-to connections; the Home screen surfaces a Recent row
|
||||||
|
- **Connection Status Indicator** — on-demand per-card dot that tests the connection through the keychain + `testConnection` (idle/checking/online/offline)
|
||||||
|
- **Bulk Move-to-Folder** — select multiple connections and move them to a folder in one step
|
||||||
- **Production Safeguards** — read-only locks and high-visibility warnings on connections tagged `Production`
|
- **Production Safeguards** — read-only locks and high-visibility warnings on connections tagged `Production`
|
||||||
|
|
||||||
### PostgreSQL Object Explorer
|
### PostgreSQL Object Explorer
|
||||||
@@ -95,6 +106,9 @@ Full tree-view navigation of all native PostgreSQL schema objects:
|
|||||||
- **Extensions** — installed extensions with version, schema, and comment
|
- **Extensions** — installed extensions with version, schema, and comment
|
||||||
- **Schema Visualizer (ER Diagram)** — interactive React Flow graph with dagre auto-layout, crow's foot notation (1:1, 1:N, N:M), color-coded relationships, schema selector, zoom controls, collapsible columns (PK/FK/unique-only), cross-schema FK support for PostgreSQL + SQLite
|
- **Schema Visualizer (ER Diagram)** — interactive React Flow graph with dagre auto-layout, crow's foot notation (1:1, 1:N, N:M), color-coded relationships, schema selector, zoom controls, collapsible columns (PK/FK/unique-only), cross-schema FK support for PostgreSQL + SQLite
|
||||||
- **Unified Objects View** — Functions, Triggers, Sequences, Enums, and Extensions share a single sidebar with an object-type dropdown switcher (title position), refresh/search, and db/schema selectors
|
- **Unified Objects View** — Functions, Triggers, Sequences, Enums, and Extensions share a single sidebar with an object-type dropdown switcher (title position), refresh/search, and db/schema selectors
|
||||||
|
- **Indexes & Constraints** — per-table index list (columns, method, unique/partial flags) and CHECK/UNIQUE constraints beyond PK/FK
|
||||||
|
- **Materialized Views** — distinct icon in the table tree, browsable (read-only)
|
||||||
|
- **Stored Procedures** — dedicated Procedures object type (`prokind='p'`); Functions now filters `prokind='f'`
|
||||||
|
|
||||||
### SQL Editor & Query Workbench
|
### SQL Editor & Query Workbench
|
||||||
- **Monaco SQL Editor** — lazy-loaded [Monaco Editor](https://microsoft.github.io/monaco-editor/) with SQL syntax highlighting, Cmd/Ctrl+Enter to run
|
- **Monaco SQL Editor** — lazy-loaded [Monaco Editor](https://microsoft.github.io/monaco-editor/) with SQL syntax highlighting, Cmd/Ctrl+Enter to run
|
||||||
@@ -114,10 +128,14 @@ Full tree-view navigation of all native PostgreSQL schema objects:
|
|||||||
- **Column Management** — resize with drag handles (double-click to auto-fit), show/hide per column, multi-column sort
|
- **Column Management** — resize with drag handles (double-click to auto-fit), show/hide per column, multi-column sort
|
||||||
- **Server-Side Filtering & Sorting** — filters and sorts pushed to SQL WHERE/ORDER BY
|
- **Server-Side Filtering & Sorting** — filters and sorts pushed to SQL WHERE/ORDER BY
|
||||||
- **Export** — JSON, CSV, SQL, Markdown via toolbar
|
- **Export** — JSON, CSV, SQL, Markdown via toolbar
|
||||||
- **FK Preview** — click a foreign key cell to preview the referenced row
|
- **FK Preview** — ↗ icon at the start of FK cells (or context menu → Open FK reference) opens a popover with the referenced row; opens a filtered tab on demand
|
||||||
- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button
|
- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button
|
||||||
- **Auto-Refresh** — configurable interval timer
|
- **Auto-Refresh** — configurable interval timer
|
||||||
- *(Inline cell editing and visual filter builder — upcoming)*
|
- **Inline Cell Editing** — double-click/Enter to edit cells; changes stage through the queue → Commit All (ctid/rowid locator for no-PK tables, PK/generated/identity read-only, stale-write guard via affected-row-count). Cells show the staged value + an amber pending dot immediately (queue is the source of truth; dot clears on commit, values survive until refetch); queue cards show an old → new diff; re-editing a cell replaces its queue entry
|
||||||
|
- **Smart Cell Editors** — PG enum columns edit via a dropdown of enum labels; FK columns edit via a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling)
|
||||||
|
- **Keyboard Navigation** — arrow keys + Tab/Shift+Tab wrap; Esc cancels editing even when the editor has lost focus
|
||||||
|
- **Cell Copy + Context Menu** — right-click / Ctrl+C copy; menu items: Copy, Copy JSON, Edit, Set NULL, Open FK reference, View Row, Select Row; closes on outside click/Esc
|
||||||
|
- **Visual Filter Builder** — drag-and-drop column palette with type-aware operators (AND semantics, persists per tab)
|
||||||
|
|
||||||
### PostgreSQL Administrative Tools
|
### PostgreSQL Administrative Tools
|
||||||
- **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar
|
- **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar
|
||||||
@@ -240,15 +258,12 @@ gridline/
|
|||||||
- **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown)
|
- **Consolidated Navigation** — merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown)
|
||||||
- **Settings (Redesigned & Fully Wired)** — DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder
|
- **Settings (Redesigned & Fully Wired)** — DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder
|
||||||
- **Editor Settings, SSH/SSL Runtime, Data Import** — Monaco editor options (font size/family, word wrap, minimap, tab size) applied live; real SSH tunnel (`ssh2`, password + key auth, keychain secrets, full lifecycle) and TLS (`rustls`, all modes + client certs) for PostgreSQL/MySQL; CSV/JSON import with preview + column mapping through the changes queue; table-menu loose ends (Copy table schema DDL, Empty/Delete Table via queue, export stubs wired)
|
- **Editor Settings, SSH/SSL Runtime, Data Import** — Monaco editor options (font size/family, word wrap, minimap, tab size) applied live; real SSH tunnel (`ssh2`, password + key auth, keychain secrets, full lifecycle) and TLS (`rustls`, all modes + client certs) for PostgreSQL/MySQL; CSV/JSON import with preview + column mapping through the changes queue; table-menu loose ends (Copy table schema DDL, Empty/Delete Table via queue, export stubs wired)
|
||||||
|
- **v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL** — inline cell editing (ctid/rowid locator, stale-write guard), keyboard navigation, cell copy + context menu, row-detail drawer, visual filter builder, bulk move-to-folder, favorites + recents, on-demand connection status indicator, PG Indexes/Constraints/Materialized Views/Stored Procedures object views; version 0.5.0.
|
||||||
### 🟡 In Progress / Upcoming
|
|
||||||
- **Inline Cell Editing** — Edit cells directly in the data grid
|
|
||||||
- **Visual Filter Builder** — drag-and-drop filter construction
|
|
||||||
|
|
||||||
### 🔮 Future
|
### 🔮 Future
|
||||||
- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL
|
- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL
|
||||||
- **Query Workbench** — Multiple result sets, visual query builder
|
- **Query Workbench** — Multiple result sets, visual query builder
|
||||||
- **Deeper PostgreSQL** — Indexes, constraints, materialized views, stored procedure view, user/role management
|
- **Deeper PostgreSQL** — user/role management, replication
|
||||||
- **Notebook Reports** — SQL-backed markdown reports with embedded results
|
- **Notebook Reports** — SQL-backed markdown reports with embedded results
|
||||||
- **AI Integration (BYOK)** — Bring-Your-Own-Key AI assistant: natural-language → SQL generation, query explanations, schema summaries, error suggestions. Key stored in OS keychain; only user's chosen provider sees SQL/text.
|
- **AI Integration (BYOK)** — Bring-Your-Own-Key AI assistant: natural-language → SQL generation, query explanations, schema summaries, error suggestions. Key stored in OS keychain; only user's chosen provider sees SQL/text.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gridline",
|
"name": "gridline",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.5.0",
|
||||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1783,7 +1783,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.2.0"
|
version = "0.5.0"
|
||||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -122,6 +122,66 @@ pub fn add_connection_tags(
|
|||||||
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
|
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_connection_favorite_inner(
|
||||||
|
state: &Mutex<Store>,
|
||||||
|
connection_id: String,
|
||||||
|
favorite: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let store = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
store.set_connection_favorite(&connection_id, favorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_recent_connection_inner(
|
||||||
|
state: &Mutex<Store>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let store = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
store.record_recent_connection(&connection_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_recent_connections_inner(
|
||||||
|
state: &Mutex<Store>,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<crate::models::RecentConnection>, String> {
|
||||||
|
let store = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
store.get_recent_connections(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_recent_connections_inner(state: &Mutex<Store>) -> Result<(), String> {
|
||||||
|
let store = state.lock().map_err(|e| e.to_string())?;
|
||||||
|
store.clear_recent_connections()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_connection_favorite(
|
||||||
|
state: tauri::State<crate::AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
favorite: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
set_connection_favorite_inner(&state.db_store, connection_id, favorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn record_recent_connection(
|
||||||
|
state: tauri::State<crate::AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
record_recent_connection_inner(&state.db_store, connection_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_recent_connections(
|
||||||
|
state: tauri::State<crate::AppState>,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<crate::models::RecentConnection>, String> {
|
||||||
|
get_recent_connections_inner(&state.db_store, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_recent_connections(state: tauri::State<crate::AppState>) -> Result<(), String> {
|
||||||
|
clear_recent_connections_inner(&state.db_store)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -232,4 +292,70 @@ mod tests {
|
|||||||
delete_connection_inner(&st, &conn.id).unwrap();
|
delete_connection_inner(&st, &conn.id).unwrap();
|
||||||
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
|
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_connection_favorite_command_persists() {
|
||||||
|
let st = state();
|
||||||
|
let input = ConnectionInput {
|
||||||
|
name: "P".into(),
|
||||||
|
db_type: "postgresql".into(),
|
||||||
|
host: "h".into(),
|
||||||
|
port: Some(5432),
|
||||||
|
username: None,
|
||||||
|
folder_id: None,
|
||||||
|
tag_ids: vec![],
|
||||||
|
password: None,
|
||||||
|
database: None,
|
||||||
|
environment: None,
|
||||||
|
ssh_host: None,
|
||||||
|
ssh_port: None,
|
||||||
|
ssh_user: None,
|
||||||
|
ssh_auth_method: None,
|
||||||
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
|
ssh_passphrase: None,
|
||||||
|
ssl_mode: None,
|
||||||
|
ssl_ca_path: None,
|
||||||
|
ssl_cert_path: None,
|
||||||
|
ssl_key_path: None,
|
||||||
|
};
|
||||||
|
let conn = create_connection_inner(&st, input).unwrap();
|
||||||
|
set_connection_favorite_inner(&st, conn.id.clone(), true).unwrap();
|
||||||
|
assert_eq!(get_connections_inner(&st).unwrap()[0].favorite, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_recent_command_upserts() {
|
||||||
|
let st = state();
|
||||||
|
let input = ConnectionInput {
|
||||||
|
name: "P".into(),
|
||||||
|
db_type: "postgresql".into(),
|
||||||
|
host: "h".into(),
|
||||||
|
port: Some(5432),
|
||||||
|
username: None,
|
||||||
|
folder_id: None,
|
||||||
|
tag_ids: vec![],
|
||||||
|
password: None,
|
||||||
|
database: None,
|
||||||
|
environment: None,
|
||||||
|
ssh_host: None,
|
||||||
|
ssh_port: None,
|
||||||
|
ssh_user: None,
|
||||||
|
ssh_auth_method: None,
|
||||||
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
|
ssh_passphrase: None,
|
||||||
|
ssl_mode: None,
|
||||||
|
ssl_ca_path: None,
|
||||||
|
ssl_cert_path: None,
|
||||||
|
ssl_key_path: None,
|
||||||
|
};
|
||||||
|
let conn = create_connection_inner(&st, input).unwrap();
|
||||||
|
record_recent_connection_inner(&st, conn.id.clone()).unwrap();
|
||||||
|
record_recent_connection_inner(&st, conn.id.clone()).unwrap(); // idempotent upsert
|
||||||
|
let recent = get_recent_connections_inner(&st, 10).unwrap();
|
||||||
|
assert_eq!(recent.len(), 1);
|
||||||
|
clear_recent_connections_inner(&st).unwrap();
|
||||||
|
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
use crate::db::pool::{DbConfig, DbHandle};
|
use crate::db::pool::{DbConfig, DbHandle};
|
||||||
use crate::models::db_viewer::{
|
use crate::models::db_viewer::{
|
||||||
Change, ColumnInfo, EnumInfo, ExtensionInfo, FunctionInfo, QueryResult,
|
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo,
|
||||||
SequenceInfo, TableInfo, TriggerInfo,
|
IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -90,6 +90,40 @@ pub fn offset(page: i64, page_size: i64) -> i64 {
|
|||||||
(page - 1) * page_size
|
(page - 1) * page_size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split a `pg_get_indexdef(...,0,true)` / `pg_attribute` column CSV into a
|
||||||
|
/// Vec, trimming whitespace. Splits on commas that are NOT inside parens
|
||||||
|
/// (to keep expression-index columns intact).
|
||||||
|
pub(crate) fn split_columns_csv(csv: &str) -> Vec<String> {
|
||||||
|
let csv = csv.trim();
|
||||||
|
if csv.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut buf = String::new();
|
||||||
|
for ch in csv.chars() {
|
||||||
|
match ch {
|
||||||
|
'(' => {
|
||||||
|
depth += 1;
|
||||||
|
buf.push(ch);
|
||||||
|
}
|
||||||
|
')' => {
|
||||||
|
depth -= 1;
|
||||||
|
buf.push(ch);
|
||||||
|
}
|
||||||
|
',' if depth == 0 => {
|
||||||
|
out.push(buf.trim().to_string());
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
_ => buf.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !buf.trim().is_empty() {
|
||||||
|
out.push(buf.trim().to_string());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Table DDL helpers
|
// Table DDL helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -275,6 +309,66 @@ fn build_order_clause(sorts: &[crate::models::db_viewer::SortRule]) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row-locator / editability helpers (Task 7)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Decide editability from pg_attribute flags: `attgenerated` ('' or 's'/'v')
|
||||||
|
/// and `attidentity` ('' or 'a'='ALWAYS' / 'd'='BY DEFAULT').
|
||||||
|
/// Generated (stored) columns and IDENTITY ALWAYS columns are non-editable.
|
||||||
|
pub(crate) fn editable_from_att(attgenerated: &str, attidentity: &str) -> bool {
|
||||||
|
attgenerated.is_empty() && attidentity != "a"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert pg_attribute's internal "char" (i8, OID 18) to the 1-char string
|
||||||
|
/// used by `editable_from_att`: '' = not set, 's' = STORED, 'v' = VIRTUAL,
|
||||||
|
/// 'a' = ALWAYS, 'd' = BY DEFAULT. `None`/`\0` → "" (safe, no panic).
|
||||||
|
pub(crate) fn pg_char_to_att(value: Option<i8>) -> String {
|
||||||
|
match value.and_then(|c| char::from_u32(c as u32)) {
|
||||||
|
Some(c) if c != '\0' => c.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble a PG SELECT statement from pre-formatted select items (already
|
||||||
|
/// quoted and optionally `::text`-cast), appending `ctid` when the table has
|
||||||
|
/// no primary key so later UPDATE/DELETE queue changes can target the exact
|
||||||
|
/// row. `ctid` is appended last so it does not shift visible column order.
|
||||||
|
fn build_pg_select_from_items(schema: &str, table: &str, items: Vec<String>, has_pk: bool) -> String {
|
||||||
|
let mut all_cols = items;
|
||||||
|
if !has_pk {
|
||||||
|
all_cols.push("ctid".to_string());
|
||||||
|
}
|
||||||
|
format!("SELECT {} FROM \"{}\".\"{}\"", all_cols.join(", "), schema, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the PG data SELECT, appending `ctid` only when the table has no PK.
|
||||||
|
pub(crate) fn build_pg_data_select(
|
||||||
|
schema: &str,
|
||||||
|
table: &str,
|
||||||
|
visible_cols: &[String],
|
||||||
|
has_pk: bool,
|
||||||
|
) -> String {
|
||||||
|
let base_cols: Vec<String> = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect();
|
||||||
|
build_pg_select_from_items(schema, table, base_cols, has_pk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SQLite data SELECT, appending `rowid` only when the table has no
|
||||||
|
/// PK. The table is unqualified; SQLite browsing in this app is always scoped
|
||||||
|
/// to the `main` schema, where an unqualified name resolves identically.
|
||||||
|
pub(crate) fn build_sqlite_data_select(
|
||||||
|
table: &str,
|
||||||
|
visible_cols: &[String],
|
||||||
|
has_pk: bool,
|
||||||
|
) -> String {
|
||||||
|
let base_cols: Vec<String> = visible_cols.iter().map(|c| format!("\"{}\"", c)).collect();
|
||||||
|
let mut all_cols = base_cols;
|
||||||
|
if !has_pk {
|
||||||
|
all_cols.push("rowid".to_string());
|
||||||
|
}
|
||||||
|
format!("SELECT {} FROM \"{}\"", all_cols.join(", "), table)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a parameterized UPDATE SQL statement.
|
/// Build a parameterized UPDATE SQL statement.
|
||||||
///
|
///
|
||||||
/// The returned SQL uses `?` placeholders for both the SET values and the
|
/// The returned SQL uses `?` placeholders for both the SET values and the
|
||||||
@@ -289,7 +383,10 @@ pub fn build_update_sql(
|
|||||||
table: &str,
|
table: &str,
|
||||||
primary_key: &[(String, serde_json::Value)],
|
primary_key: &[(String, serde_json::Value)],
|
||||||
new_data: &[(String, serde_json::Value)],
|
new_data: &[(String, serde_json::Value)],
|
||||||
) -> String {
|
) -> Result<(String, Vec<serde_json::Value>), String> {
|
||||||
|
if primary_key.is_empty() {
|
||||||
|
return Err("cannot update a row without a primary key or row locator".to_string());
|
||||||
|
}
|
||||||
let set_clause: Vec<String> = new_data
|
let set_clause: Vec<String> = new_data
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(col, _)| format!("\"{}\" = ?", col))
|
.map(|(col, _)| format!("\"{}\" = ?", col))
|
||||||
@@ -298,13 +395,22 @@ pub fn build_update_sql(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(col, _)| format!("\"{}\" = ?", col))
|
.map(|(col, _)| format!("\"{}\" = ?", col))
|
||||||
.collect();
|
.collect();
|
||||||
|
// Params must follow placeholder order: SET values first, then WHERE.
|
||||||
|
let params: Vec<serde_json::Value> = new_data
|
||||||
|
.iter()
|
||||||
|
.chain(primary_key.iter())
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.collect();
|
||||||
|
Ok((
|
||||||
format!(
|
format!(
|
||||||
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
|
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
|
||||||
schema,
|
schema,
|
||||||
table,
|
table,
|
||||||
set_clause.join(", "),
|
set_clause.join(", "),
|
||||||
where_clause.join(" AND ")
|
where_clause.join(" AND ")
|
||||||
)
|
),
|
||||||
|
params,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a parameterized DELETE SQL statement.
|
/// Build a parameterized DELETE SQL statement.
|
||||||
@@ -317,17 +423,23 @@ pub fn build_delete_sql(
|
|||||||
schema: &str,
|
schema: &str,
|
||||||
table: &str,
|
table: &str,
|
||||||
primary_key: &[(String, serde_json::Value)],
|
primary_key: &[(String, serde_json::Value)],
|
||||||
) -> String {
|
) -> Result<(String, Vec<serde_json::Value>), String> {
|
||||||
|
if primary_key.is_empty() {
|
||||||
|
return Err("cannot delete a row without a primary key or row locator".to_string());
|
||||||
|
}
|
||||||
let where_clause: Vec<String> = primary_key
|
let where_clause: Vec<String> = primary_key
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(col, _)| format!("\"{}\" = ?", col))
|
.map(|(col, _)| format!("\"{}\" = ?", col))
|
||||||
.collect();
|
.collect();
|
||||||
|
Ok((
|
||||||
format!(
|
format!(
|
||||||
"DELETE FROM \"{}\".\"{}\" WHERE {}",
|
"DELETE FROM \"{}\".\"{}\" WHERE {}",
|
||||||
schema,
|
schema,
|
||||||
table,
|
table,
|
||||||
where_clause.join(" AND ")
|
where_clause.join(" AND ")
|
||||||
)
|
),
|
||||||
|
primary_key.iter().map(|(_, v)| v.clone()).collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a parameterized INSERT SQL statement.
|
/// Build a parameterized INSERT SQL statement.
|
||||||
@@ -367,7 +479,10 @@ pub fn build_pg_update_sql(
|
|||||||
table: &str,
|
table: &str,
|
||||||
primary_key: &[(String, serde_json::Value)],
|
primary_key: &[(String, serde_json::Value)],
|
||||||
new_data: &[(String, serde_json::Value)],
|
new_data: &[(String, serde_json::Value)],
|
||||||
) -> (String, Vec<serde_json::Value>) {
|
) -> Result<(String, Vec<serde_json::Value>), String> {
|
||||||
|
if primary_key.is_empty() {
|
||||||
|
return Err("cannot update a row without a primary key or row locator".to_string());
|
||||||
|
}
|
||||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||||
let set_clause: Vec<String> = new_data
|
let set_clause: Vec<String> = new_data
|
||||||
.iter()
|
.iter()
|
||||||
@@ -383,7 +498,7 @@ pub fn build_pg_update_sql(
|
|||||||
format!("\"{}\" = ${}", col, params.len())
|
format!("\"{}\" = ${}", col, params.len())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
(
|
Ok((
|
||||||
format!(
|
format!(
|
||||||
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
|
"UPDATE \"{}\".\"{}\" SET {} WHERE {}",
|
||||||
schema,
|
schema,
|
||||||
@@ -392,7 +507,7 @@ pub fn build_pg_update_sql(
|
|||||||
where_clause.join(" AND ")
|
where_clause.join(" AND ")
|
||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a PostgreSQL DELETE statement.
|
/// Build a PostgreSQL DELETE statement.
|
||||||
@@ -400,7 +515,10 @@ pub fn build_pg_delete_sql(
|
|||||||
schema: &str,
|
schema: &str,
|
||||||
table: &str,
|
table: &str,
|
||||||
primary_key: &[(String, serde_json::Value)],
|
primary_key: &[(String, serde_json::Value)],
|
||||||
) -> (String, Vec<serde_json::Value>) {
|
) -> Result<(String, Vec<serde_json::Value>), String> {
|
||||||
|
if primary_key.is_empty() {
|
||||||
|
return Err("cannot delete a row without a primary key or row locator".to_string());
|
||||||
|
}
|
||||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||||
let where_clause: Vec<String> = primary_key
|
let where_clause: Vec<String> = primary_key
|
||||||
.iter()
|
.iter()
|
||||||
@@ -409,7 +527,7 @@ pub fn build_pg_delete_sql(
|
|||||||
format!("\"{}\" = ${}", col, params.len())
|
format!("\"{}\" = ${}", col, params.len())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
(
|
Ok((
|
||||||
format!(
|
format!(
|
||||||
"DELETE FROM \"{}\".\"{}\" WHERE {}",
|
"DELETE FROM \"{}\".\"{}\" WHERE {}",
|
||||||
schema,
|
schema,
|
||||||
@@ -417,7 +535,7 @@ pub fn build_pg_delete_sql(
|
|||||||
where_clause.join(" AND ")
|
where_clause.join(" AND ")
|
||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a PostgreSQL INSERT statement.
|
/// Build a PostgreSQL INSERT statement.
|
||||||
@@ -661,13 +779,20 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize an i64 as a JSON string to preserve precision across the IPC
|
||||||
|
/// boundary (JS `Number` loses integer fidelity beyond 2^53). The frontend
|
||||||
|
/// treats numeric columns as strings for edit round-trips.
|
||||||
|
pub(crate) fn i64_to_json(v: i64) -> serde_json::Value {
|
||||||
|
serde_json::Value::String(v.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value {
|
pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value {
|
||||||
// Integer types
|
// Integer types
|
||||||
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
|
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
|
||||||
return serde_json::json!(v);
|
return serde_json::json!(v);
|
||||||
}
|
}
|
||||||
if let Ok(Some(v)) = row.try_get::<_, Option<i64>>(i) {
|
if let Ok(Some(v)) = row.try_get::<_, Option<i64>>(i) {
|
||||||
return serde_json::json!(v);
|
return i64_to_json(v);
|
||||||
}
|
}
|
||||||
if let Ok(Some(v)) = row.try_get::<_, Option<i16>>(i) {
|
if let Ok(Some(v)) = row.try_get::<_, Option<i16>>(i) {
|
||||||
return serde_json::json!(v);
|
return serde_json::json!(v);
|
||||||
@@ -1024,7 +1149,9 @@ pub async fn get_table_data(
|
|||||||
COALESCE(fk.is_fk, false) AS is_fk,
|
COALESCE(fk.is_fk, false) AS is_fk,
|
||||||
fk.foreign_table_name,
|
fk.foreign_table_name,
|
||||||
fk.foreign_column_name,
|
fk.foreign_column_name,
|
||||||
c.column_default
|
c.column_default,
|
||||||
|
a.attgenerated,
|
||||||
|
a.attidentity
|
||||||
FROM information_schema.columns c
|
FROM information_schema.columns c
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT ku.column_name, true AS is_pk
|
SELECT ku.column_name, true AS is_pk
|
||||||
@@ -1056,6 +1183,11 @@ LEFT JOIN (
|
|||||||
AND tc.table_schema = $1
|
AND tc.table_schema = $1
|
||||||
AND tc.table_name = $2
|
AND tc.table_name = $2
|
||||||
) fk ON c.column_name = fk.column_name
|
) fk ON c.column_name = fk.column_name
|
||||||
|
LEFT JOIN pg_attribute a
|
||||||
|
ON a.attrelid = (quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass
|
||||||
|
AND a.attname = c.column_name
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped
|
||||||
WHERE c.table_schema = $1 AND c.table_name = $2
|
WHERE c.table_schema = $1 AND c.table_name = $2
|
||||||
ORDER BY c.ordinal_position"#;
|
ORDER BY c.ordinal_position"#;
|
||||||
let col_rows = client
|
let col_rows = client
|
||||||
@@ -1068,11 +1200,21 @@ ORDER BY c.ordinal_position"#;
|
|||||||
let is_fk: bool = r.get(4);
|
let is_fk: bool = r.get(4);
|
||||||
let fk_table: Option<String> = r.get(5);
|
let fk_table: Option<String> = r.get(5);
|
||||||
let fk_column: Option<String> = r.get(6);
|
let fk_column: Option<String> = r.get(6);
|
||||||
|
// pg_attribute.attgenerated/attidentity are PG's internal
|
||||||
|
// "char" type (OID 18) → tokio-postgres delivers i8, not
|
||||||
|
// String; deserializing as String panics. Convert safely.
|
||||||
|
let attgenerated = pg_char_to_att(
|
||||||
|
r.try_get::<_, Option<i8>>(8).unwrap_or(None),
|
||||||
|
);
|
||||||
|
let attidentity = pg_char_to_att(
|
||||||
|
r.try_get::<_, Option<i8>>(9).unwrap_or(None),
|
||||||
|
);
|
||||||
|
let is_pk: bool = r.get(3);
|
||||||
ColumnInfo {
|
ColumnInfo {
|
||||||
name: r.get(0),
|
name: r.get(0),
|
||||||
data_type: r.get(1),
|
data_type: r.get(1),
|
||||||
is_nullable: r.get::<_, String>(2) == "YES",
|
is_nullable: r.get::<_, String>(2) == "YES",
|
||||||
is_pk: r.get(3),
|
is_pk,
|
||||||
is_fk,
|
is_fk,
|
||||||
fk_ref: if is_fk {
|
fk_ref: if is_fk {
|
||||||
Some((fk_table.unwrap_or_default(), fk_column.unwrap_or_default()))
|
Some((fk_table.unwrap_or_default(), fk_column.unwrap_or_default()))
|
||||||
@@ -1080,6 +1222,8 @@ ORDER BY c.ordinal_position"#;
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
default_value: r.get::<_, Option<String>>(7),
|
default_value: r.get::<_, Option<String>>(7),
|
||||||
|
editable: editable_from_att(&attgenerated, &attidentity) && !is_pk,
|
||||||
|
is_generated: !attgenerated.is_empty(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1098,7 +1242,8 @@ ORDER BY c.ordinal_position"#;
|
|||||||
"timestamp without time zone", "timestamp with time zone",
|
"timestamp without time zone", "timestamp with time zone",
|
||||||
"time without time zone", "time with time zone",
|
"time without time zone", "time with time zone",
|
||||||
];
|
];
|
||||||
let select_cols: Vec<String> = columns
|
let has_pk = columns.iter().any(|c| c.is_pk);
|
||||||
|
let select_items: Vec<String> = columns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
let lower = c.data_type.to_lowercase();
|
let lower = c.data_type.to_lowercase();
|
||||||
@@ -1110,10 +1255,12 @@ ORDER BY c.ordinal_position"#;
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
// `ctid` is appended last for no-PK tables so later UPDATE/DELETE
|
||||||
|
// queue changes can target the exact row. It stays out of `columns`.
|
||||||
let data_query = format!(
|
let data_query = format!(
|
||||||
"SELECT {} FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}",
|
"{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
|
||||||
select_cols.join(", "),
|
build_pg_select_from_items(&schema, &table, select_items, has_pk),
|
||||||
schema, table, filter_clause, order_clause, ps, off
|
filter_clause, order_clause, ps, off
|
||||||
);
|
);
|
||||||
let data_rows = if filter_params.is_empty() {
|
let data_rows = if filter_params.is_empty() {
|
||||||
client
|
client
|
||||||
@@ -1207,14 +1354,21 @@ ORDER BY c.ordinal_position"#;
|
|||||||
is_fk: fk.is_some(),
|
is_fk: fk.is_some(),
|
||||||
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
|
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
|
||||||
default_value: default_val.clone(),
|
default_value: default_val.clone(),
|
||||||
|
editable: !*is_pk,
|
||||||
|
is_generated: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Get data (with filters and sorts applied)
|
// Get data (with filters and sorts applied).
|
||||||
|
// `rowid` is appended last for no-PK tables so later UPDATE/DELETE
|
||||||
|
// queue changes can target the exact row. It stays out of `columns`.
|
||||||
|
let visible_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
|
||||||
|
let has_pk = columns.iter().any(|c| c.is_pk);
|
||||||
let data_query = format!(
|
let data_query = format!(
|
||||||
"SELECT * FROM \"{}\".\"{}\" WHERE 1=1{} {} LIMIT {} OFFSET {}",
|
"{} WHERE 1=1{} {} LIMIT {} OFFSET {}",
|
||||||
schema, table, filter_clause, order_clause, ps, off
|
build_sqlite_data_select(&table, &visible_names, has_pk),
|
||||||
|
filter_clause, order_clause, ps, off
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?;
|
let mut stmt = conn.prepare(&data_query).map_err(|e| e.to_string())?;
|
||||||
let col_count = stmt.column_count();
|
let col_count = stmt.column_count();
|
||||||
@@ -1334,6 +1488,8 @@ ORDER BY c.ordinal_position"#;
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
default_value: r.get::<_, Option<String>>(7),
|
default_value: r.get::<_, Option<String>>(7),
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1411,6 +1567,8 @@ ORDER BY c.ordinal_position"#;
|
|||||||
is_fk: fk.is_some(),
|
is_fk: fk.is_some(),
|
||||||
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
|
fk_ref: fk.map(|(t, c)| (t.clone(), c.clone())),
|
||||||
default_value: default_val.clone(),
|
default_value: default_val.clone(),
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1447,6 +1605,16 @@ ORDER BY c.ordinal_position"#;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a tokio_postgres/rusqlite affected-row count to a friendly error.
|
||||||
|
/// Exactly 1 -> Ok (None). 0 -> stale; >1 -> ambiguous.
|
||||||
|
pub(crate) fn affected_count_error(n: u64) -> Option<String> {
|
||||||
|
match n {
|
||||||
|
0 => Some("row was modified or removed by another session".to_string()),
|
||||||
|
1 => None,
|
||||||
|
_ => Some("ambiguous row match".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn execute_change(
|
pub async fn execute_change(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -1467,7 +1635,7 @@ pub async fn execute_change(
|
|||||||
} => {
|
} => {
|
||||||
let pk = parse_json_pairs(primary_key)?;
|
let pk = parse_json_pairs(primary_key)?;
|
||||||
let data = parse_json_pairs(new_data)?;
|
let data = parse_json_pairs(new_data)?;
|
||||||
build_pg_update_sql(schema, table, &pk, &data)
|
build_pg_update_sql(schema, table, &pk, &data)?
|
||||||
}
|
}
|
||||||
Change::Insert {
|
Change::Insert {
|
||||||
schema,
|
schema,
|
||||||
@@ -1485,7 +1653,7 @@ pub async fn execute_change(
|
|||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let pk = parse_json_pairs(primary_key)?;
|
let pk = parse_json_pairs(primary_key)?;
|
||||||
build_pg_delete_sql(schema, table, &pk)
|
build_pg_delete_sql(schema, table, &pk)?
|
||||||
}
|
}
|
||||||
Change::AlterTable { sql, .. } => {
|
Change::AlterTable { sql, .. } => {
|
||||||
// Execute the raw DDL directly; no bound parameters.
|
// Execute the raw DDL directly; no bound parameters.
|
||||||
@@ -1536,7 +1704,13 @@ pub async fn execute_change(
|
|||||||
r
|
r
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
client.execute(&sql, &refs).await.map_err(|e| e.to_string())?;
|
let n = client
|
||||||
|
.execute(&sql, &refs)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(msg) = affected_count_error(n) {
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
|
Some(crate::db::pool::DbHandle::Sqlite(conn)) => {
|
||||||
@@ -1550,13 +1724,7 @@ pub async fn execute_change(
|
|||||||
} => {
|
} => {
|
||||||
let pk = parse_json_pairs(primary_key)?;
|
let pk = parse_json_pairs(primary_key)?;
|
||||||
let data = parse_json_pairs(new_data)?;
|
let data = parse_json_pairs(new_data)?;
|
||||||
(
|
build_update_sql(schema, table, &pk, &data)?
|
||||||
build_update_sql(schema, table, &pk, &data),
|
|
||||||
pk.iter()
|
|
||||||
.chain(data.iter())
|
|
||||||
.map(|(_, v)| v.clone())
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Change::Insert {
|
Change::Insert {
|
||||||
schema,
|
schema,
|
||||||
@@ -1579,10 +1747,7 @@ pub async fn execute_change(
|
|||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let pk = parse_json_pairs(primary_key)?;
|
let pk = parse_json_pairs(primary_key)?;
|
||||||
(
|
build_delete_sql(schema, table, &pk)?
|
||||||
build_delete_sql(schema, table, &pk),
|
|
||||||
pk.iter().map(|(_, v)| v.clone()).collect(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Change::AlterTable { sql, .. } => {
|
Change::AlterTable { sql, .. } => {
|
||||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||||
@@ -1612,8 +1777,12 @@ pub async fn execute_change(
|
|||||||
|
|
||||||
let sqlite_params: Vec<rusqlite::types::Value> =
|
let sqlite_params: Vec<rusqlite::types::Value> =
|
||||||
params.iter().map(json_to_sqlite_value).collect();
|
params.iter().map(json_to_sqlite_value).collect();
|
||||||
conn.execute(&sql, rusqlite::params_from_iter(sqlite_params))
|
let n = conn
|
||||||
|
.execute(&sql, rusqlite::params_from_iter(sqlite_params))
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(msg) = affected_count_error(n as u64) {
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
None => Err("Connection not found".to_string()),
|
None => Err("Connection not found".to_string()),
|
||||||
@@ -1678,6 +1847,85 @@ pub async fn get_functions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_indexes(
|
||||||
|
connection_id: String,
|
||||||
|
schema: Option<String>,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<Vec<IndexInfo>, String> {
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
match pm.get(&connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => {
|
||||||
|
let schema = schema.unwrap_or_else(|| "public".to_string());
|
||||||
|
let query = crate::db::introspection::pg_indexes_query(&schema);
|
||||||
|
let rows = client
|
||||||
|
.query(&query, &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| IndexInfo {
|
||||||
|
name: r.get(0),
|
||||||
|
schema: r.get(1),
|
||||||
|
table: r.get(2),
|
||||||
|
definition: r.get(3),
|
||||||
|
is_unique: r.get(4),
|
||||||
|
method: r.get::<_, Option<String>>(5).unwrap_or_default(),
|
||||||
|
columns: split_columns_csv(&r.get::<_, Option<String>>(6).unwrap_or_default()),
|
||||||
|
size_bytes: r.get::<_, Option<i64>>(7),
|
||||||
|
tablespace: r.get::<_, Option<String>>(8),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_constraints(
|
||||||
|
connection_id: String,
|
||||||
|
schema: Option<String>,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<Vec<ConstraintInfo>, String> {
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
match pm.get(&connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => {
|
||||||
|
let schema = schema.unwrap_or_else(|| "public".to_string());
|
||||||
|
let query = crate::db::introspection::pg_constraints_query(&schema);
|
||||||
|
let rows = client
|
||||||
|
.query(&query, &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
// contype::text decodes as a String ("c" | "u" | "x").
|
||||||
|
let contype = match r.get::<_, Option<String>>(3).unwrap_or_default().as_str() {
|
||||||
|
"c" => "CHECK",
|
||||||
|
"u" => "UNIQUE",
|
||||||
|
"x" => "EXCLUSION",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
ConstraintInfo {
|
||||||
|
name: r.get(0),
|
||||||
|
schema: r.get(1),
|
||||||
|
table: r.get(2),
|
||||||
|
contype,
|
||||||
|
definition: r.get(4),
|
||||||
|
deferrable: r.get(5),
|
||||||
|
validated: r.get(6),
|
||||||
|
columns: split_columns_csv(&r.get::<_, Option<String>>(7).unwrap_or_default()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
Some(DbHandle::Sqlite(_)) => Ok(vec![]),
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_triggers(
|
pub async fn get_triggers(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -1877,6 +2125,28 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::models::db_viewer::Change;
|
use crate::models::db_viewer::Change;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_columns_csv_handles_commas_and_trims() {
|
||||||
|
assert_eq!(split_columns_csv("id, name, created_at"), vec!["id", "name", "created_at"]);
|
||||||
|
assert_eq!(split_columns_csv("id"), vec!["id"]);
|
||||||
|
assert_eq!(split_columns_csv(""), Vec::<String>::new());
|
||||||
|
// expression index column list may include parens — keep raw, just split on top-level commas
|
||||||
|
assert_eq!(split_columns_csv("lower(name), id"), vec!["lower(name)", "id"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bigint precision: values beyond 2^53 must round-trip as strings.
|
||||||
|
#[test]
|
||||||
|
fn i64_preserves_precision_as_string() {
|
||||||
|
// A bigint beyond 2^53 must round-trip as a string, not a JS number.
|
||||||
|
let big: i64 = 9_007_199_254_740_993; // 2^53 + 1
|
||||||
|
let v = i64_to_json(big);
|
||||||
|
assert_eq!(v, serde_json::Value::String("9007199254740993".to_string()),
|
||||||
|
"bigint must be a string to avoid float precision loss");
|
||||||
|
let small: i64 = 42;
|
||||||
|
let v2 = i64_to_json(small);
|
||||||
|
assert_eq!(v2, serde_json::Value::String("42".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify the `offset` helper produces correct pagination offsets.
|
/// Verify the `offset` helper produces correct pagination offsets.
|
||||||
#[test]
|
#[test]
|
||||||
fn pagination_offset_is_correct() {
|
fn pagination_offset_is_correct() {
|
||||||
@@ -1930,7 +2200,7 @@ mod tests {
|
|||||||
("email".to_string(), serde_json::json!("bob@example.com")),
|
("email".to_string(), serde_json::json!("bob@example.com")),
|
||||||
];
|
];
|
||||||
|
|
||||||
let sql = build_update_sql("public", "users", &pk, &data);
|
let (sql, _params) = build_update_sql("public", "users", &pk, &data).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
sql.to_uppercase().contains("UPDATE"),
|
sql.to_uppercase().contains("UPDATE"),
|
||||||
@@ -1955,7 +2225,7 @@ mod tests {
|
|||||||
fn build_change_delete_sql_is_valid() {
|
fn build_change_delete_sql_is_valid() {
|
||||||
let pk = vec![("id".to_string(), serde_json::json!(1))];
|
let pk = vec![("id".to_string(), serde_json::json!(1))];
|
||||||
|
|
||||||
let sql = build_delete_sql("public", "users", &pk);
|
let (sql, _params) = build_delete_sql("public", "users", &pk).unwrap();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
sql.to_uppercase().contains("DELETE FROM"),
|
sql.to_uppercase().contains("DELETE FROM"),
|
||||||
@@ -2116,4 +2386,90 @@ mod tests {
|
|||||||
r#"DELETE FROM "public"."users""#
|
r#"DELETE FROM "public"."users""#
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Row-locator / editability helpers (Task 7)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editable_pg_column_flags_mark_generated_and_identity_always() {
|
||||||
|
// generated STORED ('s') -> not editable; identity ALWAYS ('a') -> not editable
|
||||||
|
assert!(!editable_from_att("s", ""));
|
||||||
|
assert!(!editable_from_att("", "a"));
|
||||||
|
// plain column -> editable
|
||||||
|
assert!(editable_from_att("", ""));
|
||||||
|
// identity BY DEFAULT ('d') -> editable
|
||||||
|
assert!(editable_from_att("", "d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_char_to_att_maps_internal_char_codes_safely() {
|
||||||
|
// pg_attribute "char" arrives as i8; None/\0 -> "", codes -> 1-char string
|
||||||
|
assert_eq!(pg_char_to_att(None), "");
|
||||||
|
assert_eq!(pg_char_to_att(Some(0)), "");
|
||||||
|
assert_eq!(pg_char_to_att(Some(b's' as i8)), "s");
|
||||||
|
assert_eq!(pg_char_to_att(Some(b'v' as i8)), "v");
|
||||||
|
assert_eq!(pg_char_to_att(Some(b'a' as i8)), "a");
|
||||||
|
assert_eq!(pg_char_to_att(Some(b'd' as i8)), "d");
|
||||||
|
// wiring: a STORED generated column must be non-editable through the helper
|
||||||
|
assert!(!editable_from_att(&pg_char_to_att(Some(b's' as i8)), ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_locator_select_adds_ctid() {
|
||||||
|
let sql = build_pg_data_select("public", "no_pk", &["id".into(), "name".into()], false);
|
||||||
|
assert!(sql.contains("ctid"), "no-PK table must select ctid; got: {}", sql);
|
||||||
|
assert!(sql.contains("\"public\""), "schema must be quoted; got: {}", sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_locator_select_omits_ctid_when_pk_present() {
|
||||||
|
let sql = build_pg_data_select("public", "with_pk", &["id".into(), "name".into()], true);
|
||||||
|
assert!(!sql.contains("ctid"), "PK table must NOT select ctid; got: {}", sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_locator_select_adds_rowid_for_no_pk() {
|
||||||
|
let sql = build_sqlite_data_select("no_pk", &["id".into(), "name".into()], false);
|
||||||
|
assert!(sql.contains("rowid"), "no-PK sqlite table must select rowid; got: {}", sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// No-PK row locator updates + affected-row-count guard (Task 8)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_update_with_locator_uses_it_in_where() {
|
||||||
|
// The frontend supplies ctid as the "primary_key" pair for no-PK rows.
|
||||||
|
let locator = vec![("ctid".to_string(), serde_json::json!("(0,1)"))];
|
||||||
|
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
|
||||||
|
let (sql, params) = build_pg_update_sql("public", "no_pk", &locator, &data).unwrap();
|
||||||
|
assert!(sql.contains("\"ctid\" = $"), "locator update must WHERE on ctid; got: {}", sql);
|
||||||
|
assert_eq!(params.len(), 2); // 1 SET value + 1 WHERE value
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_update_with_pk_uses_pk_where() {
|
||||||
|
let pk = vec![("id".to_string(), serde_json::json!(1))];
|
||||||
|
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
|
||||||
|
let (sql, _params) = build_pg_update_sql("public", "users", &pk, &data).unwrap();
|
||||||
|
assert!(sql.contains("\"id\" = $"), "PK update must WHERE on id; got: {}", sql);
|
||||||
|
assert!(!sql.contains("ctid"), "PK update must NOT use ctid; got: {}", sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_update_with_empty_primary_key_is_rejected() {
|
||||||
|
// Defense-in-depth: an empty locator must NOT yield `UPDATE ... WHERE `.
|
||||||
|
let pk: Vec<(String, serde_json::Value)> = vec![];
|
||||||
|
let data = vec![("name".to_string(), serde_json::json!("Bob"))];
|
||||||
|
let result = build_pg_update_sql("public", "no_pk", &pk, &data);
|
||||||
|
assert!(result.is_err(), "empty primary_key must be rejected, not produce broken SQL");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn affected_row_count_message_for_zero_rows() {
|
||||||
|
assert_eq!(affected_count_error(0u64), Some("row was modified or removed by another session".to_string()));
|
||||||
|
assert_eq!(affected_count_error(1u64), None);
|
||||||
|
assert_eq!(affected_count_error(2u64), Some("ambiguous row match".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -243,6 +243,8 @@ async fn execute_pg_query(
|
|||||||
is_fk: false,
|
is_fk: false,
|
||||||
fk_ref: None,
|
fk_ref: None,
|
||||||
default_value: None,
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
None => {
|
None => {
|
||||||
@@ -309,6 +311,8 @@ async fn execute_pg_raw(
|
|||||||
is_fk: false,
|
is_fk: false,
|
||||||
fk_ref: None,
|
fk_ref: None,
|
||||||
default_value: None,
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
saw_columns = true;
|
saw_columns = true;
|
||||||
@@ -432,6 +436,8 @@ fn execute_sqlite_with_query(
|
|||||||
is_fk: false,
|
is_fk: false,
|
||||||
fk_ref: None,
|
fk_ref: None,
|
||||||
default_value: None,
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pub struct TestConnectionResult {
|
|||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub server_version: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub latency_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip credentials and sensitive information from error messages while
|
/// Strip credentials and sensitive information from error messages while
|
||||||
@@ -212,6 +216,8 @@ pub async fn test_database_connection(
|
|||||||
return TestConnectionResult {
|
return TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(err),
|
error: Some(err),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,12 +229,16 @@ pub async fn test_database_connection(
|
|||||||
other => TestConnectionResult {
|
other => TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(format!("unsupported database type: {other}")),
|
error: Some(format!("unsupported database type: {other}")),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
TestConnectionResult {
|
TestConnectionResult {
|
||||||
ok: result.ok,
|
ok: result.ok,
|
||||||
error: result.error.map(|e| sanitize_error(&e)),
|
error: result.error.map(|e| sanitize_error(&e)),
|
||||||
|
server_version: result.server_version,
|
||||||
|
latency_ms: result.latency_ms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +255,14 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
|
|||||||
|
|
||||||
let target = match resolve_connect_target(config, ssh, 5432).await {
|
let target = match resolve_connect_target(config, ssh, 5432).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
Err(e) => {
|
||||||
|
return TestConnectionResult {
|
||||||
|
ok: false,
|
||||||
|
error: Some(e),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||||
@@ -263,7 +280,12 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
|
|||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
return TestConnectionResult { ok: false, error: Some(e) };
|
return TestConnectionResult {
|
||||||
|
ok: false,
|
||||||
|
error: Some(e),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,6 +300,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
|
|||||||
.dbname(dbname)
|
.dbname(dbname)
|
||||||
.connect_timeout(std::time::Duration::from_secs(10));
|
.connect_timeout(std::time::Duration::from_secs(10));
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
let result = match tls {
|
let result = match tls {
|
||||||
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
|
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
|
||||||
Some(cc) => {
|
Some(cc) => {
|
||||||
@@ -286,19 +309,33 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
|
|||||||
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
|
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let latency_ms = Some(start.elapsed().as_millis() as u64);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((_client, _handle)) => {
|
Ok((client, _handle)) => {
|
||||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
// Spawn the connection handler so it keeps running while we test.
|
// Best-effort server version from the live client; None if the
|
||||||
// (Already spawned inside `connect_pg_with`.)
|
// query fails. The driver task is already spawned inside
|
||||||
TestConnectionResult { ok: true, error: None }
|
// `connect_pg_with`, so the client is fully usable here.
|
||||||
|
let server_version = client
|
||||||
|
.query_one("SELECT current_setting('server_version')", &[])
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|row| row.try_get::<_, String>(0).ok());
|
||||||
|
TestConnectionResult {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
server_version,
|
||||||
|
latency_ms,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
TestConnectionResult {
|
TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +352,14 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
|
|
||||||
let target = match resolve_connect_target(config, ssh, 3306).await {
|
let target = match resolve_connect_target(config, ssh, 3306).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
Err(e) => {
|
||||||
|
return TestConnectionResult {
|
||||||
|
ok: false,
|
||||||
|
error: Some(e),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut opts = MySqlConnectOptions::new()
|
let mut opts = MySqlConnectOptions::new()
|
||||||
@@ -351,6 +395,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
match MySqlPoolOptions::new()
|
match MySqlPoolOptions::new()
|
||||||
.max_connections(1)
|
.max_connections(1)
|
||||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||||
@@ -359,14 +404,28 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
{
|
{
|
||||||
Ok(pool) => {
|
Ok(pool) => {
|
||||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
|
// Best-effort server version; None if the query fails.
|
||||||
|
let server_version =
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT VERSION()")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
let latency_ms = Some(start.elapsed().as_millis() as u64);
|
||||||
pool.close().await;
|
pool.close().await;
|
||||||
TestConnectionResult { ok: true, error: None }
|
TestConnectionResult {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
server_version,
|
||||||
|
latency_ms,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
TestConnectionResult {
|
TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,11 +436,27 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
/// Opens the database file at `config.host`. Returns success if the file
|
/// Opens the database file at `config.host`. Returns success if the file
|
||||||
/// can be opened as a valid SQLite database.
|
/// can be opened as a valid SQLite database.
|
||||||
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
|
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
match rusqlite::Connection::open(&config.host) {
|
match rusqlite::Connection::open(&config.host) {
|
||||||
Ok(_conn) => TestConnectionResult { ok: true, error: None },
|
Ok(conn) => {
|
||||||
|
// Best-effort server version; None if the query fails.
|
||||||
|
let server_version = conn
|
||||||
|
.query_row("SELECT sqlite_version()", [], |r| {
|
||||||
|
r.get::<_, String>(0)
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
TestConnectionResult {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
server_version,
|
||||||
|
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => TestConnectionResult {
|
Err(e) => TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,7 +471,14 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
|
|
||||||
let target = match resolve_connect_target(config, ssh, 6379).await {
|
let target = match resolve_connect_target(config, ssh, 6379).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
Err(e) => {
|
||||||
|
return TestConnectionResult {
|
||||||
|
ok: false,
|
||||||
|
error: Some(e),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let password = config.password.as_deref();
|
let password = config.password.as_deref();
|
||||||
|
|
||||||
@@ -406,6 +488,7 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
format!("redis://{}:{}/", target.host, target.port)
|
format!("redis://{}:{}/", target.host, target.port)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
match redis::Client::open(conn_str.as_str()) {
|
match redis::Client::open(conn_str.as_str()) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
match timeout(
|
match timeout(
|
||||||
@@ -416,13 +499,20 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
{
|
{
|
||||||
Ok(Ok(_conn)) => {
|
Ok(Ok(_conn)) => {
|
||||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
TestConnectionResult { ok: true, error: None }
|
TestConnectionResult {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||||
TestConnectionResult {
|
TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -430,6 +520,8 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
TestConnectionResult {
|
TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some("connection timed out after 10 seconds".to_string()),
|
error: Some("connection timed out after 10 seconds".to_string()),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,6 +531,8 @@ async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
|
|||||||
TestConnectionResult {
|
TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,18 +560,47 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_connection_result_serialization() {
|
fn test_connection_result_serialization() {
|
||||||
// ok=true result serializes correctly
|
// ok=true result with server_version/latency serializes all fields
|
||||||
let result = TestConnectionResult { ok: true, error: None };
|
let result = TestConnectionResult {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
server_version: Some("15.2".to_string()),
|
||||||
|
latency_ms: Some(12),
|
||||||
|
};
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
assert!(json.contains("\"ok\":true"), "ok=true should appear in JSON");
|
assert!(
|
||||||
|
json.contains("\"ok\":true"),
|
||||||
|
"ok=true should appear in JSON: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"server_version\":\"15.2\""),
|
||||||
|
"server_version should appear in JSON: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.contains("\"latency_ms\":12"),
|
||||||
|
"latency_ms should appear in JSON: {json}"
|
||||||
|
);
|
||||||
|
|
||||||
// error result includes the error message
|
// error result includes the error message; None fields are skipped
|
||||||
let result = TestConnectionResult {
|
let result = TestConnectionResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some("connection refused".to_string()),
|
error: Some("connection refused".to_string()),
|
||||||
|
server_version: None,
|
||||||
|
latency_ms: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&result).unwrap();
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
assert!(json.contains("\"connection refused\""), "error message should appear in JSON");
|
assert!(
|
||||||
|
json.contains("\"connection refused\""),
|
||||||
|
"error message should appear in JSON: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!json.contains("server_version"),
|
||||||
|
"None server_version should be skipped: {json}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!json.contains("latency_ms"),
|
||||||
|
"None latency_ms should be skipped: {json}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -26,12 +26,19 @@ pub fn pg_tables_query(schema: Option<&str>) -> String {
|
|||||||
match schema {
|
match schema {
|
||||||
Some(s) => format!(
|
Some(s) => format!(
|
||||||
"SELECT table_name, table_type FROM information_schema.tables \
|
"SELECT table_name, table_type FROM information_schema.tables \
|
||||||
WHERE table_schema = '{}' ORDER BY table_name",
|
WHERE table_schema = '{}' \
|
||||||
s
|
UNION ALL \
|
||||||
|
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type \
|
||||||
|
FROM pg_matviews WHERE schemaname = '{}' \
|
||||||
|
ORDER BY table_name",
|
||||||
|
s, s
|
||||||
),
|
),
|
||||||
None => {
|
None => {
|
||||||
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
|
"SELECT table_name, table_type, table_schema FROM information_schema.tables \
|
||||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
|
||||||
|
UNION ALL \
|
||||||
|
SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, schemaname AS table_schema \
|
||||||
|
FROM pg_matviews WHERE schemaname NOT IN ('pg_catalog', 'information_schema') \
|
||||||
ORDER BY table_schema, table_name"
|
ORDER BY table_schema, table_name"
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
@@ -285,6 +292,61 @@ pub fn pg_extensions_query() -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query indexes in a schema.
|
||||||
|
///
|
||||||
|
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
||||||
|
/// uniqueness, access method, columns CSV, size in bytes, and tablespace.
|
||||||
|
pub fn pg_indexes_query(_schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT \
|
||||||
|
i.relname AS index_name, \
|
||||||
|
ns.nspname AS schema, \
|
||||||
|
t.relname AS table_name, \
|
||||||
|
pg_get_indexdef(ix.indexrelid) AS definition, \
|
||||||
|
ix.indisunique AS is_unique, \
|
||||||
|
am.amname AS method, \
|
||||||
|
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
|
||||||
|
FROM unnest(ix.indkey) WITH ORDINALITY AS ord(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ord.attnum) AS columns, \
|
||||||
|
pg_relation_size(i.oid) AS size_bytes, \
|
||||||
|
ts.spcname AS tablespace \
|
||||||
|
FROM pg_index ix \
|
||||||
|
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||||
|
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||||
|
JOIN pg_namespace ns ON t.relnamespace = ns.oid \
|
||||||
|
JOIN pg_am am ON i.relam = am.oid \
|
||||||
|
LEFT JOIN pg_tablespace ts ON i.reltablespace = ts.oid \
|
||||||
|
WHERE ns.nspname = $1 \
|
||||||
|
ORDER BY i.relname"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query CHECK / UNIQUE / EXCLUSION constraints in a schema.
|
||||||
|
///
|
||||||
|
/// Primary and foreign keys are intentionally excluded — they surface in the
|
||||||
|
/// table grid. Returns name, schema, table, contype, definition
|
||||||
|
/// (`pg_get_constraintdef`), deferrability, validation, and columns CSV.
|
||||||
|
pub fn pg_constraints_query(_schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT \
|
||||||
|
c.conname AS name, \
|
||||||
|
ns.nspname AS schema, \
|
||||||
|
cl.relname AS table_name, \
|
||||||
|
c.contype::text, \
|
||||||
|
pg_get_constraintdef(c.oid) AS definition, \
|
||||||
|
c.condeferrable, \
|
||||||
|
c.convalidated, \
|
||||||
|
(SELECT string_agg(a.attname, ', ' ORDER BY ord.ord) \
|
||||||
|
FROM unnest(c.conkey) WITH ORDINALITY AS ord(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ord.attnum) AS columns \
|
||||||
|
FROM pg_constraint c \
|
||||||
|
JOIN pg_class cl ON c.conrelid = cl.oid \
|
||||||
|
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
|
||||||
|
WHERE ns.nspname = $1 AND c.contype IN ('c', 'u', 'x') \
|
||||||
|
ORDER BY c.conname"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -365,6 +427,49 @@ mod tests {
|
|||||||
assert!(sql.contains("datistemplate"));
|
assert!(sql.contains("datistemplate"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_indexes_query_is_parameterized_and_joins() {
|
||||||
|
let sql = pg_indexes_query("public");
|
||||||
|
assert!(sql.contains("$1"), "schema must be parameterized; got: {}", sql);
|
||||||
|
assert!(
|
||||||
|
sql.contains("pg_indexes") || sql.contains("pg_index"),
|
||||||
|
"should query pg_index; got: {}",
|
||||||
|
sql
|
||||||
|
);
|
||||||
|
assert!(sql.contains("pg_get_indexdef"), "should include index definition");
|
||||||
|
assert!(sql.contains("indisunique"), "should include uniqueness");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_constraints_query_filters_check_unique_exclusion() {
|
||||||
|
let sql = pg_constraints_query("public");
|
||||||
|
assert!(sql.contains("$1"), "schema must be parameterized; got: {}", sql);
|
||||||
|
assert!(
|
||||||
|
sql.contains("pg_constraint"),
|
||||||
|
"should query pg_constraint; got: {}",
|
||||||
|
sql
|
||||||
|
);
|
||||||
|
assert!(sql.contains("contype"), "should select contype");
|
||||||
|
assert!(sql.contains("'c'"), "should filter CHECK ('c')");
|
||||||
|
assert!(sql.contains("'u'"), "should filter UNIQUE ('u')");
|
||||||
|
assert!(sql.contains("'x'"), "should filter EXCLUSION ('x')");
|
||||||
|
assert!(sql.contains("pg_get_constraintdef"), "should include definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_tables_query_includes_materialized_views() {
|
||||||
|
let sql = pg_tables_query(Some("public"));
|
||||||
|
assert!(
|
||||||
|
sql.contains("pg_matviews"),
|
||||||
|
"matview UNION must source pg_matviews; got: {}",
|
||||||
|
sql,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sql.contains("MATERIALIZED VIEW"),
|
||||||
|
"should label materialized views"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// MySQL
|
// MySQL
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ pub fn run() {
|
|||||||
connections::update_connection,
|
connections::update_connection,
|
||||||
connections::delete_connection,
|
connections::delete_connection,
|
||||||
connections::add_connection_tags,
|
connections::add_connection_tags,
|
||||||
|
connections::set_connection_favorite,
|
||||||
|
connections::record_recent_connection,
|
||||||
|
connections::get_recent_connections,
|
||||||
|
connections::clear_recent_connections,
|
||||||
folders::get_folders,
|
folders::get_folders,
|
||||||
folders::create_folder,
|
folders::create_folder,
|
||||||
folders::delete_folder,
|
folders::delete_folder,
|
||||||
@@ -107,6 +111,8 @@ pub fn run() {
|
|||||||
db_viewer::get_sequences,
|
db_viewer::get_sequences,
|
||||||
db_viewer::get_enums,
|
db_viewer::get_enums,
|
||||||
db_viewer::get_extensions,
|
db_viewer::get_extensions,
|
||||||
|
db_viewer::get_indexes,
|
||||||
|
db_viewer::get_constraints,
|
||||||
keychain::save_connection_password,
|
keychain::save_connection_password,
|
||||||
keychain::get_connection_password,
|
keychain::get_connection_password,
|
||||||
keychain::delete_connection_password,
|
keychain::delete_connection_password,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct Connection {
|
|||||||
pub folder_id: Option<String>,
|
pub folder_id: Option<String>,
|
||||||
pub keychain_ref: Option<String>,
|
pub keychain_ref: Option<String>,
|
||||||
pub environment: Option<String>,
|
pub environment: Option<String>,
|
||||||
|
pub favorite: bool,
|
||||||
pub ssh_host: Option<String>,
|
pub ssh_host: Option<String>,
|
||||||
pub ssh_port: Option<i64>,
|
pub ssh_port: Option<i64>,
|
||||||
pub ssh_user: Option<String>,
|
pub ssh_user: Option<String>,
|
||||||
@@ -118,6 +119,7 @@ mod tests {
|
|||||||
folder_id: Some("folder".to_string()),
|
folder_id: Some("folder".to_string()),
|
||||||
keychain_ref: Some("keychain-ref".to_string()),
|
keychain_ref: Some("keychain-ref".to_string()),
|
||||||
environment: None,
|
environment: None,
|
||||||
|
favorite: false,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
@@ -136,4 +138,20 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&conn).unwrap();
|
let json = serde_json::to_string(&conn).unwrap();
|
||||||
assert!(!json.contains("password"), "Connection JSON should not contain password field");
|
assert!(!json.contains("password"), "Connection JSON should not contain password field");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_serializes_favorite_field() {
|
||||||
|
let conn = Connection {
|
||||||
|
id: "x".into(), name: "n".into(), db_type: "postgresql".into(),
|
||||||
|
host: "h".into(), port: Some(5432), username: None, database: None,
|
||||||
|
folder_id: None, keychain_ref: None, environment: None,
|
||||||
|
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
|
||||||
|
ssh_private_key_path: None, ssl_mode: None, ssl_ca_path: None,
|
||||||
|
ssl_cert_path: None, ssl_key_path: None, tag_ids: vec![],
|
||||||
|
favorite: true,
|
||||||
|
created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&conn).unwrap();
|
||||||
|
assert!(json.contains("\"favorite\":true"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,33 @@ pub struct ColumnInfo {
|
|||||||
pub is_fk: bool,
|
pub is_fk: bool,
|
||||||
pub fk_ref: Option<(String, String)>,
|
pub fk_ref: Option<(String, String)>,
|
||||||
pub default_value: Option<String>,
|
pub default_value: Option<String>,
|
||||||
|
pub editable: bool,
|
||||||
|
pub is_generated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IndexInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub definition: String,
|
||||||
|
pub is_unique: bool,
|
||||||
|
pub method: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub size_bytes: Option<i64>,
|
||||||
|
pub tablespace: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConstraintInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub schema: String,
|
||||||
|
pub table: String,
|
||||||
|
pub contype: String, // "CHECK" | "UNIQUE" | "EXCLUSION"
|
||||||
|
pub definition: String,
|
||||||
|
pub deferrable: bool,
|
||||||
|
pub validated: bool,
|
||||||
|
pub columns: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -338,12 +365,26 @@ mod tests {
|
|||||||
is_fk: true,
|
is_fk: true,
|
||||||
fk_ref: Some(("users".to_string(), "id".to_string())),
|
fk_ref: Some(("users".to_string(), "id".to_string())),
|
||||||
default_value: None,
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&col).unwrap();
|
let json = serde_json::to_string(&col).unwrap();
|
||||||
assert!(json.contains("user_id"));
|
assert!(json.contains("user_id"));
|
||||||
assert!(json.contains("users"));
|
assert!(json.contains("users"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn column_info_has_editability_fields() {
|
||||||
|
let c = ColumnInfo {
|
||||||
|
name: "id".into(), data_type: "integer".into(), is_nullable: false,
|
||||||
|
is_pk: true, is_fk: false, fk_ref: None, default_value: None,
|
||||||
|
editable: false, is_generated: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&c).unwrap();
|
||||||
|
assert!(json.contains("\"editable\":false"));
|
||||||
|
assert!(json.contains("\"is_generated\":false"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pagination_serialization() {
|
fn pagination_serialization() {
|
||||||
let pagination = Pagination {
|
let pagination = Pagination {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ pub mod backup;
|
|||||||
pub mod connection;
|
pub mod connection;
|
||||||
pub mod db_viewer;
|
pub mod db_viewer;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
|
pub mod recent;
|
||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
pub use connection::{Connection, ConnectionInput};
|
pub use connection::{Connection, ConnectionInput};
|
||||||
|
pub use recent::RecentConnection;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
|
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
|
||||||
pub use folder::{Folder, FolderInput};
|
pub use folder::{Folder, FolderInput};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RecentConnection {
|
||||||
|
pub connection_id: String,
|
||||||
|
pub opened_at: String,
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ const CONNECTION_COLUMNS_V3: &[(&str, &str)] = &[
|
|||||||
("environment", "TEXT"),
|
("environment", "TEXT"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// New columns added in version 7.
|
||||||
|
const CONNECTION_COLUMNS_V7: &[(&str, &str)] = &[
|
||||||
|
("favorite", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
];
|
||||||
|
|
||||||
pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
||||||
@@ -219,6 +224,42 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v7: connection favorites + recent_connections
|
||||||
|
if current_ver < 7 {
|
||||||
|
let existing: Vec<String> = {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("PRAGMA table_info(connections)")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (col_name, col_type) in CONNECTION_COLUMNS_V7 {
|
||||||
|
if !existing.contains(&col_name.to_string()) {
|
||||||
|
let sql = format!(
|
||||||
|
"ALTER TABLE connections ADD COLUMN {} {}",
|
||||||
|
col_name, col_type
|
||||||
|
);
|
||||||
|
conn.execute(&sql, []).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS recent_connections (
|
||||||
|
connection_id TEXT PRIMARY KEY REFERENCES connections(id) ON DELETE CASCADE,
|
||||||
|
opened_at TEXT NOT NULL
|
||||||
|
);"
|
||||||
|
).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_version (version) VALUES (7)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +310,7 @@ mod tests {
|
|||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 5);
|
assert_eq!(count, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -408,13 +449,77 @@ mod tests {
|
|||||||
fn v6_bumps_schema_version_to_6() {
|
fn v6_bumps_schema_version_to_6() {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
run_migrations(&conn).unwrap();
|
run_migrations(&conn).unwrap();
|
||||||
let ver: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT MAX(version) FROM schema_version",
|
"SELECT COUNT(*) FROM schema_version WHERE version = 6",
|
||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ver, 6, "Schema version should be 6 after v6 migration");
|
assert_eq!(count, 1, "Schema version 6 should be recorded after v6 migration");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v7_adds_favorite_column_to_connections() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let columns: Vec<String> = {
|
||||||
|
let mut stmt = conn.prepare("PRAGMA table_info(connections)").unwrap();
|
||||||
|
let rows = stmt.query_map([], |row| row.get::<_, String>(1)).unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
columns.contains(&"favorite".to_string()),
|
||||||
|
"Expected connections to have a 'favorite' column after v7"
|
||||||
|
);
|
||||||
|
let conn_id = "fav-test";
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
|
||||||
|
rusqlite::params![conn_id],
|
||||||
|
).unwrap();
|
||||||
|
let fav: i64 = conn.query_row(
|
||||||
|
"SELECT favorite FROM connections WHERE id = ?1", rusqlite::params![conn_id], |r| r.get(0),
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(fav, 0, "favorite defaults to 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v7_creates_recent_connections_table() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM recent_connections", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v7_recent_connections_cascade_on_connection_delete() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let conn_id = "rc-cascade";
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO connections (id, name, db_type, host, port, created_at, updated_at) VALUES (?1, 't', 'postgresql', 'h', 5432, datetime('now'), datetime('now'))",
|
||||||
|
rusqlite::params![conn_id],
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, datetime('now'))",
|
||||||
|
rusqlite::params![conn_id],
|
||||||
|
).unwrap();
|
||||||
|
conn.execute("DELETE FROM connections WHERE id = ?1", rusqlite::params![conn_id]).unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM recent_connections WHERE connection_id = ?1", rusqlite::params![conn_id], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v7_bumps_schema_version_to_7() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let ver: i64 = conn
|
||||||
|
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ver, 7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+110
-3
@@ -248,7 +248,7 @@ impl Store {
|
|||||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at FROM connections ORDER BY name",
|
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at FROM connections ORDER BY name",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
@@ -273,9 +273,10 @@ impl Store {
|
|||||||
ssl_cert_path: row.get(16)?,
|
ssl_cert_path: row.get(16)?,
|
||||||
ssl_key_path: row.get(17)?,
|
ssl_key_path: row.get(17)?,
|
||||||
environment: row.get(18)?,
|
environment: row.get(18)?,
|
||||||
|
favorite: row.get(19)?,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
created_at: row.get(19)?,
|
created_at: row.get(20)?,
|
||||||
updated_at: row.get(20)?,
|
updated_at: row.get(21)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -320,6 +321,7 @@ impl Store {
|
|||||||
database: input.database,
|
database: input.database,
|
||||||
keychain_ref: None,
|
keychain_ref: None,
|
||||||
environment: input.environment,
|
environment: input.environment,
|
||||||
|
favorite: false,
|
||||||
ssh_host: input.ssh_host,
|
ssh_host: input.ssh_host,
|
||||||
ssh_port: input.ssh_port,
|
ssh_port: input.ssh_port,
|
||||||
ssh_user: input.ssh_user,
|
ssh_user: input.ssh_user,
|
||||||
@@ -335,6 +337,64 @@ impl Store {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_connection_favorite(&self, id: &str, favorite: bool) -> Result<(), String> {
|
||||||
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
|
let fav: i64 = if favorite { 1 } else { 0 };
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE connections SET favorite = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
params![fav, Self::now(), id],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_recent_connections(
|
||||||
|
&self,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<crate::models::RecentConnection>, String> {
|
||||||
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT connection_id, opened_at FROM recent_connections ORDER BY opened_at DESC LIMIT ?1",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![limit], |row| {
|
||||||
|
Ok(crate::models::RecentConnection {
|
||||||
|
connection_id: row.get(0)?,
|
||||||
|
opened_at: row.get(1)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_recent_connection(&self, connection_id: &str) -> Result<(), String> {
|
||||||
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
|
let now = Self::now();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO recent_connections (connection_id, opened_at) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(connection_id) DO UPDATE SET opened_at = excluded.opened_at",
|
||||||
|
params![connection_id, now],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM recent_connections WHERE connection_id NOT IN (
|
||||||
|
SELECT connection_id FROM recent_connections ORDER BY opened_at DESC LIMIT 20
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_recent_connections(&self) -> Result<(), String> {
|
||||||
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
|
conn.execute("DELETE FROM recent_connections", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_connection(&self, id: &str) -> Result<(), String> {
|
pub fn delete_connection(&self, id: &str) -> Result<(), String> {
|
||||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
|
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
|
||||||
@@ -375,6 +435,7 @@ impl Store {
|
|||||||
folder_id: input.folder_id,
|
folder_id: input.folder_id,
|
||||||
keychain_ref: None,
|
keychain_ref: None,
|
||||||
environment: input.environment,
|
environment: input.environment,
|
||||||
|
favorite: false,
|
||||||
ssh_host: input.ssh_host,
|
ssh_host: input.ssh_host,
|
||||||
ssh_port: input.ssh_port,
|
ssh_port: input.ssh_port,
|
||||||
ssh_user: input.ssh_user,
|
ssh_user: input.ssh_user,
|
||||||
@@ -1282,6 +1343,52 @@ mod tests {
|
|||||||
assert!(result.is_err(), "Unknown id should be an error");
|
assert!(result.is_err(), "Unknown id should be an error");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_connection_favorite_toggles_and_persists() {
|
||||||
|
let store = fresh_store();
|
||||||
|
let conn = create_test_connection(&store, "fav-conn");
|
||||||
|
store.set_connection_favorite(&conn.id, true).unwrap();
|
||||||
|
let conns = store.get_connections().unwrap();
|
||||||
|
assert_eq!(conns[0].favorite, true);
|
||||||
|
store.set_connection_favorite(&conn.id, false).unwrap();
|
||||||
|
assert_eq!(store.get_connections().unwrap()[0].favorite, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_recent_connection_upserts_and_caps_at_20() {
|
||||||
|
let store = fresh_store();
|
||||||
|
for i in 0..25 {
|
||||||
|
let conn = create_test_connection(&store, &format!("c{}", i));
|
||||||
|
store.record_recent_connection(&conn.id).unwrap();
|
||||||
|
}
|
||||||
|
let recent = store.get_recent_connections(100).unwrap();
|
||||||
|
assert_eq!(recent.len(), 20, "recent list capped at 20");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_recent_connection_dedupes_and_bumps_to_top() {
|
||||||
|
let store = fresh_store();
|
||||||
|
let a = create_test_connection(&store, "a");
|
||||||
|
let b = create_test_connection(&store, "b");
|
||||||
|
store.record_recent_connection(&a.id).unwrap();
|
||||||
|
store.record_recent_connection(&b.id).unwrap();
|
||||||
|
store.record_recent_connection(&a.id).unwrap(); // a re-opened -> should be most recent
|
||||||
|
let recent = store.get_recent_connections(10).unwrap();
|
||||||
|
assert_eq!(recent.len(), 2, "dedupe keeps one row per connection");
|
||||||
|
let pos_a = recent.iter().position(|r| r.connection_id == a.id).unwrap();
|
||||||
|
let pos_b = recent.iter().position(|r| r.connection_id == b.id).unwrap();
|
||||||
|
assert!(pos_a < pos_b, "re-opened a must be most recent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_recent_connections_empties_table() {
|
||||||
|
let store = fresh_store();
|
||||||
|
let conn = create_test_connection(&store, "x");
|
||||||
|
store.record_recent_connection(&conn.id).unwrap();
|
||||||
|
store.clear_recent_connections().unwrap();
|
||||||
|
assert_eq!(store.get_recent_connections(10).unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
fn create_test_connection(store: &Store, name: &str) -> crate::models::Connection {
|
fn create_test_connection(store: &Store, name: &str) -> crate::models::Connection {
|
||||||
store
|
store
|
||||||
.create_connection(ConnectionInput {
|
.create_connection(ConnectionInput {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gridline",
|
"productName": "Gridline",
|
||||||
"version": "0.2.0",
|
"version": "0.5.0",
|
||||||
"identifier": "com.adrianbonpin.gridline",
|
"identifier": "com.adrianbonpin.gridline",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const tags: Tag[] = [
|
|||||||
const conn: Connection = {
|
const conn: Connection = {
|
||||||
id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com",
|
id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com",
|
||||||
port: 5432, username: null, folder_id: null, keychain_ref: null,
|
port: 5432, username: null, folder_id: null, keychain_ref: null,
|
||||||
tag_ids: ["t1", "t2"], created_at: "", updated_at: "",
|
tag_ids: ["t1", "t2"], favorite: false, created_at: "", updated_at: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
@@ -22,6 +22,7 @@ function Wrapper({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
describe("ConnectionCard", () => {
|
describe("ConnectionCard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
useUiStore.setState({ selectedItemIds: [] });
|
useUiStore.setState({ selectedItemIds: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,6 +44,24 @@ describe("ConnectionCard", () => {
|
|||||||
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
|
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
|
||||||
expect(screen.getByLabelText("Drag to move connection")).toBeInTheDocument();
|
expect(screen.getByLabelText("Drag to move connection")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it("mounts the connection actions kebab menu with action callbacks", () => {
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
const onDuplicate = vi.fn();
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConnectionCard
|
||||||
|
connection={conn}
|
||||||
|
tags={tags}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Connection actions")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Prod DB")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
|
||||||
|
});
|
||||||
it("omits port for sqlite", () => {
|
it("omits port for sqlite", () => {
|
||||||
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
|
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
|
||||||
render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper });
|
render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper });
|
||||||
@@ -74,4 +93,17 @@ describe("ConnectionCard", () => {
|
|||||||
expect(fn).not.toHaveBeenCalled();
|
expect(fn).not.toHaveBeenCalled();
|
||||||
expect(useUiStore.getState().selectedItemIds).toContain(conn.id);
|
expect(useUiStore.getState().selectedItemIds).toContain(conn.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("no longer renders a favorite star (replaced by kebab menu)", () => {
|
||||||
|
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
|
||||||
|
expect(screen.queryByLabelText(/favorite|unfavorite/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no longer renders a status indicator and still shows name/host/tags", () => {
|
||||||
|
render(<ConnectionCard connection={{ ...conn, favorite: true }} tags={tags} />, { wrapper: Wrapper });
|
||||||
|
expect(screen.queryByLabelText(/check connection/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Prod DB")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("production")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import type { Connection, Tag } from "../../lib/types";
|
import type { Connection, ConnectionInput, Tag } from "../../lib/types";
|
||||||
import { DbIcon, DB_LABELS } from "../../lib/dbIcons";
|
import { DbIcon, DB_LABELS } from "../../lib/dbIcons";
|
||||||
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
|
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
|
||||||
import { TagBadge } from "../tags/TagBadge";
|
import { TagBadge } from "../tags/TagBadge";
|
||||||
|
import { ConnectionCardMenu } from "./ConnectionCardMenu";
|
||||||
import { Check, GripVertical } from "lucide-react";
|
import { Check, GripVertical } from "lucide-react";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
@@ -13,6 +14,35 @@ interface ConnectionCardProps {
|
|||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
onTagToggle?: (id: string) => void;
|
onTagToggle?: (id: string) => void;
|
||||||
onOpenDbViewer?: (connectionId: string) => void;
|
onOpenDbViewer?: (connectionId: string) => void;
|
||||||
|
onEdit?: (connection: Connection) => void;
|
||||||
|
onDuplicate?: (connection: Connection) => void;
|
||||||
|
onDelete?: (connection: Connection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConfigFromConnection(conn: Connection, password: string | null): ConnectionInput {
|
||||||
|
return {
|
||||||
|
name: conn.name,
|
||||||
|
db_type: conn.db_type,
|
||||||
|
host: conn.host,
|
||||||
|
port: conn.port,
|
||||||
|
username: conn.username,
|
||||||
|
folder_id: conn.folder_id,
|
||||||
|
tag_ids: conn.tag_ids,
|
||||||
|
password,
|
||||||
|
database: conn.database ?? null,
|
||||||
|
environment: conn.environment ?? null,
|
||||||
|
ssh_host: conn.ssh_host ?? null,
|
||||||
|
ssh_port: conn.ssh_port ?? null,
|
||||||
|
ssh_user: conn.ssh_user ?? null,
|
||||||
|
ssh_auth_method: (conn.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null,
|
||||||
|
ssh_private_key_path: conn.ssh_private_key_path ?? null,
|
||||||
|
ssh_password: null,
|
||||||
|
ssh_passphrase: null,
|
||||||
|
ssl_mode: (conn.ssl_mode as ConnectionInput["ssl_mode"]) ?? null,
|
||||||
|
ssl_ca_path: conn.ssl_ca_path ?? null,
|
||||||
|
ssl_cert_path: conn.ssl_cert_path ?? null,
|
||||||
|
ssl_key_path: conn.ssl_key_path ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionCardBase({
|
function ConnectionCardBase({
|
||||||
@@ -20,6 +50,9 @@ function ConnectionCardBase({
|
|||||||
tags,
|
tags,
|
||||||
onTagToggle,
|
onTagToggle,
|
||||||
onOpenDbViewer,
|
onOpenDbViewer,
|
||||||
|
onEdit,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
}: ConnectionCardProps) {
|
}: ConnectionCardProps) {
|
||||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||||
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
||||||
@@ -68,7 +101,7 @@ function ConnectionCardBase({
|
|||||||
<div
|
<div
|
||||||
{...listeners}
|
{...listeners}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
className="absolute top-1/2 -translate-y-1/2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10"
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10"
|
||||||
aria-label="Drag to move connection"
|
aria-label="Drag to move connection"
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className="text-text-muted" />
|
<GripVertical size={14} className="text-text-muted" />
|
||||||
@@ -107,6 +140,12 @@ function ConnectionCardBase({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ConnectionCardMenu
|
||||||
|
connection={connection}
|
||||||
|
onEdit={() => onEdit?.(connection)}
|
||||||
|
onDuplicate={() => onDuplicate?.(connection)}
|
||||||
|
onDelete={() => onDelete?.(connection)}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { ConnectionCardMenu } from "./ConnectionCardMenu";
|
||||||
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
|
import * as commands from "../../lib/commands";
|
||||||
|
import type { Connection } from "../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../../lib/commands", () => ({
|
||||||
|
getConnectionPassword: vi.fn(),
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const conn: Connection = {
|
||||||
|
id: "c1",
|
||||||
|
name: "Prod DB",
|
||||||
|
db_type: "postgresql",
|
||||||
|
host: "prod.example.com",
|
||||||
|
port: 5432,
|
||||||
|
username: null,
|
||||||
|
folder_id: null,
|
||||||
|
keychain_ref: null,
|
||||||
|
tag_ids: [],
|
||||||
|
favorite: false,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderMenu() {
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
const onDuplicate = vi.fn();
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
const utils = render(
|
||||||
|
<ConnectionCardMenu
|
||||||
|
connection={conn}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...utils, onEdit, onDuplicate, onDelete };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMenu() {
|
||||||
|
await userEvent.click(screen.getByLabelText("Connection actions"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConnectionCardMenu", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a kebab trigger and opens the menu on click", async () => {
|
||||||
|
renderMenu();
|
||||||
|
expect(screen.getByLabelText("Connection actions")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await openMenu();
|
||||||
|
expect(screen.getByText("Add to favorites")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Test connection")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Manage")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("favorite label reflects connection.favorite", async () => {
|
||||||
|
const { unmount } = renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
expect(screen.getByText("Add to favorites")).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectionCardMenu
|
||||||
|
connection={{ ...conn, favorite: true }}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDuplicate={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await openMenu();
|
||||||
|
expect(screen.getByText("Remove from favorites")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking favorite calls toggleFavorite and closes the menu", async () => {
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(useConnectionStore.getState(), "toggleFavorite")
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
await userEvent.click(screen.getByText("Add to favorites"));
|
||||||
|
expect(spy).toHaveBeenCalledWith("c1");
|
||||||
|
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test connection keeps the menu open and shows online status", async () => {
|
||||||
|
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
|
||||||
|
vi.mocked(commands.testConnection).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
server_version: "15.2",
|
||||||
|
latency_ms: 12,
|
||||||
|
} as any);
|
||||||
|
renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Test connection"));
|
||||||
|
|
||||||
|
// Menu stays open while the check runs
|
||||||
|
expect(screen.getByText("Manage")).toBeInTheDocument();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/online/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/15\.2/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/12ms/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test connection shows just Online when no version/latency reported", async () => {
|
||||||
|
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
|
||||||
|
vi.mocked(commands.testConnection).mockResolvedValue({ ok: true } as any);
|
||||||
|
renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Test connection"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByText("Online", { exact: true }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/unknown/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/0ms/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test connection shows the offline error text", async () => {
|
||||||
|
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
|
||||||
|
vi.mocked(commands.testConnection).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: "connection refused",
|
||||||
|
} as any);
|
||||||
|
renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Test connection"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/connection refused/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Manage expands to reveal Edit/Duplicate/Delete and Edit calls onEdit + closes", async () => {
|
||||||
|
const { onEdit } = renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
await userEvent.click(screen.getByText("Manage"));
|
||||||
|
|
||||||
|
expect(screen.getByText("Edit…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Duplicate")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Delete…")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Edit…"));
|
||||||
|
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByText("Edit…")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Duplicate calls onDuplicate + closes the menu", async () => {
|
||||||
|
const { onDuplicate } = renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
await userEvent.click(screen.getByText("Manage"));
|
||||||
|
await userEvent.click(screen.getByText("Duplicate"));
|
||||||
|
expect(onDuplicate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delete is rendered in red, calls onDelete + closes the menu", async () => {
|
||||||
|
const { onDelete } = renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
await userEvent.click(screen.getByText("Manage"));
|
||||||
|
|
||||||
|
const deleteItem = screen.getByText("Delete…");
|
||||||
|
expect(deleteItem.className).toContain("text-red");
|
||||||
|
|
||||||
|
await userEvent.click(deleteItem);
|
||||||
|
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on outside mousedown", async () => {
|
||||||
|
renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
expect(screen.getByText("Manage")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape", async () => {
|
||||||
|
renderMenu();
|
||||||
|
await openMenu();
|
||||||
|
expect(screen.getByText("Manage")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ChevronRight,
|
||||||
|
CircleCheck,
|
||||||
|
CircleX,
|
||||||
|
Copy,
|
||||||
|
Loader2,
|
||||||
|
MoreVertical,
|
||||||
|
Pencil,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Connection } from "../../lib/types";
|
||||||
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
|
import { buildConfigFromConnection } from "./ConnectionCard";
|
||||||
|
import { useConnectionStatus } from "./useConnectionStatus";
|
||||||
|
|
||||||
|
interface ConnectionCardMenuProps {
|
||||||
|
connection: Connection;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItemClass =
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-text hover:bg-surface transition-colors cursor-pointer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kebab (⋮) actions menu for a connection card. Hosts the favorite toggle,
|
||||||
|
* on-demand connection test (inline status), and a Manage submenu
|
||||||
|
* (Edit… / Duplicate / Delete…). Closes on outside mousedown, Escape, and
|
||||||
|
* after selecting an action.
|
||||||
|
*/
|
||||||
|
export function ConnectionCardMenu({
|
||||||
|
connection,
|
||||||
|
onEdit,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
}: ConnectionCardMenuProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [manageOpen, setManageOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { state, info, check } = useConnectionStatus(
|
||||||
|
connection.id,
|
||||||
|
(pw) => buildConfigFromConnection(connection, pw),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setManageOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
setManageOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Capture phase so we fire before other stopPropagation handlers
|
||||||
|
document.addEventListener("mousedown", handleMouseDown, true);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleMouseDown, true);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
setOpen((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
if (!next) setManageOpen(false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setManageOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel =
|
||||||
|
state === "checking"
|
||||||
|
? "Testing…"
|
||||||
|
: state === "online"
|
||||||
|
? `Online${info ? ` · ${info}` : ""}`
|
||||||
|
: state === "offline"
|
||||||
|
? info
|
||||||
|
: "Test connection";
|
||||||
|
|
||||||
|
const statusIcon =
|
||||||
|
state === "checking" ? (
|
||||||
|
<Loader2 size={14} className="animate-spin text-text-muted" />
|
||||||
|
) : state === "online" ? (
|
||||||
|
<CircleCheck size={14} className="shrink-0 text-green-500" />
|
||||||
|
) : state === "offline" ? (
|
||||||
|
<CircleX size={14} className="shrink-0 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<Activity size={14} className="text-text-muted" />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-1/2 -translate-y-1/2 right-0.5 z-10" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Connection actions"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMenu();
|
||||||
|
}}
|
||||||
|
className={`rounded-md p-1 text-text-muted hover:text-text hover:bg-surface transition-colors cursor-pointer ${
|
||||||
|
open ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="absolute top-full right-0 mt-1 w-64 rounded-md border border-border bg-canvas shadow-lg z-20 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void useConnectionStore
|
||||||
|
.getState()
|
||||||
|
.toggleFavorite(connection.id)
|
||||||
|
.catch(() => {});
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
className={menuItemClass}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
connection.favorite
|
||||||
|
? "text-amber-400 fill-amber-400"
|
||||||
|
: "text-text-muted"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{connection.favorite
|
||||||
|
? "Remove from favorites"
|
||||||
|
: "Add to favorites"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void check();
|
||||||
|
}}
|
||||||
|
className={menuItemClass}
|
||||||
|
>
|
||||||
|
{statusIcon}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
state === "offline"
|
||||||
|
? "whitespace-normal break-words text-red-500"
|
||||||
|
: "truncate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setManageOpen((m) => !m)}
|
||||||
|
className={`${menuItemClass} justify-between`}
|
||||||
|
>
|
||||||
|
<span>Manage</span>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className={`text-text-muted transition-transform ${
|
||||||
|
manageOpen ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{manageOpen && (
|
||||||
|
<div className="mt-1 border-t border-border pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onEdit();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
className={`${menuItemClass} pl-6`}
|
||||||
|
>
|
||||||
|
<Pencil size={14} className="text-text-muted" />
|
||||||
|
Edit…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onDuplicate();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
className={`${menuItemClass} pl-6`}
|
||||||
|
>
|
||||||
|
<Copy size={14} className="text-text-muted" />
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onDelete();
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
className={`${menuItemClass} pl-6 !text-red-500 hover:!text-red-400`}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="text-red-500" />
|
||||||
|
Delete…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import type { Connection, Folder, Tag } from "../../lib/types";
|
|||||||
const makeConn = (id: string, folder_id: string | null = null): Connection => ({
|
const makeConn = (id: string, folder_id: string | null = null): Connection => ({
|
||||||
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
|
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
|
||||||
username: null, folder_id, keychain_ref: null, tag_ids: [],
|
username: null, folder_id, keychain_ref: null, tag_ids: [],
|
||||||
created_at: "", updated_at: "", environment: null,
|
created_at: "", updated_at: "", environment: null, favorite: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const folders: Folder[] = [
|
const folders: Folder[] = [
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ interface ConnectionGridProps {
|
|||||||
onEditFolder?: (folder: Folder) => void;
|
onEditFolder?: (folder: Folder) => void;
|
||||||
onDeleteFolder?: (folder: Folder) => void;
|
onDeleteFolder?: (folder: Folder) => void;
|
||||||
onOpenDbViewer?: (connectionId: string) => void;
|
onOpenDbViewer?: (connectionId: string) => void;
|
||||||
|
onEditConnection?: (conn: Connection) => void;
|
||||||
|
onDuplicateConnection?: (conn: Connection) => void;
|
||||||
|
onDeleteConnection?: (conn: Connection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectionGrid({
|
export function ConnectionGrid({
|
||||||
@@ -122,6 +125,9 @@ export function ConnectionGrid({
|
|||||||
onEditFolder,
|
onEditFolder,
|
||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onOpenDbViewer,
|
onOpenDbViewer,
|
||||||
|
onEditConnection,
|
||||||
|
onDuplicateConnection,
|
||||||
|
onDeleteConnection,
|
||||||
}: ConnectionGridProps) {
|
}: ConnectionGridProps) {
|
||||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||||
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
||||||
@@ -272,6 +278,9 @@ export function ConnectionGrid({
|
|||||||
tags={tags}
|
tags={tags}
|
||||||
onTagToggle={onTagToggle}
|
onTagToggle={onTagToggle}
|
||||||
onOpenDbViewer={onOpenDbViewer}
|
onOpenDbViewer={onOpenDbViewer}
|
||||||
|
onEdit={onEditConnection}
|
||||||
|
onDuplicate={onDuplicateConnection}
|
||||||
|
onDelete={onDeleteConnection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MoveToFolderDialog } from "./MoveToFolderDialog";
|
||||||
|
import type { Folder } from "../../lib/types";
|
||||||
|
|
||||||
|
const makeFolder = (id: string, name: string): Folder => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
parent_id: null,
|
||||||
|
tag_ids: [],
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MoveToFolderDialog", () => {
|
||||||
|
it("lists folders + Root and calls onConfirm with the chosen id", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<MoveToFolderDialog
|
||||||
|
open
|
||||||
|
folders={[makeFolder("f1", "Prod")]}
|
||||||
|
selectedCount={3}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/3 items/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("Prod"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /move/i }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith("f1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Root option passes null", () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(
|
||||||
|
<MoveToFolderDialog
|
||||||
|
open
|
||||||
|
folders={[]}
|
||||||
|
selectedCount={1}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText(/root/i));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /move/i }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cancel button calls onClose", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<MoveToFolderDialog
|
||||||
|
open
|
||||||
|
folders={[]}
|
||||||
|
selectedCount={2}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Move button is disabled until an option is chosen", () => {
|
||||||
|
render(
|
||||||
|
<MoveToFolderDialog
|
||||||
|
open
|
||||||
|
folders={[makeFolder("f1", "Prod")]}
|
||||||
|
selectedCount={1}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const move = screen.getByRole("button", { name: /move/i });
|
||||||
|
expect(move).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByText("Prod"));
|
||||||
|
expect(move).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Folder as FolderIcon, Check } from "lucide-react";
|
||||||
|
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import type { Folder } from "../../lib/types";
|
||||||
|
|
||||||
|
interface MoveToFolderDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
folders: Folder[];
|
||||||
|
selectedCount: number;
|
||||||
|
onConfirm: (targetFolderId: string | null) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoveToFolderDialog({
|
||||||
|
open,
|
||||||
|
folders,
|
||||||
|
selectedCount,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: MoveToFolderDialogProps) {
|
||||||
|
const [target, setTarget] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState(false);
|
||||||
|
|
||||||
|
// Reset on close so a fresh open starts unselected (null is a valid target = Root).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setTarget(null);
|
||||||
|
setSelected(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (id: string | null) => target === id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedModal open={open} onClose={onClose}>
|
||||||
|
<div className="w-[360px]">
|
||||||
|
<h3 className="font-heading text-lg text-text mb-4">
|
||||||
|
Move {selectedCount} item{selectedCount !== 1 ? "s" : ""} to folder
|
||||||
|
</h3>
|
||||||
|
<div className="max-h-[300px] overflow-y-auto space-y-1 pr-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTarget(null);
|
||||||
|
setSelected(true);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors cursor-pointer ${
|
||||||
|
isSelected(null)
|
||||||
|
? "bg-accent/10 text-text"
|
||||||
|
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FolderIcon size={14} />
|
||||||
|
<span className="flex-1">Root (no folder)</span>
|
||||||
|
{isSelected(null) && <Check size={14} className="text-accent" />}
|
||||||
|
</button>
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<button
|
||||||
|
key={folder.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTarget(folder.id);
|
||||||
|
setSelected(true);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors cursor-pointer ${
|
||||||
|
isSelected(folder.id)
|
||||||
|
? "bg-accent/10 text-text"
|
||||||
|
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FolderIcon size={14} />
|
||||||
|
<span className="flex-1 truncate">{folder.name}</span>
|
||||||
|
{isSelected(folder.id) && <Check size={14} className="text-accent" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={!selected}>
|
||||||
|
Move
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { RecentConnectionsStrip } from "./RecentConnectionsStrip";
|
||||||
|
import type { Connection } from "../../lib/types";
|
||||||
|
|
||||||
|
const makeConn = (id: string): Connection => ({
|
||||||
|
id,
|
||||||
|
name: id.toUpperCase(),
|
||||||
|
db_type: "postgresql",
|
||||||
|
host: "h",
|
||||||
|
port: 5432,
|
||||||
|
username: null,
|
||||||
|
folder_id: null,
|
||||||
|
keychain_ref: null,
|
||||||
|
tag_ids: [],
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
favorite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RecentConnectionsStrip", () => {
|
||||||
|
it("renders up to 8 recent connections and calls onOpen on click", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
const recents = Array.from({ length: 10 }, (_, i) => makeConn(`c${i}`));
|
||||||
|
render(<RecentConnectionsStrip recents={recents} onOpen={onOpen} />);
|
||||||
|
expect(screen.getAllByRole("button")).toHaveLength(8);
|
||||||
|
fireEvent.click(screen.getByText("C0"));
|
||||||
|
expect(onOpen).toHaveBeenCalledWith("c0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when the list is empty", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RecentConnectionsStrip recents={[]} onOpen={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { DbIcon } from "../../lib/dbIcons";
|
||||||
|
import type { Connection } from "../../lib/types";
|
||||||
|
|
||||||
|
interface RecentConnectionsStripProps {
|
||||||
|
recents: Connection[];
|
||||||
|
onOpen: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentConnectionsStrip({
|
||||||
|
recents,
|
||||||
|
onOpen,
|
||||||
|
}: RecentConnectionsStripProps) {
|
||||||
|
if (recents.length === 0) return null;
|
||||||
|
|
||||||
|
const visible = recents.slice(0, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-2">
|
||||||
|
Recent
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
|
{visible.map((connection) => (
|
||||||
|
<button
|
||||||
|
key={connection.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(connection.id)}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface border border-border hover:border-border-hover text-sm text-text transition-colors cursor-pointer whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<DbIcon type={connection.db_type} size={14} />
|
||||||
|
<span className="truncate max-w-[180px]">{connection.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useConnectionStatus } from "./useConnectionStatus";
|
||||||
|
import * as commands from "../../lib/commands";
|
||||||
|
|
||||||
|
describe("useConnectionStatus", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts idle and becomes online with server_version/latency info after a successful check", async () => {
|
||||||
|
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
|
||||||
|
vi.spyOn(commands, "testConnection").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
server_version: "15.2",
|
||||||
|
latency_ms: 12,
|
||||||
|
} as any);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useConnectionStatus("c1", () => ({
|
||||||
|
name: "P",
|
||||||
|
db_type: "postgresql",
|
||||||
|
host: "h",
|
||||||
|
port: 5432,
|
||||||
|
username: "u",
|
||||||
|
password: "pw",
|
||||||
|
} as any)),
|
||||||
|
);
|
||||||
|
expect(result.current.state).toBe("idle");
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.check();
|
||||||
|
});
|
||||||
|
expect(result.current.state).toBe("online");
|
||||||
|
expect(result.current.info).toContain("15.2");
|
||||||
|
expect(result.current.info).toContain("12ms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("becomes offline with error info when testConnection fails", async () => {
|
||||||
|
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
|
||||||
|
vi.spyOn(commands, "testConnection").mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: "timeout",
|
||||||
|
} as any);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useConnectionStatus("c1", () => ({
|
||||||
|
name: "P",
|
||||||
|
db_type: "postgresql",
|
||||||
|
host: "h",
|
||||||
|
port: 5432,
|
||||||
|
username: "u",
|
||||||
|
} as any)),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.check();
|
||||||
|
});
|
||||||
|
expect(result.current.state).toBe("offline");
|
||||||
|
expect(result.current.info).toContain("timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("online with no version/latency reported keeps info empty (no unknown/0ms fallback)", async () => {
|
||||||
|
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
|
||||||
|
vi.spyOn(commands, "testConnection").mockResolvedValue({ ok: true } as any);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useConnectionStatus("c1", () => ({
|
||||||
|
name: "P",
|
||||||
|
db_type: "postgresql",
|
||||||
|
host: "h",
|
||||||
|
port: 5432,
|
||||||
|
username: "u",
|
||||||
|
} as any)),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.check();
|
||||||
|
});
|
||||||
|
expect(result.current.state).toBe("online");
|
||||||
|
expect(result.current.info).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces: rapid check() calls run one check; a re-check is allowed after 2s", async () => {
|
||||||
|
vi.useFakeTimers({ now: 100_000 });
|
||||||
|
try {
|
||||||
|
const pw = vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
|
||||||
|
vi.spyOn(commands, "testConnection").mockResolvedValue({ ok: true } as any);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useConnectionStatus("c1", () => ({
|
||||||
|
name: "P",
|
||||||
|
db_type: "postgresql",
|
||||||
|
host: "h",
|
||||||
|
port: 5432,
|
||||||
|
} as any)),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
const p1 = result.current.check();
|
||||||
|
const p2 = result.current.check();
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
});
|
||||||
|
expect(pw).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.check();
|
||||||
|
});
|
||||||
|
expect(pw).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ConnectionInput, ConnectionTestResult } from "../../lib/types";
|
||||||
|
import * as cmd from "../../lib/commands";
|
||||||
|
|
||||||
|
export type ConnectionStatusState = "idle" | "checking" | "online" | "offline";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 2000;
|
||||||
|
const RESET_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracted from the former status-dot UI: runs a connection test on
|
||||||
|
* demand, debounced to once per 2s, and auto-resets to "idle" 5s after the
|
||||||
|
* last check. `check()` is stable (useCallback) and safe to hand to menu
|
||||||
|
* handlers; it no-ops while a check is already in flight or within the
|
||||||
|
* debounce window.
|
||||||
|
*/
|
||||||
|
export function useConnectionStatus(
|
||||||
|
connectionId: string,
|
||||||
|
buildConfig: (password: string | null) => ConnectionInput,
|
||||||
|
): { state: ConnectionStatusState; info: string; check: () => Promise<void> } {
|
||||||
|
const [state, setState] = useState<ConnectionStatusState>("idle");
|
||||||
|
const [info, setInfo] = useState<string>("");
|
||||||
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
const lastCheckRef = useRef<number>(0);
|
||||||
|
// Mirror `state` in a ref so `check` stays stable while still being able to
|
||||||
|
// skip a second run while one is already in flight.
|
||||||
|
const stateRef = useRef<ConnectionStatusState>("idle");
|
||||||
|
|
||||||
|
const setStateBoth = useCallback((next: ConnectionStatusState) => {
|
||||||
|
stateRef.current = next;
|
||||||
|
setState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const check = useCallback(async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (stateRef.current === "checking") return;
|
||||||
|
if (now - lastCheckRef.current < DEBOUNCE_MS) return;
|
||||||
|
lastCheckRef.current = now;
|
||||||
|
setStateBoth("checking");
|
||||||
|
setInfo("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const password = await cmd.getConnectionPassword(connectionId);
|
||||||
|
const input = buildConfig(password);
|
||||||
|
const result: ConnectionTestResult = await cmd.testConnection(input);
|
||||||
|
if (result.ok) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.server_version) parts.push(result.server_version);
|
||||||
|
if (result.latency_ms != null) parts.push(`${result.latency_ms}ms`);
|
||||||
|
setInfo(parts.join(" · "));
|
||||||
|
setStateBoth("online");
|
||||||
|
} else {
|
||||||
|
setInfo(result.error ?? "offline");
|
||||||
|
setStateBoth("offline");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setInfo(e instanceof Error ? e.message : "offline");
|
||||||
|
setStateBoth("offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
setStateBoth("idle");
|
||||||
|
setInfo("");
|
||||||
|
}, RESET_MS);
|
||||||
|
}, [connectionId, buildConfig, setStateBoth]);
|
||||||
|
|
||||||
|
return { state, info, check };
|
||||||
|
}
|
||||||
@@ -34,6 +34,22 @@ describe("ChangesQueuePanel", () => {
|
|||||||
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
|
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the old → new value diff on update cards", () => {
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "update",
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
primaryKey: { id: 1 },
|
||||||
|
oldData: { name: "Bob" },
|
||||||
|
newData: { name: "Alice" },
|
||||||
|
description: "Update row in users",
|
||||||
|
} as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
// the diff renders old (struck) → new (accent) as separate spans
|
||||||
|
expect(screen.getByText(/name: Bob/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("revert removes the change from the queue", async () => {
|
it("revert removes the change from the queue", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
useDbViewerStore.getState().addChange({
|
useDbViewerStore.getState().addChange({
|
||||||
@@ -156,6 +172,21 @@ describe("ChangesQueuePanel", () => {
|
|||||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls onCommitted after a successful commit cycle", async () => {
|
||||||
|
const onCommitted = vi.fn();
|
||||||
|
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "t",
|
||||||
|
newData: { a: 1 },
|
||||||
|
description: "Insert row into t",
|
||||||
|
} as any);
|
||||||
|
render(<ChangesQueuePanel onCommitted={onCommitted} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||||
|
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a green check on committed changes after Commit All", async () => {
|
it("shows a green check on committed changes after Commit All", async () => {
|
||||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ function formatChangeLabel(change: QueueItem): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render the old → new value change for update queue items. */
|
||||||
|
function formatValueDiff(change: QueueItem): string | null {
|
||||||
|
if (change.type !== "update" || !change.newData) return null;
|
||||||
|
const colName = Object.keys(change.newData)[0];
|
||||||
|
if (!colName) return null;
|
||||||
|
const oldVal =
|
||||||
|
change.oldData && change.oldData[colName] !== undefined
|
||||||
|
? String(change.oldData[colName])
|
||||||
|
: "NULL";
|
||||||
|
const newVal =
|
||||||
|
change.newData[colName] === null || change.newData[colName] === undefined
|
||||||
|
? "NULL"
|
||||||
|
: String(change.newData[colName]);
|
||||||
|
return `${colName}: ${oldVal} → ${newVal}`;
|
||||||
|
}
|
||||||
|
|
||||||
function capitalizeType(type: string) {
|
function capitalizeType(type: string) {
|
||||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
}
|
}
|
||||||
@@ -39,7 +55,7 @@ function tableRef(change: QueueItem): string {
|
|||||||
return change.table ?? "-";
|
return change.table ?? "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChangesQueuePanel() {
|
export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||||
const removeChange = useDbViewerStore((state) => state.removeChange);
|
const removeChange = useDbViewerStore((state) => state.removeChange);
|
||||||
const clearChanges = useDbViewerStore((state) => state.clearChanges);
|
const clearChanges = useDbViewerStore((state) => state.clearChanges);
|
||||||
@@ -84,6 +100,7 @@ export function ChangesQueuePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (committedCount > 0) {
|
if (committedCount > 0) {
|
||||||
|
onCommitted?.();
|
||||||
notify(`${committedCount} change(s) committed`, "success");
|
notify(`${committedCount} change(s) committed`, "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +200,17 @@ export function ChangesQueuePanel() {
|
|||||||
<div className="mt-1 text-xs text-text-muted truncate">
|
<div className="mt-1 text-xs text-text-muted truncate">
|
||||||
{formatChangeLabel(change)}
|
{formatChangeLabel(change)}
|
||||||
</div>
|
</div>
|
||||||
|
{formatValueDiff(change) && (
|
||||||
|
<div className="mt-0.5 font-mono text-xs text-text">
|
||||||
|
<span className="text-text-muted line-through">
|
||||||
|
{formatValueDiff(change)!.split(" → ")[0]}
|
||||||
|
</span>
|
||||||
|
<span className="mx-1 text-text-muted">→</span>
|
||||||
|
<span className="text-accent">
|
||||||
|
{formatValueDiff(change)!.split(" → ")[1]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
import { DbViewerScreen } from "./DbViewerScreen";
|
import {
|
||||||
|
DbViewerScreen,
|
||||||
|
deriveStagedValues,
|
||||||
|
derivePendingCellKeys,
|
||||||
|
pickDisplayColumn,
|
||||||
|
} from "./DbViewerScreen";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
import * as commands from "../../lib/commands";
|
import * as commands from "../../lib/commands";
|
||||||
|
|
||||||
vi.mock("../../hooks/useDbConnection", () => ({
|
vi.mock("../../hooks/useDbConnection", () => ({
|
||||||
@@ -12,9 +18,17 @@ vi.mock("../../hooks/useDbConnection", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tanstack/react-virtual", () => ({
|
vi.mock("@tanstack/react-virtual", () => ({
|
||||||
useVirtualizer: () => ({
|
useVirtualizer: ({ count }: any) => ({
|
||||||
getVirtualItems: () => [],
|
getVirtualItems: () =>
|
||||||
getTotalSize: () => 0,
|
count > 0
|
||||||
|
? Array.from({ length: count }, (_, i) => ({
|
||||||
|
key: i,
|
||||||
|
index: i,
|
||||||
|
start: i * 36,
|
||||||
|
size: 36,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
getTotalSize: () => count * 36,
|
||||||
measureElement: () => {},
|
measureElement: () => {},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -64,6 +78,8 @@ const mockQueryResult = {
|
|||||||
is_fk: false,
|
is_fk: false,
|
||||||
fk_ref: null,
|
fk_ref: null,
|
||||||
default_value: null,
|
default_value: null,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
rows: [[1]],
|
rows: [[1]],
|
||||||
@@ -99,6 +115,126 @@ describe("DbViewerScreen", () => {
|
|||||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("full flow: editing a cell shows the staged value + pending dot in the grid", async () => {
|
||||||
|
const store = useDbViewerStore.getState();
|
||||||
|
store.openTab("public", "users");
|
||||||
|
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||||
|
store.setTabData(tabId, {
|
||||||
|
columns: [
|
||||||
|
{ 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 },
|
||||||
|
],
|
||||||
|
rows: [[1, "Alice"]],
|
||||||
|
total_rows: 1,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
} as any);
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const cell = await waitFor(() => screen.getByText("Alice"));
|
||||||
|
fireEvent.click(cell);
|
||||||
|
fireEvent.keyDown(cell, { key: "Enter" });
|
||||||
|
// the editor's textarea is the last textbox (toolbar filter input is first)
|
||||||
|
const textboxes = screen.getAllByRole("textbox");
|
||||||
|
const input = textboxes[textboxes.length - 1]!;
|
||||||
|
fireEvent.change(input, { target: { value: "Alicia" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
// staged change carries the correct table (was the root-cause bug)
|
||||||
|
const staged = useDbViewerStore.getState().changesQueue[0];
|
||||||
|
expect(staged?.table).toBe("users");
|
||||||
|
expect(staged?.schema).toBe("public");
|
||||||
|
// grid cell shows the optimistic value + the pending dot (2nd match is the queue panel diff)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
|
||||||
|
// committing the change clears the pending dot but keeps the value until refetch
|
||||||
|
act(() => {
|
||||||
|
useDbViewerStore
|
||||||
|
.getState()
|
||||||
|
.markChangeCommitted(
|
||||||
|
useDbViewerStore.getState().changesQueue[0].id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
|
||||||
|
// clearing the queue clears the optimistic display
|
||||||
|
act(() => {
|
||||||
|
useDbViewerStore.getState().clearChanges();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("Alicia")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deriveStagedValues maps queue updates to optimistic cell values", () => {
|
||||||
|
const queue = [
|
||||||
|
{
|
||||||
|
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||||
|
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||||
|
status: "pending" as const, createdAt: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
|
||||||
|
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||||
|
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
|
||||||
|
"0:name": "Alicia",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deriveStagedValues ignores failed/other-table changes and handles NULL", () => {
|
||||||
|
const queue = [
|
||||||
|
{
|
||||||
|
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||||
|
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: null },
|
||||||
|
status: "pending" as const, createdAt: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "orders",
|
||||||
|
primaryKey: { id: 1 }, oldData: { x: 1 }, newData: { x: 2 },
|
||||||
|
status: "pending" as const, createdAt: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ch-3", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||||
|
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "X" },
|
||||||
|
status: "failed" as const, error: "boom", createdAt: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows: unknown[][] = [[1, "Alice"]];
|
||||||
|
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||||
|
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
|
||||||
|
"0:name": null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derivePendingCellKeys only includes pending updates (dot clears on commit)", () => {
|
||||||
|
const queue = [
|
||||||
|
{
|
||||||
|
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||||
|
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||||
|
status: "pending" as const, createdAt: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||||
|
primaryKey: { id: 2 }, oldData: { name: "Bob" }, newData: { name: "Bobby" },
|
||||||
|
status: "committed" as const, createdAt: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
|
||||||
|
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||||
|
expect(derivePendingCellKeys(queue as any, "public", "users", rows, loc)).toEqual({
|
||||||
|
"0:name": true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the New Query button", () => {
|
it("renders the New Query button", () => {
|
||||||
render(
|
render(
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
@@ -459,4 +595,230 @@ describe("DbViewerScreen", () => {
|
|||||||
).not.toBeInTheDocument(),
|
).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables Insert Row for a materialized-view tab", async () => {
|
||||||
|
useDbViewerStore.setState({
|
||||||
|
tables: [
|
||||||
|
{ name: "mat_users", schema: "public", table_type: "MATERIALIZED VIEW" },
|
||||||
|
],
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "tab-mv",
|
||||||
|
schema: "public",
|
||||||
|
table: "mat_users",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
data: mockQueryResult,
|
||||||
|
filterRules: [],
|
||||||
|
sortRules: [],
|
||||||
|
hiddenColumns: [],
|
||||||
|
smartSortApplied: true,
|
||||||
|
tabType: "table",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeTabId: "tab-mv",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByLabelText(/insert row/i)).toBeNull(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches the active tab after a successful Commit All", async () => {
|
||||||
|
useUiStore.setState({ activeConnectionId: "c1" });
|
||||||
|
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
|
const getTableData = vi
|
||||||
|
.spyOn(commands, "getTableData")
|
||||||
|
.mockResolvedValue({
|
||||||
|
columns: mockQueryResult.columns,
|
||||||
|
rows: [[2]],
|
||||||
|
total_rows: 1,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
useDbViewerStore.setState({
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "tab-1",
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
data: mockQueryResult,
|
||||||
|
filterRules: [],
|
||||||
|
sortRules: [],
|
||||||
|
hiddenColumns: [],
|
||||||
|
smartSortApplied: true,
|
||||||
|
tabType: "table",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeTabId: "tab-1",
|
||||||
|
changesQueue: [],
|
||||||
|
changesPanelExpanded: true,
|
||||||
|
});
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "insert",
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
newData: { id: 2, name: "Alice" },
|
||||||
|
description: "Insert row into users",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(getTableData).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches enum labels and FK reference rows for the active table tab", async () => {
|
||||||
|
const getEnums = vi
|
||||||
|
.spyOn(commands, "getEnums")
|
||||||
|
.mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "user_role",
|
||||||
|
schema: "public",
|
||||||
|
labels: ["admin", "user"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const getTableData = vi
|
||||||
|
.spyOn(commands, "getTableData")
|
||||||
|
.mockResolvedValue({
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows: [[1], [2]],
|
||||||
|
total_rows: 2,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
useDbViewerStore.setState({
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "tab-1",
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
data: {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
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: "user_id",
|
||||||
|
data_type: "integer",
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: true,
|
||||||
|
fk_ref: ["users", "id"],
|
||||||
|
default_value: null,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "role",
|
||||||
|
data_type: "user_role",
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: null,
|
||||||
|
default_value: null,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows: [[1, 2, "admin"]],
|
||||||
|
total_rows: 1,
|
||||||
|
page: 1,
|
||||||
|
page_size: 50,
|
||||||
|
} as any,
|
||||||
|
filterRules: [],
|
||||||
|
sortRules: [],
|
||||||
|
hiddenColumns: [],
|
||||||
|
smartSortApplied: true,
|
||||||
|
tabType: "table",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeTabId: "tab-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enum labels are fetched for the tab's schema (cached per schema).
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getEnums).toHaveBeenCalledWith("c1", "public"),
|
||||||
|
);
|
||||||
|
// FK reference rows are fetched from the referenced table (page 1, 50).
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getTableData).toHaveBeenCalledWith(
|
||||||
|
"c1",
|
||||||
|
"public",
|
||||||
|
"users",
|
||||||
|
1,
|
||||||
|
50,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pickDisplayColumn prefers name-like columns over the ref column", () => {
|
||||||
|
const cols = [
|
||||||
|
{ name: "id", data_type: "integer" },
|
||||||
|
{ name: "email", data_type: "text" },
|
||||||
|
{ name: "name", data_type: "text" },
|
||||||
|
];
|
||||||
|
expect(pickDisplayColumn(cols, "id")).toBe("name");
|
||||||
|
expect(pickDisplayColumn(cols, "id", "email")).toBe("email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pickDisplayColumn falls back to the ref column when nothing is name-like", () => {
|
||||||
|
const cols = [{ name: "id", data_type: "integer" }];
|
||||||
|
expect(pickDisplayColumn(cols, "id")).toBe("id");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -16,6 +16,7 @@ import { TableTree } from "./TableTree";
|
|||||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||||
import { TabBar } from "./TabBar";
|
import { TabBar } from "./TabBar";
|
||||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||||
|
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||||
import { TableControls } from "./TableControls";
|
import { TableControls } from "./TableControls";
|
||||||
import { EditConnectionModal } from "./EditConnectionModal";
|
import { EditConnectionModal } from "./EditConnectionModal";
|
||||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||||
@@ -29,6 +30,124 @@ import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
|||||||
import { QueriesPanel } from "../queries/QueriesPanel";
|
import { QueriesPanel } from "../queries/QueriesPanel";
|
||||||
import { useQueryStore } from "../../stores/queryStore";
|
import { useQueryStore } from "../../stores/queryStore";
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
|
import type { EnumInfo } from "../../lib/types";
|
||||||
|
import type { FkOption } from "../grid/CellEditor";
|
||||||
|
import type { QueueItem } from "../../stores/dbViewerStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive optimistic staged cell values from the changes queue for a table
|
||||||
|
* tab, keyed `${rowIndex}:${colName}` → the staged value (null = NULL).
|
||||||
|
* Rows are matched to queue items via the row locator (PK or ctid/rowid).
|
||||||
|
* Pending + committed updates count (survive until refetch); Clear All
|
||||||
|
* empties the queue so the optimistic display vanishes.
|
||||||
|
*/
|
||||||
|
export function deriveStagedValues(
|
||||||
|
changesQueue: QueueItem[],
|
||||||
|
schema: string,
|
||||||
|
table: string,
|
||||||
|
rows: unknown[][],
|
||||||
|
getLocator: (row: unknown[]) => Record<string, unknown>,
|
||||||
|
): Record<string, string | null> {
|
||||||
|
const map: Record<string, string | null> = {};
|
||||||
|
const updates = changesQueue.filter(
|
||||||
|
(c) =>
|
||||||
|
c.type === "update" &&
|
||||||
|
(c.status === "pending" || c.status === "committed") &&
|
||||||
|
c.schema === schema &&
|
||||||
|
c.table === table &&
|
||||||
|
c.primaryKey &&
|
||||||
|
c.newData,
|
||||||
|
);
|
||||||
|
if (updates.length === 0) return map;
|
||||||
|
rows.forEach((row, rowIdx) => {
|
||||||
|
const loc = getLocator(row);
|
||||||
|
for (const c of updates) {
|
||||||
|
const pk = c.primaryKey!;
|
||||||
|
const matches = Object.entries(pk).every(
|
||||||
|
([k, v]) => String(loc[k]) === String(v),
|
||||||
|
);
|
||||||
|
if (!matches) continue;
|
||||||
|
const colName = Object.keys(c.newData!)[0];
|
||||||
|
if (!colName) continue;
|
||||||
|
map[`${rowIdx}:${colName}`] =
|
||||||
|
(c.newData![colName] as string | null) ?? null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys of cells with a PENDING update only — drives the amber pending dot.
|
||||||
|
* Once a change is committed the dot clears even though the optimistic value
|
||||||
|
* (from `deriveStagedValues`) stays until the refetch lands.
|
||||||
|
*/
|
||||||
|
export function derivePendingCellKeys(
|
||||||
|
changesQueue: QueueItem[],
|
||||||
|
schema: string,
|
||||||
|
table: string,
|
||||||
|
rows: unknown[][],
|
||||||
|
getLocator: (row: unknown[]) => Record<string, unknown>,
|
||||||
|
): Record<string, boolean> {
|
||||||
|
const keys: Record<string, boolean> = {};
|
||||||
|
const updates = changesQueue.filter(
|
||||||
|
(c) =>
|
||||||
|
c.type === "update" &&
|
||||||
|
c.status === "pending" &&
|
||||||
|
c.schema === schema &&
|
||||||
|
c.table === table &&
|
||||||
|
c.primaryKey &&
|
||||||
|
c.newData,
|
||||||
|
);
|
||||||
|
if (updates.length === 0) return keys;
|
||||||
|
rows.forEach((row, rowIdx) => {
|
||||||
|
const loc = getLocator(row);
|
||||||
|
for (const c of updates) {
|
||||||
|
const pk = c.primaryKey!;
|
||||||
|
const matches = Object.entries(pk).every(
|
||||||
|
([k, v]) => String(loc[k]) === String(v),
|
||||||
|
);
|
||||||
|
if (!matches) continue;
|
||||||
|
const colName = Object.keys(c.newData!)[0];
|
||||||
|
if (!colName) continue;
|
||||||
|
keys[`${rowIdx}:${colName}`] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a human-friendly display column for FK option labels from the
|
||||||
|
* referenced table's columns: prefer name-like columns, else the first
|
||||||
|
* text-ish column that isn't the ref column, else the ref column itself.
|
||||||
|
*/
|
||||||
|
export function pickDisplayColumn(
|
||||||
|
columns: { name: string; data_type: string }[],
|
||||||
|
refCol: string,
|
||||||
|
preferred?: string,
|
||||||
|
): string {
|
||||||
|
if (preferred && columns.some((c) => c.name === preferred)) return preferred;
|
||||||
|
const nameLike = [
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"label",
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"display_name",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"description",
|
||||||
|
];
|
||||||
|
for (const n of nameLike) {
|
||||||
|
if (columns.some((c) => c.name === n)) return n;
|
||||||
|
}
|
||||||
|
const textish = columns.find(
|
||||||
|
(c) =>
|
||||||
|
c.name !== refCol &&
|
||||||
|
/text|char|name|uuid/i.test(c.data_type),
|
||||||
|
);
|
||||||
|
return textish ? textish.name : refCol;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DbViewerScreenProps {
|
export interface DbViewerScreenProps {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
@@ -48,6 +167,7 @@ export function DbViewerScreen({
|
|||||||
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
|
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
|
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
||||||
const connections = useConnectionStore((s) => s.connections);
|
const connections = useConnectionStore((s) => s.connections);
|
||||||
@@ -84,6 +204,19 @@ export function DbViewerScreen({
|
|||||||
const filterRules = activeTab?.filterRules ?? [];
|
const filterRules = activeTab?.filterRules ?? [];
|
||||||
const sortRules = activeTab?.sortRules ?? [];
|
const sortRules = activeTab?.sortRules ?? [];
|
||||||
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
|
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
|
||||||
|
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||||
|
const tables = useDbViewerStore((s) => s.tables);
|
||||||
|
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||||
|
|
||||||
|
const isMatview =
|
||||||
|
activeTab && activeTab.tabType === "table"
|
||||||
|
? tables.some(
|
||||||
|
(t) =>
|
||||||
|
t.schema === activeTab.schema &&
|
||||||
|
t.name === activeTab.table &&
|
||||||
|
t.table_type === "MATERIALIZED VIEW",
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||||
@@ -96,6 +229,18 @@ export function DbViewerScreen({
|
|||||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||||
const fetchingRef = useRef<Set<string>>(new Set());
|
const fetchingRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// CellEditor options for the active table tab: PG enum labels (cached per
|
||||||
|
// connection+schema) + FK reference rows (page 1, 50 per FK column).
|
||||||
|
const [editorOptions, setEditorOptions] = useState<{
|
||||||
|
enums: Record<string, string[]>;
|
||||||
|
fks: Record<string, FkOption[]>;
|
||||||
|
fkPlaceholders: Record<string, string>;
|
||||||
|
} | null>(null);
|
||||||
|
const enumCacheRef = useRef<Map<string, EnumInfo[]>>(new Map());
|
||||||
|
// Key identifying the (connection, tab, schema) the options were fetched for;
|
||||||
|
// guards against refetching on every render while data updates in place.
|
||||||
|
const editorOptionsKeyRef = useRef<string>("");
|
||||||
|
|
||||||
const fetchData = useCallback(
|
const fetchData = useCallback(
|
||||||
async (tab: NonNullable<typeof activeTab>) => {
|
async (tab: NonNullable<typeof activeTab>) => {
|
||||||
if (fetchingRef.current.has(tab.id)) return;
|
if (fetchingRef.current.has(tab.id)) return;
|
||||||
@@ -139,6 +284,31 @@ export function DbViewerScreen({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStageEdit = useCallback(
|
||||||
|
(payload: {
|
||||||
|
type: "update";
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
primaryKey: Record<string, unknown>;
|
||||||
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
if (!activeTab) return;
|
||||||
|
const { type: _, ...rest } = payload;
|
||||||
|
stageCellEdit({ tabId: activeTab.id, ...rest });
|
||||||
|
},
|
||||||
|
[activeTab, stageCellEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
||||||
|
setRowDetailIdx(rowIndex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCommitted = useCallback(() => {
|
||||||
|
if (!activeTab) return;
|
||||||
|
fetchData(activeTab);
|
||||||
|
}, [activeTab, fetchData]);
|
||||||
|
|
||||||
// Read the active tab from the store directly so the Monaco keybinding action
|
// Read the active tab from the store directly so the Monaco keybinding action
|
||||||
// (which keeps the first onRun closure) always sees the latest query text.
|
// (which keeps the first onRun closure) always sees the latest query text.
|
||||||
const handleRunQuery = useCallback(() => {
|
const handleRunQuery = useCallback(() => {
|
||||||
@@ -243,6 +413,108 @@ export function DbViewerScreen({
|
|||||||
fetchData(activeTab);
|
fetchData(activeTab);
|
||||||
}, [activeTab, fetchData]);
|
}, [activeTab, fetchData]);
|
||||||
|
|
||||||
|
// Feed the grid's CellEditor with enum labels + FK reference rows for the
|
||||||
|
// active table tab. Fetched once per tab/schema (enums additionally cached
|
||||||
|
// per connection+schema across tabs); a failed fetch for one FK column is
|
||||||
|
// skipped without breaking the tab. Never refetches on in-place data updates.
|
||||||
|
useEffect(() => {
|
||||||
|
const key =
|
||||||
|
activeTab && activeTab.tabType === "table" && activeTab.data
|
||||||
|
? `${connectionId}:${activeTab.id}:${activeTab.schema}`
|
||||||
|
: "";
|
||||||
|
if (key === editorOptionsKeyRef.current) return;
|
||||||
|
editorOptionsKeyRef.current = key;
|
||||||
|
if (!key || !activeTab || !activeTab.data) {
|
||||||
|
setEditorOptions(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cols = activeTab.data.columns;
|
||||||
|
const tab = activeTab;
|
||||||
|
void (async () => {
|
||||||
|
const enums: Record<string, string[]> = {};
|
||||||
|
const fks: Record<string, FkOption[]> = {};
|
||||||
|
const fkPlaceholders: Record<string, string> = {};
|
||||||
|
|
||||||
|
// PG enums: fetched once per connection+schema, reused across tabs.
|
||||||
|
const cacheKey = `${connectionId}:${tab.schema}`;
|
||||||
|
let enumList = enumCacheRef.current.get(cacheKey);
|
||||||
|
if (!enumList) {
|
||||||
|
try {
|
||||||
|
enumList = await cmd.getEnums(connectionId, tab.schema);
|
||||||
|
enumCacheRef.current.set(cacheKey, enumList);
|
||||||
|
} catch {
|
||||||
|
enumList = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const col of cols) {
|
||||||
|
const match = enumList.find((e) => e.name === col.data_type);
|
||||||
|
if (match) enums[col.name] = match.labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK options: referenced rows (page 1, 50) per FK column.
|
||||||
|
const fkCols = cols.filter((c) => c.is_fk && c.fk_ref);
|
||||||
|
await Promise.all(
|
||||||
|
fkCols.map(async (col) => {
|
||||||
|
const [refTable, refCol] = col.fk_ref!;
|
||||||
|
try {
|
||||||
|
const result = await cmd.getTableData(
|
||||||
|
connectionId,
|
||||||
|
tab.schema,
|
||||||
|
refTable,
|
||||||
|
1,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
const refIdx = result.columns.findIndex(
|
||||||
|
(c) => c.name === refCol,
|
||||||
|
);
|
||||||
|
if (refIdx >= 0) {
|
||||||
|
const displayCol = pickDisplayColumn(
|
||||||
|
result.columns,
|
||||||
|
refCol,
|
||||||
|
);
|
||||||
|
const displayIdx =
|
||||||
|
displayCol === refCol
|
||||||
|
? refIdx
|
||||||
|
: result.columns.findIndex(
|
||||||
|
(c) => c.name === displayCol,
|
||||||
|
);
|
||||||
|
fks[col.name] = result.rows.map((row) => {
|
||||||
|
const refValue = String(row[refIdx]);
|
||||||
|
const dispValue =
|
||||||
|
displayIdx >= 0 && displayIdx !== refIdx
|
||||||
|
? String(row[displayIdx])
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
value: refValue,
|
||||||
|
label:
|
||||||
|
dispValue && dispValue !== refValue
|
||||||
|
? `${refValue} — ${dispValue}`
|
||||||
|
: refValue,
|
||||||
|
// Referenced-row cells for the FK-reference-style
|
||||||
|
// one-row dropdown (first 5 columns).
|
||||||
|
cells: result.columns
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((c, i) => ({
|
||||||
|
name: c.name,
|
||||||
|
value: String(row[i] ?? ""),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
fkPlaceholders[col.name] = `Search ${refTable}…`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip this FK column; the cell keeps the plain editor.
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply only if no newer fetch superseded this one (tab/schema switched).
|
||||||
|
if (editorOptionsKeyRef.current === key) {
|
||||||
|
setEditorOptions({ enums, fks, fkPlaceholders });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [activeTab, connectionId]);
|
||||||
|
|
||||||
// Smart default sort: apply once when data first loads for a tab
|
// Smart default sort: apply once when data first loads for a tab
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTab) return;
|
if (!activeTab) return;
|
||||||
@@ -630,9 +902,48 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
const activeTable = activeTab?.table ?? "";
|
const activeTable = activeTab?.table ?? "";
|
||||||
|
|
||||||
function renderQueryWorkspace() {
|
function renderQueryWorkspace() {
|
||||||
|
const getLocator = (row: unknown[]) => {
|
||||||
|
const pkCol = columns.find((c) => c.is_pk);
|
||||||
|
if (pkCol) {
|
||||||
|
const pkIndex = columns.findIndex(
|
||||||
|
(c) => c.name === pkCol.name,
|
||||||
|
);
|
||||||
|
return { [pkCol.name]: row[pkIndex] };
|
||||||
|
}
|
||||||
|
const dbType = currentConnection?.db_type ?? "postgresql";
|
||||||
|
const locatorIndex = columns.length;
|
||||||
|
if (dbType === "sqlite") {
|
||||||
|
return { rowid: row[locatorIndex] };
|
||||||
|
}
|
||||||
|
return { ctid: row[locatorIndex] };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Staged cell values derived from the changes queue (single source of
|
||||||
|
// truth): keyed `${rowIndex}:${colName}` → optimistic value. Pending +
|
||||||
|
// committed updates survive until refetch; Clear All empties the queue
|
||||||
|
// so the optimistic display and pending dots vanish immediately.
|
||||||
|
const stagedValues = activeTab?.data
|
||||||
|
? deriveStagedValues(
|
||||||
|
changesQueue,
|
||||||
|
activeTab.schema,
|
||||||
|
activeTab.table,
|
||||||
|
activeTab.data.rows,
|
||||||
|
getLocator,
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
const pendingKeys = activeTab?.data
|
||||||
|
? derivePendingCellKeys(
|
||||||
|
changesQueue,
|
||||||
|
activeTab.schema,
|
||||||
|
activeTab.table,
|
||||||
|
activeTab.data.rows,
|
||||||
|
getLocator,
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||||
<TabBar />
|
<TabBar onCommitted={handleCommitted} />
|
||||||
{!activeTab ? (
|
{!activeTab ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||||
{currentView === "queries" ? (
|
{currentView === "queries" ? (
|
||||||
@@ -794,16 +1105,29 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
variant="query"
|
variant="query"
|
||||||
|
isMatview={isMatview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<VirtualDataGrid
|
<VirtualDataGrid
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
schema={activeSchema}
|
schema={activeSchema}
|
||||||
|
table={activeTable}
|
||||||
rows={processedRows}
|
rows={processedRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hiddenColumns={hiddenColumns}
|
hiddenColumns={hiddenColumns}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
|
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||||
|
tabType={activeTab?.tabType ?? "table"}
|
||||||
|
getLocator={getLocator}
|
||||||
|
onStageEdit={isMatview ? undefined : handleStageEdit}
|
||||||
|
onOpenRowDetail={handleOpenRowDetail}
|
||||||
|
readOnly={isMatview}
|
||||||
|
enumValues={editorOptions?.enums}
|
||||||
|
fkOptions={editorOptions?.fks}
|
||||||
|
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||||
|
stagedValues={stagedValues}
|
||||||
|
pendingKeys={pendingKeys}
|
||||||
onToggleRow={(rowIndex) => {
|
onToggleRow={(rowIndex) => {
|
||||||
setSelectedRows(
|
setSelectedRows(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
@@ -934,16 +1258,29 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
onClearSelection={() =>
|
onClearSelection={() =>
|
||||||
setSelectedRows(new Set())
|
setSelectedRows(new Set())
|
||||||
}
|
}
|
||||||
|
isMatview={isMatview}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<VirtualDataGrid
|
<VirtualDataGrid
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
schema={activeSchema}
|
schema={activeSchema}
|
||||||
|
table={activeTable}
|
||||||
rows={processedRows}
|
rows={processedRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hiddenColumns={hiddenColumns}
|
hiddenColumns={hiddenColumns}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
|
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||||
|
tabType={activeTab?.tabType ?? "table"}
|
||||||
|
getLocator={getLocator}
|
||||||
|
onStageEdit={isMatview ? undefined : handleStageEdit}
|
||||||
|
onOpenRowDetail={handleOpenRowDetail}
|
||||||
|
readOnly={isMatview}
|
||||||
|
enumValues={editorOptions?.enums}
|
||||||
|
fkOptions={editorOptions?.fks}
|
||||||
|
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||||
|
stagedValues={stagedValues}
|
||||||
|
pendingKeys={pendingKeys}
|
||||||
onToggleRow={(rowIndex) => {
|
onToggleRow={(rowIndex) => {
|
||||||
setSelectedRows((prev) => {
|
setSelectedRows((prev) => {
|
||||||
const next = new Set(
|
const next = new Set(
|
||||||
@@ -978,6 +1315,16 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{rowDetailIdx !== null && activeTab?.data && (
|
||||||
|
<RowDetailDrawer
|
||||||
|
columns={activeTab.data.columns}
|
||||||
|
row={activeTab.data.rows[rowDetailIdx]}
|
||||||
|
onClose={() => setRowDetailIdx(null)}
|
||||||
|
onCopy={(value) =>
|
||||||
|
navigator.clipboard.writeText(value).catch(() => {})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { FilterBuilder } from "./FilterBuilder";
|
||||||
|
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: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("FilterBuilder", () => {
|
||||||
|
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
|
||||||
|
fireEvent.click(screen.getByText("name"));
|
||||||
|
expect(onChange).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ column: "name", operator: "contains" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("removing a rule calls onChange without it", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
|
||||||
|
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Remove filter name"));
|
||||||
|
expect(onChange).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { defaultFilterOperator } from "../grid/gridEditability";
|
||||||
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
|
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
rules: FilterRule[];
|
||||||
|
onChange: (rules: FilterRule[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
|
||||||
|
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||||
|
id: `col-${col.name}`,
|
||||||
|
data: { column: col },
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={setNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={onAdd}
|
||||||
|
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
|
||||||
|
isDragging ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{col.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATORS: FilterOperator[] = [
|
||||||
|
"eq",
|
||||||
|
"neq",
|
||||||
|
"contains",
|
||||||
|
"starts",
|
||||||
|
"ends",
|
||||||
|
"gt",
|
||||||
|
"lt",
|
||||||
|
"null",
|
||||||
|
"notnull",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FilterBuilder({ columns, rules, onChange }: Props) {
|
||||||
|
const [val, setVal] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const addRule = (col: ColumnInfo) => {
|
||||||
|
const op = defaultFilterOperator(col.data_type);
|
||||||
|
onChange([
|
||||||
|
...rules,
|
||||||
|
{
|
||||||
|
id: `f-${Date.now()}-${col.name}`,
|
||||||
|
column: col.name,
|
||||||
|
operator: op,
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
|
||||||
|
|
||||||
|
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||||
|
const update = (id: string, patch: Partial<FilterRule>) =>
|
||||||
|
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
const id = e.active.id as string;
|
||||||
|
const colName = id.replace(/^col-/, "");
|
||||||
|
const col = columns.find((c) => c.name === colName);
|
||||||
|
if (col && e.over?.id === "filter-dropzone") addRule(col);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
|
||||||
|
isOver ? "border-accent bg-surface" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
Drop columns here to add filters
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rules.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-text font-semibold">{r.column}</span>
|
||||||
|
<select
|
||||||
|
value={r.operator}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(r.id, { operator: e.target.value as FilterOperator })
|
||||||
|
}
|
||||||
|
className="bg-surface border border-border rounded px-1"
|
||||||
|
>
|
||||||
|
{OPERATORS.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{!["null", "notnull"].includes(r.operator) && (
|
||||||
|
<input
|
||||||
|
value={val[r.id] ?? r.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label={`Remove filter ${r.column}`}
|
||||||
|
onClick={() => remove(r.id)}
|
||||||
|
className="text-text-muted hover:text-red-400"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
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(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
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(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
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(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
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(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
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(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
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(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
|
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
|
||||||
import {
|
import {
|
||||||
|
BookMarked,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FunctionSquare,
|
FunctionSquare,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
ListChecks,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
|
SquareFunction,
|
||||||
Tag,
|
Tag,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Search,
|
Search,
|
||||||
@@ -19,6 +22,8 @@ import type {
|
|||||||
SequenceInfo,
|
SequenceInfo,
|
||||||
EnumInfo,
|
EnumInfo,
|
||||||
ExtensionInfo,
|
ExtensionInfo,
|
||||||
|
IndexInfo,
|
||||||
|
ConstraintInfo,
|
||||||
} from "../../lib/types";
|
} from "../../lib/types";
|
||||||
|
|
||||||
export type ObjectType =
|
export type ObjectType =
|
||||||
@@ -26,7 +31,10 @@ export type ObjectType =
|
|||||||
| "triggers"
|
| "triggers"
|
||||||
| "sequences"
|
| "sequences"
|
||||||
| "enums"
|
| "enums"
|
||||||
| "extensions";
|
| "extensions"
|
||||||
|
| "indexes"
|
||||||
|
| "constraints"
|
||||||
|
| "procedures";
|
||||||
|
|
||||||
interface ObjectExplorerPageProps {
|
interface ObjectExplorerPageProps {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
@@ -38,6 +46,9 @@ const TYPE_LABELS: Record<ObjectType, string> = {
|
|||||||
sequences: "Sequences",
|
sequences: "Sequences",
|
||||||
enums: "Enums",
|
enums: "Enums",
|
||||||
extensions: "Extensions",
|
extensions: "Extensions",
|
||||||
|
indexes: "Indexes",
|
||||||
|
constraints: "Constraints",
|
||||||
|
procedures: "Procedures",
|
||||||
};
|
};
|
||||||
|
|
||||||
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
||||||
@@ -50,8 +61,21 @@ const SINGULAR_LABELS: Record<ObjectType, string> = {
|
|||||||
sequences: "sequence",
|
sequences: "sequence",
|
||||||
enums: "enum",
|
enums: "enum",
|
||||||
extensions: "extension",
|
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<string, string> = {
|
||||||
|
index: "indexes",
|
||||||
|
constraint: "constraints",
|
||||||
|
};
|
||||||
|
return irregulars[singular] ?? `${singular}s`;
|
||||||
|
}
|
||||||
|
|
||||||
const ICONS: Record<ObjectType, React.ReactNode> = {
|
const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||||
functions: (
|
functions: (
|
||||||
<FunctionSquare size={14} className="text-text-muted shrink-0" />
|
<FunctionSquare size={14} className="text-text-muted shrink-0" />
|
||||||
@@ -60,6 +84,9 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
|
|||||||
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||||
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||||
|
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
|
||||||
|
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
|
||||||
|
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
type AnyObject =
|
type AnyObject =
|
||||||
@@ -67,7 +94,9 @@ type AnyObject =
|
|||||||
| TriggerInfo
|
| TriggerInfo
|
||||||
| SequenceInfo
|
| SequenceInfo
|
||||||
| EnumInfo
|
| EnumInfo
|
||||||
| ExtensionInfo;
|
| ExtensionInfo
|
||||||
|
| IndexInfo
|
||||||
|
| ConstraintInfo;
|
||||||
|
|
||||||
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
|
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
|
||||||
function itemKey(item: AnyObject): string {
|
function itemKey(item: AnyObject): string {
|
||||||
@@ -487,10 +516,7 @@ function SyntaxCode({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail(type: ObjectType, item: AnyObject) {
|
function renderFunctionDetail(f: FunctionInfo) {
|
||||||
switch (type) {
|
|
||||||
case "functions": {
|
|
||||||
const f = item as FunctionInfo;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="border-b border-border px-4 py-2">
|
<div className="border-b border-border px-4 py-2">
|
||||||
@@ -590,6 +616,191 @@ function renderDetail(type: ObjectType, item: AnyObject) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(type: ObjectType, item: AnyObject) {
|
||||||
|
switch (type) {
|
||||||
|
case "functions":
|
||||||
|
return renderFunctionDetail(item as FunctionInfo);
|
||||||
|
case "procedures":
|
||||||
|
return renderFunctionDetail(item as FunctionInfo);
|
||||||
|
case "indexes": {
|
||||||
|
const idx = item as IndexInfo;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Index
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Table
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{idx.table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Method
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{idx.method}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Unique
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
|
||||||
|
>
|
||||||
|
{idx.is_unique ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Size
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{idx.size_bytes ?? "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{idx.columns.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Columns
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
{idx.columns.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{idx.columns.map((col, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b border-border px-4 py-2 flex items-center"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{idx.definition && (
|
||||||
|
<>
|
||||||
|
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Definition
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
SQL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SyntaxCode source={idx.definition} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "constraints": {
|
||||||
|
const c = item as ConstraintInfo;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Constraint
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Type
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{c.contype}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Table
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{c.table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Deferrable
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
|
||||||
|
>
|
||||||
|
{c.deferrable ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Validated
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
|
||||||
|
>
|
||||||
|
{c.validated ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{c.columns.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Columns
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
{c.columns.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{c.columns.map((col, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b border-border px-4 py-2 flex items-center"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{c.definition && (
|
||||||
|
<>
|
||||||
|
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Definition
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
SQL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SyntaxCode source={c.definition} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case "triggers": {
|
case "triggers": {
|
||||||
const t = item as TriggerInfo;
|
const t = item as TriggerInfo;
|
||||||
@@ -867,10 +1078,15 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
|||||||
if (type === "extensions") {
|
if (type === "extensions") {
|
||||||
result = await cmd.getExtensions(connectionId);
|
result = await cmd.getExtensions(connectionId);
|
||||||
} else if (type === "functions") {
|
} else if (type === "functions") {
|
||||||
result = await cmd.getFunctions(
|
result = (await cmd.getFunctions(
|
||||||
connectionId,
|
connectionId,
|
||||||
currentSchema ?? undefined,
|
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") {
|
} else if (type === "triggers") {
|
||||||
result = await cmd.getTriggers(
|
result = await cmd.getTriggers(
|
||||||
connectionId,
|
connectionId,
|
||||||
@@ -886,6 +1102,16 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
|||||||
connectionId,
|
connectionId,
|
||||||
currentSchema ?? undefined,
|
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 {
|
} else {
|
||||||
result = [];
|
result = [];
|
||||||
}
|
}
|
||||||
@@ -1087,11 +1313,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
|||||||
|
|
||||||
{!loading && !error && filtered.length === 0 && (
|
{!loading && !error && filtered.length === 0 && (
|
||||||
<div className="px-3 py-2 text-sm text-text-muted">
|
<div className="px-3 py-2 text-sm text-text-muted">
|
||||||
{items === null
|
{searchQuery
|
||||||
? `No ${label.toLowerCase()} found`
|
? `No ${emptyPlural(type)} matching "${searchQuery}"`
|
||||||
: searchQuery
|
: `No ${emptyPlural(type)} found`}
|
||||||
? `No ${label.toLowerCase()} matching "${searchQuery}"`
|
|
||||||
: `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
|
|||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||||
const tabs = useDbViewerStore((state) => state.tabs);
|
const tabs = useDbViewerStore((state) => state.tabs);
|
||||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||||
const closeTab = useDbViewerStore((state) => state.closeTab);
|
const closeTab = useDbViewerStore((state) => state.closeTab);
|
||||||
@@ -129,7 +129,7 @@ export function TabBar() {
|
|||||||
</button>
|
</button>
|
||||||
{changesPanelExpanded && (
|
{changesPanelExpanded && (
|
||||||
<div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden">
|
<div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden">
|
||||||
<ChangesQueuePanel />
|
<ChangesQueuePanel onCommitted={onCommitted} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const columns = [
|
|||||||
is_fk: false,
|
is_fk: false,
|
||||||
fk_ref: null,
|
fk_ref: null,
|
||||||
default_value: null,
|
default_value: null,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -314,4 +316,19 @@ describe("TableControls", () => {
|
|||||||
});
|
});
|
||||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
|
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
|
||||||
ChevronDown, FileJson, FileText, Terminal,
|
ChevronDown, FileJson, FileText, Terminal,
|
||||||
} from "lucide-react";
|
} 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 { Tooltip } from "../ui/Tooltip";
|
||||||
import { exportData } from "../../lib/exportData";
|
import { exportData } from "../../lib/exportData";
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
@@ -27,19 +28,6 @@ const EXPORT_FORMATS = [
|
|||||||
{ label: "Markdown", ext: "md" },
|
{ label: "Markdown", ext: "md" },
|
||||||
] as const;
|
] 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 ────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +124,7 @@ function FilterModal({
|
|||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||||
<select
|
<select
|
||||||
@@ -414,6 +403,8 @@ interface TableControlsProps {
|
|||||||
selectedRows: unknown[][];
|
selectedRows: unknown[][];
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
defaultRefreshRate?: number;
|
defaultRefreshRate?: number;
|
||||||
|
/** Hide data-modifying affordances (e.g. for materialized views). */
|
||||||
|
isMatview?: boolean;
|
||||||
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
||||||
variant?: "table" | "query";
|
variant?: "table" | "query";
|
||||||
}
|
}
|
||||||
@@ -435,6 +426,7 @@ export function TableControls({
|
|||||||
selectedRows,
|
selectedRows,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
defaultRefreshRate = 0,
|
defaultRefreshRate = 0,
|
||||||
|
isMatview = false,
|
||||||
variant = "table",
|
variant = "table",
|
||||||
}: TableControlsProps) {
|
}: TableControlsProps) {
|
||||||
const isQuery = variant === "query";
|
const isQuery = variant === "query";
|
||||||
@@ -630,6 +622,8 @@ export function TableControls({
|
|||||||
{columnsControl}
|
{columnsControl}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{!isMatview && (
|
||||||
<>
|
<>
|
||||||
{/* Insert Row */}
|
{/* Insert Row */}
|
||||||
<Tooltip content="Insert row" side="bottom">
|
<Tooltip content="Insert row" side="bottom">
|
||||||
@@ -642,6 +636,8 @@ export function TableControls({
|
|||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{refreshControl}
|
{refreshControl}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe("TableOverflowMenu", () => {
|
|||||||
|
|
||||||
it("Export data calls exportData when rows and columns are provided", async () => {
|
it("Export data calls exportData when rows and columns are provided", async () => {
|
||||||
const spy = vi.spyOn(exportData, "exportData");
|
const spy = vi.spyOn(exportData, "exportData");
|
||||||
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null }];
|
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
|
||||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
|
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
|
||||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ describe("TableTree", () => {
|
|||||||
expect(screen.getByText("orders")).toBeInTheDocument();
|
expect(screen.getByText("orders")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a distinct icon and label for materialized views", () => {
|
||||||
|
useDbViewerStore.setState({
|
||||||
|
schemas: ["public"],
|
||||||
|
currentSchema: "public",
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: "mv_products",
|
||||||
|
schema: "public",
|
||||||
|
table_type: "MATERIALIZED VIEW" as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<TableTree />);
|
||||||
|
expect(screen.getByText("mv_products")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Materialized View")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens a tab when table is clicked", async () => {
|
it("opens a tab when table is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
useDbViewerStore.setState({
|
useDbViewerStore.setState({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react";
|
import { ChevronRight, ChevronDown, Table2, Layers, Key, Type } from "lucide-react";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||||
@@ -70,6 +70,9 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
|||||||
const key = `${table.schema}.${table.name}`;
|
const key = `${table.schema}.${table.name}`;
|
||||||
const isExpanded = expanded.has(key);
|
const isExpanded = expanded.has(key);
|
||||||
const cols = columnCache[key] ?? table.columns ?? [];
|
const cols = columnCache[key] ?? table.columns ?? [];
|
||||||
|
const isMatView = table.table_type === "MATERIALIZED VIEW";
|
||||||
|
const TypeIcon = isMatView ? Layers : Table2;
|
||||||
|
const typeLabel = isMatView ? "Materialized View" : null;
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<div
|
<div
|
||||||
@@ -90,10 +93,15 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
|||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Table2 size={14} className="text-text-muted" />
|
<TypeIcon size={14} className="text-text-muted" />
|
||||||
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
|
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
|
||||||
{table.name}
|
{table.name}
|
||||||
</span>
|
</span>
|
||||||
|
{typeLabel && (
|
||||||
|
<span className="text-[10px] text-text-subtle shrink-0">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<TableOverflowMenu
|
<TableOverflowMenu
|
||||||
schema={table.schema}
|
schema={table.schema}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { CellContextMenu } from "./CellContextMenu";
|
||||||
|
|
||||||
|
describe("CellContextMenu", () => {
|
||||||
|
const base = {
|
||||||
|
anchorRect: { top: 0, left: 0, width: 10, height: 10 } as DOMRect,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onCopy: vi.fn(), onCopyJson: vi.fn(), onEdit: vi.fn(), onSetNull: vi.fn(), onOpenFk: vi.fn(),
|
||||||
|
onViewRow: vi.fn(), onSelectRow: vi.fn(),
|
||||||
|
};
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
it("renders Copy, Edit, Set NULL for an editable scalar cell", () => {
|
||||||
|
render(<CellContextMenu {...base} editable isJson={false} isFk={false} nullable />);
|
||||||
|
expect(screen.getByText("Copy")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Edit")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Set NULL")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("renders Copy JSON for a json cell and hides Edit when not editable", () => {
|
||||||
|
render(<CellContextMenu {...base} editable={false} isJson isFk={false} nullable={false} />);
|
||||||
|
expect(screen.getByText("Copy JSON")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Edit")).toBeNull();
|
||||||
|
});
|
||||||
|
it("renders Open FK reference for an FK cell", () => {
|
||||||
|
render(<CellContextMenu {...base} editable={false} isJson={false} isFk nullable={false} />);
|
||||||
|
expect(screen.getByText("Open FK reference")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("Open FK reference"));
|
||||||
|
expect(base.onOpenFk).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("always renders View Row and Select Row", () => {
|
||||||
|
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
|
||||||
|
expect(screen.getByText("View Row")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Select Row")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it("calls onViewRow when View Row is clicked", () => {
|
||||||
|
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
|
||||||
|
fireEvent.click(screen.getByText("View Row"));
|
||||||
|
expect(base.onViewRow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(base.onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it("calls onSelectRow when Select Row is clicked", () => {
|
||||||
|
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
|
||||||
|
fireEvent.click(screen.getByText("Select Row"));
|
||||||
|
expect(base.onSelectRow).toHaveBeenCalledTimes(1);
|
||||||
|
expect(base.onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Copy, Braces, Pencil, CircleSlash, Link2, Eye, MousePointerClick } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
anchorRect: DOMRect;
|
||||||
|
editable: boolean;
|
||||||
|
isJson: boolean;
|
||||||
|
isFk: boolean;
|
||||||
|
nullable: 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) {
|
||||||
|
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: "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 },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 min-w-[160px] bg-canvas border border-border rounded-md shadow-lg py-1 text-xs"
|
||||||
|
style={{ top: anchorRect.bottom, left: anchorRect.left }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{items.filter((i) => i.show).map((i) => (
|
||||||
|
<button key={i.label} onClick={i.onClick}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface text-left text-text">
|
||||||
|
{i.icon} {i.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { CellEditor } from "./CellEditor";
|
||||||
|
|
||||||
|
describe("CellEditor", () => {
|
||||||
|
it("renders the initial value and commits on Enter", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={onCancel} />);
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input).toHaveValue("Alice");
|
||||||
|
fireEvent.change(input, { target: { value: "Alicia" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
||||||
|
});
|
||||||
|
it("commits null when the setNull flag is toggled", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
||||||
|
const nullCheckbox = screen.getByLabelText(/set null/i);
|
||||||
|
fireEvent.click(nullCheckbox);
|
||||||
|
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||||
|
expect(onCommit).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
it("cancels on Escape", () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(<CellEditor initialValue="Alice" dataType="text" onCommit={vi.fn()} onCancel={onCancel} />);
|
||||||
|
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
||||||
|
expect(onCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("uses textarea for large/JSON columns", () => {
|
||||||
|
render(<CellEditor initialValue="{}" dataType="jsonb" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
|
||||||
|
});
|
||||||
|
it("renders a combobox with enum values and commits on change", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
render(
|
||||||
|
<CellEditor
|
||||||
|
initialValue="active"
|
||||||
|
dataType="text"
|
||||||
|
enumValues={["active", "inactive", "pending"]}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
expect(select).toBeInTheDocument();
|
||||||
|
const labels = screen.getAllByRole("option").map((o) => o.textContent);
|
||||||
|
expect(labels).toEqual(expect.arrayContaining(["active", "inactive", "pending"]));
|
||||||
|
fireEvent.change(select, { target: { value: "pending" } });
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("pending");
|
||||||
|
});
|
||||||
|
it("commits null via Set NULL in enum mode", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
render(
|
||||||
|
<CellEditor
|
||||||
|
initialValue="active"
|
||||||
|
dataType="text"
|
||||||
|
enumValues={["active", "inactive", "pending"]}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
nullable
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText(/set null/i));
|
||||||
|
expect(onCommit).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
it("filters FK options by query and commits the clicked value", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
render(
|
||||||
|
<CellEditor
|
||||||
|
initialValue="1"
|
||||||
|
dataType="integer"
|
||||||
|
fkOptions={[
|
||||||
|
{ value: "1", label: "1 — Alice" },
|
||||||
|
{ value: "2", label: "2 — Bob" },
|
||||||
|
]}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const search = screen.getByLabelText(/search foreign key/i);
|
||||||
|
fireEvent.change(search, { target: { value: "bo" } });
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
expect(buttons).toHaveLength(1);
|
||||||
|
expect(buttons[0]).toHaveTextContent("2 — Bob");
|
||||||
|
fireEvent.click(buttons[0]);
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("2");
|
||||||
|
});
|
||||||
|
it("shows all FK options when the query is empty", () => {
|
||||||
|
render(
|
||||||
|
<CellEditor
|
||||||
|
initialValue="1"
|
||||||
|
dataType="integer"
|
||||||
|
fkOptions={[
|
||||||
|
{ value: "1", label: "1 — Alice" },
|
||||||
|
{ value: "2", label: "2 — Bob" },
|
||||||
|
]}
|
||||||
|
onCommit={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
it("renders text columns as a single-line scrolling textarea", () => {
|
||||||
|
render(<CellEditor initialValue="long text" dataType="text" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input.tagName).toBe("TEXTAREA");
|
||||||
|
expect(input.className).toContain("h-6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the FK placeholder and a No matches empty state", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
render(<CellEditor initialValue="" dataType="integer"
|
||||||
|
fkOptions={[{ value: "1", label: "1 — Alice" }]} fkPlaceholder="Search users…"
|
||||||
|
onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
|
const search = screen.getByLabelText(/search foreign key/i);
|
||||||
|
expect(search).toHaveAttribute("placeholder", "Search users…");
|
||||||
|
fireEvent.change(search, { target: { value: "zzz" } });
|
||||||
|
expect(screen.getByText("No matches")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the FK option list as a fixed-position overlay so it is never clipped", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
render(<CellEditor initialValue="" dataType="integer"
|
||||||
|
fkOptions={[{ value: "1", label: "1 — Alice" }, { value: "2", label: "2 — Bob" }]}
|
||||||
|
onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
|
const list = screen.getByTestId("fk-options");
|
||||||
|
expect(list.style.position).toBe("fixed");
|
||||||
|
expect(document.body.contains(list)).toBe(true);
|
||||||
|
expect(list).toHaveTextContent("1 — Alice");
|
||||||
|
expect(list).toHaveTextContent("2 — Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders FK options as one-row values only (FK-reference style, cap 4, fixed width)", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const cells = Array.from({ length: 6 }, (_, i) => ({
|
||||||
|
name: `col${i}`,
|
||||||
|
value: `v${i}`,
|
||||||
|
}));
|
||||||
|
render(<CellEditor initialValue="" dataType="integer"
|
||||||
|
fkOptions={[{ value: "1", label: "1", cells }]}
|
||||||
|
onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
|
const list = screen.getByTestId("fk-options");
|
||||||
|
// first 3 values shown, column names NOT shown, 4th+ capped
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
expect(list).toHaveTextContent(`v${i}`);
|
||||||
|
}
|
||||||
|
expect(list).not.toHaveTextContent("col0");
|
||||||
|
expect(list).not.toHaveTextContent("v4");
|
||||||
|
// fixed 360px width, FK-viewer surface styling
|
||||||
|
expect(list.style.width).toBe("360px");
|
||||||
|
expect(list.className).toContain("bg-surface");
|
||||||
|
expect(list.className).toContain("rounded-lg");
|
||||||
|
expect(list.className).toContain("shadow-xl");
|
||||||
|
// clicking the row commits the value
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export interface FkOption {
|
||||||
|
value: string; // the referenced column's value (what gets committed)
|
||||||
|
label: string; // fallback display text (e.g. "42 — Alice")
|
||||||
|
/** Referenced row cells shown in one row (≤5 columns), FK-reference style. */
|
||||||
|
cells?: { name: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FK_CELLS = 4;
|
||||||
|
const FK_DROPDOWN_WIDTH = 360;
|
||||||
|
|
||||||
|
interface CellEditorProps {
|
||||||
|
initialValue: string;
|
||||||
|
dataType: string;
|
||||||
|
nullable?: boolean;
|
||||||
|
enumValues?: string[]; // when present → render <select> 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<HTMLTextAreaElement | HTMLInputElement>(null);
|
||||||
|
const enumRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||||
|
<select
|
||||||
|
ref={enumRef}
|
||||||
|
className={inputClass}
|
||||||
|
value={initialValue}
|
||||||
|
onChange={(e) => onCommit(setNull ? null : e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{nullable ? "NULL" : "—"}</option>
|
||||||
|
{enumValues.map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{nullable && (
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setNull}
|
||||||
|
onChange={(e) => handleSetNull(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Set NULL
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
aria-label="Search foreign key options"
|
||||||
|
placeholder={fkPlaceholder ?? "Search…"}
|
||||||
|
className={inputClass}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => 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(
|
||||||
|
<div
|
||||||
|
data-testid="fk-options"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: fkDropdownPos.top,
|
||||||
|
left: fkDropdownPos.left,
|
||||||
|
width: fkDropdownPos.width,
|
||||||
|
minWidth: fkDropdownPos.width,
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
className="max-h-28 overflow-y-auto bg-surface border border-border rounded-lg shadow-xl"
|
||||||
|
>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="px-2 py-1 text-xs text-text-muted">No matches</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left"
|
||||||
|
onClick={() => onCommit(o.value)}
|
||||||
|
>
|
||||||
|
{o.cells && o.cells.length > 0 ? (
|
||||||
|
<span className="flex items-center gap-0 min-w-0">
|
||||||
|
{o.cells.slice(0, MAX_FK_CELLS).map((c, ci) => (
|
||||||
|
<span key={ci} className="flex items-center min-w-0">
|
||||||
|
{ci > 0 && (
|
||||||
|
<span className="mx-1.5 h-3 w-px bg-border shrink-0" />
|
||||||
|
)}
|
||||||
|
{c.value === "" ? (
|
||||||
|
<span className="italic text-text-muted">NULL</span>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-text">{c.value}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
o.label
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
{nullable && (
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setNull}
|
||||||
|
onChange={(e) => handleSetNull(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Set NULL
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cls = large ? `${inputClass} h-6 resize-none overflow-y-auto leading-none` : inputClass;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||||
|
<Tag
|
||||||
|
ref={ref as any}
|
||||||
|
className={cls}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setNull}
|
||||||
|
onChange={(e) => handleSetNull(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Set NULL
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<RowDetailDrawer columns={cols} row={[1, { a: 2 }]} onClose={vi.fn()} onCopy={vi.fn()} />);
|
||||||
|
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(<RowDetailDrawer columns={cols} row={[1, { a: 2 }]} onClose={vi.fn()} onCopy={onCopy} />);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className="fixed top-0 right-0 h-full w-96 bg-canvas border-l border-border z-40 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<span className="text-sm font-semibold text-text">Row detail</span>
|
||||||
|
<button onClick={onClose} aria-label="Close" className="text-text-muted hover:text-text"><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-3 space-y-2">
|
||||||
|
{columns.map((c, i) => {
|
||||||
|
const v = row[i];
|
||||||
|
const isJson = c.data_type === "json" || c.data_type === "jsonb";
|
||||||
|
const text = fmt(v);
|
||||||
|
return (
|
||||||
|
<div key={c.name} className="border border-border rounded p-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-text">{c.name}</span>
|
||||||
|
<button onClick={() => onCopy(text)} aria-label={`Copy ${c.name}`} className="text-text-muted hover:text-text"><Copy size={12} /></button>
|
||||||
|
</div>
|
||||||
|
<pre className={`text-xs mt-1 whitespace-pre-wrap break-all ${isJson ? "font-mono text-accent/80" : "text-text-muted"}`}>{text}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { VirtualDataGrid } from "./VirtualDataGrid";
|
|||||||
import type { ColumnInfo } from "../../lib/types";
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
|
|
||||||
const mockColumns: ColumnInfo[] = [
|
const mockColumns: ColumnInfo[] = [
|
||||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, 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 },
|
{ 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[][] = [
|
const mockRows: unknown[][] = [
|
||||||
@@ -36,6 +36,78 @@ describe("VirtualDataGrid", () => {
|
|||||||
vi.clearAllMocks();
|
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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{ "0:name": true }} />);
|
||||||
|
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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{}} />);
|
||||||
|
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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} />);
|
||||||
|
expect(screen.getByText("Alicia")).toBeInTheDocument();
|
||||||
|
rerender(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" stagedValues={{}} />);
|
||||||
|
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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
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", () => {
|
it("renders all rows when row count is small", () => {
|
||||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||||
mockGetVirtualItems.mockReturnValue(
|
mockGetVirtualItems.mockReturnValue(
|
||||||
@@ -51,12 +123,15 @@ describe("VirtualDataGrid", () => {
|
|||||||
<VirtualDataGrid
|
<VirtualDataGrid
|
||||||
connectionId="conn-1"
|
connectionId="conn-1"
|
||||||
schema="public"
|
schema="public"
|
||||||
|
table="users"
|
||||||
rows={mockRows}
|
rows={mockRows}
|
||||||
columns={mockColumns}
|
columns={mockColumns}
|
||||||
hiddenColumns={new Set()}
|
hiddenColumns={new Set()}
|
||||||
selectedRows={new Set()}
|
selectedRows={new Set()}
|
||||||
onToggleRow={() => {}}
|
onToggleRow={() => {}}
|
||||||
onToggleAll={() => {}}
|
onToggleAll={() => {}}
|
||||||
|
dbType="postgresql"
|
||||||
|
tabType="table"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -79,12 +154,15 @@ describe("VirtualDataGrid", () => {
|
|||||||
<VirtualDataGrid
|
<VirtualDataGrid
|
||||||
connectionId="conn-1"
|
connectionId="conn-1"
|
||||||
schema="public"
|
schema="public"
|
||||||
|
table="users"
|
||||||
rows={mockRows}
|
rows={mockRows}
|
||||||
columns={mockColumns}
|
columns={mockColumns}
|
||||||
hiddenColumns={new Set()}
|
hiddenColumns={new Set()}
|
||||||
selectedRows={new Set()}
|
selectedRows={new Set()}
|
||||||
onToggleRow={() => {}}
|
onToggleRow={() => {}}
|
||||||
onToggleAll={() => {}}
|
onToggleAll={() => {}}
|
||||||
|
dbType="postgresql"
|
||||||
|
tabType="table"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -109,12 +187,15 @@ describe("VirtualDataGrid", () => {
|
|||||||
<VirtualDataGrid
|
<VirtualDataGrid
|
||||||
connectionId="conn-1"
|
connectionId="conn-1"
|
||||||
schema="public"
|
schema="public"
|
||||||
|
table="users"
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={mockColumns}
|
columns={mockColumns}
|
||||||
hiddenColumns={new Set()}
|
hiddenColumns={new Set()}
|
||||||
selectedRows={new Set()}
|
selectedRows={new Set()}
|
||||||
onToggleRow={() => {}}
|
onToggleRow={() => {}}
|
||||||
onToggleAll={() => {}}
|
onToggleAll={() => {}}
|
||||||
|
dbType="postgresql"
|
||||||
|
tabType="table"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,12 +211,15 @@ describe("VirtualDataGrid", () => {
|
|||||||
<VirtualDataGrid
|
<VirtualDataGrid
|
||||||
connectionId="conn-1"
|
connectionId="conn-1"
|
||||||
schema="public"
|
schema="public"
|
||||||
|
table="users"
|
||||||
rows={[]}
|
rows={[]}
|
||||||
columns={mockColumns}
|
columns={mockColumns}
|
||||||
hiddenColumns={new Set()}
|
hiddenColumns={new Set()}
|
||||||
selectedRows={new Set()}
|
selectedRows={new Set()}
|
||||||
onToggleRow={() => {}}
|
onToggleRow={() => {}}
|
||||||
onToggleAll={() => {}}
|
onToggleAll={() => {}}
|
||||||
|
dbType="postgresql"
|
||||||
|
tabType="table"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -147,41 +231,43 @@ describe("VirtualDataGrid", () => {
|
|||||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||||
|
|
||||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
|
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||||
onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}} />);
|
onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
|
||||||
const checkboxes = screen.getAllByRole("checkbox");
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
fireEvent.click(checkboxes[1]); // first row checkbox
|
fireEvent.click(checkboxes[1]); // first row checkbox
|
||||||
expect(toggled).toBe(0);
|
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[] = [
|
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);
|
mockGetTotalSize.mockReturnValue(36);
|
||||||
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
||||||
|
|
||||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[42]]} columns={fkCols}
|
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
|
||||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||||
onToggleRow={() => {}} onToggleAll={() => {}} />);
|
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
|
||||||
const fkCell = screen.getByText("42");
|
expect(screen.getByText("42")).toBeInTheDocument();
|
||||||
expect(fkCell.className).toContain("cursor-pointer");
|
expect(screen.getByLabelText("Open FK reference")).toBeInTheDocument();
|
||||||
expect(fkCell.className).toContain("underline");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders JSON cells with preview label", () => {
|
it("renders JSON cells with preview label", () => {
|
||||||
const jsonCols: ColumnInfo[] = [
|
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);
|
mockGetTotalSize.mockReturnValue(36);
|
||||||
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
||||||
|
|
||||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols}
|
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols}
|
||||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||||
onToggleRow={() => {}} onToggleAll={() => {}} />);
|
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
|
||||||
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
|
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -194,12 +280,15 @@ describe("VirtualDataGrid", () => {
|
|||||||
<VirtualDataGrid
|
<VirtualDataGrid
|
||||||
connectionId="conn-1"
|
connectionId="conn-1"
|
||||||
schema="public"
|
schema="public"
|
||||||
|
table="users"
|
||||||
rows={[]}
|
rows={[]}
|
||||||
columns={mockColumns}
|
columns={mockColumns}
|
||||||
hiddenColumns={new Set()}
|
hiddenColumns={new Set()}
|
||||||
selectedRows={new Set()}
|
selectedRows={new Set()}
|
||||||
onToggleRow={() => {}}
|
onToggleRow={() => {}}
|
||||||
onToggleAll={() => {}}
|
onToggleAll={() => {}}
|
||||||
|
dbType="postgresql"
|
||||||
|
tabType="table"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -225,12 +314,15 @@ describe("VirtualDataGrid", () => {
|
|||||||
<VirtualDataGrid
|
<VirtualDataGrid
|
||||||
connectionId="conn-1"
|
connectionId="conn-1"
|
||||||
schema="public"
|
schema="public"
|
||||||
|
table="users"
|
||||||
rows={[]}
|
rows={[]}
|
||||||
columns={[]}
|
columns={[]}
|
||||||
hiddenColumns={new Set()}
|
hiddenColumns={new Set()}
|
||||||
selectedRows={new Set()}
|
selectedRows={new Set()}
|
||||||
onToggleRow={() => {}}
|
onToggleRow={() => {}}
|
||||||
onToggleAll={() => {}}
|
onToggleAll={() => {}}
|
||||||
|
dbType="postgresql"
|
||||||
|
tabType="table"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,9 +336,10 @@ describe("VirtualDataGrid", () => {
|
|||||||
mockGetTotalSize.mockReturnValue(0);
|
mockGetTotalSize.mockReturnValue(0);
|
||||||
mockGetVirtualItems.mockReturnValue([]);
|
mockGetVirtualItems.mockReturnValue([]);
|
||||||
|
|
||||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[]} columns={mockColumns}
|
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={[]} columns={mockColumns}
|
||||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||||
onToggleRow={() => {}} onToggleAll={() => {}} />);
|
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
|
||||||
const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
|
const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
|
||||||
expect(handles.length).toBe(2); // one per visible column
|
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 }))
|
Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
|
||||||
);
|
);
|
||||||
render(
|
render(
|
||||||
<VirtualDataGrid connectionId="conn-1" schema="public" rows={bigRows} columns={mockColumns}
|
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={bigRows} columns={mockColumns}
|
||||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||||
onToggleRow={() => {}} onToggleAll={() => {}} />,
|
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||||
|
dbType="postgresql" tabType="table" />,
|
||||||
);
|
);
|
||||||
const checkboxes = screen.getAllByRole("checkbox");
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all
|
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 }))
|
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
|
||||||
);
|
);
|
||||||
render(
|
render(
|
||||||
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
|
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
hiddenColumns={new Set()} selectedRows={allSelected}
|
hiddenColumns={new Set()} selectedRows={allSelected}
|
||||||
onToggleRow={() => {}} onToggleAll={() => {}} />,
|
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||||
|
dbType="postgresql" tabType="table" />,
|
||||||
);
|
);
|
||||||
const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
|
const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
|
||||||
expect(selectAll.checked).toBe(true);
|
expect(selectAll.checked).toBe(true);
|
||||||
@@ -289,11 +384,234 @@ describe("VirtualDataGrid", () => {
|
|||||||
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
|
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
|
||||||
);
|
);
|
||||||
render(
|
render(
|
||||||
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
|
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
hiddenColumns={hidden} selectedRows={new Set()}
|
hiddenColumns={hidden} selectedRows={new Set()}
|
||||||
onToggleRow={() => {}} onToggleAll={() => {}} />,
|
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||||
|
dbType="postgresql" tabType="table" />,
|
||||||
);
|
);
|
||||||
expect(screen.queryByText("name")).not.toBeInTheDocument();
|
expect(screen.queryByText("name")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("id")).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(
|
||||||
|
<VirtualDataGrid
|
||||||
|
connectionId="conn-1"
|
||||||
|
schema="public"
|
||||||
|
table="users"
|
||||||
|
rows={mockRows}
|
||||||
|
columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()}
|
||||||
|
selectedRows={new Set()}
|
||||||
|
onToggleRow={() => {}}
|
||||||
|
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(
|
||||||
|
<VirtualDataGrid
|
||||||
|
connectionId="conn-1"
|
||||||
|
schema="public"
|
||||||
|
table="users"
|
||||||
|
rows={mockRows}
|
||||||
|
columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()}
|
||||||
|
selectedRows={new Set()}
|
||||||
|
onToggleRow={() => {}}
|
||||||
|
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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" onOpenRowDetail={(i) => { 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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={(i) => { 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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
|
||||||
|
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(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" />);
|
||||||
|
|
||||||
|
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(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||||
|
onToggleRow={() => {}} 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(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||||
|
onToggleRow={() => {}} 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 <select> with the column's labels when editing", () => {
|
||||||
|
const enumCols: ColumnInfo[] = [
|
||||||
|
{ name: "status", data_type: "user_role", is_nullable: true, 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(
|
||||||
|
<VirtualDataGrid
|
||||||
|
connectionId="c1"
|
||||||
|
schema="public"
|
||||||
|
table="users"
|
||||||
|
rows={[["active"]]}
|
||||||
|
columns={enumCols}
|
||||||
|
hiddenColumns={new Set()}
|
||||||
|
selectedRows={new Set()}
|
||||||
|
onToggleRow={vi.fn()}
|
||||||
|
onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql"
|
||||||
|
tabType="table"
|
||||||
|
enumValues={{ status: ["active", "inactive"] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = screen.getByText("active");
|
||||||
|
fireEvent.click(cell);
|
||||||
|
fireEvent.keyDown(cell, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "active" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "inactive" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a searchable FK dropdown with the referenced rows when editing", () => {
|
||||||
|
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(
|
||||||
|
<VirtualDataGrid
|
||||||
|
connectionId="c1"
|
||||||
|
schema="public"
|
||||||
|
table="orders"
|
||||||
|
rows={[[42]]}
|
||||||
|
columns={fkCols}
|
||||||
|
hiddenColumns={new Set()}
|
||||||
|
selectedRows={new Set()}
|
||||||
|
onToggleRow={vi.fn()}
|
||||||
|
onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql"
|
||||||
|
tabType="table"
|
||||||
|
fkOptions={{
|
||||||
|
user_id: [
|
||||||
|
{ value: "1", label: "1 — Alice" },
|
||||||
|
{ value: "2", label: "2 — Bob" },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = screen.getByText("42");
|
||||||
|
fireEvent.click(cell);
|
||||||
|
fireEvent.keyDown(cell, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/search foreign key/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 — Alice")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2 — Bob")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,50 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { Key, Braces } from "lucide-react";
|
import { Key, Braces, ArrowUpRight } from "lucide-react";
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
import { abbreviateType } from "../../lib/utils";
|
import { abbreviateType } from "../../lib/utils";
|
||||||
import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
|
import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
|
||||||
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
|
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
|
||||||
|
import { CellEditor, type FkOption } from "./CellEditor";
|
||||||
|
import { CellContextMenu } from "./CellContextMenu";
|
||||||
|
import { cellToUpdateChange, isCellEditable } from "./gridEditability";
|
||||||
|
import { nextCell, type CellPos } from "./keyboardNav";
|
||||||
|
|
||||||
interface VirtualDataGridProps {
|
interface VirtualDataGridProps {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
schema: string;
|
schema: string;
|
||||||
|
table?: string;
|
||||||
rows: unknown[][];
|
rows: unknown[][];
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
hiddenColumns: Set<string>;
|
hiddenColumns: Set<string>;
|
||||||
selectedRows: Set<number>;
|
selectedRows: Set<number>;
|
||||||
onToggleRow: (rowIndex: number) => void;
|
onToggleRow: (rowIndex: number) => void;
|
||||||
onToggleAll: () => void;
|
onToggleAll: () => void;
|
||||||
|
dbType?: string;
|
||||||
|
tabType?: "table" | "query";
|
||||||
|
onStageEdit?: (payload: {
|
||||||
|
type: "update";
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
primaryKey: Record<string, unknown>;
|
||||||
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown>;
|
||||||
|
}) => void;
|
||||||
|
onOpenRowDetail?: (rowIndex: number) => void;
|
||||||
|
getLocator?: (row: unknown[]) => Record<string, unknown>;
|
||||||
|
readOnly?: boolean;
|
||||||
|
/** When set, renders a pending-edit indicator on the staged cell at (row, col). */
|
||||||
|
pendingCell?: { row: number; col: number } | null;
|
||||||
|
/** Enum labels keyed by column NAME → renders a <select> in the CellEditor. */
|
||||||
|
enumValues?: Record<string, string[]>;
|
||||||
|
/** Foreign-key reference rows keyed by column NAME → renders a searchable dropdown in the CellEditor. */
|
||||||
|
fkOptions?: Record<string, FkOption[]>;
|
||||||
|
/** Placeholder text for the FK search input, keyed by column NAME. */
|
||||||
|
fkPlaceholders?: Record<string, string>;
|
||||||
|
/** Optimistic staged cell values keyed `${rowIndex}:${colName}` → value (null = NULL), from the changes queue. */
|
||||||
|
stagedValues?: Record<string, string | null>;
|
||||||
|
/** Keys of cells with a PENDING (not yet committed) update → drives the amber dot. */
|
||||||
|
pendingKeys?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_HEIGHT = 36;
|
const ROW_HEIGHT = 36;
|
||||||
@@ -25,12 +55,25 @@ const MAX_COL_WIDTH = 800;
|
|||||||
export function VirtualDataGrid({
|
export function VirtualDataGrid({
|
||||||
connectionId,
|
connectionId,
|
||||||
schema,
|
schema,
|
||||||
|
table = "",
|
||||||
rows,
|
rows,
|
||||||
columns,
|
columns,
|
||||||
hiddenColumns,
|
hiddenColumns,
|
||||||
selectedRows,
|
selectedRows,
|
||||||
onToggleRow,
|
onToggleRow,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
|
dbType = "postgresql",
|
||||||
|
tabType = "table",
|
||||||
|
onStageEdit,
|
||||||
|
onOpenRowDetail,
|
||||||
|
getLocator,
|
||||||
|
readOnly = false,
|
||||||
|
pendingCell = null,
|
||||||
|
enumValues,
|
||||||
|
fkOptions,
|
||||||
|
fkPlaceholders,
|
||||||
|
stagedValues,
|
||||||
|
pendingKeys,
|
||||||
}: VirtualDataGridProps) {
|
}: VirtualDataGridProps) {
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -53,6 +96,45 @@ export function VirtualDataGrid({
|
|||||||
overscan: 5,
|
overscan: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── focus / editing / context menu / row detail state ──
|
||||||
|
|
||||||
|
const [activeCell, setActiveCell] = useState<CellPos | null>(null);
|
||||||
|
const [editingCell, setEditingCell] = useState<CellPos | null>(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<string | null>(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 ─────────────────────────────────────
|
// ── column widths ─────────────────────────────────────
|
||||||
|
|
||||||
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||||
@@ -138,44 +220,128 @@ export function VirtualDataGrid({
|
|||||||
anchorRect: DOMRect | null;
|
anchorRect: DOMRect | null;
|
||||||
} | null>(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) ──
|
// ── cell renderer (shared between header sizing and body) ──
|
||||||
|
|
||||||
const renderCell = useCallback(
|
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 ci = columns.findIndex((c) => c.name === col.name);
|
||||||
const cell = ci >= 0 ? row[ci] : undefined;
|
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 isFk = col.is_fk && col.fk_ref && !isNull;
|
||||||
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
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) => {
|
const handleJsonClick = (e: React.MouseEvent) => {
|
||||||
if (isJson) {
|
if (isJson) {
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
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<string, unknown>;
|
||||||
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPendingCellKey(cellKey);
|
||||||
|
setEditingCell(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={col.name}
|
key={col.name}
|
||||||
className={`px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
|
className={`relative px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
|
||||||
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
|
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
|
||||||
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
|
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""} ${
|
||||||
role={isFk || isJson ? "button" : undefined}
|
isActive ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
|
||||||
tabIndex={isFk || isJson ? 0 : undefined}
|
}`}
|
||||||
|
role={isJson ? "button" : undefined}
|
||||||
|
tabIndex={isJson ? 0 : -1}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
isFk || isJson
|
isJson
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isFk) handleFkClick(col, cell, e as any);
|
|
||||||
else if (isJson) {
|
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
setJsonPopover({ value: cell, anchorRect: rect });
|
setJsonPopover({ value: cell, anchorRect: rect });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
||||||
@@ -183,37 +349,128 @@ export function VirtualDataGrid({
|
|||||||
isNull
|
isNull
|
||||||
? "NULL"
|
? "NULL"
|
||||||
: isFk
|
: isFk
|
||||||
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(cell)}`
|
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
|
||||||
: isJson
|
: isJson
|
||||||
? "Click to view JSON"
|
? "Click to view JSON"
|
||||||
: String(cell)
|
: String(displayCell)
|
||||||
}
|
|
||||||
onClick={
|
|
||||||
isFk
|
|
||||||
? (e) => handleFkClick(col, cell, e)
|
|
||||||
: isJson
|
|
||||||
? handleJsonClick
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
|
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 ? (
|
||||||
|
<div className="absolute inset-0 z-20" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CellEditor
|
||||||
|
initialValue={isNull ? "" : String(displayCell)}
|
||||||
|
dataType={col.data_type}
|
||||||
|
nullable={col.is_nullable}
|
||||||
|
enumValues={enumValues?.[col.name]}
|
||||||
|
fkOptions={fkOptions?.[col.name]}
|
||||||
|
fkPlaceholder={fkPlaceholders?.[col.name]}
|
||||||
|
onCommit={commitEdit}
|
||||||
|
onCancel={() => setEditingCell(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : isNull ? (
|
||||||
<span className="italic text-text-muted">NULL</span>
|
<span className="italic text-text-muted">NULL</span>
|
||||||
) : isJson ? (
|
) : isJson ? (
|
||||||
<span className="inline-flex items-center gap-0.5">
|
<span className="inline-flex items-center gap-0.5">
|
||||||
<Braces size={10} className="shrink-0" />
|
<Braces size={10} className="shrink-0" />
|
||||||
{jp.label}
|
{jp.label}
|
||||||
</span>
|
</span>
|
||||||
|
) : isFk && displayCell !== null && displayCell !== undefined ? (
|
||||||
|
<span className="inline-flex items-center gap-1 min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Open FK reference"
|
||||||
|
title={`FK → ${col.fk_ref![0]}.${col.fk_ref![1]}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
handleFkClick(col, displayCell, e);
|
||||||
|
}}
|
||||||
|
className="shrink-0 text-text-muted hover:text-accent"
|
||||||
|
>
|
||||||
|
<ArrowUpRight size={11} />
|
||||||
|
</button>
|
||||||
|
<span className="truncate">{String(displayCell)}</span>
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
String(cell)
|
String(displayCell)
|
||||||
|
)}
|
||||||
|
{isPending && (
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400"
|
||||||
|
data-testid="pending-edit-dot"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[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<string, unknown>;
|
||||||
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={parentRef} className="overflow-auto h-full" style={{ overscrollBehavior: "none" }}>
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
className="overflow-auto h-full outline-none"
|
||||||
|
style={{ overscrollBehavior: "none" }}
|
||||||
|
tabIndex={-1}
|
||||||
|
role="grid"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
{/* ── sticky header (hidden when no columns/table open) ── */}
|
{/* ── sticky header (hidden when no columns/table open) ── */}
|
||||||
{hasColumns && (
|
{hasColumns && (
|
||||||
<div className="sticky top-0 z-10">
|
<div className="sticky top-0 z-10">
|
||||||
@@ -285,7 +542,10 @@ export function VirtualDataGrid({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasColumns && (
|
{hasColumns && (
|
||||||
<div style={{ width: 40, minWidth: 40 }} className="flex items-center justify-center border-r border-border self-stretch">
|
<div
|
||||||
|
style={{ width: 40, minWidth: 40 }}
|
||||||
|
className="flex items-center justify-center border-r border-border self-stretch"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
@@ -294,7 +554,7 @@ export function VirtualDataGrid({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{visibleColumns.map((col) => renderCell(col, row, virtualRow.index))}
|
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -321,6 +581,69 @@ export function VirtualDataGrid({
|
|||||||
onClose={() => setJsonPopover(null)}
|
onClose={() => setJsonPopover(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Cell context menu */}
|
||||||
|
{ctxMenu && ctxCol && (
|
||||||
|
<>
|
||||||
|
{/* Click-outside-to-close backdrop (below the z-50 menu) */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
data-testid="ctx-backdrop"
|
||||||
|
onClick={() => setCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
<CellContextMenu
|
||||||
|
anchorRect={ctxMenu.pos}
|
||||||
|
editable={isCellEditable(ctxCol, tabType, dbType, readOnly)}
|
||||||
|
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
|
||||||
|
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
|
||||||
|
nullable={ctxCol.is_nullable}
|
||||||
|
onCopy={() => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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> = {}): 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)" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown>;
|
||||||
|
}): { type: ChangeItemType; schema: string; table: string;
|
||||||
|
primaryKey: Record<string, unknown>;
|
||||||
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown> } {
|
||||||
|
return { type: "update", ...input };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { ActionRow } from "./ActionRow";
|
import { ActionRow } from "./ActionRow";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
|
||||||
beforeEach(() => useUiStore.setState({ activeView: "home" }));
|
beforeEach(() => useUiStore.setState({ activeView: "home", selectedItemIds: [] }));
|
||||||
|
|
||||||
describe("ActionRow", () => {
|
describe("ActionRow", () => {
|
||||||
it("renders Saved Connections title", () => {
|
it("renders Saved Connections title", () => {
|
||||||
@@ -21,4 +21,11 @@ describe("ActionRow", () => {
|
|||||||
await userEvent.click(screen.getByText(/settings/i));
|
await userEvent.click(screen.getByText(/settings/i));
|
||||||
expect(useUiStore.getState().activeView).toBe("settings");
|
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(<ActionRow onMoveToFolder={onMoveToFolder} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /move to folder/i }));
|
||||||
|
expect(onMoveToFolder).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { Button } from "../ui/Button";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
import { ImportExportMenu } from "./ImportExportMenu";
|
import { ImportExportMenu } from "./ImportExportMenu";
|
||||||
@@ -12,10 +12,11 @@ interface ActionRowProps {
|
|||||||
onNewFolder?: () => void;
|
onNewFolder?: () => void;
|
||||||
onFilters?: () => void;
|
onFilters?: () => void;
|
||||||
onDeleteSelected?: () => void;
|
onDeleteSelected?: () => void;
|
||||||
|
onMoveToFolder?: () => void;
|
||||||
visibleItemIds?: string[];
|
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 setActiveView = useUiStore((s) => s.setActiveView);
|
||||||
const openSettings = useUiStore((s) => s.openSettings);
|
const openSettings = useUiStore((s) => s.openSettings);
|
||||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||||
@@ -52,6 +53,11 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilte
|
|||||||
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
|
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
|
||||||
<FolderPlus size={14} /> New Folder
|
<FolderPlus size={14} /> New Folder
|
||||||
</Button>
|
</Button>
|
||||||
|
{hasSelection && (
|
||||||
|
<Button variant="ghost" className="text-xs border-0" onClick={onMoveToFolder}>
|
||||||
|
<FolderInput size={14} /> Move to folder
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={menuRef}>
|
||||||
<Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}>
|
<Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { HomeScreen } from "./HomeScreen";
|
import { HomeScreen } from "./HomeScreen";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
@@ -12,6 +12,8 @@ vi.mock("../../lib/commands", () => ({
|
|||||||
getFolders: vi.fn().mockResolvedValue([]),
|
getFolders: vi.fn().mockResolvedValue([]),
|
||||||
getTags: vi.fn().mockResolvedValue([]),
|
getTags: vi.fn().mockResolvedValue([]),
|
||||||
getSettings: vi.fn().mockResolvedValue({}),
|
getSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
getRecentConnections: vi.fn().mockResolvedValue([{ connection_id: "recent-1", opened_at: "" }]),
|
||||||
|
recordRecentConnection: vi.fn().mockResolvedValue(undefined),
|
||||||
deleteConnection: vi.fn().mockResolvedValue(undefined),
|
deleteConnection: vi.fn().mockResolvedValue(undefined),
|
||||||
deleteConnectionPassword: vi.fn().mockResolvedValue(undefined),
|
deleteConnectionPassword: vi.fn().mockResolvedValue(undefined),
|
||||||
deleteFolder: vi.fn().mockResolvedValue(undefined),
|
deleteFolder: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -25,7 +27,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
|
|||||||
|
|
||||||
describe("HomeScreen", () => {
|
describe("HomeScreen", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
|
useConnectionStore.setState({ connections: [], folders: [], tags: [], recent: [], loading: false, error: null });
|
||||||
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] });
|
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] });
|
||||||
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
||||||
// SearchBar stores its debounce timer on window.__sb; clear any timer leaked
|
// SearchBar stores its debounce timer on window.__sb; clear any timer leaked
|
||||||
@@ -125,6 +127,83 @@ describe("HomeScreen", () => {
|
|||||||
expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records a recent connection when opening a connection", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||||
|
render(<HomeScreen />);
|
||||||
|
await user.click(screen.getByText("Local DB"));
|
||||||
|
const { recordRecentConnection } = await import("../../lib/commands");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(recordRecentConnection).toHaveBeenCalledWith("conn-1"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the recent connections strip at the root when recents exist", async () => {
|
||||||
|
useConnectionStore.setState({ connections: [makeConnection("recent-1")] });
|
||||||
|
useUiStore.setState({ activeFolderId: null, searchQuery: "" });
|
||||||
|
render(<HomeScreen />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /local db/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens EditConnectionModal from the connection card kebab menu", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||||
|
render(<HomeScreen />);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText("Connection actions"));
|
||||||
|
await user.click(screen.getByText("Manage"));
|
||||||
|
await user.click(screen.getByText("Edit…"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Edit Connection")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("Local DB")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the confirmation dialog before deleting a connection from the kebab menu by default", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||||
|
render(<HomeScreen />);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText("Connection actions"));
|
||||||
|
await user.click(screen.getByText("Manage"));
|
||||||
|
await user.click(screen.getByText("Delete…"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/are you sure you want to delete \"Local DB\"/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const { deleteConnection } = await import("../../lib/commands");
|
||||||
|
expect(deleteConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a connection from the kebab menu without confirmation when confirm_before_delete is false", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useSettingsStore.setState({
|
||||||
|
settings: {
|
||||||
|
...baseSettings(),
|
||||||
|
confirm_before_delete: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||||
|
render(<HomeScreen />);
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText("Connection actions"));
|
||||||
|
await user.click(screen.getByText("Manage"));
|
||||||
|
await user.click(screen.getByText("Delete…"));
|
||||||
|
|
||||||
|
const { deleteConnection } = await import("../../lib/commands");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(deleteConnection).toHaveBeenCalledWith("conn-1"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/are you sure you want to delete/i),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeConnection(id: string): Connection {
|
function makeConnection(id: string): Connection {
|
||||||
@@ -138,6 +217,7 @@ function makeConnection(id: string): Connection {
|
|||||||
folder_id: null,
|
folder_id: null,
|
||||||
keychain_ref: null,
|
keychain_ref: null,
|
||||||
tag_ids: [],
|
tag_ids: [],
|
||||||
|
favorite: false,
|
||||||
created_at: "",
|
created_at: "",
|
||||||
updated_at: "",
|
updated_at: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import { ConnectionCard } from "../connections/ConnectionCard";
|
|||||||
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
|
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
|
||||||
import { EditFolderDialog } from "../folders/EditFolderDialog";
|
import { EditFolderDialog } from "../folders/EditFolderDialog";
|
||||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||||
|
import { EditConnectionModal } from "../db-viewer/EditConnectionModal";
|
||||||
|
import { MoveToFolderDialog } from "../connections/MoveToFolderDialog";
|
||||||
|
import { RecentConnectionsStrip } from "../connections/RecentConnectionsStrip";
|
||||||
import { handleImport, handleExport } from "../../lib/importExport";
|
import { handleImport, handleExport } from "../../lib/importExport";
|
||||||
import { getChildFolders } from "../../lib/utils";
|
import { getChildFolders } from "../../lib/utils";
|
||||||
import { useShortcut } from "../../hooks/useShortcut";
|
import { useShortcut } from "../../hooks/useShortcut";
|
||||||
import type { Folder } from "../../lib/types";
|
import type { Connection, Folder } from "../../lib/types";
|
||||||
|
|
||||||
export function HomeScreen() {
|
export function HomeScreen() {
|
||||||
const connections = useFilteredConnections();
|
const connections = useFilteredConnections();
|
||||||
@@ -31,15 +34,22 @@ export function HomeScreen() {
|
|||||||
const deleteFolder = useConnectionStore((s) => s.deleteFolder);
|
const deleteFolder = useConnectionStore((s) => s.deleteFolder);
|
||||||
const deleteConnection = useConnectionStore((s) => s.deleteConnection);
|
const deleteConnection = useConnectionStore((s) => s.deleteConnection);
|
||||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||||
|
const moveSelectionToFolder = useConnectionStore((s) => s.moveSelectionToFolder);
|
||||||
|
const recordRecent = useConnectionStore((s) => s.recordRecent);
|
||||||
|
const loadRecent = useConnectionStore((s) => s.loadRecent);
|
||||||
|
const recent = useConnectionStore((s) => s.recent);
|
||||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||||
const confirmBeforeDelete =
|
const confirmBeforeDelete =
|
||||||
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
|
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
|
||||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||||
const [editFolder, setEditFolder] = useState<Folder | null>(null);
|
const [editFolder, setEditFolder] = useState<Folder | null>(null);
|
||||||
|
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
|
||||||
|
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<{
|
const [confirmDelete, setConfirmDelete] = useState<{
|
||||||
type: "folder" | "selected";
|
type: "folder" | "selected" | "connection";
|
||||||
folder?: Folder;
|
folder?: Folder;
|
||||||
|
connection?: Connection;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||||
const searchRef = useRef<SearchBarHandle>(null);
|
const searchRef = useRef<SearchBarHandle>(null);
|
||||||
@@ -51,6 +61,7 @@ export function HomeScreen() {
|
|||||||
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
|
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
|
||||||
|
|
||||||
const handleOpenDbViewer = (connectionId: string) => {
|
const handleOpenDbViewer = (connectionId: string) => {
|
||||||
|
recordRecent(connectionId);
|
||||||
setActiveConnectionId(connectionId);
|
setActiveConnectionId(connectionId);
|
||||||
setActiveView("db-viewer");
|
setActiveView("db-viewer");
|
||||||
};
|
};
|
||||||
@@ -119,6 +130,11 @@ export function HomeScreen() {
|
|||||||
}
|
}
|
||||||
}, [folders, activeFolderId, setActiveFolderId]);
|
}, [folders, activeFolderId, setActiveFolderId]);
|
||||||
|
|
||||||
|
// Load recent connections on mount for the root strip
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecent();
|
||||||
|
}, [loadRecent]);
|
||||||
|
|
||||||
const executeDeleteSelected = async () => {
|
const executeDeleteSelected = async () => {
|
||||||
const folderIds = new Set(folders.map((f) => f.id));
|
const folderIds = new Set(folders.map((f) => f.id));
|
||||||
for (const id of selectedItemIds) {
|
for (const id of selectedItemIds) {
|
||||||
@@ -148,6 +164,24 @@ export function HomeScreen() {
|
|||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicateConnection = async (conn: Connection) => {
|
||||||
|
try {
|
||||||
|
await useConnectionStore.getState().duplicateConnection(conn.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to duplicate connection:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConnection = (conn: Connection) => {
|
||||||
|
if (confirmBeforeDelete) {
|
||||||
|
setConfirmDelete({ type: "connection", connection: conn });
|
||||||
|
} else {
|
||||||
|
void deleteConnection(conn.id).catch((e) => {
|
||||||
|
console.error("Failed to delete connection:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto">
|
<main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -168,9 +202,13 @@ export function HomeScreen() {
|
|||||||
? setConfirmDelete({ type: "selected" })
|
? setConfirmDelete({ type: "selected" })
|
||||||
: executeDeleteSelected()
|
: executeDeleteSelected()
|
||||||
}
|
}
|
||||||
|
onMoveToFolder={() => setMoveToFolderOpen(true)}
|
||||||
visibleItemIds={visibleItemIds}
|
visibleItemIds={visibleItemIds}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{activeFolderId === null && !searchQuery && (
|
||||||
|
<RecentConnectionsStrip recents={recent} onOpen={handleOpenDbViewer} />
|
||||||
|
)}
|
||||||
<DndContext
|
<DndContext
|
||||||
onDragStart={(event) => setActiveDragId(event.active.id as string)}
|
onDragStart={(event) => setActiveDragId(event.active.id as string)}
|
||||||
onDragEnd={async (event) => {
|
onDragEnd={async (event) => {
|
||||||
@@ -194,6 +232,9 @@ export function HomeScreen() {
|
|||||||
? setConfirmDelete({ type: "folder", folder: f })
|
? setConfirmDelete({ type: "folder", folder: f })
|
||||||
: executeDeleteFolder(f)
|
: executeDeleteFolder(f)
|
||||||
}
|
}
|
||||||
|
onEditConnection={setEditingConnection}
|
||||||
|
onDuplicateConnection={handleDuplicateConnection}
|
||||||
|
onDeleteConnection={handleDeleteConnection}
|
||||||
/>
|
/>
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={null}>
|
||||||
{activeDragId && connections.find((c) => c.id === activeDragId) ? (
|
{activeDragId && connections.find((c) => c.id === activeDragId) ? (
|
||||||
@@ -242,6 +283,29 @@ export function HomeScreen() {
|
|||||||
}}
|
}}
|
||||||
onClose={() => setEditFolder(null)}
|
onClose={() => setEditFolder(null)}
|
||||||
/>
|
/>
|
||||||
|
<MoveToFolderDialog
|
||||||
|
open={moveToFolderOpen}
|
||||||
|
folders={folders}
|
||||||
|
selectedCount={selectedItemIds.length}
|
||||||
|
onConfirm={async (target) => {
|
||||||
|
try {
|
||||||
|
await moveSelectionToFolder(selectedItemIds, target);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to move selection:", e);
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
|
setMoveToFolderOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setMoveToFolderOpen(false)}
|
||||||
|
/>
|
||||||
|
{editingConnection && (
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={editingConnection}
|
||||||
|
open
|
||||||
|
onClose={() => setEditingConnection(null)}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{confirmDelete?.type === "selected" && (
|
{confirmDelete?.type === "selected" && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open
|
open
|
||||||
@@ -264,6 +328,30 @@ export function HomeScreen() {
|
|||||||
onCancel={() => setConfirmDelete(null)}
|
onCancel={() => setConfirmDelete(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{confirmDelete?.type === "connection" &&
|
||||||
|
confirmDelete.connection && (
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Delete Connection"
|
||||||
|
message={`Are you sure you want to delete "${confirmDelete.connection.name}"?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
confirmVariant="ghost"
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
await deleteConnection(
|
||||||
|
confirmDelete.connection!.id,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"Failed to delete connection:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,14 @@ describe("SearchBar", () => {
|
|||||||
fireEvent.change(input, { target: { value: "postgresql://user@host/db" } });
|
fireEvent.change(input, { target: { value: "postgresql://user@host/db" } });
|
||||||
expect(onDetectUrl).toHaveBeenCalledWith("postgresql://user@host/db");
|
expect(onDetectUrl).toHaveBeenCalledWith("postgresql://user@host/db");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears the search and exits search mode on Escape", () => {
|
||||||
|
useUiStore.setState({ searchQuery: "prod" });
|
||||||
|
render(<SearchBar />);
|
||||||
|
const input = screen.getByPlaceholderText(/search/i);
|
||||||
|
// input mirrors the store value
|
||||||
|
expect((input as HTMLInputElement).value).toBe("prod");
|
||||||
|
fireEvent.keyDown(input, { key: "Escape" });
|
||||||
|
expect(useUiStore.getState().searchQuery).toBe("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -44,6 +44,15 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(function Se
|
|||||||
window.clearTimeout((window as any).__sb);
|
window.clearTimeout((window as any).__sb);
|
||||||
(window as any).__sb = window.setTimeout(() => setSearchQuery(v), 150);
|
(window as any).__sb = window.setTimeout(() => setSearchQuery(v), 150);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
window.clearTimeout((window as any).__sb);
|
||||||
|
setValue("");
|
||||||
|
setSearchQuery("");
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="pl-10 pr-14"
|
className="pl-10 pr-14"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border bg-surface-raised text-text-muted text-xs pointer-events-none">
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border bg-surface-raised text-text-muted text-xs pointer-events-none">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const makeConn = (overrides: Partial<Connection> = {}): Connection => ({
|
|||||||
ssl_ca_path: null,
|
ssl_ca_path: null,
|
||||||
ssl_cert_path: null,
|
ssl_cert_path: null,
|
||||||
ssl_key_path: null,
|
ssl_key_path: null,
|
||||||
|
favorite: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import {
|
|||||||
getSavedQueries,
|
getSavedQueries,
|
||||||
updateSavedQuery,
|
updateSavedQuery,
|
||||||
deleteSavedQuery,
|
deleteSavedQuery,
|
||||||
|
setConnectionFavorite,
|
||||||
|
recordRecentConnection,
|
||||||
|
getRecentConnections,
|
||||||
|
clearRecentConnections,
|
||||||
|
getIndexes,
|
||||||
|
getConstraints,
|
||||||
} from "./commands";
|
} from "./commands";
|
||||||
import type { SchemaGraph } from "./types";
|
import type { SchemaGraph } from "./types";
|
||||||
import type { QueryHistoryEntry } from "./commands";
|
import type { QueryHistoryEntry } from "./commands";
|
||||||
@@ -236,3 +242,53 @@ describe("Query History — v6", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("v0.5.0 command wrappers", () => {
|
||||||
|
it("setConnectionFavorite invokes set_connection_favorite with camelCase", async () => {
|
||||||
|
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||||
|
|
||||||
|
await setConnectionFavorite("c1", true);
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("set_connection_favorite", { connectionId: "c1", favorite: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordRecentConnection invokes record_recent_connection", async () => {
|
||||||
|
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||||
|
|
||||||
|
await recordRecentConnection("c1");
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("record_recent_connection", { connectionId: "c1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getRecentConnections invokes get_recent_connections with limit", async () => {
|
||||||
|
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||||
|
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||||
|
|
||||||
|
await getRecentConnections(8);
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("get_recent_connections", { limit: 8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearRecentConnections invokes clear_recent_connections", async () => {
|
||||||
|
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||||
|
|
||||||
|
await clearRecentConnections();
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("clear_recent_connections", {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getIndexes invokes get_indexes with connectionId + schema", async () => {
|
||||||
|
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||||
|
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||||
|
|
||||||
|
await getIndexes("c1", "public");
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("get_indexes", { connectionId: "c1", schema: "public" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getConstraints invokes get_constraints with connectionId + schema", async () => {
|
||||||
|
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||||
|
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||||
|
|
||||||
|
await getConstraints("c1", "public");
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "public" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+27
-1
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
|
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection } from "./types";
|
||||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||||
import type { ChangePayload } from "./changePayload";
|
import type { ChangePayload } from "./changePayload";
|
||||||
|
|
||||||
@@ -269,3 +269,29 @@ export async function updateSavedQuery(
|
|||||||
export async function deleteSavedQuery(id: string): Promise<void> {
|
export async function deleteSavedQuery(id: string): Promise<void> {
|
||||||
return invoke<void>("delete_saved_query", { id });
|
return invoke<void>("delete_saved_query", { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── v0.5.0: Favorites / Recents / Indexes / Constraints ──────────
|
||||||
|
|
||||||
|
export async function setConnectionFavorite(connectionId: string, favorite: boolean): Promise<void> {
|
||||||
|
return invoke<void>("set_connection_favorite", { connectionId, favorite });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordRecentConnection(connectionId: string): Promise<void> {
|
||||||
|
return invoke<void>("record_recent_connection", { connectionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentConnections(limit: number): Promise<RecentConnection[]> {
|
||||||
|
return invoke<RecentConnection[]>("get_recent_connections", { limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearRecentConnections(): Promise<void> {
|
||||||
|
return invoke<void>("clear_recent_connections", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIndexes(connectionId: string, schema?: string): Promise<IndexInfo[]> {
|
||||||
|
return invoke<IndexInfo[]>("get_indexes", { connectionId, schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConstraints(connectionId: string, schema?: string): Promise<ConstraintInfo[]> {
|
||||||
|
return invoke<ConstraintInfo[]>("get_constraints", { connectionId, schema });
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
// Import the docs as raw strings (vite/client declares `*?raw`); this keeps the
|
||||||
|
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
|
||||||
|
import agents from "../../AGENTS.md?raw";
|
||||||
|
import readme from "../../README.md?raw";
|
||||||
|
|
||||||
|
describe("v0.5.0 docs coverage", () => {
|
||||||
|
it("AGENTS.md marks inline cell editing complete", () => {
|
||||||
|
expect(agents).toContain("Inline cell editing");
|
||||||
|
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||||
|
});
|
||||||
|
it("AGENTS.md marks indexes + constraints complete", () => {
|
||||||
|
expect(agents).toMatch(/Indexes \(per table\) \| ✅/);
|
||||||
|
expect(agents).toMatch(/Constraints \(CHECK, UNIQUE beyond PK\/FK\) \| ✅/);
|
||||||
|
});
|
||||||
|
it("AGENTS.md marks materialized views complete", () => {
|
||||||
|
expect(agents).toMatch(/Materialized views \| ✅/);
|
||||||
|
});
|
||||||
|
it("AGENTS.md marks stored procedures complete", () => {
|
||||||
|
expect(agents).toMatch(/Stored procedures \| ✅/);
|
||||||
|
});
|
||||||
|
it("AGENTS.md marks favorites + recents + status indicator complete", () => {
|
||||||
|
expect(agents).toMatch(/Favorites \/ Recent connections \| ✅/);
|
||||||
|
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||||
|
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||||
|
});
|
||||||
|
it("README declares v0.5.0", () => {
|
||||||
|
expect(readme).toContain("0.5.0");
|
||||||
|
});
|
||||||
|
it("README marks inline editing complete (not Upcoming)", () => {
|
||||||
|
// Gridline's comparison-table cell carries the ✅ marker
|
||||||
|
expect(readme).toMatch(/Inline cell editing \| ✅ \| ✅ \| \*\*✅/);
|
||||||
|
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,8 +3,8 @@ import { exportData } from "./exportData";
|
|||||||
import type { ColumnInfo } from "./types";
|
import type { ColumnInfo } from "./types";
|
||||||
|
|
||||||
const columns: ColumnInfo[] = [
|
const columns: ColumnInfo[] = [
|
||||||
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
|
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||||
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
|
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("exportData", () => {
|
describe("exportData", () => {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { pruneRecent, dedupeRecent } from "./recentConnections";
|
||||||
|
import type { RecentConnection } from "./types";
|
||||||
|
|
||||||
|
const rc = (id: string, at: string): RecentConnection => ({ connection_id: id, opened_at: at });
|
||||||
|
|
||||||
|
describe("recentConnections helpers", () => {
|
||||||
|
it("pruneRecent keeps the newest N", () => {
|
||||||
|
const list = [rc("a", "1"), rc("b", "3"), rc("c", "2")];
|
||||||
|
expect(pruneRecent(list, 2)).toEqual([rc("b", "3"), rc("c", "2")]);
|
||||||
|
});
|
||||||
|
it("dedupeRecent moves the latest occurrence of an id to the front", () => {
|
||||||
|
const list = [rc("a", "1"), rc("b", "2"), rc("a", "3")];
|
||||||
|
const out = dedupeRecent(list);
|
||||||
|
expect(out[0].connection_id).toBe("a");
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { RecentConnection } from "./types";
|
||||||
|
|
||||||
|
/** Keep the newest N entries (sorted by opened_at DESC). */
|
||||||
|
export function pruneRecent(list: RecentConnection[], n: number): RecentConnection[] {
|
||||||
|
return [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1)).slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove duplicates, keeping the most recent occurrence per connection_id at the front. */
|
||||||
|
export function dedupeRecent(list: RecentConnection[]): RecentConnection[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: RecentConnection[] = [];
|
||||||
|
for (const item of [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1))) {
|
||||||
|
if (!seen.has(item.connection_id)) {
|
||||||
|
seen.add(item.connection_id);
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
+47
-4
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, expectTypeOf } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
Connection,
|
Connection,
|
||||||
ConnectionInput,
|
ConnectionInput,
|
||||||
@@ -16,6 +16,9 @@ import type {
|
|||||||
GraphColumn,
|
GraphColumn,
|
||||||
Relationship,
|
Relationship,
|
||||||
Settings,
|
Settings,
|
||||||
|
IndexInfo,
|
||||||
|
ConstraintInfo,
|
||||||
|
RecentConnection,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
describe("ActiveView", () => {
|
describe("ActiveView", () => {
|
||||||
@@ -37,6 +40,7 @@ describe("Connection", () => {
|
|||||||
folder_id: null,
|
folder_id: null,
|
||||||
keychain_ref: null,
|
keychain_ref: null,
|
||||||
tag_ids: [],
|
tag_ids: [],
|
||||||
|
favorite: false,
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
// new SSH/SSL fields
|
// new SSH/SSL fields
|
||||||
@@ -68,6 +72,7 @@ describe("Connection", () => {
|
|||||||
folder_id: null,
|
folder_id: null,
|
||||||
keychain_ref: null,
|
keychain_ref: null,
|
||||||
tag_ids: [],
|
tag_ids: [],
|
||||||
|
favorite: false,
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
@@ -89,6 +94,7 @@ describe("Connection", () => {
|
|||||||
folder_id: null,
|
folder_id: null,
|
||||||
keychain_ref: null,
|
keychain_ref: null,
|
||||||
tag_ids: [],
|
tag_ids: [],
|
||||||
|
favorite: false,
|
||||||
created_at: "2024-01-01T00:00:00Z",
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
updated_at: "2024-01-01T00:00:00Z",
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
};
|
};
|
||||||
@@ -184,6 +190,8 @@ describe("ColumnInfo", () => {
|
|||||||
is_fk: false,
|
is_fk: false,
|
||||||
fk_ref: null,
|
fk_ref: null,
|
||||||
default_value: null,
|
default_value: null,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
};
|
};
|
||||||
expect(col.name).toBe("id");
|
expect(col.name).toBe("id");
|
||||||
expect(col.is_pk).toBe(true);
|
expect(col.is_pk).toBe(true);
|
||||||
@@ -198,6 +206,8 @@ describe("ColumnInfo", () => {
|
|||||||
is_fk: true,
|
is_fk: true,
|
||||||
fk_ref: ["users", "id"],
|
fk_ref: ["users", "id"],
|
||||||
default_value: null,
|
default_value: null,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
};
|
};
|
||||||
expect(col.fk_ref?.[0]).toBe("users");
|
expect(col.fk_ref?.[0]).toBe("users");
|
||||||
});
|
});
|
||||||
@@ -206,7 +216,7 @@ describe("ColumnInfo", () => {
|
|||||||
describe("QueryResult", () => {
|
describe("QueryResult", () => {
|
||||||
it("is well-typed with columns and rows", () => {
|
it("is well-typed with columns and rows", () => {
|
||||||
const result: QueryResult = {
|
const result: QueryResult = {
|
||||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||||
rows: [
|
rows: [
|
||||||
[1, "Alice"],
|
[1, "Alice"],
|
||||||
[2, "Bob"],
|
[2, "Bob"],
|
||||||
@@ -231,7 +241,7 @@ describe("QueryResult", () => {
|
|||||||
|
|
||||||
it("can have null execution_time", () => {
|
it("can have null execution_time", () => {
|
||||||
const result: QueryResult = {
|
const result: QueryResult = {
|
||||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||||
rows: [],
|
rows: [],
|
||||||
total_rows: 0, page: 1, page_size: 50,
|
total_rows: 0, page: 1, page_size: 50,
|
||||||
execution_time_ms: null,
|
execution_time_ms: null,
|
||||||
@@ -324,7 +334,7 @@ describe("DbViewerTab", () => {
|
|||||||
title: "SELECT * FROM users",
|
title: "SELECT * FROM users",
|
||||||
query: "SELECT * FROM users",
|
query: "SELECT * FROM users",
|
||||||
result: {
|
result: {
|
||||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||||
rows: [],
|
rows: [],
|
||||||
total_rows: 0, page: 1, page_size: 50,
|
total_rows: 0, page: 1, page_size: 50,
|
||||||
},
|
},
|
||||||
@@ -453,3 +463,36 @@ describe("Settings", () => {
|
|||||||
expect(s.editor_word_wrap).toBe("off");
|
expect(s.editor_word_wrap).toBe("off");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("v0.5.0 types", () => {
|
||||||
|
it("ColumnInfo carries editability metadata", () => {
|
||||||
|
const c: 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,
|
||||||
|
};
|
||||||
|
expectTypeOf(c.editable).toEqualTypeOf<boolean>();
|
||||||
|
expectTypeOf(c.is_generated).toEqualTypeOf<boolean>();
|
||||||
|
});
|
||||||
|
it("IndexInfo has the documented fields", () => {
|
||||||
|
const i: IndexInfo = {
|
||||||
|
name: "idx", schema: "public", table: "users", definition: "CREATE INDEX ...",
|
||||||
|
is_unique: true, method: "btree", columns: ["id"], size_bytes: 4096, tablespace: null,
|
||||||
|
};
|
||||||
|
expectTypeOf(i).toMatchTypeOf<IndexInfo>();
|
||||||
|
});
|
||||||
|
it("ConstraintInfo has the documented fields", () => {
|
||||||
|
const c: ConstraintInfo = {
|
||||||
|
name: "ck", schema: "public", table: "users", contype: "CHECK",
|
||||||
|
definition: "CHECK (x > 0)", deferrable: false, validated: true, columns: ["x"],
|
||||||
|
};
|
||||||
|
expectTypeOf(c).toMatchTypeOf<ConstraintInfo>();
|
||||||
|
});
|
||||||
|
it("RecentConnection pairs a connection id with an opened_at timestamp", () => {
|
||||||
|
const r: RecentConnection = { connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" };
|
||||||
|
expectTypeOf(r.connection_id).toEqualTypeOf<string>();
|
||||||
|
});
|
||||||
|
it("Connection carries favorite", () => {
|
||||||
|
const c = { favorite: true } as Connection;
|
||||||
|
expectTypeOf(c.favorite).toEqualTypeOf<boolean>();
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-1
@@ -44,6 +44,8 @@ export interface Connection {
|
|||||||
ssl_key_path?: string | null;
|
ssl_key_path?: string | null;
|
||||||
// Environment label (production, staging, development, etc.)
|
// Environment label (production, staging, development, etc.)
|
||||||
environment?: string | null;
|
environment?: string | null;
|
||||||
|
// Favorite flag (v0.5.0 — pinned connection)
|
||||||
|
favorite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NewConnectionMode = "simple" | "detailed";
|
export type NewConnectionMode = "simple" | "detailed";
|
||||||
@@ -131,7 +133,7 @@ export interface ImportResult {
|
|||||||
export interface TableInfo {
|
export interface TableInfo {
|
||||||
name: string;
|
name: string;
|
||||||
schema: string;
|
schema: string;
|
||||||
table_type: "TABLE" | "VIEW";
|
table_type: "TABLE" | "VIEW" | "MATERIALIZED VIEW";
|
||||||
columns?: ColumnInfo[];
|
columns?: ColumnInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +145,36 @@ export interface ColumnInfo {
|
|||||||
is_fk: boolean;
|
is_fk: boolean;
|
||||||
fk_ref: [string, string] | null;
|
fk_ref: [string, string] | null;
|
||||||
default_value: string | null;
|
default_value: string | null;
|
||||||
|
editable: boolean;
|
||||||
|
is_generated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexInfo {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
definition: string;
|
||||||
|
is_unique: boolean;
|
||||||
|
method: string;
|
||||||
|
columns: string[];
|
||||||
|
size_bytes: number | null;
|
||||||
|
tablespace: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstraintInfo {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
contype: "CHECK" | "UNIQUE" | "EXCLUSION";
|
||||||
|
definition: string;
|
||||||
|
deferrable: boolean;
|
||||||
|
validated: boolean;
|
||||||
|
columns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentConnection {
|
||||||
|
connection_id: string;
|
||||||
|
opened_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryResult {
|
export interface QueryResult {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const makeConnection = (over: Partial<Connection> = {}): Connection => ({
|
|||||||
keychain_ref: null,
|
keychain_ref: null,
|
||||||
tag_ids: [],
|
tag_ids: [],
|
||||||
environment: null,
|
environment: null,
|
||||||
|
favorite: false,
|
||||||
created_at: "2026-07-26T00:00:00Z",
|
created_at: "2026-07-26T00:00:00Z",
|
||||||
updated_at: "2026-07-26T00:00:00Z",
|
updated_at: "2026-07-26T00:00:00Z",
|
||||||
...over,
|
...over,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
|
describe("version", () => {
|
||||||
|
it("declares v0.5.0 across the app shell", () => {
|
||||||
|
expect(pkg.version).toBe("0.5.0");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
|||||||
const makeConn = (over: Partial<Connection> = {}): Connection => ({
|
const makeConn = (over: Partial<Connection> = {}): Connection => ({
|
||||||
id: "c1", name: "P", db_type: "postgresql", host: "h", port: 5432,
|
id: "c1", name: "P", db_type: "postgresql", host: "h", port: 5432,
|
||||||
username: null, folder_id: null, keychain_ref: null, tag_ids: [],
|
username: null, folder_id: null, keychain_ref: null, tag_ids: [],
|
||||||
created_at: "", updated_at: "", ...over,
|
favorite: false, created_at: "", updated_at: "", ...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -82,6 +82,7 @@ describe("moveConnection", () => {
|
|||||||
ssl_cert_path: null,
|
ssl_cert_path: null,
|
||||||
ssl_key_path: null,
|
ssl_key_path: null,
|
||||||
tag_ids: [],
|
tag_ids: [],
|
||||||
|
favorite: false,
|
||||||
created_at: "2024-01-01",
|
created_at: "2024-01-01",
|
||||||
updated_at: "2024-01-01",
|
updated_at: "2024-01-01",
|
||||||
};
|
};
|
||||||
@@ -189,3 +190,77 @@ describe("moveConnection", () => {
|
|||||||
expect(callCount).toBe(2);
|
expect(callCount).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("favorites / recents / move-selection", () => {
|
||||||
|
it("toggleFavorite optimistically flips favorite and persists", async () => {
|
||||||
|
useConnectionStore.setState({ connections: [makeConn({ id: "c1", favorite: false })] });
|
||||||
|
vi.spyOn(commands, "setConnectionFavorite").mockResolvedValue(undefined);
|
||||||
|
await useConnectionStore.getState().toggleFavorite("c1");
|
||||||
|
expect(useConnectionStore.getState().connections[0].favorite).toBe(true);
|
||||||
|
expect(commands.setConnectionFavorite).toHaveBeenCalledWith("c1", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleFavorite rolls back on failure", async () => {
|
||||||
|
useConnectionStore.setState({ connections: [makeConn({ id: "c1", favorite: false })] });
|
||||||
|
vi.spyOn(commands, "setConnectionFavorite").mockRejectedValue(new Error("boom"));
|
||||||
|
await expect(useConnectionStore.getState().toggleFavorite("c1")).rejects.toThrow("boom");
|
||||||
|
expect(useConnectionStore.getState().connections[0].favorite).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loadAll sorts favorites first within the returned list", async () => {
|
||||||
|
const fav = makeConn({ id: "a", name: "A", favorite: true });
|
||||||
|
const norm = makeConn({ id: "b", name: "B", favorite: false });
|
||||||
|
vi.spyOn(commands, "getConnections").mockResolvedValue([norm, fav]);
|
||||||
|
vi.spyOn(commands, "getFolders").mockResolvedValue([]);
|
||||||
|
vi.spyOn(commands, "getTags").mockResolvedValue([]);
|
||||||
|
vi.spyOn(commands, "getSettings").mockResolvedValue({} as any);
|
||||||
|
await useConnectionStore.getState().loadAll();
|
||||||
|
const ids = useConnectionStore.getState().connections.map((c) => c.id);
|
||||||
|
expect(ids[0]).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordRecent calls the command once", async () => {
|
||||||
|
vi.spyOn(commands, "recordRecentConnection").mockResolvedValue(undefined);
|
||||||
|
await useConnectionStore.getState().recordRecent("c1");
|
||||||
|
expect(commands.recordRecentConnection).toHaveBeenCalledWith("c1");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("duplicateConnection", () => {
|
||||||
|
it("copies fields with a '(copy)' name and favorite false", async () => {
|
||||||
|
const src = makeConn({ id: "c1", name: "Prod", folder_id: "f1", tag_ids: ["t1"], favorite: true });
|
||||||
|
useConnectionStore.setState({ connections: [src] });
|
||||||
|
const created = { ...src, id: "c2", name: "Prod (copy)", favorite: false, keychain_ref: null };
|
||||||
|
vi.spyOn(commands, "createConnection").mockResolvedValue(created as any);
|
||||||
|
const out = await useConnectionStore.getState().duplicateConnection("c1");
|
||||||
|
expect(out.name).toBe("Prod (copy)");
|
||||||
|
expect(out.favorite).toBe(false);
|
||||||
|
expect(commands.createConnection).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
name: "Prod (copy)",
|
||||||
|
host: src.host,
|
||||||
|
folder_id: "f1",
|
||||||
|
tag_ids: ["t1"],
|
||||||
|
password: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the connection is missing", async () => {
|
||||||
|
useConnectionStore.setState({ connections: [] });
|
||||||
|
await expect(useConnectionStore.getState().duplicateConnection("nope")).rejects.toThrow("not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveSelectionToFolder moves connections and reparents folders", async () => {
|
||||||
|
useConnectionStore.setState({
|
||||||
|
connections: [makeConn({ id: "c1", folder_id: null })],
|
||||||
|
folders: [{ id: "f1", name: "f", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
|
||||||
|
});
|
||||||
|
const originalMoveConnection = useConnectionStore.getState().moveConnection;
|
||||||
|
vi.spyOn(useConnectionStore.getState(), "moveConnection").mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(commands, "updateFolder").mockResolvedValue({} as any);
|
||||||
|
await useConnectionStore.getState().moveSelectionToFolder(["c1", "f1"], "target");
|
||||||
|
expect(useConnectionStore.getState().connections[0].folder_id).toBe("target");
|
||||||
|
expect(useConnectionStore.getState().folders[0].parent_id).toBe("target");
|
||||||
|
// Restore the real action so the mock doesn't linger on future state objects
|
||||||
|
useConnectionStore.setState({ moveConnection: originalMoveConnection });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,12 +5,17 @@ import * as cmd from "../lib/commands";
|
|||||||
interface ConnectionState {
|
interface ConnectionState {
|
||||||
connections: Connection[]; folders: Folder[]; tags: Tag[];
|
connections: Connection[]; folders: Folder[]; tags: Tag[];
|
||||||
tagOrder: string[];
|
tagOrder: string[];
|
||||||
|
recent: Connection[];
|
||||||
loading: boolean; error: string | null;
|
loading: boolean; error: string | null;
|
||||||
loadAll: () => Promise<void>;
|
loadAll: () => Promise<void>;
|
||||||
|
loadRecent: () => Promise<void>;
|
||||||
|
recordRecent: (id: string) => Promise<void>;
|
||||||
|
toggleFavorite: (id: string) => Promise<void>;
|
||||||
loadTagOrder: () => Promise<void>;
|
loadTagOrder: () => Promise<void>;
|
||||||
setTagOrder: (order: string[]) => Promise<void>;
|
setTagOrder: (order: string[]) => Promise<void>;
|
||||||
createConnection: (input: ConnectionInput) => Promise<void>;
|
createConnection: (input: ConnectionInput) => Promise<Connection>;
|
||||||
deleteConnection: (id: string) => Promise<void>;
|
deleteConnection: (id: string) => Promise<void>;
|
||||||
|
duplicateConnection: (id: string) => Promise<Connection>;
|
||||||
createFolder: (input: FolderInput) => Promise<void>;
|
createFolder: (input: FolderInput) => Promise<void>;
|
||||||
updateFolder: (id: string, input: FolderInput) => Promise<void>;
|
updateFolder: (id: string, input: FolderInput) => Promise<void>;
|
||||||
deleteFolder: (id: string) => Promise<void>;
|
deleteFolder: (id: string) => Promise<void>;
|
||||||
@@ -19,23 +24,48 @@ interface ConnectionState {
|
|||||||
deleteTag: (id: string) => Promise<void>;
|
deleteTag: (id: string) => Promise<void>;
|
||||||
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise<void>;
|
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise<void>;
|
||||||
moveConnection: (connectionId: string, newFolderId: string | null) => Promise<void>;
|
moveConnection: (connectionId: string, newFolderId: string | null) => Promise<void>;
|
||||||
|
moveSelectionToFolder: (selectedIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||||
cachePassword: (connectionId: string, password: string) => Promise<void>;
|
cachePassword: (connectionId: string, password: string) => Promise<void>;
|
||||||
getConnectionPassword: (connectionId: string) => Promise<string | null>;
|
getConnectionPassword: (connectionId: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||||
connections: [], folders: [], tags: [], tagOrder: [], loading: false, error: null,
|
connections: [], folders: [], tags: [], tagOrder: [], recent: [], loading: false, error: null,
|
||||||
loadAll: async () => {
|
loadAll: async () => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]);
|
const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]);
|
||||||
set({ connections, folders, tags, loading: false });
|
set({ connections, folders, tags, loading: false });
|
||||||
|
// Sort favorites first (stable sort preserves name order within groups)
|
||||||
|
set((s) => ({ connections: [...s.connections].sort((a, b) => Number(b.favorite) - Number(a.favorite)) }));
|
||||||
// Also load tag order
|
// Also load tag order
|
||||||
get().loadTagOrder();
|
get().loadTagOrder();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ loading: false, error: e instanceof Error ? e.message : String(e) });
|
set({ loading: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
loadRecent: async () => {
|
||||||
|
try {
|
||||||
|
const recent = await cmd.getRecentConnections(8);
|
||||||
|
const map = new Map(get().connections.map((c) => [c.id, c]));
|
||||||
|
set({ recent: recent.map((r) => map.get(r.connection_id)).filter(Boolean) as Connection[] });
|
||||||
|
} catch { /* best-effort: recent list is non-critical */ }
|
||||||
|
},
|
||||||
|
recordRecent: async (id) => {
|
||||||
|
try { await cmd.recordRecentConnection(id); } catch { /* best-effort */ }
|
||||||
|
},
|
||||||
|
toggleFavorite: async (id) => {
|
||||||
|
const prev = get().connections;
|
||||||
|
const next = prev.map((c) => c.id === id ? { ...c, favorite: !c.favorite } : c);
|
||||||
|
set({ connections: next });
|
||||||
|
const conn = next.find((c) => c.id === id);
|
||||||
|
try {
|
||||||
|
await cmd.setConnectionFavorite(id, conn?.favorite ?? false);
|
||||||
|
} catch (e) {
|
||||||
|
set({ connections: prev }); // rollback
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
loadTagOrder: async () => {
|
loadTagOrder: async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await cmd.getSettings();
|
const settings = await cmd.getSettings();
|
||||||
@@ -66,6 +96,36 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
|||||||
await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase);
|
await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase);
|
||||||
}
|
}
|
||||||
set((s) => ({ connections: [...s.connections, conn] }));
|
set((s) => ({ connections: [...s.connections, conn] }));
|
||||||
|
return conn;
|
||||||
|
},
|
||||||
|
duplicateConnection: async (id) => {
|
||||||
|
const source = get().connections.find((c) => c.id === id);
|
||||||
|
if (!source) throw new Error("Connection not found");
|
||||||
|
// Passwords live in the OS keychain and are NEVER copied; the duplicate
|
||||||
|
// starts unkeyed and with no favorite flag.
|
||||||
|
const input: ConnectionInput = {
|
||||||
|
name: `${source.name} (copy)`,
|
||||||
|
db_type: source.db_type,
|
||||||
|
host: source.host,
|
||||||
|
port: source.port,
|
||||||
|
username: source.username ?? null,
|
||||||
|
database: source.database ?? null,
|
||||||
|
folder_id: source.folder_id,
|
||||||
|
tag_ids: source.tag_ids ?? [],
|
||||||
|
environment: source.environment ?? null,
|
||||||
|
ssh_host: source.ssh_host ?? null,
|
||||||
|
ssh_port: source.ssh_port ?? null,
|
||||||
|
ssh_user: source.ssh_user ?? null,
|
||||||
|
ssh_auth_method: (source.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null,
|
||||||
|
ssh_private_key_path: source.ssh_private_key_path ?? null,
|
||||||
|
ssl_mode: (source.ssl_mode as ConnectionInput["ssl_mode"]) ?? null,
|
||||||
|
ssl_ca_path: source.ssl_ca_path ?? null,
|
||||||
|
ssl_cert_path: source.ssl_cert_path ?? null,
|
||||||
|
ssl_key_path: source.ssl_key_path ?? null,
|
||||||
|
password: null,
|
||||||
|
use_keychain: false,
|
||||||
|
};
|
||||||
|
return get().createConnection(input);
|
||||||
},
|
},
|
||||||
deleteConnection: async (id) => {
|
deleteConnection: async (id) => {
|
||||||
await cmd.deleteConnection(id);
|
await cmd.deleteConnection(id);
|
||||||
@@ -173,4 +233,32 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
moveSelectionToFolder: async (selectedIds, targetFolderId) => {
|
||||||
|
const prev = { connections: [...get().connections], folders: [...get().folders] };
|
||||||
|
const connIds = new Set<string>();
|
||||||
|
const folderIds = new Set<string>();
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
if (prev.connections.some((c) => c.id === id)) connIds.add(id);
|
||||||
|
else if (prev.folders.some((f) => f.id === id)) folderIds.add(id);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Persist first: moveConnection has its own optimistic logic + rollback and
|
||||||
|
// would no-op (early-return on same folder_id) if we pre-set the target.
|
||||||
|
for (const id of connIds) {
|
||||||
|
await get().moveConnection(id, targetFolderId);
|
||||||
|
}
|
||||||
|
for (const id of folderIds) {
|
||||||
|
const f = prev.folders.find((x) => x.id === id);
|
||||||
|
if (f) await cmd.updateFolder(id, { name: f.name, parent_id: targetFolderId, tag_ids: f.tag_ids });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
set({ connections: prev.connections, folders: prev.folders });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// Apply the move optimistically at the end so state always ends moved
|
||||||
|
set((s) => ({
|
||||||
|
connections: s.connections.map((c) => connIds.has(c.id) ? { ...c, folder_id: targetFolderId } : c),
|
||||||
|
folders: s.folders.map((f) => folderIds.has(f.id) ? { ...f, parent_id: targetFolderId } : f),
|
||||||
|
}));
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
@@ -109,7 +109,7 @@ describe("dbViewerStore", () => {
|
|||||||
store.setTabLoading(tabId, true);
|
store.setTabLoading(tabId, true);
|
||||||
|
|
||||||
const mockData: QueryResult = {
|
const mockData: QueryResult = {
|
||||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||||
rows: [[1, "Alice"]],
|
rows: [[1, "Alice"]],
|
||||||
total_rows: 1, page: 1, page_size: 50,
|
total_rows: 1, page: 1, page_size: 50,
|
||||||
};
|
};
|
||||||
@@ -231,6 +231,76 @@ describe("dbViewerStore", () => {
|
|||||||
expect(state.schemas).toEqual(["public", "private"]);
|
expect(state.schemas).toEqual(["public", "private"]);
|
||||||
expect(state.tables).toEqual(tables);
|
expect(state.tables).toEqual(tables);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stageCellEdit appends an update QueueItem with primaryKey + old/new data", () => {
|
||||||
|
const store = useDbViewerStore.getState();
|
||||||
|
store.openTab("public", "users");
|
||||||
|
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||||
|
store.stageCellEdit({
|
||||||
|
tabId,
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
primaryKey: { id: 1 },
|
||||||
|
oldData: { name: "Alice" },
|
||||||
|
newData: { name: "Alicia" },
|
||||||
|
description: "Edit users.name",
|
||||||
|
});
|
||||||
|
const q = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(q).toHaveLength(1);
|
||||||
|
expect(q[0].type).toBe("update");
|
||||||
|
expect(q[0].primaryKey).toEqual({ id: 1 });
|
||||||
|
expect(q[0].oldData).toEqual({ name: "Alice" });
|
||||||
|
expect(q[0].newData).toEqual({ name: "Alicia" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-staging the same cell replaces the pending entry (keeps original oldData)", () => {
|
||||||
|
const store = useDbViewerStore.getState();
|
||||||
|
store.openTab("public", "users");
|
||||||
|
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||||
|
store.stageCellEdit({
|
||||||
|
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
||||||
|
oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||||
|
description: "Edit users.name",
|
||||||
|
});
|
||||||
|
store.stageCellEdit({
|
||||||
|
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
||||||
|
oldData: { name: "Alice" }, newData: { name: "Alicia 2" },
|
||||||
|
description: "Edit users.name",
|
||||||
|
});
|
||||||
|
const q = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(q).toHaveLength(1);
|
||||||
|
expect(q[0].newData).toEqual({ name: "Alicia 2" });
|
||||||
|
expect(q[0].oldData).toEqual({ name: "Alice" }); // original DB value preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staging a different cell appends a second entry", () => {
|
||||||
|
const store = useDbViewerStore.getState();
|
||||||
|
store.openTab("public", "users");
|
||||||
|
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||||
|
store.stageCellEdit({
|
||||||
|
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
||||||
|
oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||||
|
});
|
||||||
|
store.stageCellEdit({
|
||||||
|
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
||||||
|
oldData: { age: 30 }, newData: { age: 31 },
|
||||||
|
});
|
||||||
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setIndexes / setConstraints update store slices", () => {
|
||||||
|
const store = useDbViewerStore.getState();
|
||||||
|
store.setIndexes([{ name: "idx", schema: "public", table: "t", definition: "", is_unique: true, method: "btree", columns: ["id"], size_bytes: 1, tablespace: null }]);
|
||||||
|
store.setConstraints([{ name: "ck", schema: "public", table: "t", contype: "CHECK", definition: "", deferrable: false, validated: true, columns: ["x"] }]);
|
||||||
|
expect(useDbViewerStore.getState().indexes).toHaveLength(1);
|
||||||
|
expect(useDbViewerStore.getState().constraints).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset clears indexes and constraints", () => {
|
||||||
|
useDbViewerStore.getState().setIndexes([{ name: "x", schema: "s", table: "t", definition: "", is_unique: false, method: "btree", columns: [], size_bytes: null, tablespace: null }]);
|
||||||
|
useDbViewerStore.getState().reset();
|
||||||
|
expect(useDbViewerStore.getState().indexes).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("refreshTree", () => {
|
describe("refreshTree", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../lib/types";
|
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo } from "../lib/types";
|
||||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||||
|
|
||||||
// ─── Local types ────────────────────────────────────────────────
|
// ─── Local types ────────────────────────────────────────────────
|
||||||
@@ -96,6 +96,8 @@ interface DbViewerState {
|
|||||||
sequences: SequenceInfo[] | null;
|
sequences: SequenceInfo[] | null;
|
||||||
enums: EnumInfo[] | null;
|
enums: EnumInfo[] | null;
|
||||||
extensions: ExtensionInfo[] | null;
|
extensions: ExtensionInfo[] | null;
|
||||||
|
indexes: IndexInfo[] | null;
|
||||||
|
constraints: ConstraintInfo[] | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
openTab: (schema: string, table: string, forceNew?: boolean) => void;
|
openTab: (schema: string, table: string, forceNew?: boolean) => void;
|
||||||
@@ -141,6 +143,17 @@ interface DbViewerState {
|
|||||||
setSequences: (sequences: SequenceInfo[]) => void;
|
setSequences: (sequences: SequenceInfo[]) => void;
|
||||||
setEnums: (enums: EnumInfo[]) => void;
|
setEnums: (enums: EnumInfo[]) => void;
|
||||||
setExtensions: (extensions: ExtensionInfo[]) => void;
|
setExtensions: (extensions: ExtensionInfo[]) => void;
|
||||||
|
setIndexes: (indexes: IndexInfo[]) => void;
|
||||||
|
setConstraints: (constraints: ConstraintInfo[]) => void;
|
||||||
|
stageCellEdit: (input: {
|
||||||
|
tabId: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
primaryKey: Record<string, unknown>;
|
||||||
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
}) => void;
|
||||||
populate: (
|
populate: (
|
||||||
databases: string[],
|
databases: string[],
|
||||||
schemas: string[],
|
schemas: string[],
|
||||||
@@ -168,6 +181,8 @@ const initialState = {
|
|||||||
sequences: null as SequenceInfo[] | null,
|
sequences: null as SequenceInfo[] | null,
|
||||||
enums: null as EnumInfo[] | null,
|
enums: null as EnumInfo[] | null,
|
||||||
extensions: null as ExtensionInfo[] | null,
|
extensions: null as ExtensionInfo[] | null,
|
||||||
|
indexes: null as IndexInfo[] | null,
|
||||||
|
constraints: null as ConstraintInfo[] | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Store ──────────────────────────────────────────────────────
|
// ─── Store ──────────────────────────────────────────────────────
|
||||||
@@ -400,6 +415,48 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
setSequences: (sequences) => set({ sequences }),
|
setSequences: (sequences) => set({ sequences }),
|
||||||
setEnums: (enums) => set({ enums }),
|
setEnums: (enums) => set({ enums }),
|
||||||
setExtensions: (extensions) => set({ extensions }),
|
setExtensions: (extensions) => set({ extensions }),
|
||||||
|
setIndexes: (indexes) => set({ indexes }),
|
||||||
|
setConstraints: (constraints) => set({ constraints }),
|
||||||
|
|
||||||
|
stageCellEdit: (input) => {
|
||||||
|
// Re-staging the same cell replaces the existing pending entry (keeps the
|
||||||
|
// original oldData so revert restores the DB value) instead of stacking
|
||||||
|
// a second queue item.
|
||||||
|
const colName = Object.keys(input.newData)[0];
|
||||||
|
const existing = get().changesQueue.find(
|
||||||
|
(c) =>
|
||||||
|
c.status === "pending" &&
|
||||||
|
c.type === "update" &&
|
||||||
|
c.schema === input.schema &&
|
||||||
|
c.table === input.table &&
|
||||||
|
c.primaryKey &&
|
||||||
|
JSON.stringify(c.primaryKey) === JSON.stringify(input.primaryKey) &&
|
||||||
|
Object.keys(c.newData ?? {})[0] === colName,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
set((state) => ({
|
||||||
|
changesQueue: state.changesQueue.map((c) =>
|
||||||
|
c.id === existing.id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
newData: input.newData,
|
||||||
|
description: input.description ?? c.description,
|
||||||
|
}
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
get().addChange({
|
||||||
|
type: "update",
|
||||||
|
schema: input.schema,
|
||||||
|
table: input.table,
|
||||||
|
primaryKey: input.primaryKey,
|
||||||
|
oldData: input.oldData,
|
||||||
|
newData: input.newData,
|
||||||
|
description: input.description ?? `Edit ${input.table}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
populate: (databases, schemas, tables) =>
|
populate: (databases, schemas, tables) =>
|
||||||
set({ databases, schemas, tables }),
|
set({ databases, schemas, tables }),
|
||||||
|
|||||||
Reference in New Issue
Block a user