diff --git a/BACKEND_DOCUMENTATION.MD b/BACKEND_DOCUMENTATION.MD index 484f17a..9c485a9 100644 --- a/BACKEND_DOCUMENTATION.MD +++ b/BACKEND_DOCUMENTATION.MD @@ -102,22 +102,18 @@ reassembles this same shape on every read, so API consumers see no difference: { "id": "uuid", "name": "Reports", + "color": "#6366f1", // hex accent color or null (any type; used by table folders) "parentId": null, // parent folder id or null (root) — folders nest into subdirectories - "ts": 1719600000000 + "type": "query", // "query" | "dashboard" | "workflow" | "table" + "ts": 1719600000000, + "tables": ["orders", "order_details"] // type="table" only: the tables grouped in this folder } ``` - -### Domain -```jsonc -{ - "id": "uuid", - "name": "Orders", - "color": "#6366f1", // hex color or null - "ts": 1719600000000, // epoch ms - "tables": ["orders", "order_details"] // tables grouped under this domain -} -``` -A table belongs to **at most one** domain (a domain is a grouping "by domain"). +Folders of `type: "table"` group the connected database's tables. A table belongs +to **at most one** folder; membership lives server-side in `connection_tables`, +the per-table metadata row (tables themselves aren't rows in the metadata DB). These replaced the old *domains* concept +in meta migration v5 — each domain became a root-level table folder, keeping its +id, name and color, so old domain ids still resolve. ### Query history entry ```jsonc @@ -233,21 +229,28 @@ CREATE TABLE saved_folders ( parent_id TEXT, -- nullable self-FK to saved_folders.id (NULL = root); enables nesting ts INTEGER ); -CREATE TABLE domains ( +CREATE TABLE folders ( -- one polymorphic tree per (connection, type) id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, + type TEXT NOT NULL, -- "query" | "dashboard" | "workflow" | "table" name TEXT NOT NULL, - color TEXT, -- hex color string, e.g. "#6366f1" + color TEXT, -- hex color string, e.g. "#6366f1" (nullable) + parent_id TEXT, -- nullable self-FK (NULL = root); enables nesting ts INTEGER ); -CREATE TABLE table_domains ( -- one domain per table +CREATE TABLE connection_tables ( -- per-table metadata, one row per (connection, table) id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, table_name TEXT NOT NULL, - domain_id TEXT NOT NULL, + folder_id TEXT, -- nullable FK to folders.id (type="table"); NULL = ungrouped ts INTEGER, UNIQUE(connection_id, table_name) ); +-- Tables live in the user's database, so this row is where anything the app +-- knows about a table hangs. Folder membership is the first such fact; future +-- per-table config (access, display, …) becomes additional columns here. +-- domains / table_domains: DEPRECATED, superseded by the two tables above in +-- meta migration v5. Kept for rollback compatibility; no live code reads them. CREATE TABLE query_history ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, @@ -498,7 +501,8 @@ pools/handles so the next query reconnects with new settings. - **Example:** `PUT http://localhost:3000/api/connections/demo-sqlite` ### DELETE `/api/connections/:id` -Delete a connection and cascade-delete its saved queries, folders and domains. +Delete a connection and cascade-delete its saved queries, folders (including +table-folder membership), workflows, dashboards and history. Also closes any open pool/handle. - **200:** `{ "ok": true }` @@ -566,25 +570,36 @@ Delete a saved query. ## Folders (per connection) +Folders are polymorphic: one tree per resource `type`. `type=query` groups saved +queries (uncapped nesting), `type=dashboard` groups dashboards (3-level cap), +`type=workflow` groups workflows (3-level cap), `type=table` groups the connected +database's tables (3-level cap). The `type` param defaults to `query` for older +clients. Each item points back via its own `folderId` — except tables, which live +in the user's database and are mapped by name in `connection_tables` (see the +`/tables/:table/folder` endpoint below). Every folder may carry a `color`. + ### GET `/api/connections/:id/folders` -List folders (oldest first). Folders nest via `parentId` (null = root). +List folders of a given `type` (oldest first). Folders nest via `parentId` (null = root). -- **200:** `Folder[]` → `[{ "id", "name", "parentId", "ts" }]` -- **Example:** `GET http://localhost:3000/api/connections/demo-sqlite/folders` +- **Query:** `?type=query|dashboard|workflow|table` (defaults to `query`) +- **200:** `Folder[]` → `[{ "id", "name", "color", "parentId", "type", "ts" }]` + (`type=table` rows also carry `"tables": ["orders", …]`) +- **Example:** `GET http://localhost:3000/api/connections/demo-sqlite/folders?type=workflow` ### POST `/api/connections/:id/folders` Create a folder. Pass `parentId` to create a subfolder under an existing folder. -- **Body:** `{ "name": "Reports", "parentId": null }` // `parentId` optional (defaults to root) -- **200:** `{ "id", "name", "parentId", "ts" }` -- **400:** `{ "error": "A folder name is required" }` / `{ "error": "Parent folder not found" }` +- **Body:** `{ "type": "workflow", "name": "Reports", "parentId": null, "color": null }` // `type` defaults to `query`; `parentId` (root) and `color` optional +- **200:** `{ "id", "name", "color", "parentId", "type", "ts" }` (plus `"tables": []` when `type=table`) +- **400:** `{ "error": "A folder name is required" }` / `{ "error": "Parent folder not found" }` / `{ "error": "Folders can only nest 3 levels deep" }` - **Example:** `POST http://localhost:3000/api/connections/demo-sqlite/folders` ### PUT `/api/connections/:id/folders/:fid` -Rename and/or move (reparent) a folder. Send `name`, `parentId`, or both. -`parentId: null` moves the folder to the root. +Rename, recolor and/or move (reparent) a folder. Send any subset of `name`, +`color`, `parentId`. `parentId: null` moves the folder to the root; `color: null` +clears the color. -- **Body:** `{ "name": "Renamed" }` and/or `{ "parentId": "fld-2" }` +- **Body:** `{ "name": "Renamed" }` and/or `{ "color": "#ef4444" }` and/or `{ "parentId": "fld-2" }` - **200:** `{ "ok": true }` - **400:** `{ "error": "A folder name is required" }` / `{ "error": "Parent folder not found" }` / `{ "error": "Can't move a folder into its own subfolder" }` / `{ "error": "A folder can't be its own parent" }` / @@ -594,57 +609,36 @@ Rename and/or move (reparent) a folder. Send `name`, `parentId`, or both. ### DELETE `/api/connections/:id/folders/:fid` Delete a folder. Its contents move up one level (to the deleted folder's parent, -or the root for a top-level folder): child subfolders are reparented and queries -are re-attached (`folder_id`/`parent_id` set to the parent), not deleted. +or the root for a top-level folder): child subfolders are reparented and the +folder's items (queries/dashboards/workflows/tables, per `type`) are re-attached +(`folder_id`/`parent_id` set to the parent), not deleted. For `type=table`, +items that land at the root are simply ungrouped (their mapping row is dropped). - **200:** `{ "ok": true }` - **Example:** `DELETE http://localhost:3000/api/connections/demo-sqlite/folders/fld-1` --- -## Domains (per connection) - -A domain is a named, colored entity that groups a connection's tables — each -table belongs to **at most one** domain ("group tables by domain"). Table names -are plain strings, so the contract is database-agnostic across every dialect. +## Table folders (per connection) -### GET `/api/connections/:id/domains` -List domains (oldest first). Each domain embeds the table names it groups. +Tables are grouped by the generic folders tree above, using `type=table`. Each +table belongs to **at most one** folder; table names are plain strings, so the +contract is database-agnostic across every dialect. Folder create/list/update/ +delete all go through `/api/connections/:id/folders` — the only extra endpoint is +membership: -- **200:** `Domain[]` → `[{ "id", "name", "color", "ts", "tables": ["orders", "order_details"] }]` -- **Example:** `GET http://localhost:3000/api/connections/demo-sqlite/domains` - -### POST `/api/connections/:id/domains` -Create a domain. - -- **Body:** `{ "name": "Orders", "color": "#6366f1" }` (`color` optional) -- **200:** `{ "id", "name", "color", "ts", "tables": [] }` -- **400:** `{ "error": "A domain name is required" }` -- **Example:** `POST http://localhost:3000/api/connections/demo-sqlite/domains` - -### PUT `/api/connections/:id/domains/:domainId` -Update a domain. Any subset of `name`, `color`. - -- **Body examples:** `{ "name": "Renamed" }` · `{ "color": "#ef4444" }` -- **200:** the updated `Domain` (with `tables`) -- **400:** `{ "error": "Nothing to update" }` (or name validation) -- **404:** `{ "error": "Not found" }` -- **Example:** `PUT http://localhost:3000/api/connections/demo-sqlite/domains/dom-1` - -### DELETE `/api/connections/:id/domains/:domainId` -Delete a domain and detach all of its tables. - -- **200:** `{ "ok": true }` -- **Example:** `DELETE http://localhost:3000/api/connections/demo-sqlite/domains/dom-1` +> **Migrated from domains.** Meta migration v5 folded every domain into this tree +> (same id, name and color), so previously assigned tables keep their grouping. +> The `/api/connections/:id/domains*` endpoints are gone. -### PUT `/api/connections/:id/tables/:table/domain` -Set (or clear) which domain a table belongs to. Reassigning replaces the -table's previous domain (upsert); `domainId: null` removes it from any domain. +### PUT `/api/connections/:id/tables/:table/folder` +Set (or clear) which folder a table belongs to. Reassigning replaces the table's +previous folder (upsert); `folderId: null` ungroups it. -- **Body:** `{ "domainId": "dom-1" }` (or `{ "domainId": null }` to clear) +- **Body:** `{ "folderId": "fld-1" }` (or `{ "folderId": null }` to clear) - **200:** `{ "ok": true }` -- **404:** `{ "error": "Domain not found" }` -- **Example:** `PUT http://localhost:3000/api/connections/demo-sqlite/tables/orders/domain` +- **404:** `{ "error": "Folder not found" }` +- **Example:** `PUT http://localhost:3000/api/connections/demo-sqlite/tables/orders/folder` --- @@ -743,16 +737,20 @@ not a workflow. > since a full database dump + upload can legitimately take longer. ### GET `/api/connections/:id/workflows` -List workflows for a connection (newest first; no graph body). +List workflows for a connection (newest first; no graph body). `folderId` is the +containing folder (null = root; see the polymorphic Folders API with `type=workflow`). -- **200:** `[{ "id", "name", "ts", "protected", "scheduleEnabled" }]` +- **200:** `[{ "id", "name", "ts", "protected", "scheduleEnabled", "folderId" }]` - **Example:** `GET http://localhost:3000/api/connections/demo-sqlite/workflows` ### POST `/api/connections/:id/workflows` -Create a workflow with an empty graph. +Create a workflow. `graph` is optional — omit it (or send a non-object) for an +empty graph; the frontend's JSON-import flow ("Import from JSON…" in the +Workflows panel) posts a sanitized graph here to create the workflow in one call. +`folderId` (optional) creates the workflow inside a folder. -- **Body:** `{ "name": "Nightly sync" }` -- **200:** `{ "id", "name", "graph": { "nodes": [], "edges": [] }, "ts", "protected": false, "scheduleEnabled": false }` +- **Body:** `{ "name": "Nightly sync", "graph"?: { "nodes": [...], "edges": [...] }, "folderId"?: null }` +- **200:** `{ "id", "name", "graph", "folderId", "ts", "protected": false, "scheduleEnabled": false }` - **400:** `{ "error": "A workflow name is required" }` - **Example:** `POST http://localhost:3000/api/connections/demo-sqlite/workflows` @@ -764,14 +762,16 @@ Fetch one workflow with its full graph. - **Example:** `GET http://localhost:3000/api/connections/demo-sqlite/workflows/wf-1` ### PUT `/api/connections/:id/workflows/:wid` -Update a workflow. Any subset of `name`, `graph`, `scheduleEnabled`. Posting -`graph` or `scheduleEnabled` recomputes `next_run_at` from the graph's `schedule` -node (cleared to `null` if the result wouldn't actually be scheduled). +Update a workflow. Any subset of `name`, `graph`, `scheduleEnabled`, `folderId`. +Posting `graph` or `scheduleEnabled` recomputes `next_run_at` from the graph's +`schedule` node (cleared to `null` if the result wouldn't actually be scheduled). +`folderId: null` moves the workflow back to the root. - **Body examples:** - Rename: `{ "name": "Renamed" }` - Save graph: `{ "graph": { "nodes": [...], "edges": [...] } }` - Activate schedule: `{ "scheduleEnabled": true }` + - Move to folder: `{ "folderId": "fld-1" }` - **200:** `{ "ok": true }` - **400:** `{ "error": "Nothing to update" }` (or name validation) - **404:** `{ "error": "Not found" }` @@ -1500,14 +1500,10 @@ Liveness probe. Also reports boot readiness and the running identity. | POST | `/api/connections/:id/saved` | Create saved query | | PUT | `/api/connections/:id/saved/:sid` | Update saved query / move | | DELETE | `/api/connections/:id/saved/:sid` | Delete saved query | -| GET | `/api/connections/:id/domains` | List domains (with tables) | -| POST | `/api/connections/:id/domains` | Create a domain | -| PUT | `/api/connections/:id/domains/:domainId` | Update a domain (name/color) | -| DELETE | `/api/connections/:id/domains/:domainId` | Delete a domain + detach tables | -| PUT | `/api/connections/:id/tables/:table/domain` | Set/clear a table's domain | -| GET | `/api/connections/:id/folders` | List folders | -| POST | `/api/connections/:id/folders` | Create folder (opt. `parentId`) | -| PUT | `/api/connections/:id/folders/:fid` | Rename / move (reparent) folder | +| GET | `/api/connections/:id/folders` | List folders (`?type=`; tables embed `tables`) | +| POST | `/api/connections/:id/folders` | Create folder (opt. `parentId`, `color`) | +| PUT | `/api/connections/:id/folders/:fid` | Rename / recolor / move folder | +| PUT | `/api/connections/:id/tables/:table/folder` | Set/clear a table's folder | | DELETE | `/api/connections/:id/folders/:fid` | Delete folder (contents move up)| | GET | `/api/connections/:id/history` | List query history | | POST | `/api/connections/:id/history` | Record a query execution | diff --git a/CLAUDE.md b/CLAUDE.md index 33de9e8..5574629 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,31 +53,55 @@ src/ │ │ # ConnectionAccessPanel, DbTypePickerModal (owns DB_CATALOG/TYPE_LABEL); │ │ # api (connection access get/set) │ ├── settings/ # stores/SettingsContext -│ ├── domains/ # named+colored table groupings (one domain per table): DomainQuickMenu -│ │ # (the table context-menu row: hover flyout for one-click assign, or -│ │ # opens the panel), DomainPickerPanel (set a table's domain — a right-side -│ │ # slide-over with inline create/edit/delete), DomainEditPanel, DomainDot; -│ │ # api (domains CRUD + -│ │ # set-table-domain). Drives the sidebar group-by-domain view and the -│ │ # schema-designer's draggable/editable domain regions. +│ ├── table-folders/ # the connected DB's tables grouped by the generic folders tree +│ │ # (type='table', 3-level cap, one folder per table), each folder +│ │ # carrying an optional color. Replaced the old "domains" feature — +│ │ # meta migration v5 folded every domain into the folders tree, +│ │ # keeping its id/name/color. Components: TableFolderList (the console +│ │ # Tables sidebar — always folder-grouped, like the dashboards/workflows +│ │ # panels: collapsible color-tinted folders + subfolders, drag a table +│ │ # into a folder / a folder into a folder, un-foldered tables listed +│ │ # plainly beneath as the root drop zone, inline new folder (name only — +│ │ # the row is controlled via `creating`/`onCreatingChange` so the panel +│ │ # header's folder+ button starts it) + rename, and one swatch picker +│ │ # reachable two ways: click the folder icon, or ⋮ > "Change color" (a +│ │ # ContextMenuSub flyout)), TableFolderPickerPanel + TableFolderEditPanel (right-side +│ │ # slide-overs, now only reached from the schema diagram's node menu / +│ │ # region label), FolderDot; lib/api (wraps shared/api/folders with the +│ │ # 'table' type bound + set-table-folder), lib/assign, lib/tree +│ │ # (path/tree order). Drives the sidebar folder view and the +│ │ # schema-designer's draggable/editable folder regions. │ ├── keymap/ # stores/KeymapContext (useKeymap/useShortcut) + KeymapSetting │ ├── workspace/ # the DB console (one connection): data browsing + querying -│ │ ├── components/ # DataGrid, TableView, SchemaView, QueryEditor, FunctionView, -│ │ │ # QueryHistoryView, InsertRowPanel, ChangesPanel, SavedQueriesPanel, IconRail +│ │ ├── components/ # DataGrid (drag / Shift+click selects a rectangular cell range — +│ │ │ # ⌘/Ctrl+C copies it as TSV, Esc clears; the range rides along in +│ │ │ # onCellContextMenu's payload as `selection`), TableView (its cell +│ │ │ # context menu acts on that selection: copy as TSV/CSV/JSON, set +│ │ │ # NULL/EMPTY/DEFAULT, duplicate/delete the spanned rows), +│ │ │ # SchemaView, QueryEditor, FunctionView, +│ │ │ # QueryHistoryView, InsertRowPanel, ChangesPanel, SavedQueriesPanel, IconRail, +│ │ │ # TabBar (the open-tab strip: drag a tab left/right to reorder — insertion +│ │ │ # caret on drag-over, committed on drop; right-click closes this/others/ +│ │ │ # to-the-right/all) │ │ └── lib/ # savedQueries, queryHistory (backend calls) │ ├── schema-designer/ # visual schema design (React Flow ERD + table/column editors; tables -│ │ │ # sharing a domain are clustered into a draggable, editable region) +│ │ │ # sharing a folder are clustered into a draggable, editable region) │ │ ├── components/ # SchemaEditor, SchemaSidebar (accordion: Draft Schema / Table List / -│ │ │ # References / Domain Group — click to focus/edit), CreateTablePanel, +│ │ │ # References / Table Folders — click to focus/edit), CreateTablePanel, │ │ │ # TableEditPanel, columnFields, SchemaHistoryPanel (schema-version audit trail) │ │ └── lib/ # rollback (best-effort rollback SQL for staged DDL) │ ├── workflow/ # workflow automations (React Flow builder + server-side runner, incl. -│ │ │ # real hourly/daily scheduling via the Schedule trigger node's Active toggle) -│ │ ├── components/ # WorkflowEditor, WorkflowsPanel, NodePalette, NodeConfigPanel, -│ │ │ # RunLogPanel, nodes/WorkflowNode (one spec-driven card) -│ │ └── lib/ # api (per-connection CRUD + run), nodeSpec (node catalog: manual, -│ │ # schedule, query, http, js, switch, loop, export "Export SQL", -│ │ # storage "Store to Storage") +│ │ │ # real hourly/daily scheduling via the Schedule trigger node's Active toggle; +│ │ │ # JSON export/import of a workflow's graph; folders — grouped via the generic +│ │ │ # folders tree (type='workflow'), 3-level cap — mirroring the dashboard feature) +│ │ ├── components/ # WorkflowEditor (export/import toolbar buttons), WorkflowsPanel (folder +│ │ │ # tree: create/rename/delete/drag into folders + "Import from JSON…"), +│ │ │ # NodePalette, NodeConfigPanel, RunLogPanel, nodes/WorkflowNode (spec card) +│ │ └── lib/ # api (per-connection CRUD + run + folder CRUD via shared/api/folders), +│ │ # nodeSpec (node catalog: manual, schedule, query, http, js, switch, loop, +│ │ # export "Export SQL", storage "Store to Storage"), exportImport +│ │ # (WorkflowExport type + sanitizeGraph/stripSecrets — webhook tokens +│ │ # never round-trip a file) │ ├── dashboard/ # per-connection query dashboards (New Relic style): dynamic variables │ │ │ # ({{name}} in widget SQL, query-backed or static lists), free-placement │ │ │ # 12-col drag/resize grid (hard collision blocking), fullscreen, JSON @@ -101,6 +125,19 @@ src/ │ │ │ # BackupCalendarHeatmap, BackupVersionList (run-based restore), │ │ │ # RestorePanel (restore from uploaded file or browsed storage object) │ │ └── lib/ # api (storages CRUD, backup schedule/runs/calendar/restore), types +│ ├── templates/ # built-in, read-only template catalog (browse + apply only — no +│ │ │ # authoring). A template bundles workflows + dashboards; applying +│ │ │ # creates the workflows first, resolves `{{workflow:}}` +│ │ │ # placeholders in dashboard row-action `workflowId`s against the +│ │ │ # newly created ids, then creates the dashboards. Filterable by +│ │ │ # DB type ("postgresql" | "sqlite") against Template.databases. +│ │ │ # VSCode-style entry: a Templates icon in the console IconRail opens +│ │ │ # TemplatesPanel in the sidebar; picking a template opens its detail +│ │ │ # in a main-area tab (kind: 'template'). +│ │ ├── catalog/ # TEMPLATES: Template[] — the shipped templates (plain TS objects) +│ │ ├── components/ # TemplatesPanel (sidebar list + DB-type filter chips), +│ │ │ # TemplateDetailView (main-area tab: contents + Apply button) +│ │ └── lib/ # apply (applyTemplate: create workflows → resolve refs → create dashboards) │ └── system-update/ # in-app update checking + guided update wizard (backup → pre-flight → │ │ # apply → verify). Compares running (version, sha) to the latest GitHub │ │ # Release; docker-socket-mounted ⇒ one-click self-update, else a manual diff --git a/README.md b/README.md index 915fc3f..0d95613 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,13 @@ Self‑hosted, single Docker image, your data stays yours. | | Feature | What it does | |---|---|---| | 🗂️ | **Connections** | Organize databases by environment, folder, and tags. Credentials encrypted at rest. Per‑connection access control. | -| 📊 | **Data browser** | Fast, spreadsheet‑style data grid — filter, multi‑column sort (click a header, Shift+click to add), edit rows inline, insert & delete, with a staged **Changes** panel before you commit. | +| 📊 | **Data browser** | Fast, spreadsheet‑style data grid — filter, multi‑column sort (click a header, Shift+click to add), edit rows inline, insert & delete, with a staged **Changes** panel before you commit. Drag (or Shift+click) across cells to select a block, then `⌘/Ctrl+C` to copy it as TSV or right‑click for copy as CSV/JSON, set NULL/EMPTY/DEFAULT, and duplicate/delete the rows it spans. | +| 📁 | **Table folders** | Group a connection's tables into colored, nestable folders (up to 3 levels) — create one inline from the Tables sidebar header, drag tables (and folders) between them, and click a folder's icon to pick its color. The schema designer clusters each folder's tables into its own region on the ERD. | | 🧮 | **SQL editor** | CodeMirror‑powered editor with SQL highlighting, formatting, query history, and reusable **saved queries**. | | 🎨 | **Schema designer** | Visual ERD (React Flow) to create/edit tables and columns; staged DDL with a **schema version** audit trail and best‑effort rollback SQL. | -| ⚙️ | **Workflows** | Drag‑and‑drop automation builder: `Manual`/`Schedule`/`Webhook` triggers → `Run query`, `HTTP Request`, `Run JavaScript`, `Switch`, `Loop`, `Export SQL`, `Store to Storage`. Real hourly/daily scheduling, a public **webhook** trigger URL, and an **Activity** trail of past runs (every trigger). The JavaScript node has a built‑in `crypto` helper for HMAC/hash signing (e.g. signed HTTP headers). Runs can carry an input payload; query nodes inline it as `{{input.field}}`. | +| ⚙️ | **Workflows** | Drag‑and‑drop automation builder: `Manual`/`Schedule`/`Webhook` triggers → `Run query`, `HTTP Request`, `Run JavaScript`, `Switch`, `Loop`, `Export SQL`, `Store to Storage`. Real hourly/daily scheduling, a public **webhook** trigger URL, **folders** to organize them (drag into nested folders), JSON export/import (share or version‑control a workflow's graph — webhook tokens are stripped on export and re‑minted on import), and an **Activity** trail of past runs (every trigger). The JavaScript node has a built‑in `crypto` helper for HMAC/hash signing (e.g. signed HTTP headers). Runs can carry an input payload; query nodes inline it as `{{input.field}}`. | | 📈 | **Dashboards** | Per‑connection query dashboards with dynamic `{{variables}}` (single‑ or **multi‑select** with "select all" — multi values expand to a SQL list for `IN (…)`, shown as glanceable filter chips), drag/resize grid, JSON export/import, **auto‑refresh** (10s–5m with a "last updated" indicator) and a fullscreen **kiosk mode** (auto‑hiding toolbar for wall displays). Table widgets support server‑side pagination and per‑row **action buttons** that run a workflow with the clicked row as its input. | +| 🧩 | **Templates** | Browse built‑in dashboard/workflow templates, filtered by database type, and apply one with a click to instantly create the bundled dashboard(s) and workflow(s) on your connection — e.g. a PostgreSQL health dashboard wired to a one‑click **Vacuum** workflow. | | 💾 | **Backups & restore** | S3‑compatible storage destinations + per‑connection scheduled backups. Calendar heatmap of runs and point‑in‑time restore — from a tracked backup version, any file browsed out of a storage destination, or a backup file uploaded from your computer. | | 👥 | **Workspaces & teams** | Multi‑workspace (org/tenant) model with members, teams, and `admin`/`member` roles. Email invites (SMTP optional — always get a copyable link). | | 🔑 | **Auth & security** | First‑run setup wizard, token‑based auth, per‑workspace roles, and membership‑guarded connection routes. | diff --git a/server.js b/server.js index 2e8642e..683b56d 100644 --- a/server.js +++ b/server.js @@ -2144,8 +2144,7 @@ app.delete('/api/workspaces/:id', (req, res) => { if (JSON.parse(row.data).workspaceId === req.params.id) { deleteConnectionRow(row.id) meta.prepare('DELETE FROM saved_queries WHERE connection_id = ?').run(row.id) - meta.prepare('DELETE FROM domains WHERE connection_id = ?').run(row.id) - meta.prepare('DELETE FROM table_domains WHERE connection_id = ?').run(row.id) + meta.prepare('DELETE FROM connection_tables WHERE connection_id = ?').run(row.id) meta.prepare('DELETE FROM workflows WHERE connection_id = ?').run(row.id) meta.prepare('DELETE FROM dashboards WHERE connection_id = ?').run(row.id) meta.prepare('DELETE FROM folders WHERE connection_id = ?').run(row.id) @@ -2560,8 +2559,7 @@ app.delete('/api/connections/:id', (req, res) => { deleteConnectionRow(req.params.id) meta.prepare('DELETE FROM saved_queries WHERE connection_id = ?').run(req.params.id) meta.prepare('DELETE FROM folders WHERE connection_id = ?').run(req.params.id) - meta.prepare('DELETE FROM domains WHERE connection_id = ?').run(req.params.id) - meta.prepare('DELETE FROM table_domains WHERE connection_id = ?').run(req.params.id) + meta.prepare('DELETE FROM connection_tables WHERE connection_id = ?').run(req.params.id) meta.prepare('DELETE FROM workflows WHERE connection_id = ?').run(req.params.id) meta.prepare('DELETE FROM workflow_runs WHERE connection_id = ?').run(req.params.id) meta.prepare('DELETE FROM dashboards WHERE connection_id = ?').run(req.params.id) @@ -2598,13 +2596,22 @@ app.get('/api/connections/:id/saved', (req, res) => { // ---- Folders (per connection, polymorphic by `type`) ---- // One tree per (connection, type): 'query' groups saved queries, 'dashboard' -// groups dashboards. Each item points back via its own `folder_id` column, and +// groups dashboards, 'workflow' groups workflows, 'table' groups the connected +// database's tables. Each item points back via its own `folder_id` column, and // `parent_id` builds the tree (NULL = root). Nesting caps are per type (queries -// uncapped for back-compat; dashboards capped at 3) and enforced here. Adding a +// uncapped for back-compat; the rest capped at 3) and enforced here. Adding a // new folderable resource = one entry in FOLDER_TYPES + a `folder_id` column. +// +// 'table' is the one type whose items don't live in the meta DB (tables belong +// to the user's database), so its "item table" is `connection_tables` — the +// per-table metadata row (one per connection+table), of which `folder_id` is +// currently the only fact. It replaced the old domains/table_domains pair in +// meta migration v5 — a folder's `color` is what a domain's color became. const FOLDER_TYPES = { query: { itemTable: 'saved_queries', maxDepth: Infinity }, dashboard: { itemTable: 'dashboards', maxDepth: 3 }, + workflow: { itemTable: 'workflows', maxDepth: 3 }, + table: { itemTable: 'connection_tables', maxDepth: 3 }, } // Coerce an untrusted `type` to a known one (defaults to 'query' for older // clients that predate the `type` param). Guards the itemTable interpolation. @@ -2655,16 +2662,34 @@ function folderHasAncestor(connectionId, type, folderId, candidateAncestor, pare return false } +// Table names grouped under a 'table' folder (empty for every other type — +// those items carry their own folder_id and are listed by their own endpoint). +const folderTables = (connectionId, folderId) => + meta + .prepare('SELECT table_name FROM connection_tables WHERE connection_id = ? AND folder_id = ? ORDER BY table_name ASC') + .all(connectionId, folderId) + .map((r) => r.table_name) + +const folderRow = (connectionId, type, r) => ({ + id: r.id, + name: r.name, + color: r.color || null, + parentId: r.parent_id || null, + type, + ts: r.ts, + ...(type === 'table' ? { tables: folderTables(connectionId, r.id) } : null), +}) + app.get('/api/connections/:id/folders', (req, res) => { const type = folderTypeOf(req.query.type) const rows = meta - .prepare('SELECT id, name, parent_id, ts FROM folders WHERE connection_id = ? AND type = ? ORDER BY ts ASC') + .prepare('SELECT id, name, color, parent_id, ts FROM folders WHERE connection_id = ? AND type = ? ORDER BY ts ASC') .all(req.params.id, type) - res.json(rows.map((r) => ({ id: r.id, name: r.name, parentId: r.parent_id || null, type, ts: r.ts }))) + res.json(rows.map((r) => folderRow(req.params.id, type, r))) }) app.post('/api/connections/:id/folders', (req, res) => { - const { name, parentId } = req.body || {} + const { name, parentId, color } = req.body || {} const type = folderTypeOf(req.body?.type) if (!name?.trim()) return res.status(400).json({ error: 'A folder name is required' }) if (parentId) { @@ -2675,11 +2700,18 @@ app.post('/api/connections/:id/folders', (req, res) => { if (folderDepth(req.params.id, type, parentId) >= FOLDER_TYPES[type].maxDepth) return res.status(400).json({ error: `Folders can only nest ${FOLDER_TYPES[type].maxDepth} levels deep` }) } - const entry = { id: randomUUID(), name: name.trim(), parentId: parentId || null, type, ts: Date.now() } + const entry = { + id: randomUUID(), + name: name.trim(), + color: color || null, + parentId: parentId || null, + type, + ts: Date.now(), + } meta - .prepare('INSERT INTO folders (id, connection_id, type, name, parent_id, ts) VALUES (?, ?, ?, ?, ?, ?)') - .run(entry.id, req.params.id, entry.type, entry.name, entry.parentId, entry.ts) - res.json(entry) + .prepare('INSERT INTO folders (id, connection_id, type, name, color, parent_id, ts) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(entry.id, req.params.id, entry.type, entry.name, entry.color, entry.parentId, entry.ts) + res.json(type === 'table' ? { ...entry, tables: [] } : entry) }) app.put('/api/connections/:id/folders/:fid', (req, res) => { @@ -2697,6 +2729,11 @@ app.put('/api/connections/:id/folders/:fid', (req, res) => { sets.push('name = ?') vals.push(name.trim()) } + // color is explicitly settable (null clears it back to the default look). + if ('color' in body) { + sets.push('color = ?') + vals.push(body.color || null) + } // parentId is explicitly settable (null moves the folder to the root). if ('parentId' in body) { const parentId = body.parentId || null @@ -2739,10 +2776,37 @@ app.delete('/api/connections/:id/folders/:fid', (req, res) => { meta .prepare(`UPDATE ${FOLDER_TYPES[type].itemTable} SET folder_id = ? WHERE folder_id = ? AND connection_id = ?`) .run(parentId, req.params.fid, req.params.id) + // A connection_tables row only records membership, so "moved to the root" means + // ungrouped — drop the row rather than leaving a folder-less mapping behind. + if (type === 'table' && !parentId) + meta.prepare('DELETE FROM connection_tables WHERE folder_id IS NULL AND connection_id = ?').run(req.params.id) meta.prepare('DELETE FROM folders WHERE id = ? AND connection_id = ?').run(req.params.fid, req.params.id) res.json({ ok: true }) }) +// Set (or clear) a table's folder. `folderId: null` ungroups the table; +// otherwise it's reassigned to that single folder (upsert — one folder per +// table). Table names are plain strings, so this works for any dialect. +app.put('/api/connections/:id/tables/:table/folder', (req, res) => { + const folderId = req.body?.folderId ?? null + const table = req.params.table + if (folderId === null) { + meta.prepare('DELETE FROM connection_tables WHERE connection_id = ? AND table_name = ?').run(req.params.id, table) + return res.json({ ok: true }) + } + const folder = meta + .prepare("SELECT id FROM folders WHERE id = ? AND connection_id = ? AND type = 'table'") + .get(folderId, req.params.id) + if (!folder) return res.status(404).json({ error: 'Folder not found' }) + meta + .prepare( + `INSERT INTO connection_tables (id, connection_id, table_name, folder_id, ts) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(connection_id, table_name) DO UPDATE SET folder_id = excluded.folder_id, ts = excluded.ts` + ) + .run(randomUUID(), req.params.id, table, folderId, Date.now()) + res.json({ ok: true }) +}) + app.post('/api/connections/:id/saved', (req, res) => { const { name, sql, kind } = req.body || {} if (!name?.trim() || !sql?.trim()) return res.status(400).json({ error: 'A name and SQL are required' }) @@ -2786,105 +2850,39 @@ app.delete('/api/connections/:id/saved/:sid', (req, res) => { res.json({ ok: true }) }) -// ---- Domains (per connection) ---- -// A domain is a named, colored entity that groups tables; each table belongs to -// at most one domain. The shape is database-agnostic (table names are plain -// strings) so it works for any dialect the connection targets. Each domain -// carries the list of table names it currently groups. -const domainWithTables = (connectionId, domain) => ({ - id: domain.id, - name: domain.name, - color: domain.color || null, - ts: domain.ts, - tables: meta - .prepare('SELECT table_name FROM table_domains WHERE connection_id = ? AND domain_id = ? ORDER BY table_name ASC') - .all(connectionId, domain.id) - .map((r) => r.table_name), -}) - -app.get('/api/connections/:id/domains', (req, res) => { - const rows = meta - .prepare('SELECT id, name, color, ts FROM domains WHERE connection_id = ? ORDER BY ts ASC') - .all(req.params.id) - res.json(rows.map((d) => domainWithTables(req.params.id, d))) -}) - -app.post('/api/connections/:id/domains', (req, res) => { - const { name, color } = req.body || {} - if (!name?.trim()) return res.status(400).json({ error: 'A domain name is required' }) - const entry = { id: randomUUID(), name: name.trim(), color: color || null, ts: Date.now() } - meta - .prepare('INSERT INTO domains (id, connection_id, name, color, ts) VALUES (?, ?, ?, ?, ?)') - .run(entry.id, req.params.id, entry.name, entry.color, entry.ts) - res.json(domainWithTables(req.params.id, entry)) -}) - -app.put('/api/connections/:id/domains/:domainId', (req, res) => { - const body = req.body || {} - const sets = [] - const vals = [] - if (body.name != null) { - if (!body.name.trim()) return res.status(400).json({ error: 'A domain name is required' }) - sets.push('name = ?') - vals.push(body.name.trim()) - } - if ('color' in body) { - sets.push('color = ?') - vals.push(body.color || null) - } - if (!sets.length) return res.status(400).json({ error: 'Nothing to update' }) - const r = meta - .prepare(`UPDATE domains SET ${sets.join(', ')} WHERE id = ? AND connection_id = ?`) - .run(...vals, req.params.domainId, req.params.id) - if (!r.changes) return res.status(404).json({ error: 'Not found' }) - const domain = meta.prepare('SELECT id, name, color, ts FROM domains WHERE id = ?').get(req.params.domainId) - res.json(domainWithTables(req.params.id, domain)) -}) - -app.delete('/api/connections/:id/domains/:domainId', (req, res) => { - meta.prepare('DELETE FROM table_domains WHERE domain_id = ? AND connection_id = ?').run(req.params.domainId, req.params.id) - meta.prepare('DELETE FROM domains WHERE id = ? AND connection_id = ?').run(req.params.domainId, req.params.id) - res.json({ ok: true }) -}) - -// Set (or clear) a table's domain. `domainId: null` removes the table from any -// domain; otherwise the table is reassigned to that single domain (upsert). -app.put('/api/connections/:id/tables/:table/domain', (req, res) => { - const domainId = req.body?.domainId ?? null - const table = req.params.table - if (domainId === null) { - meta.prepare('DELETE FROM table_domains WHERE connection_id = ? AND table_name = ?').run(req.params.id, table) - return res.json({ ok: true }) - } - const domain = meta.prepare('SELECT id FROM domains WHERE id = ? AND connection_id = ?').get(domainId, req.params.id) - if (!domain) return res.status(404).json({ error: 'Domain not found' }) - meta - .prepare( - `INSERT INTO table_domains (id, connection_id, table_name, domain_id, ts) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(connection_id, table_name) DO UPDATE SET domain_id = excluded.domain_id, ts = excluded.ts` - ) - .run(randomUUID(), req.params.id, table, domainId, Date.now()) - res.json({ ok: true }) -}) - // ---- Workflows (per connection) ---- // A workflow can be marked `protected` (undeletable) — `DELETE` 409s on it, // everything else behaves like a normal workflow. Nothing currently sets this // automatically (backups are a separate system — see the Backup section below). app.get('/api/connections/:id/workflows', (req, res) => { const rows = meta - .prepare('SELECT id, name, ts, protected, schedule_enabled FROM workflows WHERE connection_id = ? ORDER BY ts DESC') + .prepare('SELECT id, name, ts, protected, schedule_enabled, folder_id FROM workflows WHERE connection_id = ? ORDER BY ts DESC') .all(req.params.id) - res.json(rows.map((r) => ({ id: r.id, name: r.name, ts: r.ts, protected: !!r.protected, scheduleEnabled: !!r.schedule_enabled }))) + res.json( + rows.map((r) => ({ + id: r.id, + name: r.name, + ts: r.ts, + protected: !!r.protected, + scheduleEnabled: !!r.schedule_enabled, + folderId: r.folder_id || null, + })) + ) }) app.post('/api/connections/:id/workflows', (req, res) => { - const { name } = req.body || {} + const { name, graph, folderId } = req.body || {} if (!name?.trim()) return res.status(400).json({ error: 'A workflow name is required' }) - const entry = { id: randomUUID(), name: name.trim(), graph: { nodes: [], edges: [] }, ts: Date.now() } + const entry = { + id: randomUUID(), + name: name.trim(), + graph: graph && typeof graph === 'object' ? graph : { nodes: [], edges: [] }, + folderId: folderId || null, + ts: Date.now(), + } meta - .prepare('INSERT INTO workflows (id, connection_id, name, graph, ts) VALUES (?, ?, ?, ?, ?)') - .run(entry.id, req.params.id, entry.name, JSON.stringify(entry.graph), entry.ts) + .prepare('INSERT INTO workflows (id, connection_id, name, graph, folder_id, ts) VALUES (?, ?, ?, ?, ?, ?)') + .run(entry.id, req.params.id, entry.name, JSON.stringify(entry.graph), entry.folderId, entry.ts) res.json({ ...entry, protected: false, scheduleEnabled: false }) }) @@ -2921,6 +2919,11 @@ app.put('/api/connections/:id/workflows/:wid', (req, res) => { sets.push('schedule_enabled = ?') vals.push(body.scheduleEnabled ? 1 : 0) } + // folderId is explicitly settable (null moves the workflow back to the root). + if ('folderId' in body) { + sets.push('folder_id = ?') + vals.push(body.folderId || null) + } if (!sets.length) return res.status(400).json({ error: 'Nothing to update' }) // Recompute next_run_at whenever the graph or the enabled flag changes — // whichever the request just posted wins over what's already stored. diff --git a/server/migrations.js b/server/migrations.js index 46d5468..5da9008 100644 --- a/server/migrations.js +++ b/server/migrations.js @@ -68,9 +68,25 @@ function ensureBaseSchema(db) { name TEXT NOT NULL, ts INTEGER ); - -- Domains: named, colored groupings for a connection's tables. A domain is - -- an entity (name + color); table_domains maps each table to exactly one - -- domain (UNIQUE per table), so tables can be grouped by domain. + -- Per-table metadata: one row per (connection, table_name). Tables live in + -- the user's database, not here, so this is where anything the app knows + -- about a table hangs. Today that's folder membership (folders.id, + -- type='table') — it doubles as the "item table" the generic folders tree + -- needs; future per-table config (access, display, …) becomes new columns. + -- Superseded the domains/table_domains pair in migration v5; + -- CREATE ... IF NOT EXISTS is idempotent with v5, so fresh + existing + -- installs agree. + CREATE TABLE IF NOT EXISTS connection_tables ( + id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL, + table_name TEXT NOT NULL, + folder_id TEXT, -- folders.id (type='table'), NULL = ungrouped + ts INTEGER, + UNIQUE(connection_id, table_name) -- one row (so one folder) per table + ); + CREATE INDEX IF NOT EXISTS idx_connection_tables_conn ON connection_tables(connection_id); + -- DEPRECATED (superseded by folders type='table' + connection_tables in v5). + -- Kept for rollback compat only — live code must not read these. CREATE TABLE IF NOT EXISTS domains ( id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, @@ -93,6 +109,9 @@ function ensureBaseSchema(db) { name TEXT NOT NULL, graph TEXT, -- JSON { nodes, edges } ts INTEGER + -- folder_id (folders.id, NULL = root) is added by migration v4 via ALTER; + -- like dashboards.folder_id, it is intentionally NOT inlined here so v4's + -- plain ALTER doesn't collide on fresh installs (which also run v4). ); CREATE TABLE IF NOT EXISTS dashboards ( id TEXT PRIMARY KEY, @@ -105,8 +124,9 @@ function ensureBaseSchema(db) { -- on fresh installs (which also run v3). ); -- Generic, polymorphic folders: one tree per (connection, type). The type - -- discriminates what the folder groups ('query' | 'dashboard' | future); - -- items point back via their own folder_id (saved_queries, dashboards, …). + -- discriminates what the folder groups ('query' | 'dashboard' | 'workflow' | + -- 'table'); items point back via their own folder_id (saved_queries, + -- dashboards, workflows, connection_tables). -- parent_id builds the tree (NULL = root); nesting caps are per-type and -- enforced in server.js. Supersedes the legacy saved_folders table, whose -- rows migration v3 copies in as type='query'. CREATE ... IF NOT EXISTS is @@ -411,9 +431,53 @@ export const MIGRATIONS = [ db.exec(`ALTER TABLE dashboards ADD COLUMN folder_id TEXT`) }, }, - // v4+: append plain, run-exactly-once steps here, e.g. + { + version: 4, + name: 'workflows.folder_id (workflows become folderable, type=workflow)', + up(db) { + // Workflows can now live in a folder, grouped by the generic folders tree + // under type='workflow' (see FOLDER_TYPES in server.js). Runs on both fresh + // and existing installs, so folder_id is not inlined in the baseline. + db.exec(`ALTER TABLE workflows ADD COLUMN folder_id TEXT`) + }, + }, + { + version: 5, + name: 'domains become table folders (folders.color + connection_tables; backfill domains)', + up(db) { + // Folders carry a color now — it came over with the domains they absorb, + // and every folder type can use it. NULL = no color (the default look). + db.exec(`ALTER TABLE folders ADD COLUMN color TEXT`) + // Tables live in the user's database, not here, so anything the app knows + // about one needs a row of its own. Folder membership is the first such + // fact (this is the "item table" for type='table' folders); later + // per-table config lands here as extra columns. + db.exec(` + CREATE TABLE IF NOT EXISTS connection_tables ( + id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL, + table_name TEXT NOT NULL, + folder_id TEXT, + ts INTEGER, + UNIQUE(connection_id, table_name) + ); + `) + db.exec(`CREATE INDEX IF NOT EXISTS idx_connection_tables_conn ON connection_tables(connection_id)`) + // Each domain becomes a root-level folder of type='table', keeping its id + // (so table_domains.domain_id maps straight across), name and color. + db.exec(` + INSERT OR IGNORE INTO folders (id, connection_id, type, name, color, parent_id, ts) + SELECT id, connection_id, 'table', name, color, NULL, ts FROM domains + `) + db.exec(` + INSERT OR IGNORE INTO connection_tables (id, connection_id, table_name, folder_id, ts) + SELECT id, connection_id, table_name, domain_id, ts FROM table_domains + `) + }, + }, + // v6+: append plain, run-exactly-once steps here, e.g. // { - // version: 4, + // version: 6, // name: 'connections: last_used_at', // up(db) { // db.exec(`ALTER TABLE connections ADD COLUMN last_used_at INTEGER`) @@ -428,6 +492,8 @@ export const MIGRATIONS = [ // moved past every image that still used the table. export const DEPRECATED_TABLES = [ { table: 'saved_folders', supersededBy: 'folders', sinceStep: 3 }, + { table: 'domains', supersededBy: 'folders', sinceStep: 5 }, + { table: 'table_domains', supersededBy: 'connection_tables', sinceStep: 5 }, ] export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version diff --git a/src/features/domains/components/DomainQuickMenu.tsx b/src/features/domains/components/DomainQuickMenu.tsx deleted file mode 100644 index abd43f2..0000000 --- a/src/features/domains/components/DomainQuickMenu.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import Button from '@/shared/ui/buttons/Button' -import MenuItem from '@/shared/ui/navigation/MenuItem' -import EmptyState from '@/shared/ui/feedback/EmptyState' -import { MENU_PANEL_CLASS } from '@/shared/ui/overlay/Popover' -import { useToast } from '@/shared/ui/feedback/Toast' -import { CheckIcon, ChevronRight, SettingsIcon, TagIcon } from '@/shared/ui/icons' -import { setTableDomain } from '../lib/api' -import { withTableAssignment } from '../lib/assign' -import type { Domain } from '../types' -import DomainDot from './DomainDot' - -const WIDTH = 200 -const GAP = 4 -const MARGIN = 8 - -/** - * The "Set domain" row inside a table's context menu. Hovering it opens a flyout - * of the connection's domains for one-click assignment (the fast path), so most - * reassignments never need the full slide-over. Mirrors ContextMenuSub: the - * flyout is a `fixed`, JS-positioned panel (opened on hover with a small close - * delay so a cursor that cuts a corner is forgiven) — not a CSS opacity fade, - * which flickers see-through while scrolling a long list. It flips to whichever - * side has room and caps its height so many domains scroll in place. When none - * exist yet, it points the user at the "Configure domains…" panel instead. - * - * Self-contained like the picker panel: it performs the assign call itself and - * hands the parent the recomputed list via `onChange`. `onConfigure` opens the - * full slide-over; `onAssigned` closes the surrounding context menu. - */ -export default function DomainQuickMenu({ - connectionId, - table, - domains, - onChange, - onConfigure, - onAssigned, -}: { - connectionId: string - table: string - domains: Domain[] - onChange: (next: Domain[]) => void - onConfigure: () => void - onAssigned: () => void -}) { - const toast = useToast() - const rowRef = useRef(null) - const panelRef = useRef(null) - const closeTimer = useRef | undefined>(undefined) - const [open, setOpen] = useState(false) - const [pos, setPos] = useState({ left: 0, top: 0, maxHeight: 0 }) - - useEffect(() => () => clearTimeout(closeTimer.current), []) - - // Position the fixed flyout on open: prefer the right of the row, flip left - // when it can't fit; clamp the top and cap the height to the viewport so a - // long list scrolls within the panel instead of running off-screen. - useLayoutEffect(() => { - if (!open) return - const r = rowRef.current?.getBoundingClientRect() - if (!r) return - const roomRight = window.innerWidth - r.right - const left = roomRight >= WIDTH + GAP || roomRight >= r.left ? r.right + GAP : r.left - WIDTH - GAP - const maxHeight = window.innerHeight - MARGIN * 2 - const panelH = Math.min(panelRef.current?.offsetHeight ?? 0, maxHeight) - const top = Math.max(MARGIN, Math.min(r.top, window.innerHeight - MARGIN - panelH)) - setPos({ left, top, maxHeight }) - }, [open, domains.length]) - - const cancelClose = () => clearTimeout(closeTimer.current) - const scheduleClose = () => { - closeTimer.current = setTimeout(() => setOpen(false), 150) - } - - const currentId = domains.find((d) => d.tables.includes(table))?.id ?? null - const current = domains.find((d) => d.id === currentId) ?? null - - const assign = async (domainId: string | null) => { - onAssigned() - if (domainId === currentId) return - const prev = domains - onChange(withTableAssignment(domains, table, domainId)) - try { - await setTableDomain(connectionId, table, domainId) - } catch (e: any) { - toast.error(`Couldn't set domain: ${e.message}`) - onChange(prev) - } - } - - const item = 'flex w-full items-center gap-2.5 rounded px-2.5 py-1.5 text-left text-[12px] transition-colors' - - return ( -
(cancelClose(), setOpen(true))} onMouseLeave={scheduleClose}> - - - Set domain - - - {current && } - - - - - {open && ( -
- {domains.length === 0 ? ( -
- No domains yet. - -
- ) : ( - <> - - - {domains.map((d) => ( - - ))} - -
- - Manage domains… - - - )} -
- )} -
- ) -} diff --git a/src/features/domains/index.ts b/src/features/domains/index.ts deleted file mode 100644 index c55ebef..0000000 --- a/src/features/domains/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Public API for the domains feature: colored, named groupings assignable to a -// connection's tables (one domain per table), with a picker slide-over and a -// colored dot. Drives the sidebar's group-by-domain view and the schema-diagram -// regions. -export { default as DomainPickerPanel } from './components/DomainPickerPanel' -export { default as DomainQuickMenu } from './components/DomainQuickMenu' -export { default as DomainEditPanel } from './components/DomainEditPanel' -export { default as DomainDot } from './components/DomainDot' -export { fetchDomains, createDomain, updateDomain, deleteDomain, setTableDomain } from './lib/api' -export { DOMAIN_COLORS } from './types' -export type { Domain } from './types' diff --git a/src/features/domains/lib/api.ts b/src/features/domains/lib/api.ts deleted file mode 100644 index 3bf3c18..0000000 --- a/src/features/domains/lib/api.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Per-connection domains, persisted on the backend. A domain (name + color) -// groups tables; each table belongs to at most one domain. The list endpoint -// embeds each domain's tables so one fetch drives both the picker and the -// grouped sidebar / schema-diagram regions. - -import { request, safeRequest } from '@/shared/api/request' -import type { Domain } from '../types' - -export async function fetchDomains(connectionId): Promise { - if (!connectionId) return [] - return safeRequest(`/connections/${connectionId}/domains`, []) -} - -export async function createDomain(connectionId, { name, color }: { name: string; color?: string | null }) { - return request(`/connections/${connectionId}/domains`, { method: 'POST', body: { name, color } }) -} - -export async function updateDomain(connectionId, domainId, fields: { name?: string; color?: string | null }) { - return request(`/connections/${connectionId}/domains/${domainId}`, { method: 'PUT', body: fields }) -} - -export async function deleteDomain(connectionId, domainId) { - await request(`/connections/${connectionId}/domains/${domainId}`, { method: 'DELETE' }) -} - -// Assign a table to a domain, or clear it with `domainId: null`. -export async function setTableDomain(connectionId, table, domainId: string | null) { - await request(`/connections/${connectionId}/tables/${encodeURIComponent(table)}/domain`, { - method: 'PUT', - body: { domainId }, - }) -} diff --git a/src/features/domains/lib/assign.ts b/src/features/domains/lib/assign.ts deleted file mode 100644 index c63ae1f..0000000 --- a/src/features/domains/lib/assign.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Domain } from '../types' - -/** - * Return `list` with `table` moved out of every domain and into `domainId` - * (`null` = ungrouped). A table belongs to at most one domain, so this is the - * single source of truth for the optimistic reassignment both the picker panel - * and the quick-assign menu apply before the API call resolves. - */ -export function withTableAssignment(list: Domain[], table: string, domainId: string | null): Domain[] { - return list.map((d) => ({ - ...d, - tables: - d.id === domainId - ? [...d.tables.filter((n) => n !== table), table] - : d.tables.filter((n) => n !== table), - })) -} diff --git a/src/features/domains/types.ts b/src/features/domains/types.ts deleted file mode 100644 index e870af4..0000000 --- a/src/features/domains/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -// A domain is a named, colored grouping of a connection's tables. Each table -// belongs to at most one domain. `tables` lists the tables grouped under it. -export type Domain = { - id: string - name: string - color: string | null - ts: number - tables: string[] -} - -// The palette offered when creating/editing a domain. Kept small and legible in -// both themes; the first entry is the default for a new domain. -export const DOMAIN_COLORS = [ - '#6366f1', // indigo - '#3b82f6', // blue - '#06b6d4', // cyan - '#10b981', // emerald - '#84cc16', // lime - '#eab308', // amber - '#f97316', // orange - '#ef4444', // red - '#ec4899', // pink - '#a855f7', // purple - '#64748b', // slate -] as const diff --git a/src/features/schema-designer/components/SchemaEditor.tsx b/src/features/schema-designer/components/SchemaEditor.tsx index 8d2566b..a2d0849 100644 --- a/src/features/schema-designer/components/SchemaEditor.tsx +++ b/src/features/schema-designer/components/SchemaEditor.tsx @@ -29,10 +29,10 @@ import CreateTablePanel from '@/features/schema-designer/components/CreateTableP import SchemaSidebar from '@/features/schema-designer/components/SchemaSidebar' import SaveQueryPanel from '@/shared/ui/SaveQueryPanel' import { newItemId } from '@/shared/lib/schemaDraft' -import { DomainEditPanel } from '@/features/domains' +import { TableFolderEditPanel } from '@/features/table-folders' import { columnTypeSql, FK_ACTIONS, fkEligible, normFkAction, parseColumnDefs, useColumnTypes } from '@/features/schema-designer/components/columnFields' import { useShortcut } from '@/features/keymap' -import { ChevronRight, ColumnsIcon, DownloadIcon, EditIcon, PlusIcon, SaveIcon, TableIcon, TagIcon, TrashIcon, WandIcon } from '@/shared/ui/icons' +import { ChevronRight, ColumnsIcon, DownloadIcon, EditIcon, FolderIcon, PlusIcon, SaveIcon, TableIcon, TagIcon, TrashIcon, WandIcon } from '@/shared/ui/icons' // Fixed metrics so per-column handles line up with their rows. const HEADER_H = 34 @@ -200,12 +200,12 @@ function TableNode({ data, selected }) { ) } -// ---- Custom node: a translucent region wrapping a domain's tables ---- +// ---- Custom node: a translucent region wrapping a folder's tables ---- // A translucent backdrop sized to the bounding box of its member tables (see -// domainGroups below). The whole region is a drag surface, so it's painted +// folderGroups below). The whole region is a drag surface, so it's painted // under both the tables and the FK lines (zIndex -1) to stay out of the way of // clicks meant for them. -function DomainGroupNode({ data }) { +function FolderGroupNode({ data }) { const [hover, setHover] = useState(false) const color = data.color || '#94a3b8' return ( @@ -224,7 +224,7 @@ function DomainGroupNode({ data }) { }} > {/* Header bar: an obvious, wide grab target. Its edit button opens the - domain editor (detected via the `.domain-edit` class in onNodeClick). */} + folder editor (detected via the `.folder-edit` class in onNodeClick). */}
{/* Edit affordance — revealed on hover, matching the table cards; click - opens the domain editor (detected via `.domain-edit` in onNodeClick). */} + opens the folder editor (detected via `.folder-edit` in onNodeClick). */} @@ -250,7 +250,7 @@ function DomainGroupNode({ data }) { ) } -const nodeTypes = { table: TableNode, domainGroup: DomainGroupNode } +const nodeTypes = { table: TableNode, folderGroup: FolderGroupNode } // ---- Parse staged change SQL into pending tables / columns ---- // A staged CREATE TABLE — the source of truth for a not-yet-committed table, @@ -545,7 +545,7 @@ function pathWithJumps(points, verticals) { return d } -export default function SchemaEditor({ conn, changes, domains = [], onUpdateDomain, onDeleteDomain, onSetDomain, pending = [], onPendingChange, onStageItems, onSaveDraft, onUpdateDraft, draftId, onOpenTable, onOpenSchema }) { +export default function SchemaEditor({ conn, changes, folders = [], onUpdateFolder, onDeleteFolder, onSetFolder, pending = [], onPendingChange, onStageItems, onSaveDraft, onUpdateDraft, draftId, onOpenTable, onOpenSchema }) { const dialect = conn.type === 'postgresql' ? 'postgresql' : 'sqlite' const types = useColumnTypes(conn) const toast = useToast() @@ -561,7 +561,7 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma const [hiddenTables, setHiddenTables] = useState(() => new Set()) // tables hidden from the diagram const [menu, setMenu] = useState(null) // canvas context menu { x, y } const [nodeMenu, setNodeMenu] = useState(null) // table right-click menu { x, y, table, pending } - const [editingDomain, setEditingDomain] = useState(null) // domain being edited from the canvas | null + const [editingFolder, setEditingFolder] = useState(null) // folder being edited from the canvas | null const [creating, setCreating] = useState(false) // create-table panel open const [editingDraft, setEditingDraft] = useState(null) // staged new table being re-edited { table, columns } const [naming, setNaming] = useState(false) // "save as draft" name prompt open @@ -766,18 +766,18 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma // Node size (matches the fixed row metrics) so dagre arranges without overlaps. const sizeOf = (t) => ({ w: NODE_W, h: HEADER_H + PAD_T * 2 + t.columns.length * ROW_H }) - // Inner padding a domain region reserves around its member tables, plus the - // top strip for its draggable label. Kept in sync with domainGroups (below), + // Inner padding a folder region reserves around its member tables, plus the + // top strip for its draggable label. Kept in sync with folderGroups (below), // which derives the region rectangle from the same members. - const DOMAIN_PAD = 22 - const DOMAIN_LABEL_H = 26 + const FOLDER_PAD = 22 + const FOLDER_LABEL_H = 26 // Build a table React Flow node at an absolute position. const tableNodeAt = useCallback( (t, position) => ({ id: t.name, type: 'table', - zIndex: 1, // paint above the FK lines and the domain regions (zIndex -1) + zIndex: 1, // paint above the FK lines and the folder regions (zIndex -1) position, style: { width: NODE_W }, data: { @@ -796,8 +796,8 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma [fkInfo, canEditFk] ) - // Arrange tables with dagre. Tables sharing a domain are clustered into their - // own block (laid out internally, then placed as one unit), so each domain + // Arrange tables with dagre. Tables sharing a folder are clustered into their + // own block (laid out internally, then placed as one unit), so each folder // region wraps only its members and never overlaps a foreign table. Ranks // follow FK relationships, no collisions. const layoutNodes = useCallback(() => { @@ -805,21 +805,21 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma const sizes = {} for (const t of visible) sizes[t.name] = sizeOf(t) - // Which domain (if any, and only if it has a visible member) each table is in. - const domainOf = {} - for (const d of domains) for (const tn of d.tables || []) domainOf[tn] = d.id - const activeDomains = new Set(visible.map((t) => domainOf[t.name]).filter(Boolean)) + // Which folder (if any, and only if it has a visible member) each table is in. + const folderOf = {} + for (const d of folders) for (const tn of d.tables || []) folderOf[tn] = d.id + const activeFolders = new Set(visible.map((t) => folderOf[t.name]).filter(Boolean)) - // 1) Lay out each domain's members internally → relative offsets + inner size. - const inner = {} // domainId -> { rel: {table -> {x,y}}, w, h } - for (const did of activeDomains) { - const members = visible.filter((t) => domainOf[t.name] === did) + // 1) Lay out each folder's members internally → relative offsets + inner size. + const inner = {} // folderId -> { rel: {table -> {x,y}}, w, h } + for (const did of activeFolders) { + const members = visible.filter((t) => folderOf[t.name] === did) const g = new dagre.graphlib.Graph() g.setDefaultEdgeLabel(() => ({})) g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 80, marginx: 0, marginy: 0 }) for (const t of members) g.setNode(t.name, { width: sizes[t.name].w, height: sizes[t.name].h }) for (const fk of diagram.foreignKeys) { - if (domainOf[fk.table] === did && domainOf[fk.refTable] === did && fk.table !== fk.refTable) g.setEdge(fk.table, fk.refTable) + if (folderOf[fk.table] === did && folderOf[fk.refTable] === did && fk.table !== fk.refTable) g.setEdge(fk.table, fk.refTable) } dagre.layout(g) let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity @@ -832,17 +832,17 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x + s.w); maxY = Math.max(maxY, y + s.h) } // Normalize so the top-left member sits at (PAD, PAD + LABEL_H) inside the block. - for (const t of members) { rel[t.name].x += DOMAIN_PAD - minX; rel[t.name].y += DOMAIN_PAD + DOMAIN_LABEL_H - minY } - inner[did] = { rel, w: maxX - minX + DOMAIN_PAD * 2, h: maxY - minY + DOMAIN_PAD * 2 + DOMAIN_LABEL_H } + for (const t of members) { rel[t.name].x += FOLDER_PAD - minX; rel[t.name].y += FOLDER_PAD + FOLDER_LABEL_H - minY } + inner[did] = { rel, w: maxX - minX + FOLDER_PAD * 2, h: maxY - minY + FOLDER_PAD * 2 + FOLDER_LABEL_H } } - // 2) Lay out blocks: each domain (as one node) + each ungrouped table. - const blockOf = (table) => (activeDomains.has(domainOf[table]) ? `d:${domainOf[table]}` : `t:${table}`) + // 2) Lay out blocks: each folder (as one node) + each ungrouped table. + const blockOf = (table) => (activeFolders.has(folderOf[table]) ? `d:${folderOf[table]}` : `t:${table}`) const g2 = new dagre.graphlib.Graph() g2.setDefaultEdgeLabel(() => ({})) g2.setGraph({ rankdir: 'LR', nodesep: 60, ranksep: 120, marginx: 24, marginy: 24 }) - for (const did of activeDomains) g2.setNode(`d:${did}`, { width: inner[did].w, height: inner[did].h }) - for (const t of visible) if (!activeDomains.has(domainOf[t.name])) g2.setNode(`t:${t.name}`, { width: sizes[t.name].w, height: sizes[t.name].h }) + for (const did of activeFolders) g2.setNode(`d:${did}`, { width: inner[did].w, height: inner[did].h }) + for (const t of visible) if (!activeFolders.has(folderOf[t.name])) g2.setNode(`t:${t.name}`, { width: sizes[t.name].w, height: sizes[t.name].h }) const seen = new Set() for (const fk of diagram.foreignKeys) { if (fk.table === fk.refTable || hiddenTables.has(fk.table) || hiddenTables.has(fk.refTable)) continue @@ -857,23 +857,23 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma // 3) Expand blocks back into absolute table positions. const out = [] - for (const did of activeDomains) { + for (const did of activeFolders) { const b = g2.node(`d:${did}`) const bx = b.x - inner[did].w / 2, by = b.y - inner[did].h / 2 for (const t of visible) { - if (domainOf[t.name] !== did) continue + if (folderOf[t.name] !== did) continue const r = inner[did].rel[t.name] out.push(tableNodeAt(t, { x: bx + r.x, y: by + r.y })) } } for (const t of visible) { - if (activeDomains.has(domainOf[t.name])) continue + if (activeFolders.has(folderOf[t.name])) continue const b = g2.node(`t:${t.name}`) const s = sizes[t.name] out.push(tableNodeAt(t, { x: b.x - s.w / 2, y: b.y - s.h / 2 })) } return out - }, [augmented, diagram, hiddenTables, domains, tableNodeAt]) + }, [augmented, diagram, hiddenTables, folders, tableNodeAt]) const toggleTable = (name) => setHiddenTables((s) => { @@ -1039,17 +1039,17 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma // Inject each table's connection endpoints into node data (without re-running // dagre), so a connected column can paint a solid dot on the exact edge its // line lands on. Kept off `layoutNodes` so it never reshuffles the diagram. - // Domain regions: one translucent region per domain, sized to the bounding + // Folder regions: one translucent region per folder, sized to the bounding // box of its visible member tables (using live node positions, so the region // tracks member drags). Painted behind the tables and the FK lines (see the // zIndex note below). Grabbing anywhere on the region drags the whole group - // (see handleNodesChange); its header's edit button opens the domain editor. - const domainGroups = useMemo(() => { - if (!domains.length || !nodes.length) return [] + // (see handleNodesChange); its header's edit button opens the folder editor. + const folderGroups = useMemo(() => { + if (!folders.length || !nodes.length) return [] const byTable = {} for (const n of nodes) byTable[n.id] = n const heightOf = (n) => HEADER_H + PAD_T * 2 + (n.data.columns?.length || 0) * ROW_H - return domains + return folders .map((d) => { const members = (d.tables || []).map((t) => byTable[t]).filter(Boolean) if (!members.length) return null @@ -1061,12 +1061,12 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma maxX = Math.max(maxX, n.position.x + w) maxY = Math.max(maxY, n.position.y + heightOf(n)) } - const w = maxX - minX + DOMAIN_PAD * 2 - const h = maxY - minY + DOMAIN_PAD * 2 + DOMAIN_LABEL_H + const w = maxX - minX + FOLDER_PAD * 2 + const h = maxY - minY + FOLDER_PAD * 2 + FOLDER_LABEL_H return { - id: `domain:${d.id}`, - type: 'domainGroup', - position: { x: minX - DOMAIN_PAD, y: minY - DOMAIN_PAD - DOMAIN_LABEL_H }, + id: `folder:${d.id}`, + type: 'folderGroup', + position: { x: minX - FOLDER_PAD, y: minY - FOLDER_PAD - FOLDER_LABEL_H }, // Provide explicit dimensions so React Flow never has to (re)measure // the node — otherwise it flips to visibility:hidden each time this // memo rebuilds during a drag, and a mousedown in that window falls @@ -1090,17 +1090,17 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma } }) .filter(Boolean) - }, [domains, nodes]) + }, [folders, nodes]) const displayNodes = useMemo( () => [ - ...domainGroups, + ...folderGroups, ...nodes.map((n) => (n.data.fkSides === fkEndpoints[n.id] ? n : { ...n, data: { ...n.data, fkSides: fkEndpoints[n.id] } })), ], - [domainGroups, nodes, fkEndpoints] + [folderGroups, nodes, fkEndpoints] ) - // Domain regions aren't stored in node state (they're derived from members), + // Folder regions aren't stored in node state (they're derived from members), // so dragging one is translated here into position changes for its member // tables — the region then follows the members it wraps. Everything else // passes straight through to useNodesState's handler. @@ -1109,14 +1109,14 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma const passthrough = [] const extra = [] for (const ch of changes) { - if (ch.id?.startsWith?.('domain:')) { + if (ch.id?.startsWith?.('folder:')) { if (ch.type === 'position' && ch.position) { - const grp = domainGroups.find((g) => g.id === ch.id) + const grp = folderGroups.find((g) => g.id === ch.id) if (grp) { const dx = ch.position.x - grp.position.x const dy = ch.position.y - grp.position.y if (dx || dy) { - const dom = domains.find((d) => `domain:${d.id}` === ch.id) + const dom = folders.find((d) => `folder:${d.id}` === ch.id) for (const tn of dom?.tables || []) { const node = liveNodes.current.find((n) => n.id === tn) if (node) extra.push({ id: tn, type: 'position', position: { x: node.position.x + dx, y: node.position.y + dy }, dragging: ch.dragging }) @@ -1124,13 +1124,13 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma } } } - continue // never apply domain-node changes to table state + continue // never apply folder-node changes to table state } passthrough.push(ch) } onNodesChange([...passthrough, ...extra]) }, - [onNodesChange, domainGroups, domains] + [onNodesChange, folderGroups, folders] ) // ---- Sidebar focus / edit actions ---- @@ -1154,7 +1154,7 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma } } const focusTable = (name) => revealAndFit([name], [name]) - const focusDomain = (d) => revealAndFit(d.tables || [], d.tables || []) + const focusFolder = (d) => revealAndFit(d.tables || [], d.tables || []) // Focus a foreign key's two tables and open its edit popup (centered near the // top of the canvas, since there's no click point from the list). const openReference = (fk, i) => { @@ -1442,8 +1442,8 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma onFocusTable={focusTable} foreignKeys={augmentedForeignKeys} onEditReference={openReference} - domains={domains} - onFocusDomain={focusDomain} + folders={folders} + onFocusFolder={focusFolder} />
{ const target = e.target as HTMLElement - // Domains and tables are edited only via their hover edit icon - // (`.domain-edit` / `.table-edit`); a plain click just leaves the + // Folders and tables are edited only via their hover edit icon + // (`.folder-edit` / `.table-edit`); a plain click just leaves the // node draggable and never opens the editor. - if (node.id.startsWith('domain:')) { - if (target?.closest?.('.domain-edit')) { - const d = domains.find((dm) => `domain:${dm.id}` === node.id) - if (d) setEditingDomain(d) + if (node.id.startsWith('folder:')) { + if (target?.closest?.('.folder-edit')) { + const d = folders.find((dm) => `folder:${dm.id}` === node.id) + if (d) setEditingFolder(d) } return } @@ -1495,7 +1495,7 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma // Stop the event bubbling to the canvas' onContextMenu, which // would otherwise also open the empty-space menu on top. e.stopPropagation() - if (node.id.startsWith('domain:')) return + if (node.id.startsWith('folder:')) return setMenu(null) setNodeMenu({ x: e.clientX, y: e.clientY, table: node.id, pending: !!node.data?.pending }) }} @@ -1589,12 +1589,12 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma /> )} - {editingDomain && ( - onUpdateDomain?.(editingDomain.id, fields)} - onDelete={() => onDeleteDomain?.(editingDomain.id)} - onClose={() => setEditingDomain(null)} + {editingFolder && ( + onUpdateFolder?.(editingFolder.id, fields)} + onDelete={() => onDeleteFolder?.(editingFolder.id)} + onClose={() => setEditingFolder(null)} /> )} @@ -1790,8 +1790,8 @@ export default function SchemaEditor({ conn, changes, domains = [], onUpdateDoma { openTableEditor(nodeMenu.table, nodeMenu.pending); setNodeMenu(null) }}> Edit table - { onSetDomain?.(nodeMenu.table); setNodeMenu(null) }}> - Move to domain… + { onSetFolder?.(nodeMenu.table); setNodeMenu(null) }}> + Move to folder…
{ deleteTable(nodeMenu.table, nodeMenu.pending); setNodeMenu(null) }}> diff --git a/src/features/schema-designer/components/SchemaSidebar.tsx b/src/features/schema-designer/components/SchemaSidebar.tsx index 861c3d9..2dafe56 100644 --- a/src/features/schema-designer/components/SchemaSidebar.tsx +++ b/src/features/schema-designer/components/SchemaSidebar.tsx @@ -4,7 +4,7 @@ import Badge from '@/shared/ui/Badge' import IconButton from '@/shared/ui/buttons/IconButton' import EmptyState from '@/shared/ui/feedback/EmptyState' import { ChevronRight, TableIcon, TrashIcon } from '@/shared/ui/icons' -import { DomainDot } from '@/features/domains' +import { FolderDot } from '@/features/table-folders' // Accordion section header — one open section at a time, like the console // browser sidebar. @@ -46,8 +46,8 @@ function opBadge(p) { /** * Left sidebar for the schema diagram — an accordion of Draft Schema (staged - * changes), Table List, References (FKs) and Domain Groups. Clicking a table or - * domain focuses the diagram on it; clicking a reference focuses it and opens + * changes), Table List, References (FKs) and Table Folders. Clicking a table or + * folder focuses the diagram on it; clicking a reference focuses it and opens * its edit popup (wired via the callbacks). */ export default function SchemaSidebar({ @@ -59,8 +59,8 @@ export default function SchemaSidebar({ onFocusTable, foreignKeys, onEditReference, - domains, - onFocusDomain, + folders, + onFocusFolder, }) { const [open, setOpen] = useState('tables') const [q, setQ] = useState('') @@ -73,7 +73,7 @@ export default function SchemaSidebar({ { key: 'draft', label: 'Schema Changes', count: pending.length }, { key: 'tables', label: 'Table List', count: tables.length }, { key: 'refs', label: 'References Key', count: foreignKeys.length }, - { key: 'domains', label: 'Domain Group', count: domains.length }, + { key: 'folders', label: 'Table Folders', count: folders.length }, ] return ( @@ -156,13 +156,13 @@ export default function SchemaSidebar({ )) ))} - {s.key === 'domains' && - (domains.length === 0 ? ( - No domains yet. + {s.key === 'folders' && + (folders.length === 0 ? ( + No folders yet. ) : ( - domains.map((d) => ( -
onFocusDomain(d)}> - + folders.map((d) => ( +
onFocusFolder(d)}> + {d.name} {d.tables.length}
diff --git a/src/features/domains/components/DomainDot.tsx b/src/features/table-folders/components/FolderDot.tsx similarity index 54% rename from src/features/domains/components/DomainDot.tsx rename to src/features/table-folders/components/FolderDot.tsx index 338bc4d..576e205 100644 --- a/src/features/domains/components/DomainDot.tsx +++ b/src/features/table-folders/components/FolderDot.tsx @@ -1,6 +1,6 @@ -// A small colored dot representing a domain's color. Used inline on table rows, -// in the domain picker, and on the schema-diagram region labels. -export default function DomainDot({ color, size = 8, className = '' }: { color?: string | null; size?: number; className?: string }) { +// A small dot in a folder's color. Used inline on table rows, in the folder +// picker, and on the schema-diagram region labels. +export default function FolderDot({ color, size = 8, className = '' }: { color?: string | null; size?: number; className?: string }) { return ( void onDelete: () => void onClose: () => void }) { const { show, close } = useSlideOver(onClose) - const [name, setName] = useState(domain.name) - const [color, setColor] = useState(domain.color || DOMAIN_COLORS[0]) + const [name, setName] = useState(folder.name) + const [color, setColor] = useState(folder.color || FOLDER_COLORS[0]) const [confirmDelete, setConfirmDelete] = useState(false) const save = () => { const trimmed = name.trim() if (!trimmed) return close(() => { - if (trimmed !== domain.name || color !== domain.color) onSave({ name: trimmed, color }) + if (trimmed !== folder.name || color !== folder.color) onSave({ name: trimmed, color }) onClose() }) } @@ -50,7 +50,7 @@ export default function DomainEditPanel({ title={ - Edit domain + Edit folder } footer={ @@ -74,13 +74,13 @@ export default function DomainEditPanel({ value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && save()} - placeholder="Domain name" + placeholder="Folder name" autoFocus />
- {DOMAIN_COLORS.map((c) => ( + {FOLDER_COLORS.map((c) => (
+ +
+ ) + + const renderNewFolderInput = () => ( +
+ + setNewName(e.target.value)} + onBlur={commitNewFolder} + onKeyDown={(e) => { + if (e.key === 'Enter') commitNewFolder() + else if (e.key === 'Escape') { + setNewName('') + onCreatingChange(null) + } + }} + /> +
+ ) + + const renderFolder = (folder: TableFolder) => { + const items = itemsIn(folder) + const subfolders = childFolders(folder.id) + const expanded = !collapsed.has(folder.id) || (searching && hasMatch(folder)) + const isDrop = dropTarget === folder.id + const addingHere = creating?.parentId === folder.id + const color = folder.color || undefined + const tint = color ? { color } : undefined + + if (renaming?.id === folder.id) { + return ( +
+ + setRenaming({ id: folder.id, value: e.target.value })} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename() + else if (e.key === 'Escape') setRenaming(null) + }} + /> +
+ ) + } + + const FolderGlyph = expanded ? FolderOpenIcon : FolderIcon + return ( + // The whole folder (header + contents) is the drop zone, so a dragged + // table can land anywhere inside it, not just on the header row. +
+
toggle(folder.id)} + className={`${rowBase} cursor-pointer ${isDrop ? 'text-ink' : rowIdle}`} + > + {/* The folder icon doubles as the color picker — click it for swatches. */} +
e.stopPropagation()}> + ( + + )} + > + {({ close }) => renderColorPicker(folder, close)} + +
+ {folder.name} + {subtreeCount(folder) > 0 && {subtreeCount(folder)}} +
e.stopPropagation()}> + ( + + + + )} + > + {({ close }) => ( +
+ {canNestUnder(folder.id) && ( + { startSubfolder(folder.id); close() }}> + New subfolder + + )} + { setRenaming({ id: folder.id, value: folder.name }); close() }}> + Rename + + {/* Same picker the folder icon opens, as a side flyout. */} + + {renderColorPicker(folder, close, 'p-0.5')} + +
+ { onDelete(folder.id); close() }}> + Delete folder + +
+ )} + +
+
+ + {expanded && ( +
+ {subfolders.map(renderFolder)} + {addingHere && renderNewFolderInput()} + {items.map((o) => renderTable(o, tableRowProps(o)))} + {subfolders.length === 0 && items.length === 0 && !addingHere && ( +
Empty — drag tables here
+ )} +
+ )} +
+ ) + } + + return ( +
+ {childFolders(null).map(renderFolder)} + {creating && creating.parentId == null && renderNewFolderInput()} + + {/* Un-foldered tables, listed straight after the folders (like the + dashboards panel) — also the drop zone that pulls a table or a folder + back out to the root. */} +
+ {ungrouped.map((o) => renderTable(o, tableRowProps(o)))} + {ungrouped.length === 0 && folders.length > 0 && ( +
Drag tables out of folders here
+ )} +
+
+ ) +} diff --git a/src/features/domains/components/DomainPickerPanel.tsx b/src/features/table-folders/components/TableFolderPickerPanel.tsx similarity index 70% rename from src/features/domains/components/DomainPickerPanel.tsx rename to src/features/table-folders/components/TableFolderPickerPanel.tsx index 3928c6e..62aaeeb 100644 --- a/src/features/domains/components/DomainPickerPanel.tsx +++ b/src/features/table-folders/components/TableFolderPickerPanel.tsx @@ -8,16 +8,17 @@ import SlideOverPanel from '@/shared/ui/overlay/SlideOverPanel' import { useSlideOver } from '@/shared/hooks/useSlideOver' import { useToast } from '@/shared/ui/feedback/Toast' import { CheckIcon, CloseIcon, EditIcon, PlusIcon, TrashIcon } from '@/shared/ui/icons' -import { setTableDomain, createDomain, updateDomain, deleteDomain } from '../lib/api' +import { setTableFolder, createTableFolder, updateTableFolder, deleteTableFolder } from '../lib/api' import { withTableAssignment } from '../lib/assign' -import { DOMAIN_COLORS, type Domain } from '../types' -import DomainDot from './DomainDot' +import { FOLDER_COLORS, type TableFolder } from '../types' +import { folderParentPath, foldersInTreeOrder } from '../lib/tree' +import FolderDot from './FolderDot' /** Row of selectable color swatches, shared by the create + inline-edit forms. */ function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) { return (
- {DOMAIN_COLORS.map((c) => ( + {FOLDER_COLORS.map((c) => ( - {domains.length === 0 ? ( - No domains yet. Create one below. + {folders.length === 0 ? ( + No folders yet. Create one below. ) : ( - domains.map((d) => { + foldersInTreeOrder(folders).map((d) => { const selected = d.id === currentId + const path = folderParentPath(folders, d) if (editing?.id === d.id) { return (
@@ -166,7 +168,7 @@ export default function DomainPickerPanel({ if (e.key === 'Enter') saveEdit() if (e.key === 'Escape') setEditing(null) }} - placeholder="Domain name" + placeholder="Folder name" className="flex-1" autoFocus /> @@ -189,8 +191,11 @@ export default function DomainPickerPanel({ onClick={() => assign(d.id)} className={`${row} cursor-pointer ${selected ? 'border-green bg-green/10' : 'border-edge bg-elevated/40 hover:border-edge-strong'}`} > - - {d.name} + + + {path && {path} / } + {d.name} + {d.tables.length} {selected && } { e.stopPropagation() - setEditing({ id: d.id, name: d.name, color: d.color || DOMAIN_COLORS[0] }) + setEditing({ id: d.id, name: d.name, color: d.color || FOLDER_COLORS[0] }) }} - aria-label={`Edit domain ${d.name}`} + aria-label={`Edit folder ${d.name}`} > @@ -211,7 +216,7 @@ export default function DomainPickerPanel({ e.stopPropagation() setConfirmDelete(d) }} - aria-label={`Delete domain ${d.name}`} + aria-label={`Delete folder ${d.name}`} > @@ -222,13 +227,13 @@ export default function DomainPickerPanel({
-

New domain

+

New folder

setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} - placeholder="Domain name" + placeholder="Folder name" className="flex-1" /> + {!supported && ( + Not available for {TYPE_LABEL[dbType] ?? dbType}. + )} +
+
+
+ ) +} diff --git a/src/features/templates/components/TemplatesPanel.tsx b/src/features/templates/components/TemplatesPanel.tsx new file mode 100644 index 0000000..c6f0443 --- /dev/null +++ b/src/features/templates/components/TemplatesPanel.tsx @@ -0,0 +1,88 @@ +import { useMemo, useState } from 'react' +import ListRow from '@/shared/ui/ListRow' +import RowLabel from '@/shared/ui/RowLabel' +import Badge from '@/shared/ui/Badge' +import EmptyState from '@/shared/ui/feedback/EmptyState' +import { WandIcon } from '@/shared/ui/icons' +import { TYPE_LABEL } from '@/features/connections' +import { TEMPLATES } from '../catalog' +import type { Template } from '../types' + +// Left-rail list of built-in templates (browse only — the catalog ships with +// the app). Filter chips narrow by DB type; clicking a template opens its +// detail in a main-area tab (VSCode-style). Modeled on the other rail panels. +export default function TemplatesPanel({ + dbType, + activeId, + onOpen, +}: { + dbType: string + activeId: string | null + onOpen?: (t: Template) => void +}) { + const dbTypes = useMemo(() => Array.from(new Set(TEMPLATES.flatMap((t) => t.databases))), []) + const [filter, setFilter] = useState(() => (dbTypes.includes(dbType as any) ? dbType : 'all')) + + const visible = filter === 'all' ? TEMPLATES : TEMPLATES.filter((t) => t.databases.includes(filter as any)) + + return ( + <> +
+ Templates +
+ +
+ setFilter('all')}> + All + + {dbTypes.map((t) => ( + setFilter(t)}> + {TYPE_LABEL[t] ?? t} + + ))} +
+ +
+ {visible.length === 0 ? ( + No templates for this filter. + ) : ( +
+ {visible.map((t) => ( + onOpen?.(t)} + icon={} + > + {t.name} +
+ {t.databases.map((d) => ( + + {TYPE_LABEL[d] ?? d} + + ))} +
+
+ ))} +
+ )} +
+ + ) +} + +function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ) +} diff --git a/src/features/templates/index.ts b/src/features/templates/index.ts new file mode 100644 index 0000000..993634a --- /dev/null +++ b/src/features/templates/index.ts @@ -0,0 +1,10 @@ +// Public API for the templates feature (built-in, read-only template catalog — +// browse and apply only, no user authoring). Browsed from the console's icon +// rail: TemplatesPanel lists them in the sidebar; picking one opens +// TemplateDetailView in a main-area tab. +export { default as TemplatesPanel } from './components/TemplatesPanel' +export { default as TemplateDetailView } from './components/TemplateDetailView' +export { TEMPLATES } from './catalog' +export { applyTemplate } from './lib/apply' +export type { ApplyResult } from './lib/apply' +export type { Template, TemplateDbType, TemplateWorkflow, TemplateDashboard } from './types' diff --git a/src/features/templates/lib/apply.ts b/src/features/templates/lib/apply.ts new file mode 100644 index 0000000..7a27081 --- /dev/null +++ b/src/features/templates/lib/apply.ts @@ -0,0 +1,55 @@ +// Applies a template to a connection: creates its workflows, resolves any +// {{workflow:}} placeholders in dashboard row actions against the newly +// created ids, then creates its dashboards. No rollback on partial failure — +// whatever was created before the error stays; the caller surfaces the error +// and the user can delete stray artifacts manually. + +import { createWorkflow, runWorkflow, sanitizeGraph } from '@/features/workflow' +import type { Workflow } from '@/features/workflow' +import { createDashboard, sanitizeConfig } from '@/features/dashboard' +import type { Dashboard } from '@/features/dashboard' +import type { Template } from '../types' + +export type ApplyResult = { workflows: Workflow[]; dashboards: Dashboard[] } + +const WORKFLOW_REF = /^\{\{workflow:(.+)\}\}$/ + +// Deep-walks the raw config, replacing any string that's *exactly* a +// `{{workflow:}}` reference with the real created workflow id. +function resolveWorkflowRefs(raw: unknown, ids: Record): unknown { + if (typeof raw === 'string') { + const m = raw.match(WORKFLOW_REF) + if (!m) return raw + const id = ids[m[1]] + if (!id) throw new Error(`Template references unknown workflow "${m[1]}"`) + return id + } + if (Array.isArray(raw)) return raw.map((v) => resolveWorkflowRefs(v, ids)) + if (raw && typeof raw === 'object') { + return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, resolveWorkflowRefs(v, ids)])) + } + return raw +} + +export async function applyTemplate(connectionId: string, template: Template): Promise { + const ids: Record = {} + const workflows: Workflow[] = [] + for (const tw of template.workflows) { + const wf = await createWorkflow(connectionId, tw.name, sanitizeGraph(tw.graph)) + ids[tw.key] = wf.id + workflows.push(wf) + if (tw.runOnApply) { + const result = await runWorkflow(connectionId, wf.id) + if (!result.ok) throw new Error(`Workflow "${tw.name}" failed: ${result.error || 'unknown error'}`) + } + } + + const dashboards: Dashboard[] = [] + for (const td of template.dashboards) { + const resolved = resolveWorkflowRefs(td.config, ids) + const d = await createDashboard(connectionId, td.name, sanitizeConfig(resolved)) + dashboards.push(d) + } + + return { workflows, dashboards } +} diff --git a/src/features/templates/types.ts b/src/features/templates/types.ts new file mode 100644 index 0000000..6292280 --- /dev/null +++ b/src/features/templates/types.ts @@ -0,0 +1,37 @@ +// Shapes for the built-in template catalog (browse + apply only — templates +// are bundled with the app, not user-editable; see lib/apply.ts for the apply +// pipeline and catalog/ for the shipped templates). + +import type { WorkflowGraph } from '@/features/workflow' + +/** DB types a template can target — a subset of connections' DB_CATALOG ids. */ +export type TemplateDbType = 'postgresql' | 'sqlite' + +export type TemplateWorkflow = { + /** Template-local key, referenced by other artifacts as `{{workflow:}}`. */ + key: string + name: string + graph: WorkflowGraph + /** Run once right after creation (setup/seed workflows). Most templates omit this. */ + runOnApply?: boolean +} + +export type TemplateDashboard = { + name: string + /** Raw, DashboardConfig-shaped JSON. May contain `{{workflow:}}` placeholders + * in row-action `workflowId` fields; resolved then sanitized at apply time. */ + config: unknown +} + +export type Template = { + /** Stable slug, e.g. 'pg-health-vacuum'. */ + id: string + name: string + /** 1-3 sentences shown on the gallery card and detail view. */ + description: string + databases: TemplateDbType[] + /** Applied first, in order, so dashboards can reference the created ids. */ + workflows: TemplateWorkflow[] + /** Applied second, in order. */ + dashboards: TemplateDashboard[] +} diff --git a/src/features/workflow/components/WorkflowEditor.tsx b/src/features/workflow/components/WorkflowEditor.tsx index 8417f1e..c441b11 100644 --- a/src/features/workflow/components/WorkflowEditor.tsx +++ b/src/features/workflow/components/WorkflowEditor.tsx @@ -13,14 +13,17 @@ import IconButton from '@/shared/ui/buttons/IconButton' import TextButton from '@/shared/ui/buttons/TextButton' import Popover from '@/shared/ui/overlay/Popover' import Tooltip from '@/shared/ui/overlay/Tooltip' +import ConfirmDialog from '@/shared/ui/feedback/ConfirmDialog' import { useToast } from '@/shared/ui/feedback/Toast' -import { ClockIcon, HistoryIcon, PlayIcon, PlusIcon } from '@/shared/ui/icons' +import { ClockIcon, DownloadIcon, HistoryIcon, PlayIcon, PlusIcon, UploadIcon } from '@/shared/ui/icons' import { getSchema } from '@/shared/api/database' import type { SqlSchema } from '@/shared/ui/SqlEditor' import { getWorkflow, updateWorkflow, runWorkflow, getWorkflowRun } from '@/features/workflow/lib/api' -import type { RunResult } from '@/features/workflow/lib/api' +import type { RunResult, WorkflowGraph } from '@/features/workflow/lib/api' import { NODE_SPECS, sourceHandles } from '@/features/workflow/lib/nodeSpec' import type { NodeType } from '@/features/workflow/lib/nodeSpec' +import { sanitizeGraph, stripSecrets } from '@/features/workflow/lib/exportImport' +import type { WorkflowExport } from '@/features/workflow/lib/exportImport' import WorkflowNode from '@/features/workflow/components/nodes/WorkflowNode' import NodePalette from '@/features/workflow/components/NodePalette' import NodeConfigPanel from '@/features/workflow/components/NodeConfigPanel' @@ -53,6 +56,9 @@ export default function WorkflowEditor({ conn, workflowId }: any) { const [runToken, setRunToken] = useState(0) // bumped after each run to refresh Activity const [isProtected, setIsProtected] = useState(false) const [scheduleEnabled, setScheduleEnabled] = useState(false) + const [name, setName] = useState('') + const [pendingImport, setPendingImport] = useState(null) + const fileRef = useRef(null) // Right-click "add node" menu: screen coords for placement + flow coords for the node. const [menu, setMenu] = useState<{ x: number; y: number; flow: { x: number; y: number } } | null>(null) // Table→columns map for the query node's SQL autocomplete (same source as the query tab). @@ -74,6 +80,7 @@ export default function WorkflowEditor({ conn, workflowId }: any) { setEdges(wf.graph?.edges || []) setIsProtected(!!wf.protected) setScheduleEnabled(!!wf.scheduleEnabled) + setName(wf.name) loadedFor.current = workflowId setLoading(false) }) @@ -272,6 +279,36 @@ export default function WorkflowEditor({ conn, workflowId }: any) { } } + // ---- Export / import ---- + const exportJson = () => { + const graph: WorkflowGraph = stripSecrets({ nodes: nodes.map((n) => ({ ...n, data: stripStatus(n.data) })), edges }) + const doc: WorkflowExport = { kind: 'workflow', version: 1, name, graph } + const blob = new Blob([JSON.stringify(doc, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${name.replace(/[^\w-]+/g, '-').toLowerCase()}.workflow.json` + a.click() + URL.revokeObjectURL(url) + } + const onImportFile = async (file: File) => { + try { + const doc = JSON.parse(await file.text()) + if (doc?.kind !== 'workflow' || !doc.graph) throw new Error('Not a workflow export file.') + setPendingImport(doc) + } catch (e: any) { + toast.error(`Import failed: ${e.message}`) + } + } + const applyImport = () => { + if (!pendingImport) return + const graph = sanitizeGraph(pendingImport.graph) + setNodes(graph.nodes as any) + setEdges(graph.edges as any) + setPendingImport(null) + toast.success('Workflow structure imported.') + } + return (
@@ -303,6 +340,18 @@ export default function WorkflowEditor({ conn, workflowId }: any) { Show run log )} + {!isProtected && ( + + fileRef.current?.click()}> + + + + )} + + + + +
+ { + const f = e.target.files?.[0] + if (f) onImportFile(f) + e.target.value = '' + }} + /> + {pendingImport && ( + setPendingImport(null)} + /> + )}
diff --git a/src/features/workflow/components/WorkflowsPanel.tsx b/src/features/workflow/components/WorkflowsPanel.tsx index a5afe63..8f5e8a2 100644 --- a/src/features/workflow/components/WorkflowsPanel.tsx +++ b/src/features/workflow/components/WorkflowsPanel.tsx @@ -1,5 +1,17 @@ import { useMemo, useState } from 'react' -import { EditIcon, MoreVerticalIcon, PlusIcon, RefreshIcon, SearchIcon, ShieldIcon, TrashIcon, WorkflowIcon } from '@/shared/ui/icons' +import { + EditIcon, + FolderIcon, + FolderOpenIcon, + FolderPlusIcon, + MoreVerticalIcon, + PlusIcon, + RefreshIcon, + SearchIcon, + ShieldIcon, + TrashIcon, + WorkflowIcon, +} from '@/shared/ui/icons' import { Input } from '@/shared/ui/form/Input' import IconButton from '@/shared/ui/buttons/IconButton' import MenuItem from '@/shared/ui/navigation/MenuItem' @@ -7,24 +19,370 @@ import RowLabel from '@/shared/ui/RowLabel' import ListRow from '@/shared/ui/ListRow' import Popover from '@/shared/ui/overlay/Popover' import Tooltip from '@/shared/ui/overlay/Tooltip' +import { MAX_WORKFLOW_FOLDER_DEPTH } from '@/features/workflow/lib/api' +import type { WorkflowFolder, WorkflowSummary } from '@/features/workflow/lib/api' -// Left-rail list of this connection's workflows. Modeled on SavedQueriesPanel -// (minus folders): open in a tab, create, rename inline, delete. -export default function WorkflowsPanel({ workflows = [], activeId, onOpen, onNew, onRename, onDelete, onRefresh }: any) { +// Compact folder rows that match the dashboards/saved-queries panel style. +const rowBase = 'group flex w-full items-center gap-2 rounded-[7px] px-2.5 py-1.5 text-left text-xs' +const rowIdle = 'text-ink-dim hover:bg-elevated hover:text-ink' + +const byName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name) + +// Left-rail list of this connection's workflows, organized into folders (nesting +// capped at MAX_WORKFLOW_FOLDER_DEPTH levels). Modeled on DashboardsPanel: open in +// a tab, create, rename inline, delete, drag into folders, import from JSON. +export default function WorkflowsPanel({ + workflows = [], + folders = [], + activeId, + onOpen, + onNew, + onImport, + onRename, + onDelete, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + onMoveToFolder, + onMoveFolder, + onRefresh, +}: { + workflows: WorkflowSummary[] + folders: WorkflowFolder[] + activeId: string | null + onOpen?: (w: WorkflowSummary) => void + onNew?: (folderId?: string | null) => void + onImport?: () => void + onRename?: (id: string, name: string) => void + onDelete?: (id: string) => void + onCreateFolder?: (name: string, parentId?: string | null) => void + onRenameFolder?: (id: string, name: string) => void + onDeleteFolder?: (id: string) => void + onMoveToFolder?: (id: string, folderId: string | null) => void + onMoveFolder?: (id: string, parentId: string | null) => void + onRefresh?: () => void +}) { const [searchOpen, setSearchOpen] = useState(false) const [filter, setFilter] = useState('') - const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null) + const [openFolders, setOpenFolders] = useState(() => new Set()) // expanded folder ids + const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null) // workflow rename + const [renamingFolder, setRenamingFolder] = useState<{ id: string; value: string } | null>(null) + const [creatingFolder, setCreatingFolder] = useState<{ parentId: string | null } | null>(null) + const [newFolderName, setNewFolderName] = useState('') + const [drag, setDrag] = useState<{ type: 'workflow' | 'folder'; id: string } | null>(null) + const [dropTarget, setDropTarget] = useState(undefined) // folder id | null (root) | undefined (none) const q = filter.trim().toLowerCase() - const visible = useMemo( - () => workflows.filter((w: any) => !q || w.name.toLowerCase().includes(q)), - [workflows, q] - ) + const matches = (w: WorkflowSummary) => !q || w.name.toLowerCase().includes(q) + + const sortedFolders = useMemo(() => [...folders].sort(byName), [folders]) + const visibleWorkflows = useMemo(() => workflows.filter(matches), [workflows, q]) + const childFolders = (pid: string | null) => sortedFolders.filter((f) => (f.parentId || null) === pid) + const itemsIn = (fid: string | null) => visibleWorkflows.filter((w) => (w.folderId || null) === fid).sort(byName) + // Count of visible workflows in a folder plus all of its subfolders. + const subtreeItemCount = (fid: string): number => + itemsIn(fid).length + childFolders(fid).reduce((n, c) => n + subtreeItemCount(c.id), 0) + const rootItems = useMemo(() => itemsIn(null), [visibleWorkflows]) + + // parentId lookup for cycle-safe / depth-safe drop-target checks. + const parentOf = useMemo(() => new Map(folders.map((f) => [f.id, f.parentId || null])), [folders]) + // Number of levels from root down to `fid` (root folder = 1, null = 0). + const depthOf = (fid: string | null) => { + let depth = 0 + let cur = fid + const seen = new Set() + while (cur && !seen.has(cur)) { + depth++ + seen.add(cur) + cur = parentOf.get(cur) || null + } + return depth + } + // Height of the subtree rooted at `fid` (the folder itself = 1). + const heightOf = (fid: string): number => + 1 + childFolders(fid).reduce((m, c) => Math.max(m, heightOf(c.id)), 0) + // True if `targetId` is `ancestorId` or nested somewhere beneath it. + const isSelfOrDescendant = (targetId: string | null, ancestorId: string) => { + let cur = targetId + const seen = new Set() + while (cur && !seen.has(cur)) { + if (cur === ancestorId) return true + seen.add(cur) + cur = parentOf.get(cur) || null + } + return false + } + // Whether a folder at `fid` may still hold a subfolder (depth cap). + const canNestUnder = (fid: string) => depthOf(fid) < MAX_WORKFLOW_FOLDER_DEPTH + + // A folder auto-expands during search if it (or any descendant) holds a match. + const folderHasMatch = (fid: string): boolean => + itemsIn(fid).length > 0 || childFolders(fid).some((c) => folderHasMatch(c.id)) + + const toggleFolder = (fid: string) => + setOpenFolders((s) => { + const n = new Set(s) + n.has(fid) ? n.delete(fid) : n.add(fid) + return n + }) + const expandFolder = (fid: string) => setOpenFolders((s) => new Set(s).add(fid)) const commitRename = () => { if (renaming?.value.trim()) onRename?.(renaming.id, renaming.value.trim()) setRenaming(null) } + const commitFolderRename = () => { + if (renamingFolder?.value.trim()) onRenameFolder?.(renamingFolder.id, renamingFolder.value.trim()) + setRenamingFolder(null) + } + const commitNewFolder = () => { + if (newFolderName.trim()) onCreateFolder?.(newFolderName.trim(), creatingFolder?.parentId ?? null) + setNewFolderName('') + setCreatingFolder(null) + } + const startSubfolder = (parentId: string) => { + expandFolder(parentId) + setNewFolderName('') + setCreatingFolder({ parentId }) + } + + // ---- Drag and drop: move a workflow OR a folder into a folder / back to root ---- + const onWorkflowDragStart = (e: React.DragEvent, id: string) => { + setDrag({ type: 'workflow', id }) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', id) + } + const onFolderDragStart = (e: React.DragEvent, id: string) => { + e.stopPropagation() + setDrag({ type: 'folder', id }) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', id) + } + const onDragEnd = () => { + setDrag(null) + setDropTarget(undefined) + } + // Whether the current drag may land on folder `fid` (null = root). + const canDropOn = (fid: string | null) => { + if (!drag) return false + if (drag.type === 'workflow') return true + // A folder can't drop onto itself/its subtree, and the moved subtree must + // still fit within the depth cap (root is always fine height-wise). + if (fid != null && isSelfOrDescendant(fid, drag.id)) return false + return depthOf(fid) + heightOf(drag.id) <= MAX_WORKFLOW_FOLDER_DEPTH + } + const handleDrop = (fid: string | null) => { + if (drag?.type === 'workflow') onMoveToFolder?.(drag.id, fid) + else if (drag?.type === 'folder' && canDropOn(fid)) onMoveFolder?.(drag.id, fid) + setDrag(null) + setDropTarget(undefined) + } + const dropProps = (fid: string | null) => ({ + onDragOver: (e: React.DragEvent) => { + if (!canDropOn(fid)) return + e.preventDefault() + setDropTarget(fid) + }, + onDragLeave: () => setDropTarget((t) => (t === fid ? undefined : t)), + onDrop: (e: React.DragEvent) => { + e.preventDefault() + handleDrop(fid) + }, + }) + + // ---- Renderers (plain functions so rows reconcile by key, never remount) ---- + const renderItem = (w: WorkflowSummary) => { + if (renaming?.id === w.id) { + return ( +
+ + setRenaming({ id: w.id, value: e.target.value })} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename() + else if (e.key === 'Escape') setRenaming(null) + }} + /> +
+ ) + } + + return ( + onWorkflowDragStart(e, w.id)} + onDragEnd={onDragEnd} + onClick={() => onOpen?.(w)} + className={drag?.type === 'workflow' && drag.id === w.id ? 'opacity-50' : ''} + icon={} + trailing={ + ( + + + + )} + > + {({ close }) => ( +
+ { setRenaming({ id: w.id, value: w.name }); close() }}> + Rename + + {!w.protected && ( + <> +
+ { onDelete?.(w.id); close() }}> + Delete + + + )} +
+ )} + + } + > + {w.name} + {w.protected && ( + + + + )} + + ) + } + + const renderFolder = (f: WorkflowFolder) => { + const items = itemsIn(f.id) + const subfolders = childFolders(f.id) + const expanded = openFolders.has(f.id) || (!!q && folderHasMatch(f.id)) + const isDrop = dropTarget === f.id + const addingHere = creatingFolder?.parentId === f.id + const canAddSub = canNestUnder(f.id) + + if (renamingFolder?.id === f.id) { + return ( +
+ + setRenamingFolder({ id: f.id, value: e.target.value })} + onBlur={commitFolderRename} + onKeyDown={(e) => { + if (e.key === 'Enter') commitFolderRename() + else if (e.key === 'Escape') setRenamingFolder(null) + }} + /> +
+ ) + } + + return ( +
+
onFolderDragStart(e, f.id)} + onDragEnd={onDragEnd} + onClick={() => toggleFolder(f.id)} + {...dropProps(f.id)} + className={`${rowBase} cursor-pointer ${drag?.type === 'folder' && drag.id === f.id ? 'opacity-50' : ''} ${ + isDrop ? 'bg-green/15 text-ink ring-1 ring-green-dim' : rowIdle + }`} + > + {expanded ? ( + + ) : ( + + )} + {f.name} + {subtreeItemCount(f.id) > 0 && ( + {subtreeItemCount(f.id)} + )} +
e.stopPropagation()}> + ( + + + + )} + > + {({ close }) => ( +
+ { onNew?.(f.id); close() }}> + New workflow + + {canAddSub && ( + { startSubfolder(f.id); close() }}> + New subfolder + + )} + { setRenamingFolder({ id: f.id, value: f.name }); close() }}> + Rename + +
+ { onDeleteFolder?.(f.id); close() }}> + Delete folder + +
+ )} + +
+
+ + {expanded && ( +
+ {subfolders.map(renderFolder)} + {addingHere && renderNewFolderInput()} + {items.map(renderItem)} + {subfolders.length === 0 && items.length === 0 && !addingHere && ( +
Empty — drag workflows here
+ )} +
+ )} +
+ ) + } + + const renderNewFolderInput = () => ( +
+ + setNewFolderName(e.target.value)} + onBlur={commitNewFolder} + onKeyDown={(e) => { + if (e.key === 'Enter') commitNewFolder() + else if (e.key === 'Escape') { + setNewFolderName('') + setCreatingFolder(null) + } + }} + /> +
+ ) + + const rootFolders = childFolders(null) + const nothing = workflows.length === 0 && folders.length === 0 return ( <> @@ -47,8 +405,13 @@ export default function WorkflowsPanel({ workflows = [], activeId, onOpen, onNew + + { setNewFolderName(''); setCreatingFolder({ parentId: null }) }}> + + + - + onNew?.(null)}> @@ -67,77 +430,40 @@ export default function WorkflowsPanel({ workflows = [], activeId, onOpen, onNew )}
- {visible.length === 0 ? ( + {creatingFolder && creatingFolder.parentId == null && renderNewFolderInput()} + + {nothing && !creatingFolder ? (
{q ? 'No matches' : 'No workflows yet.'}
) : (
- {visible.map((w: any) => - renaming?.id === w.id ? ( -
- - setRenaming({ id: w.id, value: e.target.value })} - onBlur={commitRename} - onKeyDown={(e) => { - if (e.key === 'Enter') commitRename() - else if (e.key === 'Escape') setRenaming(null) - }} - /> + {rootFolders.map(renderFolder)} + + {/* Root (un-foldered) workflows — also the drop zone for "remove from folder" */} +
+ {rootItems.map(renderItem)} + {rootItems.length === 0 && ( +
+ {q ? 'No matches' : folders.length ? 'Drag workflows out of folders here' : 'No workflows.'}
- ) : ( - onOpen?.(w)} - icon={} - trailing={ - ( - - - - )} - > - {({ close }) => ( -
- { setRenaming({ id: w.id, value: w.name }); close() }}> - Rename - - {!w.protected && ( - <> -
- { onDelete?.(w.id); close() }}> - Delete - - - )} -
- )} - - } - > - {w.name} - {w.protected && ( - - - - )} - - ) - )} + )} +
)} + + {onImport && ( + + )}
) diff --git a/src/features/workflow/index.ts b/src/features/workflow/index.ts index e236be9..d9a29b2 100644 --- a/src/features/workflow/index.ts +++ b/src/features/workflow/index.ts @@ -14,5 +14,13 @@ export { updateWorkflow, deleteWorkflow, runWorkflow, + fetchWorkflowFolders, + createWorkflowFolder, + renameWorkflowFolder, + moveWorkflowFolder, + deleteWorkflowFolder, + MAX_WORKFLOW_FOLDER_DEPTH, } from '@/features/workflow/lib/api' -export type { Workflow, WorkflowSummary, WorkflowGraph, RunResult, RunLogEntry } from '@/features/workflow/lib/api' +export type { Workflow, WorkflowSummary, WorkflowFolder, WorkflowGraph, RunResult, RunLogEntry } from '@/features/workflow/lib/api' +export { sanitizeGraph, stripSecrets } from '@/features/workflow/lib/exportImport' +export type { WorkflowExport } from '@/features/workflow/lib/exportImport' diff --git a/src/features/workflow/lib/api.ts b/src/features/workflow/lib/api.ts index a86b160..3f962fe 100644 --- a/src/features/workflow/lib/api.ts +++ b/src/features/workflow/lib/api.ts @@ -2,6 +2,7 @@ // saved-queries client (savedQueries.ts) — reads degrade quietly, mutations throw. import { request, safeRequest } from '@/shared/api/request' +import * as folders from '@/shared/api/folders' export type WorkflowGraph = { nodes: any[]; edges: any[] } export type WorkflowSummary = { @@ -10,7 +11,15 @@ export type WorkflowSummary = { ts: number protected: boolean scheduleEnabled: boolean + folderId?: string | null } + +// A folder in the workflows rail. `parentId` builds the tree (null = root); +// nesting is capped at MAX_WORKFLOW_FOLDER_DEPTH levels (enforced server-side). +export type WorkflowFolder = { id: string; name: string; parentId: string | null; ts: number } + +// Max folder nesting depth surfaced in the UI (matches the server cap). +export const MAX_WORKFLOW_FOLDER_DEPTH = 3 export type Workflow = { id: string name: string @@ -55,14 +64,21 @@ export async function getWorkflow(connectionId: string, wid: string): Promise { - return request(`/connections/${connectionId}/workflows`, { method: 'POST', body: { name } }) +// `graph` is optional — passed when importing a JSON export as a new workflow. +// `folderId` (optional) creates the workflow inside a folder. +export async function createWorkflow( + connectionId: string, + name: string, + graph?: WorkflowGraph, + folderId?: string | null +): Promise { + return request(`/connections/${connectionId}/workflows`, { method: 'POST', body: { name, graph, folderId: folderId || null } }) } export async function updateWorkflow( connectionId: string, wid: string, - fields: { name?: string; graph?: WorkflowGraph; scheduleEnabled?: boolean } + fields: { name?: string; graph?: WorkflowGraph; scheduleEnabled?: boolean; folderId?: string | null } ): Promise { await request(`/connections/${connectionId}/workflows/${wid}`, { method: 'PUT', body: fields }) } @@ -98,3 +114,31 @@ export async function listWorkflowRuns(connectionId: string, wid: string): Promi export async function getWorkflowRun(connectionId: string, wid: string, runId: string): Promise { return request(`/connections/${connectionId}/workflows/${wid}/runs/${runId}`) } + +// ---- Workflow folders (nesting capped at MAX_WORKFLOW_FOLDER_DEPTH, enforced server-side) ---- +// The generic, polymorphic folders (shared/api/folders) bound to type='workflow'. + +export function fetchWorkflowFolders(connectionId: string): Promise { + return folders.fetchFolders(connectionId, 'workflow') +} + +export function createWorkflowFolder( + connectionId: string, + name: string, + parentId: string | null = null +): Promise { + return folders.createFolder(connectionId, 'workflow', name, parentId) +} + +export function renameWorkflowFolder(connectionId: string, folderId: string, name: string): Promise { + return folders.renameFolder(connectionId, folderId, name) +} + +// Move a folder under a new parent (null = root). Nesting builds subdirectories. +export function moveWorkflowFolder(connectionId: string, folderId: string, parentId: string | null): Promise { + return folders.moveFolder(connectionId, folderId, parentId) +} + +export function deleteWorkflowFolder(connectionId: string, folderId: string): Promise { + return folders.deleteFolder(connectionId, folderId) +} diff --git a/src/features/workflow/lib/exportImport.ts b/src/features/workflow/lib/exportImport.ts new file mode 100644 index 0000000..2fbed04 --- /dev/null +++ b/src/features/workflow/lib/exportImport.ts @@ -0,0 +1,64 @@ +// JSON export/import for a single workflow's graph. Mirrors the dashboard +// feature's export/import (types.ts DashboardExport + sanitizeConfig). + +import { NODE_SPECS, genWebhookToken } from './nodeSpec' +import type { NodeType } from './nodeSpec' +import type { WorkflowGraph } from './api' + +/** The JSON export/import document (graph + name, no ids tied to a server). */ +export type WorkflowExport = { kind: 'workflow'; version: 1; name: string; graph: WorkflowGraph } + +/** Normalize an untrusted parsed graph (import path) into a safe WorkflowGraph. + * Also mints a fresh webhook token — tokens are secrets and never round-trip + * through an export file (see `stripSecrets` used on export). */ +export function sanitizeGraph(raw: any): WorkflowGraph { + const rawNodes = Array.isArray(raw?.nodes) ? raw.nodes : [] + const rawEdges = Array.isArray(raw?.edges) ? raw.edges : [] + + const nodes = rawNodes + .filter((n: any) => n && typeof n === 'object' && typeof n.id === 'string' && n.type in NODE_SPECS) + .map((n: any) => { + const type = n.type as NodeType + const { __status, ...data } = n.data && typeof n.data === 'object' ? n.data : {} + return { + id: n.id, + type, + position: { + x: Number.isFinite(Number(n.position?.x)) ? Number(n.position.x) : 0, + y: Number.isFinite(Number(n.position?.y)) ? Number(n.position.y) : 0, + }, + data: type === 'webhook' ? { ...data, token: genWebhookToken() } : data, + } + }) + + const nodeIds = new Set(nodes.map((n) => n.id)) + const edges = rawEdges + .filter( + (e: any) => + e && typeof e === 'object' && typeof e.id === 'string' && nodeIds.has(e.source) && nodeIds.has(e.target) + ) + .map((e: any) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: typeof e.sourceHandle === 'string' ? e.sourceHandle : undefined, + targetHandle: typeof e.targetHandle === 'string' ? e.targetHandle : undefined, + })) + + return { nodes, edges } +} + +/** Strip transient run status + webhook secrets before an export leaves the browser. */ +export function stripSecrets(graph: WorkflowGraph): WorkflowGraph { + return { + nodes: graph.nodes.map((n: any) => { + const { __status, ...data } = n.data || {} + if (n.type === 'webhook') { + const { token, ...rest } = data + return { ...n, data: rest } + } + return { ...n, data } + }), + edges: graph.edges, + } +} diff --git a/src/features/workspace/components/DataGrid.tsx b/src/features/workspace/components/DataGrid.tsx index 2f75bbe..760c21f 100644 --- a/src/features/workspace/components/DataGrid.tsx +++ b/src/features/workspace/components/DataGrid.tsx @@ -1,5 +1,6 @@ -import { useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import Checkbox from '@/shared/ui/form/Checkbox' +import { useToast } from '@/shared/ui/feedback/Toast' import Button from '@/shared/ui/buttons/Button' import TextButton from '@/shared/ui/buttons/TextButton' import Tooltip from '@/shared/ui/overlay/Tooltip' @@ -24,6 +25,19 @@ export const cellText = (v) => { if (v instanceof Date) return isoDateTime(v) return isJsonValue(v) ? safeStringify(v) : String(v) } +// ---- Rectangular cell selection ---- +// A selection is stored as anchor (r1,c1) → focus (r2,c2) with *column indices*, +// so a drag can run in any direction; normalize before asking "is this in it?". +const normalizeRange = (r) => + r && { + r1: Math.min(r.r1, r.r2), + r2: Math.max(r.r1, r.r2), + c1: Math.min(r.c1, r.c2), + c2: Math.max(r.c1, r.c2), + } +// Tab-separated text — the format spreadsheets expect on paste. +const toTsv = (values) => values.map((row) => row.map((v) => cellText(v).replace(/[\t\r\n]+/g, ' ')).join('\t')).join('\n') + // Parse a string to a JSON object/array, or null if it isn't one. const parseJsonObject = (s) => { if (typeof s !== 'string') return null @@ -152,8 +166,11 @@ export default function DataGrid({ draftRef.current = v setDraftState(v) } - const [sel, setSel] = useState(null) // selected cell { r, c } + const [range, setRange] = useState(null) // cell selection { r1, c1, r2, c2 } (c = column index) + const [dragging, setDragging] = useState(false) // mouse is painting a selection + const draggingRef = useRef(false) const scrollRef = useRef(null) + const toast = useToast() const [fill, setFill] = useState({ rowH: 24, remaining: 0 }) // empty grid fill const [clientW, setClientW] = useState(0) // container width (to fill horizontally) const [widths, setWidths] = useState({}) // per-column override widths @@ -176,6 +193,21 @@ export default function DataGrid({ window.addEventListener('mouseup', onUp) } + // A drag can end anywhere (outside the grid, outside the window), so the + // release is watched globally rather than on the cells. + useEffect(() => { + const stop = () => { + draggingRef.current = false + setDragging(false) + } + window.addEventListener('mouseup', stop) + return () => window.removeEventListener('mouseup', stop) + }, []) + + // Row/column indices are only meaningful for the data currently on screen — + // a page change, re-sort or column toggle invalidates the selection. + useEffect(() => setRange(null), [rows, columns]) + // Measure leftover space so we can pad the grid with empty rows. useLayoutEffect(() => { const el = scrollRef.current @@ -202,6 +234,74 @@ export default function DataGrid({ const keyOf = (row, i) => (getRowKey ? getRowKey(row, i) : i) + // The value the grid shows for a cell — the pending edit when there is one. + const cellValue = (row, i, j) => { + const c = columns[j] + const raw = Array.isArray(row) ? row[j] : row[c] + const rowEdits = editable && edits ? edits[keyOf(row, i)] : null + return rowEdits && Object.prototype.hasOwnProperty.call(rowEdits, c) ? rowEdits[c] : raw + } + + // ---- Cell range selection (click-drag, Shift+click) ---- + const box = normalizeRange(range) + const inBox = (i, j) => !!box && i >= box.r1 && i <= box.r2 && j >= box.c1 && j <= box.c2 + + // Everything the selection covers, in the grid's own order — handed to the + // context menu so callers can copy/act on it without redoing the geometry. + const selectionPayload = () => { + if (!box) return null + const rowIndexes = [] + for (let i = box.r1; i <= Math.min(box.r2, rows.length - 1); i++) rowIndexes.push(i) + const colIndexes = [] + for (let j = box.c1; j <= Math.min(box.c2, columns.length - 1); j++) colIndexes.push(j) + return { + rows: rowIndexes.map((i) => rows[i]), + columns: colIndexes.map((j) => columns[j]), + values: rowIndexes.map((i) => colIndexes.map((j) => cellValue(rows[i], i, j))), + rowCount: rowIndexes.length, + colCount: colIndexes.length, + cellCount: rowIndexes.length * colIndexes.length, + } + } + + const startSelect = (e, i, j) => { + if (e.button !== 0) return + // Suppress the browser's own text selection during the drag; focus is moved + // by hand since preventDefault() also cancels the implicit focus. + e.preventDefault() + scrollRef.current?.focus() + if (e.shiftKey && range) setRange((p) => ({ ...p, r2: i, c2: j })) + else { + setRange({ r1: i, c1: j, r2: i, c2: j }) + draggingRef.current = true + setDragging(true) + } + } + const extendSelect = (i, j) => { + if (draggingRef.current) setRange((p) => (p ? { ...p, r2: i, c2: j } : p)) + } + + const copySelection = async () => { + const s = selectionPayload() + if (!s) return + try { + await navigator.clipboard.writeText(toTsv(s.values)) + toast.success(`Copied ${s.cellCount} cell${s.cellCount > 1 ? 's' : ''}`) + } catch { + toast.error('Could not copy to clipboard') + } + } + + const onGridKeyDown = (e) => { + if (editing) return + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c' && box) { + e.preventDefault() + copySelection() + } else if (e.key === 'Escape' && box) { + setRange(null) + } + } + // All edits open a type-aware modal (never inline). const startEdit = (rowIndex, col, val) => { const kind = editorKind(columnTypes?.[col], val) @@ -260,8 +360,16 @@ export default function DataGrid({ return ( <> -
- +
+
{columns.map((c) => ( @@ -345,7 +453,23 @@ export default function DataGrid({ const rowEdits = editable && edits ? edits[key] : null const dirty = rowEdits && Object.prototype.hasOwnProperty.call(rowEdits, c) const val = dirty ? rowEdits[c] : raw - const isSel = sel && sel.r === i && sel.c === c + const isSel = inBox(i, j) + // Only the rectangle's outer edges get a line, so a + // multi-cell selection reads as one block instead of a mesh + // of boxes. Inset shadows (not borders) — they don't take up + // space, so selecting never nudges the layout. + const ringStyle = isSel + ? { + boxShadow: [ + i === box.r1 && 'inset 0 1px 0 0 var(--color-green)', + i === box.r2 && 'inset 0 -1px 0 0 var(--color-green)', + j === box.c1 && 'inset 1px 0 0 0 var(--color-green)', + j === box.c2 && 'inset -1px 0 0 0 var(--color-green)', + ] + .filter(Boolean) + .join(', '), + } + : undefined const ref = columnRefs?.[c] const hasRef = ref && val != null && val !== '' return ( @@ -353,14 +477,27 @@ export default function DataGrid({ key={j} className={`${tdBase} ${editable ? 'cursor-pointer' : ''} ${ dirty ? '!bg-amber/10 text-amber' : '' - } ${isSel ? '!bg-green/15 outline outline-1 -outline-offset-1 outline-green' : ''}`} - onClick={() => setSel({ r: i, c })} + } ${isSel ? '!bg-green/15' : ''}`} + style={ringStyle} + onMouseDown={(e) => startSelect(e, i, j)} + onMouseEnter={() => extendSelect(i, j)} onDoubleClick={() => editable && !Array.isArray(row) && startEdit(i, c, val)} onContextMenu={(e) => { if (!onCellContextMenu || Array.isArray(row)) return e.preventDefault() - setSel({ r: i, c }) - onCellContextMenu(e, { row, rowIndex: i, col: c, value: val }) + // Right-clicking outside the selection moves it, the + // way every spreadsheet behaves; inside, it's kept. + const keep = inBox(i, j) + if (!keep) setRange({ r1: i, c1: j, r2: i, c2: j }) + onCellContextMenu(e, { + row, + rowIndex: i, + col: c, + value: val, + selection: keep + ? selectionPayload() + : { rows: [row], columns: [c], values: [[val]], rowCount: 1, colCount: 1, cellCount: 1 }, + }) }} title={editable ? 'Double-click to edit' : undefined} > diff --git a/src/features/workspace/components/IconRail.tsx b/src/features/workspace/components/IconRail.tsx index 10ff946..7964e9b 100644 --- a/src/features/workspace/components/IconRail.tsx +++ b/src/features/workspace/components/IconRail.tsx @@ -1,4 +1,4 @@ -import { CodeIcon, DatabaseIcon, DbLogo, DiagramIcon, GridIcon, HomeIcon, LogoutIcon, WorkflowIcon } from '@/shared/ui/icons' +import { CodeIcon, DatabaseIcon, DbLogo, DiagramIcon, GridIcon, HomeIcon, LogoutIcon, WandIcon, WorkflowIcon } from '@/shared/ui/icons' import Popover from '@/shared/ui/overlay/Popover' import Tooltip from '@/shared/ui/overlay/Tooltip' import IconButton from '@/shared/ui/buttons/IconButton' @@ -25,6 +25,7 @@ export default function IconRail({ onWorkflows, onDashboards, onSchema, + onTemplates, onHome, onLogout, }) { @@ -74,6 +75,7 @@ export default function IconRail({ +
diff --git a/src/features/workspace/components/TabBar.tsx b/src/features/workspace/components/TabBar.tsx new file mode 100644 index 0000000..0e1b929 --- /dev/null +++ b/src/features/workspace/components/TabBar.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react' +import { + CloseIcon, + CodeIcon, + ColumnsIcon, + DiagramIcon, + GridIcon, + HistoryIcon, + TableIcon, + WandIcon, + WorkflowIcon, +} from '@/shared/ui/icons' +import IconButton from '@/shared/ui/buttons/IconButton' + +// One icon per tab kind; anything unknown falls back to a table. +const KIND_ICON = { + query: CodeIcon, + schema: ColumnsIcon, + schemaEditor: DiagramIcon, + history: HistoryIcon, + schemaHistory: HistoryIcon, + function: CodeIcon, + workflow: WorkflowIcon, + dashboard: GridIcon, + template: WandIcon, +} + +/** + * The console's open-tab strip. Tabs can be dragged left/right to reorder: + * dragging shows an insertion caret on the half of the tab the pointer is + * nearest, and the move is committed on drop via `onReorder(fromKey, toKey, + * before)`. Dropping past the last tab (the trailing filler) passes + * `toKey = null`, meaning "move to the end". + */ +export default function TabBar({ tabs = [], activeTab, onSelect, onClose, onContextMenu, onReorder }) { + const [dragKey, setDragKey] = useState(null) // key of the tab being dragged + const [dropAt, setDropAt] = useState(null) // { key: string | null, before: boolean } + + const clearDrag = () => { + setDragKey(null) + setDropAt(null) + } + + // A drop is a no-op when it lands on either side of the dragged tab itself. + const isNoop = (key, before) => { + if (key === dragKey) return true + const from = tabs.findIndex((t) => t.key === dragKey) + const to = tabs.findIndex((t) => t.key === key) + if (from === -1 || to === -1) return false + return before ? to === from + 1 : to === from - 1 + } + + const dragOverTab = (key) => (e) => { + if (!dragKey) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = e.currentTarget.getBoundingClientRect() + const before = e.clientX < rect.left + rect.width / 2 + setDropAt(isNoop(key, before) ? null : { key, before }) + } + + // Commit at the caret's position (no caret = the pointer sits where the tab + // already is, so the drop is a no-op). + const dropOnTab = (e) => { + if (!dragKey) return + e.preventDefault() + if (dropAt) onReorder?.(dragKey, dropAt.key, dropAt.before) + clearDrag() + } + + // Trailing zone: dropping here always means "append", unless already last. + const atEnd = dragKey && tabs[tabs.length - 1]?.key !== dragKey + const endProps = { + onDragOver: (e) => { + if (!atEnd) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setDropAt({ key: null, before: false }) + }, + onDrop: (e) => { + if (!atEnd) return + e.preventDefault() + onReorder?.(dragKey, null, false) + clearDrag() + }, + } + + // Insertion caret; positioned by the caller relative to its (relative) parent. + const caret = (pos) => + + return ( +
+ {tabs.map((t) => { + const active = activeTab === t.key + const Icon = KIND_ICON[t.kind] || TableIcon + return ( +
{ + setDragKey(t.key) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', t.key) + }} + onDragEnd={clearDrag} + onDragOver={dragOverTab(t.key)} + onDrop={dropOnTab} + onClick={() => onSelect(t.key)} + onContextMenu={(e) => onContextMenu(e, t.key)} + className={`group/tab relative flex cursor-pointer items-center gap-2.5 whitespace-nowrap rounded-t-[8px] px-3.5 py-2.5 text-xs transition-colors ${ + active ? 'bg-elevated font-medium text-ink' : 'text-ink-dim hover:bg-elevated/40 hover:text-ink' + } ${dragKey === t.key ? 'opacity-50' : ''}`} + > + {active && } + {dropAt?.key === t.key && caret(dropAt.before ? '-left-[3px]' : '-right-[3px]')} + + {t.title} + onClose(e, t.key)} + aria-label="Close tab" + > + + +
+ ) + })} + {tabs.length === 0 &&
No open tabs
} + {/* Free space after the last tab — an "append here" drop target. Collapses + to nothing once the tabs overflow (then the last tab's right half does it). */} +
+ {dropAt?.key === null && caret('left-0')} +
+
+ ) +} diff --git a/src/features/workspace/components/TableView.tsx b/src/features/workspace/components/TableView.tsx index d836a9c..f737d93 100644 --- a/src/features/workspace/components/TableView.tsx +++ b/src/features/workspace/components/TableView.tsx @@ -4,7 +4,7 @@ import { useSettings } from '@/features/settings' import { useShortcut } from '@/features/keymap' import DataGrid, { cellText } from '@/features/workspace/components/DataGrid' import RowEditorPanel from '@/features/workspace/components/RowEditorPanel' -import { EXPORT_FORMATS, downloadRows, sqlValue, toCsv } from '@/features/workspace/lib/exportRows' +import { EXPORT_FORMATS, downloadRows, sqlValue, toCsv, toJson } from '@/features/workspace/lib/exportRows' import Button from '@/shared/ui/buttons/Button' import TextButton from '@/shared/ui/buttons/TextButton' import MenuItem from '@/shared/ui/navigation/MenuItem' @@ -256,13 +256,24 @@ export default function TableView({ conn, table, onChange, onOpenReference, filt stagedInfo('Added insert to changes — commit to apply.') } - // Cell context-menu actions - const setCellAs = (row, col, mode) => { - const expr = mode === 'default' ? colDefaults[col] : mode === 'null' ? 'NULL' : sqlValue('') - if (expr == null) return - const sql = `UPDATE "${table}" SET "${col}" = ${expr} WHERE ${rowWhere(row)}` - onChange?.({ kind: 'update', label: `Set ${col} to ${mode.toUpperCase()}`, sql, table }) - stagedInfo('Added update to changes — commit to apply.') + // Cell context-menu actions. `sel` is the grid's rectangular cell selection + // (always at least the right-clicked cell), so one code path covers both a + // single cell and a dragged block: one UPDATE per row, every selected column. + const setCellsAs = (sel, mode) => { + const expr = (col) => (mode === 'default' ? colDefaults[col] : mode === 'null' ? 'NULL' : sqlValue('')) + let staged = 0 + for (const row of sel.rows) { + const parts = sel.columns.filter((c) => expr(c) != null).map((c) => `"${c}" = ${expr(c)}`) + if (!parts.length) continue + onChange?.({ + kind: 'update', + label: `Set ${sel.columns.join(', ')} to ${mode.toUpperCase()}`, + sql: `UPDATE "${table}" SET ${parts.join(', ')} WHERE ${rowWhere(row)}`, + table, + }) + staged++ + } + if (staged) stagedInfo(`Added ${staged} update(s) to changes — commit to apply.`) } const copyText = async (text, label = 'Copied to clipboard') => { @@ -273,7 +284,15 @@ export default function TableView({ conn, table, onChange, onOpenReference, filt toast.error('Could not copy to clipboard') } } - const rowToCsv = (row) => toCsv(columns, [row]) + // Serialize a cell selection. TSV is the default (what spreadsheets expect); + // CSV/JSON keep only the selected columns, so the text mirrors the block. + const selectionText = (sel, format) => { + if (format === 'tsv') { + return sel.values.map((vals) => vals.map((v) => cellText(v).replace(/[\t\r\n]+/g, ' ')).join('\t')).join('\n') + } + const objs = sel.values.map((vals) => Object.fromEntries(sel.columns.map((c, k) => [c, vals[k]]))) + return format === 'json' ? toJson(sel.columns, objs) : toCsv(sel.columns, objs) + } const addQuickFilter = (col, op, value) => { onFiltersChange([...filters, makeFilter(col, op, op === 'isnull' || op === 'notnull' ? '' : cellText(value))]) @@ -321,8 +340,8 @@ export default function TableView({ conn, table, onChange, onOpenReference, filt } const discardEdits = () => setEdits({}) - const handleCellContextMenu = (e, { row, col, value }) => { - setCellMenu({ x: e.clientX, y: e.clientY, row, col, value }) + const handleCellContextMenu = (e, { row, col, value, selection }) => { + setCellMenu({ x: e.clientX, y: e.clientY, row, col, value, selection }) } useShortcut('workspace.newRow', () => setShowInsert(true)) @@ -597,6 +616,11 @@ export default function TableView({ conn, table, onChange, onOpenReference, filt (() => { const { row, col, value } = cellMenu const isNum = NUMERIC_TYPE.test(colTypes[col] || '') + // Always a rectangle — a lone right-clicked cell is a 1×1 selection. + const sel = cellMenu.selection || { rows: [row], columns: [col], values: [[value]], cellCount: 1 } + const multi = sel.cellCount > 1 + const selLabel = multi ? `${sel.cellCount} cells` : 'cell value' + const rowLabel = sel.rows.length > 1 ? `${sel.rows.length} rows` : 'row' return ( setCellMenu(null)}> @@ -614,21 +638,35 @@ export default function TableView({ conn, table, onChange, onOpenReference, filt { addQuickFilter(col, 'isnull', ''); setCellMenu(null) }}>is null { addQuickFilter(col, 'notnull', ''); setCellMenu(null) }}>is not null - - { setCellAs(row, col, 'null'); setCellMenu(null) }}>NULL - { setCellAs(row, col, 'empty'); setCellMenu(null) }}>EMPTY - { setCellAs(row, col, 'default'); setCellMenu(null) }}> + + { setCellsAs(sel, 'null'); setCellMenu(null) }}>NULL + { setCellsAs(sel, 'empty'); setCellMenu(null) }}>EMPTY + colDefaults[c] == null)} + onClick={() => { setCellsAs(sel, 'default'); setCellMenu(null) }} + > DEFAULT - - { copyText(cellText(value)); setCellMenu(null) }}> - Copy cell value + + { + copyText(multi ? selectionText(sel, 'tsv') : cellText(value), `Copied ${selLabel}`) + setCellMenu(null) + }} + > + Copy {selLabel} - - { copyText(insertSql(row), 'Copied SQL'); setCellMenu(null) }}>SQL - { copyText(rowToCsv(row), 'Copied CSV'); setCellMenu(null) }}>CSV - { copyText(JSON.stringify(row, null, 2), 'Copied JSON'); setCellMenu(null) }}>JSON + {multi && ( + + { copyText(selectionText(sel, 'csv'), 'Copied CSV'); setCellMenu(null) }}>CSV + { copyText(selectionText(sel, 'json'), 'Copied JSON'); setCellMenu(null) }}>JSON + + )} + + { copyText(sel.rows.map(insertSql).join('\n'), 'Copied SQL'); setCellMenu(null) }}>SQL + { copyText(toCsv(columns, sel.rows), 'Copied CSV'); setCellMenu(null) }}>CSV + { copyText(JSON.stringify(sel.rows.length > 1 ? sel.rows : row, null, 2), 'Copied JSON'); setCellMenu(null) }}>JSON
@@ -639,13 +677,13 @@ export default function TableView({ conn, table, onChange, onOpenReference, filt { setShowInsert(true); setCellMenu(null) }}> Insert row - { stageDuplicate([row], { clear: false }); setCellMenu(null) }}> - Duplicate row + { stageDuplicate(sel.rows, { clear: false }); setCellMenu(null) }}> + Duplicate {rowLabel}
- { stageDelete([row], { clear: false }); setCellMenu(null) }}> - Delete row + { stageDelete(sel.rows, { clear: false }); setCellMenu(null) }}> + Delete {rowLabel} diff --git a/src/features/workspace/index.ts b/src/features/workspace/index.ts index a89f71f..0d1873f 100644 --- a/src/features/workspace/index.ts +++ b/src/features/workspace/index.ts @@ -8,6 +8,7 @@ export { default as RowEditorPanel } from './components/RowEditorPanel' export { default as ChangesPanel } from './components/ChangesPanel' export { default as SavedQueriesPanel } from './components/SavedQueriesPanel' export { default as IconRail } from './components/IconRail' +export { default as TabBar } from './components/TabBar' export * from './lib/savedQueries' export * from './lib/queryHistory' diff --git a/src/pages/console/WorkspacePage.tsx b/src/pages/console/WorkspacePage.tsx index 69fdef8..d6d4ecd 100644 --- a/src/pages/console/WorkspacePage.tsx +++ b/src/pages/console/WorkspacePage.tsx @@ -49,11 +49,24 @@ const WorkflowEditor = lazy(() => import('@/features/workflow/components/Workflo // Lazy — recharts + react-grid-layout; only load when a dashboard tab opens. const DashboardView = lazy(() => import('@/features/dashboard/components/DashboardView')) import IconRail from '@/features/workspace/components/IconRail' +import TabBar from '@/features/workspace/components/TabBar' import { formatCombo, useKeymap, useShortcut } from '@/features/keymap' import SavedQueriesPanel from '@/features/workspace/components/SavedQueriesPanel' import AnalyzePanel from '@/features/workspace/components/AnalyzePanel' import AnalyzeFolderPanel from '@/features/workspace/components/AnalyzeFolderPanel' -import { WorkflowsPanel, listWorkflows, createWorkflow, deleteWorkflow, updateWorkflow } from '@/features/workflow' +import { + WorkflowsPanel, + listWorkflows, + createWorkflow, + deleteWorkflow, + updateWorkflow, + sanitizeGraph, + fetchWorkflowFolders, + createWorkflowFolder, + renameWorkflowFolder, + moveWorkflowFolder, + deleteWorkflowFolder, +} from '@/features/workflow' import { DashboardsPanel, listDashboards, @@ -85,12 +98,12 @@ import ListRow from '@/shared/ui/ListRow' import TextButton from '@/shared/ui/buttons/TextButton' import { ChevronRight, - CloseIcon, CodeIcon, ColumnsIcon, DiagramIcon, EditIcon, EyeIcon, + FolderPlusIcon, GridIcon, HistoryIcon, MenuIcon, @@ -101,9 +114,18 @@ import { TableIcon, TagIcon, TrashIcon, + WandIcon, WorkflowIcon, } from '@/shared/ui/icons' -import { DomainPickerPanel, DomainQuickMenu, DomainDot, fetchDomains, updateDomain, deleteDomain } from '@/features/domains' +import { + TableFolderPickerPanel, + TableFolderList, + FolderDot, + fetchTableFolders, + updateTableFolder, + deleteTableFolder, +} from '@/features/table-folders' +import { TemplatesPanel, TemplateDetailView, TEMPLATES } from '@/features/templates' const kbd = 'inline-flex min-w-[20px] items-center justify-center rounded-[5px] border border-edge bg-elevated px-1.5 py-0.5 text-[11px] text-ink-dim' @@ -152,6 +174,7 @@ export default function Workspace() { const [saved, setSaved] = useState([]) const [folders, setFolders] = useState([]) const [workflows, setWorkflows] = useState([]) + const [workflowFolders, setWorkflowFolders] = useState([]) const [dashboards, setDashboards] = useState([]) const [dashboardFolders, setDashboardFolders] = useState([]) const [tabMenu, setTabMenu] = useState(null) // { x, y, key } | null @@ -170,13 +193,13 @@ export default function Workspace() { const [sidebarOpen, setSidebarOpen] = useState(false) // mobile drawer const [openGroup, setOpenGroup] = useState('table') // accordion: the one expanded browser section const [tablesVisible, setTablesVisible] = useState(true) // left panel visible - const [panel, setPanel] = useState('browser') // 'browser' | 'queries' | 'workflows' + const [panel, setPanel] = useState('browser') // 'browser' | 'queries' | 'workflows' | 'dashboards' | 'schema' | 'templates' const [searchOpen, setSearchOpen] = useState(false) // table search toggle const [paletteOpen, setPaletteOpen] = useState(false) // ⌘K command palette const [tableSort, setTableSort] = useState('az') // 'az' | 'za' - const [tableView, setTableView] = useState('flat') // 'flat' | 'domains' — Tables list grouping - const [domains, setDomains] = useState([]) // per-connection domains (with grouped table names) - const [domainTable, setDomainTable] = useState(null) // table whose domain picker is open | null + const [tableFolders, setTableFolders] = useState([]) // per-connection tableFolders (with grouped table names) + const [creatingTableFolder, setCreatingTableFolder] = useState(null) // { parentId } while naming a new folder | null + const [folderPickerTable, setFolderPickerTable] = useState(null) // table whose folder picker is open (schema diagram) | null const searchRef = useRef(null) const autoOpenedFor = useRef(null) // connection id we've already auto-opened a tab for @@ -304,9 +327,10 @@ export default function Workspace() { fetchSaved(id).then((list) => alive && setSaved(list)) fetchFolders(id).then((list) => alive && setFolders(list)) listWorkflows(id).then((list) => alive && setWorkflows(list)) + fetchWorkflowFolders(id).then((list) => alive && setWorkflowFolders(list)) listDashboards(id).then((list) => alive && setDashboards(list)) fetchDashboardFolders(id).then((list) => alive && setDashboardFolders(list)) - fetchDomains(id).then((list) => alive && setDomains(list)) + fetchTableFolders(id).then((list) => alive && setTableFolders(list)) return () => { alive = false } @@ -601,10 +625,10 @@ export default function Workspace() { setActiveTab(key) setSidebarOpen(false) } - const newWorkflow = async () => { + const newWorkflow = async (folderId = null) => { try { - const wf = await createWorkflow(id, `Workflow ${workflows.length + 1}`) - setWorkflows((prev) => [{ id: wf.id, name: wf.name, ts: Date.now(), protected: false, scheduleEnabled: false }, ...prev]) + const wf = await createWorkflow(id, `Workflow ${workflows.length + 1}`, undefined, folderId) + setWorkflows((prev) => [{ id: wf.id, name: wf.name, ts: Date.now(), protected: false, scheduleEnabled: false, folderId: folderId || null }, ...prev]) openWorkflow(wf) } catch (e) { toast.error(`Couldn't create workflow: ${e.message}`) @@ -630,6 +654,79 @@ export default function Workspace() { toast.error(`Delete failed: ${e.message}`) } } + const workflowFileRef = useRef(null) + const importWorkflowFile = async (file) => { + try { + const doc = JSON.parse(await file.text()) + if (doc?.kind !== 'workflow' || !doc.graph) throw new Error('Not a workflow export file.') + const wf = await createWorkflow(id, doc.name || 'Imported workflow', sanitizeGraph(doc.graph)) + setWorkflows((prev) => [{ id: wf.id, name: wf.name, ts: Date.now(), protected: false, scheduleEnabled: false, folderId: null }, ...prev]) + openWorkflow(wf) + } catch (e) { + toast.error(`Import failed: ${e.message}`) + } + } + + // ---- Workflow folders (nesting capped at 3 levels, enforced server-side) ---- + const addWorkflowFolder = async (name, parentId = null) => { + const next = name?.trim() + if (!next) return + try { + const folder = await createWorkflowFolder(id, next, parentId) + setWorkflowFolders((prev) => [...prev, folder]) + } catch (e) { + toast.error(`Couldn't create folder: ${e.message}`) + } + } + const renameWorkflowFolderById = async (fid, name) => { + const next = name?.trim() + if (!next) return + setWorkflowFolders((prev) => prev.map((f) => (f.id === fid ? { ...f, name: next } : f))) + try { + await renameWorkflowFolder(id, fid, next) + } catch (e) { + toast.error(`Rename failed: ${e.message}`) + } + } + const removeWorkflowFolder = async (fid) => { + // Reparent this folder's contents up one level locally, mirroring the server: + // its subfolders and workflows move to its own parent (root for a top-level folder). + const parentId = workflowFolders.find((f) => f.id === fid)?.parentId || null + setWorkflowFolders((prev) => + prev.filter((f) => f.id !== fid).map((f) => (f.parentId === fid ? { ...f, parentId } : f)) + ) + setWorkflows((prev) => prev.map((w) => (w.folderId === fid ? { ...w, folderId: parentId } : w))) + try { + await deleteWorkflowFolder(id, fid) + } catch (e) { + toast.error(`Delete failed: ${e.message}`) + } + } + const moveWorkflowFolderToParent = async (fid, parentId) => { + const target = parentId || null + if (fid === target) return + // Guard against cycles: refuse to nest a folder under its own descendant. + const parentOf = new Map(workflowFolders.map((f) => [f.id, f.parentId || null])) + for (let cur = target; cur; cur = parentOf.get(cur)) { + if (cur === fid) return + } + setWorkflowFolders((prev) => prev.map((f) => (f.id === fid ? { ...f, parentId: target } : f))) + try { + await moveWorkflowFolder(id, fid, target) + } catch (e) { + // Depth-cap or cycle rejection — refresh to resync with the server truth. + toast.error(`Move failed: ${e.message}`) + fetchWorkflowFolders(id).then(setWorkflowFolders) + } + } + const moveWorkflowToFolder = async (wid, folderId) => { + setWorkflows((prev) => prev.map((w) => (w.id === wid ? { ...w, folderId: folderId || null } : w))) + try { + await updateWorkflow(id, wid, { folderId: folderId || null }) + } catch (e) { + toast.error(`Move failed: ${e.message}`) + } + } // ---- Dashboards (per connection) ---- // Mirrors the workflow handlers: open in a tab, create, rename, delete, @@ -643,6 +740,26 @@ export default function Workspace() { setActiveTab(key) setSidebarOpen(false) } + // ---- Templates (built-in catalog, browse + apply) ---- + // Open a template's detail in its own tab (VSCode-style). Applying creates + // its workflows + dashboards on this connection, then refreshes the rails. + const openTemplate = (t) => { + const key = `template:${t.id}` + setTabs((prev) => + prev.some((tab) => tab.key === key) ? prev : [...prev, { key, kind: 'template', templateId: t.id, title: t.name }] + ) + setActiveTab(key) + setSidebarOpen(false) + } + const onTemplateApplied = (res) => { + listWorkflows(id).then(setWorkflows) + fetchWorkflowFolders(id).then(setWorkflowFolders) + listDashboards(id).then(setDashboards) + fetchDashboardFolders(id).then(setDashboardFolders) + const d = res.dashboards[0] + if (d) openDashboard(d) + else if (res.workflows[0]) openWorkflow(res.workflows[0]) + } const newDashboard = async (folderId = null) => { try { const d = await createDashboard(id, `Dashboard ${dashboards.length + 1}`, undefined, folderId) @@ -1010,11 +1127,32 @@ export default function Workspace() { }) } + const closeOtherTabs = (key) => { + setTabs((prev) => { + if (!prev.some((t) => t.key === key)) return prev + if (activeTab !== key) setActiveTab(key) + return prev.filter((t) => t.key === key) + }) + } + const closeAllTabs = () => { setTabs([]) setActiveTab(null) } + // Drag-reorder from the tab bar: move `fromKey` next to `toKey` (before or + // after it); `toKey === null` moves it to the end. + const moveTab = (fromKey, toKey, before) => { + setTabs((prev) => { + const from = prev.findIndex((t) => t.key === fromKey) + if (from === -1) return prev + const next = prev.filter((t) => t.key !== fromKey) + const at = toKey == null ? next.length : next.findIndex((t) => t.key === toKey) + next.splice(at === -1 ? next.length : at + (before ? 0 : 1), 0, prev[from]) + return next + }) + } + const openTabMenu = (e, key) => { e.preventDefault() e.stopPropagation() @@ -1029,65 +1167,79 @@ export default function Workspace() { { type: 'table', label: 'Tables', items: visibleObjects.filter((o) => o.type === 'table') }, { type: 'view', label: 'Views', items: visibleObjects.filter((o) => o.type === 'view') }, { type: 'function', label: 'Functions', items: visibleObjects.filter((o) => o.type === 'function') }, - ].filter((g) => g.items.length > 0) + // Tables also stays visible while it only holds folders (empty ones, or all + // of their tables filtered out) — otherwise the folder tree would vanish. + ].filter((g) => g.items.length > 0 || (g.type === 'table' && (tableFolders.length > 0 || creatingTableFolder))) const current = tabs.find((t) => t.key === activeTab) - // tableName -> its single domain (drives the inline dot + the grouped view). - const domainByTable = useMemo(() => { + // tableName -> its folder (drives the inline dot + the grouped view). + const folderByTable = useMemo(() => { const map = {} - for (const d of domains) for (const tn of d.tables || []) map[tn] = d + for (const d of tableFolders) for (const tn of d.tables || []) map[tn] = d return map - }, [domains]) + }, [tableFolders]) - // Grouped-by-domain buckets for the Tables section (only built in 'domains' - // view). Tables with no domain fall into a trailing "Ungrouped" bucket. + // Tables shown in the 'folders' view — TableFolderList buckets them into one + // folder per table folder plus a trailing "Ungrouped" folder. const tableObjects = visibleObjects.filter((o) => o.type === 'table') - const domainBuckets = domains - .map((d) => ({ key: d.id, domain: d, items: tableObjects.filter((o) => d.tables?.includes(o.name)) })) - .filter((b) => b.items.length > 0) - const ungroupedTables = tableObjects.filter((o) => !domainByTable[o.name]) - - // Edit/delete a domain from the schema diagram (optimistic local update). - const updateDomainById = async (domainId, fields) => { - setDomains((prev) => prev.map((d) => (d.id === domainId ? { ...d, ...fields } : d))) + + // Edit/delete a folder from the schema diagram (optimistic local update). + const updateFolderById = async (folderId, fields) => { + setTableFolders((prev) => prev.map((d) => (d.id === folderId ? { ...d, ...fields } : d))) try { - await updateDomain(id, domainId, fields) + await updateTableFolder(id, folderId, fields) } catch (e) { - toast.error(`Couldn't update domain: ${e.message}`) + toast.error(`Couldn't update folder: ${e.message}`) } } - const removeDomain = async (domainId) => { - setDomains((prev) => prev.filter((d) => d.id !== domainId)) + const removeTableFolder = async (folderId) => { + // Mirror the server locally: subfolders and member tables move up one level + // (to this folder's parent — the root, i.e. ungrouped, for a top-level one). + const prev = tableFolders + const gone = tableFolders.find((f) => f.id === folderId) + const parentId = gone?.parentId || null + setTableFolders((list) => + list + .filter((f) => f.id !== folderId) + .map((f) => ({ + ...f, + parentId: (f.parentId || null) === folderId ? parentId : f.parentId, + tables: f.id === parentId ? [...f.tables, ...(gone?.tables || [])] : f.tables, + })) + ) try { - await deleteDomain(id, domainId) + await deleteTableFolder(id, folderId) } catch (e) { + setTableFolders(prev) toast.error(`Delete failed: ${e.message}`) } } - // One table/view/function sidebar row (used flat and inside tag buckets). - const renderObject = (obj) => { + // One table/view/function sidebar row (used flat and inside table folders). + // `rowProps` is spread onto the row so the folder view can make it draggable. + const renderObject = (obj, rowProps = {}) => { const active = obj.type === 'function' ? current?.kind === 'function' && current.name === obj.name : current?.kind === 'table' && current.table === obj.name const Icon = obj.type === 'view' ? EyeIcon : obj.type === 'function' ? CodeIcon : TableIcon const onOpen = obj.type === 'function' ? () => openFunction(obj.name) : () => openTable(obj.name) - const rowDomain = obj.type === 'table' ? domainByTable[obj.name] : null + const rowFolder = obj.type === 'table' ? folderByTable[obj.name] : null return ( } + {...rowProps} > {obj.name} - {rowDomain && ( - - + {rowFolder && ( + + )}
e.stopPropagation()}> @@ -1145,14 +1297,6 @@ export default function Workspace() { { setCreatingTable({ table: obj.name }); close() }}> Edit Table - { setDomainTable(obj.name); close() }} - onAssigned={close} - />
{ setTableAction({ table: obj.name, mode: 'empty' }); close() }}> Empty Table @@ -1185,7 +1329,7 @@ export default function Workspace() { const commands: Command[] = [ { id: 'new-query', group: 'Create', label: 'New SQL query', keywords: 'sql add query tab', icon: , hint: formatCombo(bindings['general.newTab']), run: () => openQuery() }, { id: 'new-schema', group: 'Create', label: 'New schema diagram', keywords: 'erd designer table diagram', icon: , run: openSchemaEditor }, - { id: 'new-workflow', group: 'Create', label: 'New workflow', keywords: 'automation flow', icon: , run: newWorkflow }, + { id: 'new-workflow', group: 'Create', label: 'New workflow', keywords: 'automation flow', icon: , run: () => newWorkflow() }, { id: 'new-dashboard', group: 'Create', label: 'New dashboard', keywords: 'charts widgets analytics', icon: , run: () => newDashboard() }, { id: 'new-table', group: 'Create', label: 'New table', keywords: 'create table ddl', icon: , run: () => setCreatingTable(true) }, @@ -1194,6 +1338,7 @@ export default function Workspace() { { id: 'go-workflows', group: 'Navigate', label: 'Workflows', keywords: 'automation', icon: , hint: formatCombo(bindings['workspace.panelWorkflows']), run: () => selectPanel('workflows') }, { id: 'go-schema', group: 'Navigate', label: 'Schema', keywords: 'designer diagram', icon: , hint: formatCombo(bindings['workspace.panelSchema']), run: () => selectPanel('schema') }, { id: 'go-dashboards', group: 'Navigate', label: 'Dashboards', keywords: 'charts analytics', icon: , hint: formatCombo(bindings['workspace.panelDashboards']), run: () => selectPanel('dashboards') }, + { id: 'go-templates', group: 'Navigate', label: 'Templates', keywords: 'presets starter gallery scaffold', icon: , run: () => selectPanel('templates') }, { id: 'view-history', group: 'View', label: 'Query history', keywords: 'recent past', icon: , run: openHistory }, { id: 'view-schema-history', group: 'View', label: `Schema version history (v${conn.schemaVersion ?? 1})`, keywords: 'migrations audit', icon: , run: openSchemaHistory }, @@ -1227,6 +1372,7 @@ export default function Workspace() { onWorkflows={() => selectPanel('workflows')} onDashboards={() => selectPanel('dashboards')} onSchema={() => selectPanel('schema')} + onTemplates={() => selectPanel('templates')} onHome={() => navigate('/')} onLogout={logout} /> @@ -1275,13 +1421,15 @@ export default function Workspace() { - + setTableView((v) => (v === 'domains' ? 'flat' : 'domains'))} - aria-label="Group tables by domain" + onClick={() => { + setOpenGroup('table') + setCreatingTableFolder({ parentId: null }) + }} + aria-label="New table folder" > - + @@ -1345,36 +1493,26 @@ export default function Workspace() { {openGroup === group.type && (
- {group.type === 'table' && tableView === 'domains' ? ( - <> - {domainBuckets.map((b) => ( -
-
- - {b.domain.name} - {b.items.length} -
- {b.items.map(renderObject)} -
- ))} - {ungroupedTables.length > 0 && ( -
-
- Ungrouped - {ungroupedTables.length} -
- {ungroupedTables.map(renderObject)} -
- )} - + {group.type === 'table' ? ( + ) : ( - group.items.map(renderObject) + group.items.map((o) => renderObject(o)) )}
)}
))} - {!loading && visibleObjects.length === 0 && ( + {!loading && objectGroups.length === 0 && (
No objects
)}
@@ -1382,12 +1520,22 @@ export default function Workspace() { ) : panel === 'workflows' ? ( workflowFileRef.current?.click()} onRename={renameWorkflow} onDelete={removeWorkflow} - onRefresh={() => listWorkflows(id).then(setWorkflows)} + onCreateFolder={addWorkflowFolder} + onRenameFolder={renameWorkflowFolderById} + onDeleteFolder={removeWorkflowFolder} + onMoveToFolder={moveWorkflowToFolder} + onMoveFolder={moveWorkflowFolderToParent} + onRefresh={() => { + listWorkflows(id).then(setWorkflows) + fetchWorkflowFolders(id).then(setWorkflowFolders) + }} /> ) : panel === 'dashboards' ? ( + ) : panel === 'templates' ? ( + ) : ( s.kind !== 'schema')} @@ -1469,7 +1623,7 @@ export default function Workspace() { - + newWorkflow()} aria-label="New workflow"> @@ -1490,6 +1644,17 @@ export default function Workspace() { e.target.value = '' }} /> + { + const f = e.target.files?.[0] + if (f) importWorkflowFile(f) + e.target.value = '' + }} + />
-
- {tabs.map((t) => { - const active = activeTab === t.key - return ( -
setActiveTab(t.key)} - onContextMenu={(e) => openTabMenu(e, t.key)} - className={`group/tab relative flex cursor-pointer items-center gap-2.5 whitespace-nowrap rounded-t-[8px] px-3.5 py-2.5 text-xs transition-colors ${ - active - ? 'bg-elevated font-medium text-ink' - : 'text-ink-dim hover:bg-elevated/40 hover:text-ink' - }`} - > - {active && } - {t.kind === 'query' ? ( - - ) : t.kind === 'schema' ? ( - - ) : t.kind === 'schemaEditor' ? ( - - ) : t.kind === 'history' ? ( - - ) : t.kind === 'schemaHistory' ? ( - - ) : t.kind === 'function' ? ( - - ) : t.kind === 'workflow' ? ( - - ) : t.kind === 'dashboard' ? ( - - ) : ( - - )} - {t.title} - closeTab(e, t.key)} - aria-label="Close tab" - > - - -
- ) - })} - {tabs.length === 0 &&
No open tabs
} -
+
{conn && current?.kind === 'table' && ( @@ -1640,10 +1765,10 @@ export default function Workspace() { key={`${current.key}:${dataVersion}:${ns.database}:${ns.schema}`} conn={nsConn} changes={changes} - domains={domains} - onUpdateDomain={updateDomainById} - onDeleteDomain={removeDomain} - onSetDomain={setDomainTable} + folders={tableFolders} + onUpdateFolder={updateFolderById} + onDeleteFolder={removeTableFolder} + onSetFolder={setFolderPickerTable} pending={schemaPending[current.key] || []} onPendingChange={(items) => setSchemaPending((p) => ({ ...p, [current.key]: items }))} onStageItems={stageSchemaItems} @@ -1704,6 +1829,20 @@ export default function Workspace() { /> )} + {conn && current?.kind === 'template' && (() => { + const tpl = TEMPLATES.find((t) => t.id === current.templateId) + return tpl ? ( + + ) : ( +
Template not found.
+ ) + })()} {!current && (
@@ -1757,6 +1896,15 @@ export default function Workspace() { > Close + { + closeOtherTabs(tabMenu.key) + setTabMenu(null) + }} + > + Close other tabs + t.key === tabMenu.key) === tabs.length - 1} onClick={() => { @@ -1787,13 +1935,15 @@ export default function Workspace() { /> )} - {domainTable && ( - setDomainTable(null)} + table={folderPickerTable} + folders={tableFolders} + onChange={setTableFolders} + onClose={() => setFolderPickerTable(null)} /> )} diff --git a/src/shared/api/folders.ts b/src/shared/api/folders.ts index a18306c..de75129 100644 --- a/src/shared/api/folders.ts +++ b/src/shared/api/folders.ts @@ -1,13 +1,23 @@ // Generic, per-connection folders — one polymorphic tree per resource `type` -// ('query' groups saved queries, 'dashboard' groups dashboards). Backed by the -// single `folders` table on the server; features wrap these with their own -// type bound (see workspace/lib/savedQueries.ts and dashboard/lib/api.ts). +// ('query' groups saved queries, 'dashboard' groups dashboards, 'workflow' +// groups workflows, 'table' groups the connected database's tables). Backed by +// the single `folders` table on the server; features wrap these with their own +// type bound (see workspace/lib/savedQueries.ts, dashboard/lib/api.ts, +// workflow/lib/api.ts and table-folders/lib/api.ts). // Reads degrade quietly; mutations throw (caller try/catches + toasts). import { request, safeRequest } from '@/shared/api/request' -export type FolderType = 'query' | 'dashboard' -export type Folder = { id: string; name: string; parentId: string | null; type: FolderType; ts: number } +export type FolderType = 'query' | 'dashboard' | 'workflow' | 'table' +export type Folder = { + id: string + name: string + color: string | null // optional accent color (nullable for every type) + parentId: string | null + type: FolderType + ts: number + tables?: string[] // type='table' only: the table names grouped in this folder +} export async function fetchFolders(connectionId: string, type: FolderType): Promise { if (!connectionId) return [] @@ -18,20 +28,43 @@ export async function createFolder( connectionId: string, type: FolderType, name: string, - parentId: string | null = null + parentId: string | null = null, + color: string | null = null ): Promise { - return request(`/connections/${connectionId}/folders`, { method: 'POST', body: { type, name, parentId: parentId || null } }) + return request(`/connections/${connectionId}/folders`, { + method: 'POST', + body: { type, name, parentId: parentId || null, color: color || null }, + }) +} + +// Patch a folder: any subset of name / color / parentId (parentId null = root). +export async function updateFolder( + connectionId: string, + folderId: string, + fields: { name?: string; color?: string | null; parentId?: string | null } +): Promise { + await request(`/connections/${connectionId}/folders/${folderId}`, { method: 'PUT', body: fields }) } export async function renameFolder(connectionId: string, folderId: string, name: string): Promise { - await request(`/connections/${connectionId}/folders/${folderId}`, { method: 'PUT', body: { name } }) + await updateFolder(connectionId, folderId, { name }) } // Move a folder under a new parent (null = root). Nesting builds subdirectories. export async function moveFolder(connectionId: string, folderId: string, parentId: string | null): Promise { - await request(`/connections/${connectionId}/folders/${folderId}`, { method: 'PUT', body: { parentId: parentId || null } }) + await updateFolder(connectionId, folderId, { parentId: parentId || null }) } export async function deleteFolder(connectionId: string, folderId: string): Promise { await request(`/connections/${connectionId}/folders/${folderId}`, { method: 'DELETE' }) } + +// Put a table in a folder, or ungroup it with `folderId: null`. Tables live in +// the user's database, so membership is keyed by plain table name (one folder +// per table) — it's stored on the server's per-table `connection_tables` row. +export async function setTableFolder(connectionId: string, table: string, folderId: string | null): Promise { + await request(`/connections/${connectionId}/tables/${encodeURIComponent(table)}/folder`, { + method: 'PUT', + body: { folderId }, + }) +} diff --git a/src/shared/ui/icons.tsx b/src/shared/ui/icons.tsx index a31f695..05b82e6 100644 --- a/src/shared/ui/icons.tsx +++ b/src/shared/ui/icons.tsx @@ -109,6 +109,17 @@ export const TagIcon = (props) => ( ) +export const PaletteIcon = (props) => ( + + + + + + + +) + export const RefreshIcon = (props) => ( diff --git a/src/shared/ui/overlay/ContextMenu.tsx b/src/shared/ui/overlay/ContextMenu.tsx index 149f2f8..f0dad79 100644 --- a/src/shared/ui/overlay/ContextMenu.tsx +++ b/src/shared/ui/overlay/ContextMenu.tsx @@ -52,10 +52,17 @@ export default function ContextMenu({ x, y, onClose, children, width = 220 }) { ) } -/** A MenuItem row that opens a flyout panel to its side on hover. */ +/** + * A MenuItem row that opens a flyout panel to its side on hover. Inside a + * ContextMenu the parent coordinates which sub is open; used standalone (e.g. + * in a Popover action menu, where there are no siblings to coordinate with) it + * falls back to its own state. + */ export function ContextMenuSub({ label, icon: Icon, disabled = false, width = 200, children }) { const id = useId() - const { openSub, setOpenSub } = useContext(OpenSubContext) + const parent = useContext(OpenSubContext) + const [localSub, setLocalSub] = useState(null) + const { openSub, setOpenSub } = parent ?? { openSub: localSub, setOpenSub: setLocalSub } const open = openSub === id const [pos, setPos] = useState({ left: 0, top: 0 }) const rowRef = useRef(null)