From 43102218dbeaaa833c286154cf0dbfdd9661e583 Mon Sep 17 00:00:00 2001 From: Kurniaji Gunawan Date: Thu, 23 Jul 2026 20:47:56 +0700 Subject: [PATCH 1/6] feat: add export and import workflow --- BACKEND_DOCUMENTATION.MD | 8 +- CLAUDE.md | 11 ++- README.md | 2 +- server.js | 4 +- .../workflow/components/WorkflowEditor.tsx | 74 ++++++++++++++++++- .../workflow/components/WorkflowsPanel.tsx | 14 +++- src/features/workflow/index.ts | 2 + src/features/workflow/lib/api.ts | 4 +- src/features/workflow/lib/exportImport.ts | 64 ++++++++++++++++ src/pages/console/WorkspacePage.tsx | 26 ++++++- 10 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 src/features/workflow/lib/exportImport.ts diff --git a/BACKEND_DOCUMENTATION.MD b/BACKEND_DOCUMENTATION.MD index 484f17a..3880753 100644 --- a/BACKEND_DOCUMENTATION.MD +++ b/BACKEND_DOCUMENTATION.MD @@ -749,10 +749,12 @@ List workflows for a connection (newest first; no graph body). - **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. -- **Body:** `{ "name": "Nightly sync" }` -- **200:** `{ "id", "name", "graph": { "nodes": [], "edges": [] }, "ts", "protected": false, "scheduleEnabled": false }` +- **Body:** `{ "name": "Nightly sync", "graph"?: { "nodes": [...], "edges": [...] } }` +- **200:** `{ "id", "name", "graph", "ts", "protected": false, "scheduleEnabled": false }` - **400:** `{ "error": "A workflow name is required" }` - **Example:** `POST http://localhost:3000/api/connections/demo-sqlite/workflows` diff --git a/CLAUDE.md b/CLAUDE.md index 33de9e8..edb0880 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,12 +72,15 @@ src/ │ │ │ # 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) +│ │ │ # real hourly/daily scheduling via the Schedule trigger node's Active toggle; +│ │ │ # JSON export/import of a workflow's graph, mirroring the dashboard feature) +│ │ ├── components/ # WorkflowEditor (export/import toolbar buttons), WorkflowsPanel +│ │ │ # (+ "Import from JSON…" → creates a new workflow), 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") +│ │ # 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 diff --git a/README.md b/README.md index 915fc3f..f197c2f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Self‑hosted, single Docker image, your data stays yours. | 📊 | **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. | | 🧮 | **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, 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. | | 💾 | **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). | diff --git a/server.js b/server.js index 2e8642e..70fcb20 100644 --- a/server.js +++ b/server.js @@ -2879,9 +2879,9 @@ app.get('/api/connections/:id/workflows', (req, res) => { }) app.post('/api/connections/:id/workflows', (req, res) => { - const { name } = req.body || {} + const { name, graph } = 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: [] }, 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) 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..1a58303 100644 --- a/src/features/workflow/components/WorkflowsPanel.tsx +++ b/src/features/workflow/components/WorkflowsPanel.tsx @@ -9,8 +9,8 @@ import Popover from '@/shared/ui/overlay/Popover' import Tooltip from '@/shared/ui/overlay/Tooltip' // 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) { +// (minus folders): open in a tab, create, rename inline, delete, import from JSON. +export default function WorkflowsPanel({ workflows = [], activeId, onOpen, onNew, onImport, onRename, onDelete, onRefresh }: any) { const [searchOpen, setSearchOpen] = useState(false) const [filter, setFilter] = useState('') const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null) @@ -138,6 +138,16 @@ export default function WorkflowsPanel({ workflows = [], activeId, onOpen, onNew )}
)} + + {onImport && ( + + )}
) diff --git a/src/features/workflow/index.ts b/src/features/workflow/index.ts index e236be9..2f5b0b4 100644 --- a/src/features/workflow/index.ts +++ b/src/features/workflow/index.ts @@ -16,3 +16,5 @@ export { runWorkflow, } from '@/features/workflow/lib/api' export type { Workflow, WorkflowSummary, 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..84696df 100644 --- a/src/features/workflow/lib/api.ts +++ b/src/features/workflow/lib/api.ts @@ -55,8 +55,8 @@ export async function getWorkflow(connectionId: string, wid: string): Promise { - return request(`/connections/${connectionId}/workflows`, { method: 'POST', body: { name } }) +export async function createWorkflow(connectionId: string, name: string, graph?: WorkflowGraph): Promise { + return request(`/connections/${connectionId}/workflows`, { method: 'POST', body: { name, graph } }) } export async function updateWorkflow( 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/pages/console/WorkspacePage.tsx b/src/pages/console/WorkspacePage.tsx index 69fdef8..e18f83d 100644 --- a/src/pages/console/WorkspacePage.tsx +++ b/src/pages/console/WorkspacePage.tsx @@ -53,7 +53,7 @@ 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 } from '@/features/workflow' import { DashboardsPanel, listDashboards, @@ -630,6 +630,18 @@ 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 }, ...prev]) + openWorkflow(wf) + } catch (e) { + toast.error(`Import failed: ${e.message}`) + } + } // ---- Dashboards (per connection) ---- // Mirrors the workflow handlers: open in a tab, create, rename, delete, @@ -1385,6 +1397,7 @@ export default function Workspace() { activeId={current?.kind === 'workflow' ? current.workflowId : null} onOpen={openWorkflow} onNew={newWorkflow} + onImport={() => workflowFileRef.current?.click()} onRename={renameWorkflow} onDelete={removeWorkflow} onRefresh={() => listWorkflows(id).then(setWorkflows)} @@ -1490,6 +1503,17 @@ export default function Workspace() { e.target.value = '' }} /> + { + const f = e.target.files?.[0] + if (f) importWorkflowFile(f) + e.target.value = '' + }} + /> + {!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/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/pages/console/WorkspacePage.tsx b/src/pages/console/WorkspacePage.tsx index 4377507..c21376c 100644 --- a/src/pages/console/WorkspacePage.tsx +++ b/src/pages/console/WorkspacePage.tsx @@ -113,9 +113,11 @@ import { TableIcon, TagIcon, TrashIcon, + WandIcon, WorkflowIcon, } from '@/shared/ui/icons' import { DomainPickerPanel, DomainQuickMenu, DomainDot, fetchDomains, updateDomain, deleteDomain } from '@/features/domains' +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' @@ -183,7 +185,7 @@ 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' @@ -730,6 +732,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) @@ -1281,6 +1303,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 }, @@ -1314,6 +1337,7 @@ export default function Workspace() { onWorkflows={() => selectPanel('workflows')} onDashboards={() => selectPanel('dashboards')} onSchema={() => selectPanel('schema')} + onTemplates={() => selectPanel('templates')} onHome={() => navigate('/')} onLogout={logout} /> @@ -1520,6 +1544,12 @@ export default function Workspace() { onRefreshMigrations={loadSchemaHistory} onRollback={setRollbackTarget} /> + ) : panel === 'templates' ? ( + ) : ( s.kind !== 'schema')} @@ -1706,6 +1736,8 @@ export default function Workspace() { ) : t.kind === 'dashboard' ? ( + ) : t.kind === 'template' ? ( + ) : ( )} @@ -1812,6 +1844,20 @@ export default function Workspace() { /> )} + {conn && current?.kind === 'template' && (() => { + const tpl = TEMPLATES.find((t) => t.id === current.templateId) + return tpl ? ( + + ) : ( +
Template not found.
+ ) + })()} {!current && (
From 247a517484d7fba48ce1de680f162dd21a56da39 Mon Sep 17 00:00:00 2001 From: Kurniaji Gunawan Date: Mon, 27 Jul 2026 21:03:24 +0700 Subject: [PATCH 4/6] feat: add connection tables --- BACKEND_DOCUMENTATION.MD | 133 +++--- CLAUDE.md | 26 +- README.md | 1 + server.js | 170 ++++---- server/migrations.js | 67 ++- src/features/domains/index.ts | 11 - src/features/domains/lib/api.ts | 32 -- src/features/domains/lib/assign.ts | 17 - src/features/domains/types.ts | 25 -- .../components/SchemaEditor.tsx | 150 +++---- .../components/SchemaSidebar.tsx | 24 +- .../components/FolderDot.tsx} | 6 +- .../components/TableFolderEditPanel.tsx} | 34 +- .../components/TableFolderList.tsx | 393 ++++++++++++++++++ .../components/TableFolderPickerPanel.tsx} | 113 ++--- .../components/TableFolderQuickMenu.tsx} | 92 ++-- src/features/table-folders/index.ts | 22 + src/features/table-folders/lib/api.ts | 36 ++ src/features/table-folders/lib/assign.ts | 17 + src/features/table-folders/lib/tree.ts | 38 ++ src/features/table-folders/types.ts | 27 ++ src/pages/console/WorkspacePage.tsx | 165 ++++---- src/shared/api/folders.ts | 50 ++- 23 files changed, 1095 insertions(+), 554 deletions(-) delete mode 100644 src/features/domains/index.ts delete mode 100644 src/features/domains/lib/api.ts delete mode 100644 src/features/domains/lib/assign.ts delete mode 100644 src/features/domains/types.ts rename src/features/{domains/components/DomainDot.tsx => table-folders/components/FolderDot.tsx} (54%) rename src/features/{domains/components/DomainEditPanel.tsx => table-folders/components/TableFolderEditPanel.tsx} (78%) create mode 100644 src/features/table-folders/components/TableFolderList.tsx rename src/features/{domains/components/DomainPickerPanel.tsx => table-folders/components/TableFolderPickerPanel.tsx} (70%) rename src/features/{domains/components/DomainQuickMenu.tsx => table-folders/components/TableFolderQuickMenu.tsx} (62%) create mode 100644 src/features/table-folders/index.ts create mode 100644 src/features/table-folders/lib/api.ts create mode 100644 src/features/table-folders/lib/assign.ts create mode 100644 src/features/table-folders/lib/tree.ts create mode 100644 src/features/table-folders/types.ts diff --git a/BACKEND_DOCUMENTATION.MD b/BACKEND_DOCUMENTATION.MD index ee922c5..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 }` @@ -568,29 +572,34 @@ Delete a saved query. 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). The `type` param defaults to -`query` for older clients. Each item points back via its own `folderId`. +`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 of a given `type` (oldest first). Folders nest via `parentId` (null = root). -- **Query:** `?type=query|dashboard|workflow` (defaults to `query`) -- **200:** `Folder[]` → `[{ "id", "name", "parentId", "type", "ts" }]` +- **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:** `{ "type": "workflow", "name": "Reports", "parentId": null }` // `type` defaults to `query`, `parentId` optional (defaults to root) -- **200:** `{ "id", "name", "parentId", "type", "ts" }` +- **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" }` / @@ -601,57 +610,35 @@ 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 the -folder's items (queries/dashboards/workflows, per `type`) are re-attached -(`folder_id`/`parent_id` set to the parent), not deleted. +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. - -### GET `/api/connections/:id/domains` -List domains (oldest first). Each domain embeds the table names it groups. +## Table folders (per connection) -- **200:** `Domain[]` → `[{ "id", "name", "color", "ts", "tables": ["orders", "order_details"] }]` -- **Example:** `GET http://localhost:3000/api/connections/demo-sqlite/domains` +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: -### 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` --- @@ -1513,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 46e1f42..b282ab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,22 +53,30 @@ 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 +│ │ # sidebar's folder view: collapsible color-tinted folders + subfolders, +│ │ # drag a table into a folder / a folder into a folder, trailing +│ │ # "Ungrouped" drop zone, inline new folder + rename), +│ │ # TableFolderQuickMenu (the table context-menu row: hover flyout for +│ │ # one-click assign), TableFolderPickerPanel (right-side slide-over +│ │ # with inline create/edit/delete), TableFolderEditPanel (name+color), +│ │ # 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 │ │ └── 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. diff --git a/README.md b/README.md index 45f69f1..162babc 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Self‑hosted, single Docker image, your data stays yours. |---|---|---| | 🗂️ | **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. | +| 📁 | **Table folders** | Group a connection's tables into colored, nestable folders (up to 3 levels) — drag a table into a folder in the sidebar, or assign it from the table menu. 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, **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}}`. | diff --git a/server.js b/server.js index be2d8ae..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,14 +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. @@ -2656,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) { @@ -2676,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) => { @@ -2698,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 @@ -2740,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' }) @@ -2787,87 +2850,6 @@ 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 diff --git a/server/migrations.js b/server/migrations.js index c277891..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, @@ -108,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 @@ -424,9 +441,43 @@ export const MIGRATIONS = [ db.exec(`ALTER TABLE workflows ADD COLUMN folder_id TEXT`) }, }, - // v5+: append plain, run-exactly-once steps here, e.g. + { + 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: 5, + // version: 6, // name: 'connections: last_used_at', // up(db) { // db.exec(`ALTER TABLE connections ADD COLUMN last_used_at INTEGER`) @@ -441,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/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) => ( +
+ ) +} 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" />
) : ( @@ -124,28 +125,35 @@ export default function DomainQuickMenu({ onClick={() => assign(null)} className={`${item} ${currentId === null ? 'bg-card-hover text-ink' : 'text-ink-dim hover:bg-card-hover hover:text-ink'}`} > - - No domain + + No folder {currentId === null && } - {domains.map((d) => ( - - ))} + {foldersInTreeOrder(folders).map((d) => { + const path = folderParentPath(folders, d) + return ( + + ) + })}
- Manage domains… + Manage folders… )} diff --git a/src/features/table-folders/index.ts b/src/features/table-folders/index.ts new file mode 100644 index 0000000..c4eb980 --- /dev/null +++ b/src/features/table-folders/index.ts @@ -0,0 +1,22 @@ +// Public API for the table-folders feature: a connection's tables grouped by +// the generic folders tree (type='table'), each folder carrying an optional +// color. A table belongs to at most one folder; folders nest 3 levels deep. +// Drives the console sidebar's folder view and the schema-diagram regions. +// (Replaced the old "domains" feature — meta migration v5 folded every domain +// into the folders tree, keeping its id, name and color.) +export { default as TableFolderList } from './components/TableFolderList' +export { default as TableFolderPickerPanel } from './components/TableFolderPickerPanel' +export { default as TableFolderQuickMenu } from './components/TableFolderQuickMenu' +export { default as TableFolderEditPanel } from './components/TableFolderEditPanel' +export { default as FolderDot } from './components/FolderDot' +export { + fetchTableFolders, + createTableFolder, + updateTableFolder, + deleteTableFolder, + setTableFolder, +} from './lib/api' +export { withTableAssignment } from './lib/assign' +export { folderParentPath, foldersInTreeOrder } from './lib/tree' +export { FOLDER_COLORS, MAX_TABLE_FOLDER_DEPTH } from './types' +export type { TableFolder } from './types' diff --git a/src/features/table-folders/lib/api.ts b/src/features/table-folders/lib/api.ts new file mode 100644 index 0000000..19dbff9 --- /dev/null +++ b/src/features/table-folders/lib/api.ts @@ -0,0 +1,36 @@ +// Table folders bound to the generic per-connection folders tree (type='table'). +// The list endpoint embeds each folder's table names, so one fetch drives the +// sidebar tree, the picker and the schema-diagram regions. + +import { fetchFolders, createFolder, updateFolder, deleteFolder, setTableFolder } from '@/shared/api/folders' +import type { TableFolder } from '../types' + +export async function fetchTableFolders(connectionId: string): Promise { + const list = await fetchFolders(connectionId, 'table') + return list.map((f) => ({ ...f, tables: f.tables || [] })) +} + +export async function createTableFolder( + connectionId: string, + { name, color, parentId }: { name: string; color?: string | null; parentId?: string | null } +): Promise { + const created = await createFolder(connectionId, 'table', name, parentId || null, color || null) + return { ...created, tables: created.tables || [] } +} + +// Patch a folder: any subset of name / color / parentId (parentId null = root). +export async function updateTableFolder( + connectionId: string, + folderId: string, + fields: { name?: string; color?: string | null; parentId?: string | null } +): Promise { + await updateFolder(connectionId, folderId, fields) +} + +// Deleting a folder ungroups its tables: the server reparents them to the +// folder's own parent, or drops the mapping when that parent is the root. +export async function deleteTableFolder(connectionId: string, folderId: string): Promise { + await deleteFolder(connectionId, folderId) +} + +export { setTableFolder } diff --git a/src/features/table-folders/lib/assign.ts b/src/features/table-folders/lib/assign.ts new file mode 100644 index 0000000..70f0677 --- /dev/null +++ b/src/features/table-folders/lib/assign.ts @@ -0,0 +1,17 @@ +import type { TableFolder } from '../types' + +/** + * Return `list` with `table` moved out of every folder and into `folderId` + * (`null` = ungrouped). A table belongs to at most one folder, so this is the + * single source of truth for the optimistic reassignment the sidebar tree, the + * picker panel and the quick-assign menu apply before the API call resolves. + */ +export function withTableAssignment(list: TableFolder[], table: string, folderId: string | null): TableFolder[] { + return list.map((f) => ({ + ...f, + tables: + f.id === folderId + ? [...f.tables.filter((n) => n !== table), table] + : f.tables.filter((n) => n !== table), + })) +} diff --git a/src/features/table-folders/lib/tree.ts b/src/features/table-folders/lib/tree.ts new file mode 100644 index 0000000..aaea4d0 --- /dev/null +++ b/src/features/table-folders/lib/tree.ts @@ -0,0 +1,38 @@ +import type { TableFolder } from '../types' + +/** + * Helpers for the (up to 3 level) table-folder tree. The flat lists — the + * picker panel and the quick-assign flyout — use these to show where a nested + * folder sits without rendering the tree itself. + */ + +// "Parent / Child" trail of a folder's ancestors ('' for a root folder). +export function folderParentPath(folders: TableFolder[], folder: TableFolder): string { + const byId = new Map(folders.map((f) => [f.id, f])) + const names: string[] = [] + const seen = new Set() + let cur = folder.parentId ? byId.get(folder.parentId) : undefined + while (cur && !seen.has(cur.id)) { + names.unshift(cur.name) + seen.add(cur.id) + cur = cur.parentId ? byId.get(cur.parentId) : undefined + } + return names.join(' / ') +} + +// Folders ordered as the tree reads: each parent immediately followed by its +// children, siblings A–Z. Keeps flat lists predictable once folders nest. +export function foldersInTreeOrder(folders: TableFolder[]): TableFolder[] { + const byName = (a: TableFolder, b: TableFolder) => a.name.localeCompare(b.name) + const out: TableFolder[] = [] + const walk = (parentId: string | null) => { + for (const f of folders.filter((x) => (x.parentId || null) === parentId).sort(byName)) { + out.push(f) + walk(f.id) + } + } + walk(null) + // Anything unreachable (orphaned parent) still gets listed, once. + for (const f of folders) if (!out.includes(f)) out.push(f) + return out +} diff --git a/src/features/table-folders/types.ts b/src/features/table-folders/types.ts new file mode 100644 index 0000000..aef4718 --- /dev/null +++ b/src/features/table-folders/types.ts @@ -0,0 +1,27 @@ +import type { Folder } from '@/shared/api/folders' + +// A table folder is a generic folder (type='table') that groups a connection's +// tables: a name, an optional color and the table names inside it. Each table +// belongs to at most one folder; folders nest up to MAX_TABLE_FOLDER_DEPTH. +// Superseded the old "domains" concept — meta migration v5 folded every domain +// into the folders tree, keeping its id, name and color. +export type TableFolder = Folder & { tables: string[] } + +// Table folders nest like dashboard/workflow folders (server-enforced). +export const MAX_TABLE_FOLDER_DEPTH = 3 + +// The palette offered when creating/editing a folder. Kept small and legible in +// both themes; the first entry is the default for a new folder. +export const FOLDER_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/pages/console/WorkspacePage.tsx b/src/pages/console/WorkspacePage.tsx index c21376c..f091594 100644 --- a/src/pages/console/WorkspacePage.tsx +++ b/src/pages/console/WorkspacePage.tsx @@ -103,6 +103,7 @@ import { DiagramIcon, EditIcon, EyeIcon, + FolderIcon, GridIcon, HistoryIcon, MenuIcon, @@ -116,7 +117,16 @@ import { WandIcon, WorkflowIcon, } from '@/shared/ui/icons' -import { DomainPickerPanel, DomainQuickMenu, DomainDot, fetchDomains, updateDomain, deleteDomain } from '@/features/domains' +import { + TableFolderPickerPanel, + TableFolderQuickMenu, + TableFolderEditPanel, + TableFolderList, + FolderDot, + fetchTableFolders, + updateTableFolder, + deleteTableFolder, +} from '@/features/table-folders' import { TemplatesPanel, TemplateDetailView, TEMPLATES } from '@/features/templates' const kbd = @@ -189,9 +199,10 @@ export default function Workspace() { 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 [tableView, setTableView] = useState('flat') // 'flat' | 'folders' — Tables list grouping + const [tableFolders, setTableFolders] = useState([]) // per-connection tableFolders (with grouped table names) + const [folderPickerTable, setFolderPickerTable] = useState(null) // table whose folder picker is open | null + const [editingFolder, setEditingFolder] = useState(null) // folder being renamed/recolored | null const searchRef = useRef(null) const autoOpenedFor = useRef(null) // connection id we've already auto-opened a tab for @@ -322,7 +333,7 @@ export default function Workspace() { 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 } @@ -1142,61 +1153,73 @@ export default function Workspace() { 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()}> @@ -1254,12 +1277,12 @@ export default function Workspace() { { setCreatingTable({ table: obj.name }); close() }}> Edit Table - { setDomainTable(obj.name); close() }} + folders={tableFolders} + onChange={setTableFolders} + onConfigure={() => { setFolderPickerTable(obj.name); close() }} onAssigned={close} />
@@ -1386,13 +1409,13 @@ export default function Workspace() { - + setTableView((v) => (v === 'domains' ? 'flat' : 'domains'))} - aria-label="Group tables by domain" + active={tableView === 'folders'} + onClick={() => setTableView((v) => (v === 'folders' ? 'flat' : 'folders'))} + aria-label="Group tables by folder" > - + @@ -1456,30 +1479,19 @@ 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' && tableView === 'folders' ? ( + ) : ( - group.items.map(renderObject) + group.items.map((o) => renderObject(o)) )}
)} @@ -1780,10 +1792,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} @@ -1941,13 +1953,22 @@ export default function Workspace() { /> )} - {domainTable && ( - updateFolderById(editingFolder.id, fields)} + onDelete={() => removeTableFolder(editingFolder.id)} + onClose={() => setEditingFolder(null)} + /> + )} + + {folderPickerTable && ( + 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 a3030ea..de75129 100644 --- a/src/shared/api/folders.ts +++ b/src/shared/api/folders.ts @@ -1,14 +1,23 @@ // Generic, per-connection folders — one polymorphic tree per resource `type` // ('query' groups saved queries, 'dashboard' groups dashboards, 'workflow' -// groups workflows). 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 and workflow/lib/api.ts). +// 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' | 'workflow' -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 [] @@ -19,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 }, + }) +} From 7348d0fabdd16520eb0b99720b82a7477b463263 Mon Sep 17 00:00:00 2001 From: Kurniaji Gunawan Date: Mon, 27 Jul 2026 21:32:07 +0700 Subject: [PATCH 5/6] feat: update tab and select cell multi --- CLAUDE.md | 12 +- README.md | 2 +- .../workspace/components/DataGrid.tsx | 155 +++++++++++++++++- src/features/workspace/components/TabBar.tsx | 137 ++++++++++++++++ .../workspace/components/TableView.tsx | 90 +++++++--- src/features/workspace/index.ts | 1 + src/pages/console/WorkspacePage.tsx | 90 +++++----- 7 files changed, 398 insertions(+), 89 deletions(-) create mode 100644 src/features/workspace/components/TabBar.tsx diff --git a/CLAUDE.md b/CLAUDE.md index b282ab4..423d8e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,8 +70,16 @@ src/ │ │ # 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 folder are clustered into a draggable, editable region) diff --git a/README.md b/README.md index 162babc..aa29008 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ 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) — drag a table into a folder in the sidebar, or assign it from the table menu. 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. | 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/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 f091594..08c0445 100644 --- a/src/pages/console/WorkspacePage.tsx +++ b/src/pages/console/WorkspacePage.tsx @@ -49,6 +49,7 @@ 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' @@ -97,7 +98,6 @@ import ListRow from '@/shared/ui/ListRow' import TextButton from '@/shared/ui/buttons/TextButton' import { ChevronRight, - CloseIcon, CodeIcon, ColumnsIcon, DiagramIcon, @@ -1130,11 +1130,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() @@ -1717,56 +1738,14 @@ export default function Workspace() {
-
- {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.kind === 'template' ? ( - - ) : ( - - )} - {t.title} - closeTab(e, t.key)} - aria-label="Close tab" - > - - -
- ) - })} - {tabs.length === 0 &&
No open tabs
} -
+
{conn && current?.kind === 'table' && ( @@ -1923,6 +1902,15 @@ export default function Workspace() { > Close + { + closeOtherTabs(tabMenu.key) + setTabMenu(null) + }} + > + Close other tabs + t.key === tabMenu.key) === tabs.length - 1} onClick={() => { From 40ef8974b5011cc2e81b3b4d98857257c1cdb656 Mon Sep 17 00:00:00 2001 From: Kurniaji Gunawan Date: Mon, 27 Jul 2026 21:50:56 +0700 Subject: [PATCH 6/6] feat: folder color and view tables --- CLAUDE.md | 23 +-- README.md | 2 +- .../components/TableFolderList.tsx | 123 +++++++++---- .../components/TableFolderQuickMenu.tsx | 164 ------------------ src/features/table-folders/index.ts | 3 +- src/pages/console/WorkspacePage.tsx | 51 ++---- src/shared/ui/icons.tsx | 11 ++ src/shared/ui/overlay/ContextMenu.tsx | 11 +- 8 files changed, 145 insertions(+), 243 deletions(-) delete mode 100644 src/features/table-folders/components/TableFolderQuickMenu.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 423d8e9..5574629 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,16 +58,19 @@ src/ │ │ # 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 -│ │ # sidebar's folder view: collapsible color-tinted folders + subfolders, -│ │ # drag a table into a folder / a folder into a folder, trailing -│ │ # "Ungrouped" drop zone, inline new folder + rename), -│ │ # TableFolderQuickMenu (the table context-menu row: hover flyout for -│ │ # one-click assign), TableFolderPickerPanel (right-side slide-over -│ │ # with inline create/edit/delete), TableFolderEditPanel (name+color), -│ │ # 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. +│ │ # 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 (drag / Shift+click selects a rectangular cell range — diff --git a/README.md b/README.md index aa29008..0d95613 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Self‑hosted, single Docker image, your data stays yours. |---|---|---| | 🗂️ | **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. 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) — drag a table into a folder in the sidebar, or assign it from the table menu. The schema designer clusters each folder's tables into its own region on the ERD. | +| 📁 | **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, **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}}`. | diff --git a/src/features/table-folders/components/TableFolderList.tsx b/src/features/table-folders/components/TableFolderList.tsx index 3b59816..b281b4b 100644 --- a/src/features/table-folders/components/TableFolderList.tsx +++ b/src/features/table-folders/components/TableFolderList.tsx @@ -6,12 +6,13 @@ import { FolderOpenIcon, FolderPlusIcon, MoreVerticalIcon, - SettingsIcon, + PaletteIcon, TrashIcon, } from '@/shared/ui/icons' import IconButton from '@/shared/ui/buttons/IconButton' import MenuItem from '@/shared/ui/navigation/MenuItem' import Popover from '@/shared/ui/overlay/Popover' +import { ContextMenuSub } from '@/shared/ui/overlay/ContextMenu' import { Input } from '@/shared/ui/form/Input' import { useToast } from '@/shared/ui/feedback/Toast' import { createTableFolder, updateTableFolder, setTableFolder } from '../lib/api' @@ -27,16 +28,22 @@ type TableObject = { name: string; type: string } const byName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name) /** - * The Tables sidebar in "folders" mode: the connection's tables grouped by the - * generic folders tree (type='table'), nesting up to MAX_TABLE_FOLDER_DEPTH — + * The console's Tables sidebar: the connection's tables grouped by the generic + * folders tree (type='table'), nesting up to MAX_TABLE_FOLDER_DEPTH — * the same folder UX as saved queries, dashboards and workflows, plus a color - * per folder (what a domain's color became). Tables with no folder sit in a - * trailing "Ungrouped" area, which doubles as the drop target that ungroups a + * per folder (what a domain's color became). Tables with no folder are simply + * listed under the folders (no "Ungrouped" heading — same shape as the + * dashboards panel); that root area doubles as the drop target that ungroups a * table and the root drop target for folders. * - * Self-contained like the picker panel: assignment, folder creation, renames - * and folder moves happen here and are handed back via `onChange`; recoloring - * and deleting are delegated so they reuse the caller's slide-over wiring. + * Self-contained: assignment, folder creation, renames, recoloring and folder + * moves all happen here and are handed back via `onChange` — no slide-over. + * A folder is created by typing its name inline (like a dashboard folder); its + * color is picked from the swatch popover behind the folder icon. Only deleting + * is delegated, so the caller can mirror the server's reparenting locally. + * + * The "new folder" row is controlled (`creating` / `onCreatingChange`) so the + * Tables panel header's + button can start one from outside this component. * * `renderTable` is the caller's own table row renderer; the second argument is * spread onto the row so it becomes draggable without duplicating that markup. @@ -46,8 +53,9 @@ export default function TableFolderList({ folders, tables, searching = false, + creating, + onCreatingChange, onChange, - onEdit, onDelete, renderTable, }: { @@ -55,8 +63,9 @@ export default function TableFolderList({ folders: TableFolder[] tables: TableObject[] searching?: boolean + creating: { parentId: string | null } | null + onCreatingChange: (next: { parentId: string | null } | null) => void onChange: (next: TableFolder[]) => void - onEdit: (folder: TableFolder) => void onDelete: (folderId: string) => void renderTable: (obj: TableObject, rowProps: Record) => ReactNode }) { @@ -65,7 +74,6 @@ export default function TableFolderList({ // track the collapsed ones instead of the open ones. const [collapsed, setCollapsed] = useState(() => new Set()) const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null) - const [creating, setCreating] = useState<{ parentId: string | null } | null>(null) const [newName, setNewName] = useState('') const [drag, setDrag] = useState<{ type: 'table' | 'folder'; id: string } | null>(null) const [dropTarget, setDropTarget] = useState(undefined) // folder id | null (root) | undefined @@ -153,6 +161,19 @@ export default function TableFolderList({ } } + // Recolor from the swatch popover behind the folder icon (what the old + // "Name & color…" slide-over used to do). + const recolor = async (folderId: string, color: string | null) => { + const prev = folders + onChange(folders.map((f) => (f.id === folderId ? { ...f, color } : f))) + try { + await updateTableFolder(connectionId, folderId, { color }) + } catch (e) { + onChange(prev) + toast.error(`Couldn't recolor folder: ${e.message}`) + } + } + const commitRename = async () => { const target = renaming ? folders.find((f) => f.id === renaming.id) : null const name = renaming?.value.trim() @@ -172,7 +193,7 @@ export default function TableFolderList({ const name = newName.trim() const parentId = creating?.parentId ?? null setNewName('') - setCreating(null) + onCreatingChange(null) if (!name) return try { const created = await createTableFolder(connectionId, { @@ -188,7 +209,7 @@ export default function TableFolderList({ const startSubfolder = (parentId: string) => { expand(parentId) setNewName('') - setCreating({ parentId }) + onCreatingChange({ parentId }) } // ---- Drag and drop: a table or a folder into a folder / out to the root ---- @@ -232,6 +253,33 @@ export default function TableFolderList({ className: drag?.type === 'table' && drag.id === obj.name ? 'opacity-50' : '', }) + // The swatch grid, shared by the folder icon's popover and its ⋮ menu. + const renderColorPicker = (folder: TableFolder, close: () => void, className = 'p-2') => ( +
+
+ {FOLDER_COLORS.map((c) => ( +
+ +
+ ) + const renderNewFolderInput = () => (
@@ -246,7 +294,7 @@ export default function TableFolderList({ if (e.key === 'Enter') commitNewFolder() else if (e.key === 'Escape') { setNewName('') - setCreating(null) + onCreatingChange(null) } }} /> @@ -299,7 +347,28 @@ export default function TableFolderList({ onClick={() => 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()}> @@ -329,9 +398,10 @@ export default function TableFolderList({ { setRenaming({ id: folder.id, value: folder.name }); close() }}> Rename - { onEdit(folder); close() }}> - Name & color… - + {/* Same picker the folder icon opens, as a side flyout. */} + + {renderColorPicker(folder, close, 'p-0.5')} +
{ onDelete(folder.id); close() }}> Delete folder @@ -361,7 +431,8 @@ export default function TableFolderList({ {childFolders(null).map(renderFolder)} {creating && creating.parentId == null && renderNewFolderInput()} - {/* Ungrouped tables — also the drop zone that pulls a table or a folder + {/* 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. */}
- {folders.length > 0 && ( -
- Ungrouped - {ungrouped.length > 0 && {ungrouped.length}} -
- )} {ungrouped.map((o) => renderTable(o, tableRowProps(o)))} {ungrouped.length === 0 && folders.length > 0 && (
Drag tables out of folders here
)}
- -
) } diff --git a/src/features/table-folders/components/TableFolderQuickMenu.tsx b/src/features/table-folders/components/TableFolderQuickMenu.tsx deleted file mode 100644 index 4fcd9e4..0000000 --- a/src/features/table-folders/components/TableFolderQuickMenu.tsx +++ /dev/null @@ -1,164 +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, FolderIcon, SettingsIcon } from '@/shared/ui/icons' -import { setTableFolder } from '../lib/api' -import { withTableAssignment } from '../lib/assign' -import type { TableFolder } from '../types' -import { folderParentPath, foldersInTreeOrder } from '../lib/tree' -import FolderDot from './FolderDot' - -const WIDTH = 200 -const GAP = 4 -const MARGIN = 8 - -/** - * The "Set folder" row inside a table's context menu. Hovering it opens a flyout - * of the connection's folders 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 folders scroll in place. When none - * exist yet, it points the user at the "Configure folders…" 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 TableFolderQuickMenu({ - connectionId, - table, - folders, - onChange, - onConfigure, - onAssigned, -}: { - connectionId: string - table: string - folders: TableFolder[] - onChange: (next: TableFolder[]) => 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, folders.length]) - - const cancelClose = () => clearTimeout(closeTimer.current) - const scheduleClose = () => { - closeTimer.current = setTimeout(() => setOpen(false), 150) - } - - const currentId = folders.find((d) => d.tables.includes(table))?.id ?? null - const current = folders.find((d) => d.id === currentId) ?? null - - const assign = async (folderId: string | null) => { - onAssigned() - if (folderId === currentId) return - const prev = folders - onChange(withTableAssignment(folders, table, folderId)) - try { - await setTableFolder(connectionId, table, folderId) - } catch (e: any) { - toast.error(`Couldn't set folder: ${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 folder - - - {current && } - - - - - {open && ( -
- {folders.length === 0 ? ( -
- No folders yet. - -
- ) : ( - <> - - - {foldersInTreeOrder(folders).map((d) => { - const path = folderParentPath(folders, d) - return ( - - ) - })} - -
- - Manage folders… - - - )} -
- )} -
- ) -} diff --git a/src/features/table-folders/index.ts b/src/features/table-folders/index.ts index c4eb980..cf465ae 100644 --- a/src/features/table-folders/index.ts +++ b/src/features/table-folders/index.ts @@ -5,8 +5,9 @@ // (Replaced the old "domains" feature — meta migration v5 folded every domain // into the folders tree, keeping its id, name and color.) export { default as TableFolderList } from './components/TableFolderList' +// Slide-overs kept for the schema diagram only — the Tables sidebar assigns +// folders by drag and drop and edits them inline. export { default as TableFolderPickerPanel } from './components/TableFolderPickerPanel' -export { default as TableFolderQuickMenu } from './components/TableFolderQuickMenu' export { default as TableFolderEditPanel } from './components/TableFolderEditPanel' export { default as FolderDot } from './components/FolderDot' export { diff --git a/src/pages/console/WorkspacePage.tsx b/src/pages/console/WorkspacePage.tsx index 08c0445..d6d4ecd 100644 --- a/src/pages/console/WorkspacePage.tsx +++ b/src/pages/console/WorkspacePage.tsx @@ -103,7 +103,7 @@ import { DiagramIcon, EditIcon, EyeIcon, - FolderIcon, + FolderPlusIcon, GridIcon, HistoryIcon, MenuIcon, @@ -119,8 +119,6 @@ import { } from '@/shared/ui/icons' import { TableFolderPickerPanel, - TableFolderQuickMenu, - TableFolderEditPanel, TableFolderList, FolderDot, fetchTableFolders, @@ -199,10 +197,9 @@ export default function Workspace() { 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' | 'folders' — Tables list grouping const [tableFolders, setTableFolders] = useState([]) // per-connection tableFolders (with grouped table names) - const [folderPickerTable, setFolderPickerTable] = useState(null) // table whose folder picker is open | null - const [editingFolder, setEditingFolder] = useState(null) // folder being renamed/recolored | null + 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 @@ -1170,7 +1167,9 @@ 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) @@ -1298,14 +1297,6 @@ export default function Workspace() { { setCreatingTable({ table: obj.name }); close() }}> Edit Table - { setFolderPickerTable(obj.name); close() }} - onAssigned={close} - />
{ setTableAction({ table: obj.name, mode: 'empty' }); close() }}> Empty Table @@ -1430,13 +1421,15 @@ export default function Workspace() { - + setTableView((v) => (v === 'folders' ? 'flat' : 'folders'))} - aria-label="Group tables by folder" + onClick={() => { + setOpenGroup('table') + setCreatingTableFolder({ parentId: null }) + }} + aria-label="New table folder" > - + @@ -1500,14 +1493,15 @@ export default function Workspace() { {openGroup === group.type && (
- {group.type === 'table' && tableView === 'folders' ? ( + {group.type === 'table' ? ( @@ -1518,7 +1512,7 @@ export default function Workspace() { )}
))} - {!loading && visibleObjects.length === 0 && ( + {!loading && objectGroups.length === 0 && (
No objects
)}
@@ -1941,15 +1935,8 @@ export default function Workspace() { /> )} - {editingFolder && ( - updateFolderById(editingFolder.id, fields)} - onDelete={() => removeTableFolder(editingFolder.id)} - onClose={() => setEditingFolder(null)} - /> - )} - + {/* Only the schema diagram's "Move to folder…" opens this — the Tables + sidebar assigns folders by drag and drop. */} {folderPickerTable && ( ( ) +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)