From 829d5eef6f5977480ef0e0f1539e5325721c0a8d Mon Sep 17 00:00:00 2001 From: Kurniaji Gunawan Date: Sun, 19 Jul 2026 09:58:20 +0700 Subject: [PATCH 1/2] feat: button table widget --- BACKEND_DOCUMENTATION.MD | 21 +- CLAUDE.md | 5 + README.md | 3 +- server.js | 32 +- .../dashboard/components/ShapeGuide.tsx | 114 ++-- .../dashboard/components/WidgetCard.tsx | 79 ++- .../dashboard/components/WidgetChart.tsx | 64 ++- .../dashboard/components/WidgetEditor.tsx | 532 +++++++++++++++--- src/features/dashboard/lib/rowActionIcons.tsx | 43 ++ src/features/dashboard/lib/widgetGuide.ts | 2 +- src/features/dashboard/types.ts | 102 ++++ src/features/workflow/lib/api.ts | 13 +- src/features/workflow/lib/nodeSpec.tsx | 2 +- 13 files changed, 861 insertions(+), 151 deletions(-) create mode 100644 src/features/dashboard/lib/rowActionIcons.tsx diff --git a/BACKEND_DOCUMENTATION.MD b/BACKEND_DOCUMENTATION.MD index 73f9ea9..f65db43 100644 --- a/BACKEND_DOCUMENTATION.MD +++ b/BACKEND_DOCUMENTATION.MD @@ -682,7 +682,11 @@ Node-graph automations (React Flow) run against the connection. A workflow's `graph` is `{ nodes, edges }` in React Flow shape; each node's `type` is one of `manual` (trigger), `schedule` (trigger), `query`, `http`, `js`, `switch`, `loop`, `export`, `storage`, and its config lives in `node.data`: -- `query`: `{ sql }` · `http`: `{ method, url, headers, body }` · `js`: `{ code }` +- `query`: `{ sql }` — the SQL may inline scalar values from the node's input as + `{{input.path}}` (dotted paths supported, e.g. `{{input.row.id}}`); values are + rendered as SQL literals (strings quoted with `''` doubling, numbers bare, + booleans `TRUE`/`FALSE`, missing → `NULL`; a non-scalar value fails the node). + · `http`: `{ method, url, headers, body }` · `js`: `{ code }` - `switch`: `{ cases: [{ expr, label }] }` · `loop`: `{ itemsExpr }` - `schedule`: `{ frequency: "manual"|"hourly"|"daily", hourOfDay?: 0-23 }` — UTC. **Breaking change:** this replaces the old freeform `{ cron }` field, which is now inert. @@ -698,6 +702,9 @@ Node-graph automations (React Flow) run against the connection. A workflow's Edges thread a single `input` (a node's output) to the next node; `sourceHandle` selects a branch (`out` default; `case-N`/`default` for switch; `body`/`done` for loop). +Trigger nodes (`manual`/`schedule`) pass the run's initial input straight through — +`null` normally, or the `input` posted to `/run` (e.g. a dashboard table row from a +row-action button). A workflow can be scheduled: set `workflows.schedule_enabled = 1` (via `PUT .../workflows/:wid { "scheduleEnabled": true }`) with a graph containing exactly @@ -766,9 +773,15 @@ Delete a workflow. ### POST `/api/connections/:id/workflows/:wid/run` Execute a workflow. Runs the posted `graph` (so unsaved edits can be tested), or the stored graph when `graph` is omitted. Always records the outcome in `workflow_runs` -(`trigger_kind: "manual"`). - -- **Body:** `{ "graph"?: { "nodes": [...], "edges": [...] } }` +(`trigger_kind` from `trigger`). + +- **Body:** `{ "graph"?: { "nodes": [...], "edges": [...] }, "input"?: , "trigger"?: "manual" | "dashboard" }` + - `input` (optional) seeds the trigger node — it flows to downstream nodes as their + initial input (JS nodes read it as `input`, query nodes can inline scalars via + `{{input.field}}`). Dashboard table-widget row actions post the clicked row here + as a column→value object. + - `trigger` (optional) tags the run in `workflow_runs.trigger_kind` for auditing: + `"dashboard"` for dashboard row-action runs; anything else records `"manual"`. - **200:** `{ "ok": true, "log": [{ "nodeId", "nodeType", "status", "ms", "output", "logs"?, "error"? }], "output": }` - On a failed node: `{ "ok": false, "log": [...], "error": "" }` (the failing node's `log` entry has `status: "error"`) - **404:** `{ "error": "Connection not found" }` or `{ "error": "Not found" }` diff --git a/CLAUDE.md b/CLAUDE.md index 91c42cf..4f7b235 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +79,11 @@ src/ │ │ │ # ({{name}} in widget SQL, query-backed or static lists), free-placement │ │ │ # 12-col drag/resize grid (hard collision blocking), fullscreen, JSON │ │ │ # export/import. Charts are hand-rolled SVG (no chart/grid deps). +│ │ │ # Table widgets: optional server-side pagination (LIMIT/OFFSET wrap, +│ │ │ # N+1 has-more probe) + per-row action buttons (up to 3, styled via +│ │ │ # variant/icon, optional per-row condition that disables/hides by a +│ │ │ # column value) that run a workflow with the clicked row as its trigger +│ │ │ # input (Widget.pageSize/rowActions; icons in lib/rowActionIcons). │ │ ├── components/ # DashboardView (tab content: toolbar + VariableBar + WidgetGrid), │ │ │ # DashboardsPanel (rail list), WidgetCard (runs its query), WidgetChart, │ │ │ # WidgetEditor + DashboardSettings (modals), MarkdownText, diff --git a/README.md b/README.md index 7bbe899..7fb4508 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ Self‑hosted, single Docker image, your data stays yours. | 📊 | **Data browser** | Fast, spreadsheet‑style data grid — filter, sort, 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` triggers → `Run query`, `HTTP Request`, `Run JavaScript`, `Switch`, `Loop`, `Export SQL`, `Store to Storage`. Real hourly/daily scheduling + run logs. | +| ⚙️ | **Workflows** | Drag‑and‑drop automation builder: `Manual`/`Schedule` triggers → `Run query`, `HTTP Request`, `Run JavaScript`, `Switch`, `Loop`, `Export SQL`, `Store to Storage`. Real hourly/daily scheduling + run logs. Runs can carry an input payload; query nodes inline it as `{{input.field}}`. | +| 📈 | **Dashboards** | Per‑connection query dashboards with dynamic `{{variables}}`, drag/resize grid, and JSON export/import. 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). | | 🔑 | **Auth & security** | First‑run setup wizard, token‑based auth, per‑workspace roles, and membership‑guarded connection routes. | diff --git a/server.js b/server.js index a945e62..f981897 100644 --- a/server.js +++ b/server.js @@ -1334,6 +1334,23 @@ function runUserJs(code, input) { return { out: sandbox.__out, logs } } +// Substitute {{input.path}} placeholders in a query node's SQL with values +// from the node's input (e.g. a dashboard table row), rendered as SQL literals. +// Dialect-agnostic on purpose: single-quoted strings with '' doubling, bare +// numeric literals, and NULL are valid in every roadmap dialect. Non-scalar +// values are an error, not silently stringified. +function substituteWorkflowInput(sql, input) { + return sql.replace(/\{\{\s*input((?:\.[A-Za-z_][A-Za-z0-9_]*)+)\s*\}\}/g, (_, path) => { + let v = input + for (const key of path.slice(1).split('.')) v = v?.[key] + if (v === null || v === undefined) return 'NULL' + if (typeof v === 'number' && Number.isFinite(v)) return String(v) + if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE' + if (typeof v === 'string') return `'${v.replace(/'/g, "''")}'` + throw new Error(`{{input${path}}} is not a scalar value (got ${Array.isArray(v) ? 'array' : typeof v})`) + }) +} + // Run one query node against the connection (dialect-dispatched). async function execWorkflowQuery(conn, sql) { if (conn.type === 'sqlite') return runSqliteQuery(getSqliteDb(conn.filepath), sql) @@ -1582,7 +1599,7 @@ async function pruneOldBackups(dest, folder, retentionDays) { // Execute a workflow graph. Threads each node's output to its successor(s). // Returns { ok, log, output, error? }. -async function runWorkflow(conn, graph) { +async function runWorkflow(conn, graph, initialInput = null) { const nodes = new Map((graph?.nodes || []).map((n) => [n.id, n])) const edges = graph?.edges || [] const outgoing = (id, handle = 'out') => edges.filter((e) => e.source === id && (e.sourceHandle || 'out') === handle) @@ -1614,7 +1631,7 @@ async function runWorkflow(conn, graph) { output = input // trigger node — pass the initial input straight through } else if (node.type === 'query') { if (!d.sql?.trim()) throw new Error('No query configured') - const r = await execWorkflowQuery(conn, d.sql) + const r = await execWorkflowQuery(conn, substituteWorkflowInput(d.sql, input)) if (r.error) throw new Error(r.error) output = r.type === 'rows' ? { columns: r.columns, rows: r.rows } : { message: r.message } } else if (node.type === 'http') { @@ -1740,7 +1757,7 @@ async function runWorkflow(conn, graph) { const roots = (graph?.nodes || []).filter((n) => !hasIncoming.has(n.id)) if (!roots.length) return { ok: false, log, error: 'No start node (every node has an incoming connection).' } let output - for (const r of roots) output = await walk(r, null) + for (const r of roots) output = await walk(r, initialInput) return { ok: true, log, output: jsonPreview(output, 8000) } } catch (err) { return { ok: false, log, error: err.message } @@ -1750,9 +1767,9 @@ async function runWorkflow(conn, graph) { // Run a workflow and persist the outcome to workflow_runs — shared by the // manual "Run" route and the scheduler tick, so both count toward a workflow's // run history (and, for the backup workflow, its monitoring calendar). -async function executeAndRecord(workflowId, connectionId, conn, graph, triggerKind) { +async function executeAndRecord(workflowId, connectionId, conn, graph, triggerKind, input = null) { const startedAt = Date.now() - const result = await runWorkflow(conn, graph) + const result = await runWorkflow(conn, graph, input) meta .prepare( `INSERT INTO workflow_runs (id, workflow_id, connection_id, trigger_kind, status, log, error, started_at, finished_at, ts) @@ -2992,7 +3009,10 @@ app.post('/api/connections/:id/workflows/:wid/run', async (req, res) => { if (!row) return res.status(404).json({ error: 'Not found' }) graph = safeJson(row.graph) } - const result = await executeAndRecord(req.params.wid, req.params.id, conn, graph, 'manual') + // `trigger` distinguishes dashboard row-action runs in the workflow_runs + // audit trail; anything unrecognized falls back to 'manual'. + const trigger = req.body?.trigger === 'dashboard' ? 'dashboard' : 'manual' + const result = await executeAndRecord(req.params.wid, req.params.id, conn, graph, trigger, req.body?.input ?? null) res.json(result) }) diff --git a/src/features/dashboard/components/ShapeGuide.tsx b/src/features/dashboard/components/ShapeGuide.tsx index dd7948d..5fcd596 100644 --- a/src/features/dashboard/components/ShapeGuide.tsx +++ b/src/features/dashboard/components/ShapeGuide.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' import Button from '@/shared/ui/buttons/Button' import TextButton from '@/shared/ui/buttons/TextButton' -import { InfoIcon } from '@/shared/ui/icons' +import { ChevronDown, InfoIcon } from '@/shared/ui/icons' import type { WidgetType } from '../types' import { WIDGET_GUIDE, recommendQuery } from '../lib/widgetGuide' @@ -9,6 +9,7 @@ import { WIDGET_GUIDE, recommendQuery } from '../lib/widgetGuide' // type, a query recommended from the connection's own tables/columns (when a // schema is available), and a generic fallback example. Rendered inline (not // in a popover) because the editor body scrolls, which would clip an overlay. +// Collapsed by default so it stays out of the way once you know the shape. export default function ShapeGuide({ type, schema, @@ -18,62 +19,79 @@ export default function ShapeGuide({ schema?: Record onUseExample?: (sql: string) => void }) { - const [open, setOpen] = useState(false) + const [expanded, setExpanded] = useState(false) + const [showExample, setShowExample] = useState(false) const guide = WIDGET_GUIDE[type] const recommended = useMemo(() => recommendQuery(type, schema), [type, schema]) return ( -
-
+
+
+ + -
    - {guide.columns.map((c) => ( -
  • - {c.label} - {c.role} -
  • - ))} -
+ {expanded && ( +
+
    + {guide.columns.map((c) => ( +
  • + {c.label} + {c.role} +
  • + ))} +
- {recommended && ( -
- Recommended for your schema -
-            {recommended}
-          
-
- {onUseExample && ( - - )} - A starting point — adjust the columns to what you need. -
-
- )} + {recommended && ( +
+ Recommended for your schema +
+                {recommended}
+              
+
+ {onUseExample && ( + + )} + A starting point — adjust the columns to what you need. +
+
+ )} - {open && !recommended && guide.example && ( - <> -
-            {guide.example}
-          
-
- {onUseExample && ( - - )} - Template — swap in your own tables and columns. -
- + {!recommended && guide.example && ( +
+ setShowExample((o) => !o)}> + {showExample ? 'Hide example' : 'Show example'} + + {showExample && ( + <> +
+                    {guide.example}
+                  
+
+ {onUseExample && ( + + )} + Template — swap in your own tables and columns. +
+ + )} +
+ )} +
)}
) diff --git a/src/features/dashboard/components/WidgetCard.tsx b/src/features/dashboard/components/WidgetCard.tsx index ae3abb3..5f873e0 100644 --- a/src/features/dashboard/components/WidgetCard.tsx +++ b/src/features/dashboard/components/WidgetCard.tsx @@ -1,12 +1,14 @@ import { useEffect, useMemo, useState } from 'react' import { runQuery } from '@/shared/api/database' +import { runWorkflow } from '@/features/workflow' import LoadingState from '@/shared/ui/feedback/LoadingState' import EmptyState from '@/shared/ui/feedback/EmptyState' +import { useToast } from '@/shared/ui/feedback/Toast' import IconButton from '@/shared/ui/buttons/IconButton' import MenuItem from '@/shared/ui/navigation/MenuItem' import Popover from '@/shared/ui/overlay/Popover' -import { CopyIcon, EditIcon, MoreVerticalIcon, TrashIcon } from '@/shared/ui/icons' -import type { Widget } from '../types' +import { ChevronLeft, ChevronRight, CopyIcon, EditIcon, MoreVerticalIcon, TrashIcon } from '@/shared/ui/icons' +import { widgetRowActions, type Widget } from '../types' import { referencedVariables, substituteVariables } from '../lib/variables' import type { QueryResult } from '../lib/queryData' import WidgetChart from './WidgetChart' @@ -42,13 +44,28 @@ export default function WidgetCard({ [widget.query, values] ) - const sql = useMemo( + const baseSql = useMemo( () => (widget.type === 'text' || !widget.query || missingVars.length > 0 ? '' : substituteVariables(widget.query, values)), [widget.type, widget.query, values, missingVars.length] ) + // Server-side pagination for table widgets: wrap the query in LIMIT/OFFSET + // (valid on every roadmap dialect) and fetch pageSize+1 rows — the extra row + // only signals that a next page exists, without a costly COUNT(*). + const pageSize = widget.type === 'table' && widget.pageSize ? widget.pageSize : 0 + const [page, setPage] = useState(0) + useEffect(() => setPage(0), [baseSql, pageSize]) + + const sql = useMemo(() => { + if (!baseSql.trim() || !pageSize) return baseSql + return `SELECT * FROM (${baseSql.replace(/;+\s*$/, '')}) AS _page LIMIT ${pageSize + 1} OFFSET ${page * pageSize}` + }, [baseSql, pageSize, page]) + const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) + // Bumped after a successful row-action run so the widget re-queries (the + // workflow likely changed the rows it displays). + const [actionRefresh, setActionRefresh] = useState(0) useEffect(() => { if (!sql.trim()) { @@ -66,7 +83,38 @@ export default function WidgetCard({ alive = false } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sql, refreshKey, conn?.id, conn?.ns?.database, conn?.ns?.schema]) + }, [sql, refreshKey, actionRefresh, conn?.id, conn?.ns?.database, conn?.ns?.schema]) + + // Trim the has-more probe row before rendering. + const hasMore = !!pageSize && (result?.rows?.length ?? 0) > pageSize + const display = useMemo( + () => (result && hasMore ? { ...result, rows: result.rows!.slice(0, pageSize) } : result), + [result, hasMore, pageSize] + ) + + const toast = useToast() + const [running, setRunning] = useState<{ row: number; action: number } | null>(null) + + const runRowAction = async (row: Record, rowIndex: number, actionIndex: number) => { + const action = widgetRowActions(widget)[actionIndex] + if (!action || running !== null) return + const name = action.workflowName || 'Workflow' + setRunning({ row: rowIndex, action: actionIndex }) + try { + const res = await runWorkflow(conn.id, action.workflowId, undefined, row, 'dashboard') + if (res.ok) { + toast.success(`${name} finished.`) + setActionRefresh((n) => n + 1) + } else { + toast.error(`${name} failed: ${res.error}`) + } + } catch (e: any) { + // 404 here usually means the workflow was deleted (or the dashboard was imported from elsewhere). + toast.error(`Couldn't run ${name}: ${e.message}`) + } finally { + setRunning(null) + } + } return (
@@ -114,11 +162,24 @@ export default function WidgetCard({ Pick a value for {missingVars.map((v) => `{{${v}}}`).join(', ')} in Filters to run this widget. ) : loading && !result ? ( - ) : result?.error ? ( -
{result.error}
- ) : result ? ( -
- + ) : display?.error ? ( +
{display.error}
+ ) : display ? ( +
+
+ +
+ {pageSize > 0 && ( +
+ Page {page + 1} + setPage((p) => Math.max(0, p - 1))}> + + + setPage((p) => p + 1)}> + + +
+ )}
) : null}
diff --git a/src/features/dashboard/components/WidgetChart.tsx b/src/features/dashboard/components/WidgetChart.tsx index ff9f9c9..e4de04b 100644 --- a/src/features/dashboard/components/WidgetChart.tsx +++ b/src/features/dashboard/components/WidgetChart.tsx @@ -1,6 +1,8 @@ import { useMemo } from 'react' import EmptyState from '@/shared/ui/feedback/EmptyState' -import type { Widget } from '../types' +import Button from '@/shared/ui/buttons/Button' +import { rowActionState, widgetRowActions, type Widget, type WidgetRowAction } from '../types' +import { rowActionIcon } from '../lib/rowActionIcons' import { toMetric, toPieData, toSankeyData, toXYSeries, firstRows, cellAt, type QueryResult } from '../lib/queryData' import { useChartPalette, MAX_SERIES } from '../lib/palette' import XYChart from './charts/XYChart' @@ -12,10 +14,24 @@ import { fmtNum, useMountAnimation } from './charts/chrome' // live in lib/queryData.ts; colors come from the validated palette (assigned // to series in fixed slot order, capped at MAX_SERIES — never cycled). -function TableWidget({ result }: { result: QueryResult }) { +function TableWidget({ + result, + rowActions, + running, + onRowAction, +}: { + result: QueryResult + rowActions?: WidgetRowAction[] + running?: { row: number; action: number } | null + onRowAction?: (row: Record, rowIndex: number, actionIndex: number) => void +}) { const { columns, rows } = firstRows(result) const ready = useMountAnimation(result) if (!columns.length) return Query returned no rows. + // Normalize a row (array or object depending on dialect) into a stable + // column→value object — the shape the workflow receives as its input. + const rowObject = (row: any): Record => + Object.fromEntries(columns.map((c, j) => [c, cellAt(row, columns, j)])) return (
@@ -26,6 +42,7 @@ function TableWidget({ result }: { result: QueryResult }) { {c} ))} + {!!rowActions?.length && @@ -46,6 +63,34 @@ function TableWidget({ result }: { result: QueryResult }) { ) })} + {!!rowActions?.length && (() => { + // One row object drives both the condition checks and the workflow payload. + const ro = rowObject(row) + return ( + + ) + })()} ))} @@ -75,7 +120,18 @@ function MetricWidget({ result, unit }: { result: QueryResult; unit?: string }) ) } -export default function WidgetChart({ widget, result }: { widget: Widget; result: QueryResult }) { +export default function WidgetChart({ + widget, + result, + running, + onRowAction, +}: { + widget: Widget + result: QueryResult + /** table row-action state/handler — owned by WidgetCard; absent in previews (buttons render disabled). */ + running?: { row: number; action: number } | null + onRowAction?: (row: Record, rowIndex: number, actionIndex: number) => void +}) { const base = useChartPalette() // Per-widget color overrides replace individual palette slots; unset slots keep the theme default. const palette = useMemo( @@ -117,7 +173,7 @@ export default function WidgetChart({ widget, result }: { widget: Widget; result } return case 'table': - return + return case 'metric': return default: diff --git a/src/features/dashboard/components/WidgetEditor.tsx b/src/features/dashboard/components/WidgetEditor.tsx index da5f300..9a4781d 100644 --- a/src/features/dashboard/components/WidgetEditor.tsx +++ b/src/features/dashboard/components/WidgetEditor.tsx @@ -1,17 +1,30 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import Button from '@/shared/ui/buttons/Button' import TextButton from '@/shared/ui/buttons/TextButton' import Badge from '@/shared/ui/Badge' import { Input, Textarea, controlClass } from '@/shared/ui/form/Input' import { FormField } from '@/shared/ui/form/Form' import Select from '@/shared/ui/form/Select' +import Toggle from '@/shared/ui/form/Toggle' +import Tab from '@/shared/ui/navigation/Tab' +import { listWorkflows, type WorkflowSummary } from '@/features/workflow' import SqlEditor from '@/shared/ui/SqlEditor' import Popover from '@/shared/ui/overlay/Popover' -import { CloseIcon, PlayIcon } from '@/shared/ui/icons' +import { ChevronDown, CloseIcon, PlayIcon, TrashIcon } from '@/shared/ui/icons' import { runQuery } from '@/shared/api/database' -import type { DashboardVariable, Widget, WidgetType } from '../types' -import { WIDGET_TYPE_LABEL } from '../types' -import { substituteVariables } from '../lib/variables' +import IconButton from '@/shared/ui/buttons/IconButton' +import type { + DashboardVariable, + Widget, + WidgetRowAction, + WidgetRowActionCondition, + WidgetRowActionOperator, + WidgetRowActionVariant, + WidgetType, +} from '../types' +import { MAX_TABLE_ROW_ACTIONS, ROW_ACTION_OPERATORS, ROW_ACTION_VARIANTS, WIDGET_TYPE_LABEL, widgetRowActions } from '../types' +import { ROW_ACTION_ICONS, ROW_ACTION_ICON_KEYS, rowActionIcon } from '../lib/rowActionIcons' +import { referencedVariables, substituteVariables } from '../lib/variables' import { sampleResult } from '../lib/sampleData' import { WIDGET_GUIDE } from '../lib/widgetGuide' import { toXYSeries, toPieData, type QueryResult } from '../lib/queryData' @@ -80,6 +93,64 @@ const TYPE_OPTIONS = (Object.entries(WIDGET_TYPE_LABEL) as [WidgetType, string][ label, })) +const VARIANT_LABEL: Record = { + ghost: 'Neutral', + primary: 'Primary', + danger: 'Danger', +} +const VARIANT_OPTIONS = ROW_ACTION_VARIANTS.map((value) => ({ value, label: VARIANT_LABEL[value] })) + +// Popover grid to pick (or clear) a row-action button icon. Mirrors ColorSwatch. +function IconPicker({ value, onChange }: { value?: string; onChange: (key: string | undefined) => void }) { + const Current = rowActionIcon(value) + return ( + ( + + )} + > + {({ close }) => ( +
+
+ + {ROW_ACTION_ICON_KEYS.map((key) => { + const { label, Icon } = ROW_ACTION_ICONS[key] + return ( + + ) + })} +
+
+ )} +
+ ) +} + // Create/edit one widget. Works on a draft copy; `onSave` receives the final // widget (layout untouched — placement is the grid's job). export default function WidgetEditor({ @@ -101,13 +172,65 @@ export default function WidgetEditor({ onSave: (w: Widget) => void onClose: () => void }) { - const [draft, setDraft] = useState({ ...widget }) + // Normalize the legacy single `rowAction` shape into the `rowActions` list on open. + const [draft, setDraft] = useState(() => { + const { rowAction: _legacy, ...rest } = widget as any + const actions = widgetRowActions(widget) + return { ...rest, rowActions: actions.length ? actions : undefined } + }) const [preview, setPreview] = useState(null) const [previewing, setPreviewing] = useState(false) + const [previewOpen, setPreviewOpen] = useState(true) const patch = (fields: Partial) => setDraft((d) => ({ ...d, ...fields })) const isText = draft.type === 'text' - const canSave = draft.title.trim() && (isText ? true : !!draft.query?.trim()) + const isTable = draft.type === 'table' + const isMetric = draft.type === 'metric' + const canSave = + draft.title.trim() && + (isText ? true : !!draft.query?.trim()) && + // Every row action needs a workflow picked before the widget can be saved. + (!draft.rowActions || draft.rowActions.every((a) => a.workflowId)) + + // Workflows for the row-action picker — loaded once, only when editing a table widget. + const [workflows, setWorkflows] = useState(null) + useEffect(() => { + if (!isTable || workflows !== null) return + listWorkflows(conn.id).then((ws) => { + setWorkflows(ws) + // No workflows to pick from: drop half-configured (empty-id) actions so the + // toggle resets instead of silently blocking Save. Fully configured ones are + // kept — the list can be empty because of a fetch hiccup, not just deletion. + if (!ws.length) + setDraft((d) => { + const kept = d.rowActions?.filter((a) => a.workflowId) + return { ...d, rowActions: kept?.length ? kept : undefined } + }) + }) + }, [isTable, workflows, conn.id]) + + // Columns offered to the row-action condition picker. A run preview provides + // them for free; otherwise fetch them by running the query once (LIMIT 1) so + // the picker is a proper single-select without forcing a manual "Run preview". + const [fetchedColumns, setFetchedColumns] = useState([]) + const conditionColumns = preview?.columns?.length ? preview.columns : fetchedColumns + const hasRowActions = isTable && !!draft.rowActions?.length + useEffect(() => { + if (!hasRowActions || preview?.columns?.length) return + const q = draft.query?.trim() + if (!q) return + // Same rule as WidgetCard: don't run until every referenced {{variable}} is resolved. + if (referencedVariables(draft.query).some((n) => !variableValues[n])) return + let alive = true + const sql = `SELECT * FROM (${substituteVariables(q, variableValues).replace(/;+\s*$/, '')}) AS _cols LIMIT 1` + runQuery(conn, sql).then((r: QueryResult) => { + if (alive && r?.columns?.length) setFetchedColumns(r.columns) + }) + return () => { + alive = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasRowActions, draft.query, variableValues, preview?.columns, conn?.id]) // Until a query has run, preview the selected type with sample data so the // diagram is visible while picking a type / writing SQL. @@ -116,6 +239,23 @@ export default function WidgetEditor({ const palette = useChartPalette() const isColorable = COLORABLE.includes(draft.type) + + // Editor tabs — the preview lives outside this set (always visible). Which + // tabs exist depends on the widget type; 'query'/'content' is always first. + type EditorTab = 'content' | 'query' | 'table' | 'style' | 'format' + const tabs: { key: EditorTab; label: string }[] = isText + ? [{ key: 'content', label: 'Content' }] + : [ + { key: 'query', label: 'Query' }, + ...(isTable ? [{ key: 'table' as EditorTab, label: 'Table' }] : []), + ...(isMetric ? [{ key: 'format' as EditorTab, label: 'Format' }] : []), + ...(isColorable ? [{ key: 'style' as EditorTab, label: 'Colors' }] : []), + ] + const [activeTab, setActiveTab] = useState(isText ? 'content' : 'query') + // Type changes can remove the active tab (e.g. table→pie drops 'table') — snap back to the default. + useEffect(() => { + setActiveTab(draft.type === 'text' ? 'content' : 'query') + }, [draft.type]) // Labels for the color pickers, in the same order colors are applied — series // names for xy charts, slice names for pie (sample data while no real preview ran). const colorLabels = useMemo(() => { @@ -156,7 +296,8 @@ export default function WidgetEditor({ {widget.title ? 'Edit widget' : 'New widget'} -
+ {/* Identity fields stay visible above the tabs — they're not tab-specific. */} +
{ setPreview(null) - patch({ type }) + // Row actions are a table-only, deliberate choice — drop them when the type changes. + patch({ type, ...(type !== 'table' ? { rowActions: undefined } : null) }) }} options={TYPE_OPTIONS} /> - {draft.type === 'metric' && ( - - patch({ unit: e.target.value })} placeholder="ms, %…" /> - - )}
+
+ + {/* Tab bar — only shown when there's more than one tab to switch between. */} + {tabs.length > 1 && ( +
+ {tabs.map((t) => ( + setActiveTab(t.key)}> + {t.label} + + ))} +
+ )} - {isText ? ( +
+ {activeTab === 'content' && isText && (
}
+
+ {rowActions.map((action, a) => { + const { hidden, disabled } = rowActionState(action, ro) + if (hidden) return null + const isRunning = running?.row === i && running?.action === a + return ( + + ) + })} +
+