Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 68c2567f02 fix(sql-workbench): consume history inserts once; review nits
The history insert request is parent state and NotebookDraft remounts on
save, restore, duplicate and notebook switch, so the last history SQL was
re-appended on every remount (duplicate cells, injected into other
notebooks, spurious dirty state). The draft now applies each request nonce
once and calls onInsertConsumed, which clears it in the widget.

Also: key the result view and quick chart by executionUuid so paging and
chart state reset per run; show an error instead of "No notebooks yet."
when the notebook list fails; use aria-pressed toggle buttons instead of a
partial tab pattern for My notebooks / Shared library; disable the default
backend select and hide cell move/delete controls when the notebook is not
editable. Regression tests cover save remount, notebook switch, the list
error and the read-only controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:31:33 -05:00
james.maesandClaude Opus 5.5 26ab79f752 feat(sql-workbench): SQL Workbench notebook UI for the SQL_WORKBENCH widget
Renders the qbit-sql-workbench QBit's SQL_WORKBENCH widget (its
sqlWorkbench app page) as a super-user SQL notebook over the
permission-checked /qqq/v1/sql-workbench endpoints, following the ESB
widget pattern (app presence advertises the feature):

- Notebook library (mine / shared), recent-run history, notebook
  editor with SQL and markdown cells, :parameters with types, save
  (versioned), publish, duplicate, delete, version restore, pinned
  result snapshots.
- CodeMirror 6 SQL editor: dialect per backend (PostgreSQL, MySQL,
  SQLite, standard), schema autocomplete plus QQQ table/field names
  that insert SQL names, Ctrl/Cmd+Enter to run; Tab is unbound so
  focus is never trapped. sql-formatter Format (four dialects only).
- Typed result grid (NULL, JSON, binary, numbers) with server-side
  paging and sorting over the cached result, copy as TSV, CSV/XLSX/
  JSON export links; Recharts quick charts; EXPLAIN plan tree with
  cost bars and raw view.
- Read-only by default; write mode (write permission only) previews
  a rolled-back dry run and commits only after WriteConfirmDialog
  (type the backend name, acknowledge the row guard).
- Run/cancel with a client-chosen execution id.
- CodeMirror and the formatter load only on that page; perf budget
  gains sqlWorkbench (127.2 KB) and sqlFormatter (60.2 KB) lazyChunks
  entries, and /app/_/_/dev's cap moves to 355 KB: it measured 353.1
  KB with the lazy import (main 350.0 KB at its cap; 350.1 KB without
  the WidgetRenderer hook-in).
- Tests: API client and helpers at 100%, component tests (widget
  flows, grid, plan, markdown, chart, real CodeMirror), MSW handlers,
  and a mocked Playwright spec (read-only run, write confirmation,
  axe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:14:22 -05:00
james.maes 18d1fd6e37 Merge pull request #44 from QRun-IO/feature/GH-660-header-off-bulk-load
fix: omit null header names from header-off bulk loads (QRun-IO/qqq#660)
2026-10-09 18:19:25 -05:00
james.maes eee644a280 Merge pull request #43 from QRun-IO/feature/GH-723-record-view-process-order
fix: sort record view processes by label (QRun-IO/qqq#723)
2026-10-07 07:15:16 -05:00
james.maes 7682aa1ff0 fix: omit null header names from header-off bulk loads (QRun-IO/qqq#660) 2026-10-06 21:00:27 -05:00
james.maes 8e334cd944 fix: sort record view processes by label (QRun-IO/qqq#723)
Matches Material RecordView: the table's own processes sort by label, all-screens processes keep their configured order, and the query page is unchanged.
2026-10-06 18:08:48 -05:00
james.maes 255554a184 Merge pull request #35 from QRun-IO/feature/GH-1001-dashboard-readiness
test(navigation): await dashboard dependencies before departure
2026-10-06 17:58:55 -05:00
james.maes e1d60b9634 Merge pull request #32 from QRun-IO/feature/widget-date-defaults
fix(widgets): apply backend defaults to untouched date pickers
2026-10-06 17:58:44 -05:00
james.maes b8313b6819 chore(#1001): integrate current main for readiness verification 2026-10-06 07:25:21 -05:00
james.maes c1a4efd7d7 test(navigation): integrate current main readiness (#1001) 2026-10-02 07:58:11 -05:00
james.maes e0fb2598b0 test(navigation): separate unit and browser discovery (#1001) 2026-10-02 05:33:32 -05:00
james.maes 61286717e5 test(navigation): await dashboard dependencies before leaving (#1001) 2026-10-02 05:13:33 -05:00
38 changed files with 5665 additions and 10 deletions

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
[RC11 is published and immutable](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11). It includes the searchable-selector and script-completion corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24); its release details remain in [RC11 notes](1.0.0-RC.11.md). Current main also contains the later menu, copy-focus and grid fixes, which are not in that published artifact. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
## New features
- SQL Workbench: render the `SQL_WORKBENCH` widget of the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit as a super-user SQL notebook.
- Personal and shared notebooks with SQL and markdown cells, `:parameters`, versions and pinned results.
- A CodeMirror 6 editor with dialect highlighting, schema and QQQ-name autocomplete, and formatting.
- A typed, sortable, paged result grid with CSV/XLSX/JSON export, quick charts, and EXPLAIN plan trees.
- Read-only runs by default, and a two-step write confirmation for users with the write permission.
- CodeMirror and the formatter load only on that page (new `lazyChunks` budgets).
## Product fixes
- Clear remembered query filters and saved-view selections on logout or a detected user identity change. Same-user resumes keep their query state; global appearance, density and legacy column preferences remain intact. This correction awaits its own branch acceptance. [#1006](https://github.com/QRun-IO/qqq/issues/1006)
+8
View File
@@ -28,7 +28,14 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1",
"@codemirror/lang-sql": "6.9.0",
"@codemirror/language": "6.11.2",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.1",
"@hookform/resolvers": "^5.2.2",
"@lezer/highlight": "1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -54,6 +61,7 @@
"react-hook-form": "^7.71.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"sql-formatter": "15.6.6",
"tailwind-merge": "^3.5.0",
"zod": "^3.25.76"
},
+13 -2
View File
@@ -1,5 +1,5 @@
{
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. Raise a cap only with a measured reason in the commit.",
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. Raise a cap only with a measured reason in the commit. SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB), so that route's cap is 355 KB.",
"firstLoadJsGzipKB": 350,
"routes": {
"/": 255,
@@ -14,7 +14,8 @@
"/app/_/_/copy": 390,
"/app/_/_/edit": 350,
"/app/_/create": 350,
"/app/_/savedView/_": 350
"/app/_/savedView/_": 350,
"/app/_/_/dev": 355
},
"cssGzipKB": 21,
"totalJsGzipKB": 1050,
@@ -24,6 +25,16 @@
"$comment": "RapiDoc (the table developer view's API docs, QRun-IO/qqq#714) loads only when that view opens: import('rapidoc') in TableApiDocs. Measured 204.5 KB gzip at 1.0; budget about 5% above.",
"marker": "customElements.define(\"rapi-doc\"",
"gzipKB": 215
},
"sqlWorkbench": {
"$comment": "CodeMirror 6 (state, view, language, autocomplete, lang-sql) plus the SQL Workbench widget, loaded when the SQL_WORKBENCH widget renders: import() in WidgetRenderer. Measured 127.2 KB gzip at introduction; budget about 6% above.",
"marker": "qqq-sql-workbench-editor",
"gzipKB": 135
},
"sqlFormatter": {
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
"marker": "logicalOperatorNewline",
"gzipKB": 65
}
}
}
+175 -2
View File
@@ -13,9 +13,30 @@ importers:
.:
dependencies:
'@codemirror/autocomplete':
specifier: 6.18.6
version: 6.18.6
'@codemirror/commands':
specifier: 6.8.1
version: 6.8.1
'@codemirror/lang-sql':
specifier: 6.9.0
version: 6.9.0
'@codemirror/language':
specifier: 6.11.2
version: 6.11.2
'@codemirror/state':
specifier: 6.5.2
version: 6.5.2
'@codemirror/view':
specifier: 6.38.1
version: 6.38.1
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))
'@lezer/highlight':
specifier: 1.2.1
version: 1.2.1
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -91,6 +112,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
sql-formatter:
specifier: 15.6.6
version: 15.6.6
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -335,6 +359,24 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@codemirror/autocomplete@6.18.6':
resolution: {integrity: sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==}
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-sql@6.9.0':
resolution: {integrity: sha512-xmtpWqKSgum1B1J3Ro6rf7nuPqf2+kJQg5SjrofCAcyCThOe0ihSktSoXfXuhQBnwx1QbmreBbLJM5Jru6zitg==}
'@codemirror/language@6.11.2':
resolution: {integrity: sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==}
'@codemirror/state@6.5.2':
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
'@codemirror/view@6.38.1':
resolution: {integrity: sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
@@ -875,12 +917,24 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.3':
resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
'@lezer/lr@1.4.11':
resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==}
'@lit-labs/ssr-dom-shim@1.6.0':
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@marijn/find-cluster-break@1.0.4':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -2748,6 +2802,9 @@ packages:
core-js-pure@3.50.0:
resolution: {integrity: sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2909,6 +2966,9 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -3881,6 +3941,9 @@ packages:
module-alias@2.3.4:
resolution: {integrity: sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==}
moo@0.5.3:
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -3918,6 +3981,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
needle@2.9.1:
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
engines: {node: '>= 4.4.x'}
@@ -4163,6 +4230,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
ramda-adjunct@5.1.0:
resolution: {integrity: sha512-8qCpl2vZBXEJyNbi4zqcgdfHtcdsWjOGbiNSEnEBrM6Y0OKOT8UxJbIVGm1TIcjaSu2MxaWcgtsNlKlCk7o7qg==}
engines: {node: '>=0.10.3'}
@@ -4172,6 +4242,10 @@ packages:
ramda@0.30.1:
resolution: {integrity: sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==}
randexp@0.4.6:
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
engines: {node: '>=0.12'}
randexp@0.5.3:
resolution: {integrity: sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==}
engines: {node: '>=4'}
@@ -4314,6 +4388,10 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
ret@0.2.2:
resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==}
engines: {node: '>=4'}
@@ -4470,6 +4548,10 @@ packages:
spdx-license-ids@3.0.23:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
sql-formatter@15.6.6:
resolution: {integrity: sha512-bZydXEXhaNDQBr8xYHC3a8thwcaMuTBp0CkKGjwGYDsIB26tnlWeWPwJtSQ0TEwiJcz9iJJON5mFPkx7XroHcg==}
hasBin: true
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -4559,6 +4641,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
style-mod@4.1.4:
resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -4913,6 +4998,9 @@ packages:
jsdom:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -5207,6 +5295,49 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@codemirror/autocomplete@6.18.6':
dependencies:
'@codemirror/language': 6.11.2
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.1
'@lezer/common': 1.5.3
'@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.11.2
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.1
'@lezer/common': 1.5.3
'@codemirror/lang-sql@6.9.0':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.11.2
'@codemirror/state': 6.5.2
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.11
'@codemirror/language@6.11.2':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.1
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.11
style-mod: 4.1.4
'@codemirror/state@6.5.2':
dependencies:
'@marijn/find-cluster-break': 1.0.4
'@codemirror/view@6.38.1':
dependencies:
'@codemirror/state': 6.5.2
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
'@csstools/color-helpers@5.1.0': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
@@ -5651,12 +5782,24 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@lezer/common@1.5.3': {}
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.5.3
'@lezer/lr@1.4.11':
dependencies:
'@lezer/common': 1.5.3
'@lit-labs/ssr-dom-shim@1.6.0': {}
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@marijn/find-cluster-break@1.0.4': {}
'@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
'@types/mdx': 2.0.14
@@ -7702,8 +7845,7 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3:
optional: true
commander@2.20.3: {}
comment-parser@1.4.5: {}
@@ -7715,6 +7857,8 @@ snapshots:
core-js-pure@3.50.0: {}
crelt@1.0.7: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -7848,6 +7992,8 @@ snapshots:
diff-sequences@29.6.3: {}
discontinuous-range@1.0.0: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -8996,6 +9142,8 @@ snapshots:
module-alias@2.3.4: {}
moo@0.5.3: {}
mrmime@2.0.1: {}
ms@2.0.0: {}
@@ -9035,6 +9183,13 @@ snapshots:
natural-compare@1.4.0: {}
nearley@2.20.1:
dependencies:
commander: 2.20.3
moo: 0.5.3
railroad-diagrams: 1.0.0
randexp: 0.4.6
needle@2.9.1:
dependencies:
debug: 3.2.7
@@ -9327,12 +9482,19 @@ snapshots:
queue-microtask@1.2.3: {}
railroad-diagrams@1.0.0: {}
ramda-adjunct@5.1.0(ramda@0.30.1):
dependencies:
ramda: 0.30.1
ramda@0.30.1: {}
randexp@0.4.6:
dependencies:
discontinuous-range: 1.0.0
ret: 0.1.15
randexp@0.5.3:
dependencies:
drange: 1.1.1
@@ -9510,6 +9672,8 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
ret@0.1.15: {}
ret@0.2.2: {}
rettime@0.10.1: {}
@@ -9719,6 +9883,11 @@ snapshots:
spdx-license-ids@3.0.23: {}
sql-formatter@15.6.6:
dependencies:
argparse: 2.0.1
nearley: 2.20.1
stable-hash@0.0.5: {}
stack-utils@2.0.6:
@@ -9842,6 +10011,8 @@ snapshots:
strip-json-comments@3.1.1: {}
style-mod@4.1.4: {}
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4):
dependencies:
client-only: 0.0.1
@@ -10245,6 +10416,8 @@ snapshots:
transitivePeerDependencies:
- msw
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
@@ -36,7 +36,7 @@ import { useRouteParams } from '@/lib/hooks/use-route-params'
import { addRecentRecord, removeRecentRecord } from '@/lib/utils/recent-records'
import { recordAnalytics } from '@/lib/analytics'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import { getProcessesForTable } from '@/lib/utils/process-utils'
import { getProcessesForRecordView } from '@/lib/utils/process-utils'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
import { auditSource } from '@/lib/api/audits'
@@ -191,7 +191,7 @@ export default function RecordViewPage() {
)
}
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
const tableProcesses = metaData ? getProcessesForRecordView(metaData, slug) : []
return (
<>
@@ -78,7 +78,7 @@ describe('BulkLoadMapping', () => {
mapping.requiredFields[1].defaultValue = 'all@example.invalid'
mapping.requiredFields[0].columnIndex = 0
expect(mapping.toProfile().profile.fieldList).toEqual([
{ fieldName: 'firstName', columnIndex: 0, headerName: null, doValueMapping: false, clearIfEmpty: false },
{ fieldName: 'firstName', columnIndex: 0, doValueMapping: false, clearIfEmpty: false },
{ fieldName: 'email', defaultValue: 'all@example.invalid' },
])
})
@@ -133,6 +133,21 @@ describe('BulkLoadMapping header row, key fields and repeats (#726)', () => {
expect(mapping.requiredFields[0]).toMatchObject({ columnIndex: 0, headerName: null })
})
it('omits the header name from a header-off profile and keeps the column index (QRun-IO/qqq#660)', () => {
const mapping = new BulkLoadMapping(person)
mapping.changeHasHeaderRow(false, file)
mapping.requiredFields[0].columnIndex = 0
mapping.requiredFields[1].columnIndex = 1
const { haveErrors, profile } = mapping.toProfile()
expect(haveErrors).toBe(false)
for (const profileField of profile.fieldList) expect(profileField).not.toHaveProperty('headerName')
const sent: unknown = JSON.parse(String(profileSubmitValues(mapping, profile).fieldListJSON))
expect(sent).toStrictEqual([
{ fieldName: 'firstName', columnIndex: 0, doValueMapping: false, clearIfEmpty: false },
{ fieldName: 'email', columnIndex: 1, doValueMapping: false, clearIfEmpty: false },
])
})
it('maps a saved profile by header name in a file whose columns moved', () => {
const moved = new FileDescription(['Email', 'First Name'], ['A', 'B'], [[], []])
const mapping = BulkLoadMapping.fromProfile(person, {
+2 -1
View File
@@ -366,8 +366,9 @@ export class BulkLoadMapping {
haveErrors = true
field.error = 'You must select a column.'
} else {
// Header-off files have no header name; published backends throw on `headerName: null`.
const profileField: BulkLoadProfileField = {
fieldName, columnIndex: field.columnIndex, headerName: field.headerName, doValueMapping: field.doValueMapping, clearIfEmpty: field.clearIfEmpty,
fieldName, columnIndex: field.columnIndex, ...(field.headerName === null ? {} : { headerName: field.headerName }), doValueMapping: field.doValueMapping, clearIfEmpty: field.clearIfEmpty,
}
if (this.valueMappings[fieldName]) profileField.valueMappings = this.valueMappings[fieldName]
profile.fieldList.push(profileField)
@@ -0,0 +1,151 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file MarkdownView — renders a markdown cell's text as React elements (never as HTML):
* headings, paragraphs, bullet and numbered lists, fenced code, and inline bold, italic, code
* and http(s) links. Anything else is shown as text, so a notebook can never inject markup.
*/
'use client'
import React from 'react'
/**
* Renders inline markdown (bold, italic, code, links) as React nodes.
*
* @param text - One line or paragraph.
* @param keyPrefix - Key prefix for the produced nodes.
* @returns The nodes.
*/
export function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
const nodes: React.ReactNode[] = []
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\((https?:\/\/[^\s)]+)\))/g
let last = 0
let match: RegExpExecArray | null
let index = 0
while ((match = pattern.exec(text)) !== null) {
if (match.index > last) nodes.push(text.slice(last, match.index))
const token = match[0]
const key = `${keyPrefix}-${index++}`
if (token.startsWith('`')) {
nodes.push(
<code key={key} className="rounded bg-muted px-1 font-mono text-[0.9em]">
{token.slice(1, -1)}
</code>
)
} else if (token.startsWith('**')) {
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>)
} else if (token.startsWith('*')) {
nodes.push(<em key={key}>{token.slice(1, -1)}</em>)
} else {
const label = token.slice(1, token.indexOf(']'))
nodes.push(
<a
key={key}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
{label}
</a>
)
}
last = match.index + token.length
}
if (last < text.length) nodes.push(text.slice(last))
return nodes
}
/**
* Renders a markdown document.
*
* @param props - Component props.
* @param props.source - The markdown text.
* @returns The rendered blocks.
*/
export function MarkdownView({ source }: { source: string }) {
const blocks: React.ReactNode[] = []
const lines = source.split('\n')
let i = 0
while (i < lines.length) {
const line = lines[i]
const key = `b${i}`
if (line.trim().startsWith('```')) {
const code: string[] = []
i++
while (i < lines.length && !lines[i].trim().startsWith('```')) code.push(lines[i++])
i++
blocks.push(
<pre key={key} className="overflow-x-auto rounded bg-muted p-2 font-mono text-xs">
{code.join('\n')}
</pre>
)
continue
}
const heading = /^(#{1,6})\s+(.*)$/.exec(line)
if (heading) {
const level = heading[1].length
const sizes = ['text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm', 'text-sm']
const Tag = `h${Math.min(level + 1, 6)}` as 'h2'
blocks.push(
<Tag key={key} className={`${sizes[level - 1]} font-semibold`}>
{renderInline(heading[2], key)}
</Tag>
)
i++
continue
}
if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
const ordered = /^\s*\d+\./.test(line)
const items: React.ReactNode[] = []
while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) {
items.push(
<li key={`${key}-${items.length}`}>
{renderInline(lines[i].replace(/^\s*([-*]|\d+\.)\s+/, ''), `${key}-${items.length}`)}
</li>
)
i++
}
blocks.push(
ordered ? (
<ol key={key} className="list-decimal pl-5">
{items}
</ol>
) : (
<ul key={key} className="list-disc pl-5">
{items}
</ul>
)
)
continue
}
if (line.trim() === '') {
i++
continue
}
const paragraph: string[] = []
while (
i < lines.length &&
lines[i].trim() !== '' &&
!/^(#{1,6}\s|```|\s*([-*]|\d+\.)\s+)/.test(lines[i])
)
paragraph.push(lines[i++])
blocks.push(<p key={key}>{renderInline(paragraph.join(' '), key)}</p>)
}
return <div className="space-y-2 text-sm">{blocks}</div>
}
@@ -0,0 +1,598 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file NotebookEditor — one notebook: title, default backend, :parameters, ordered SQL and
* markdown cells, save (each save is a version), publish to the shared library, duplicate,
* delete, and the version history with restore. With no notebook id it edits an unsaved
* scratch notebook that Save creates.
*/
'use client'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type {
SqlNotebook,
SqlNotebookCell,
SqlNotebookInput,
SqlNotebookParameter,
SqlWorkbenchCapabilities,
} from '@/types'
import {
createSqlNotebook,
deleteSqlNotebook,
duplicateSqlNotebook,
getSqlNotebook,
listSqlNotebookVersions,
listSqlSnapshots,
publishSqlNotebook,
restoreSqlNotebookVersion,
saveSqlNotebook,
} from '@/lib/api/sql-workbench'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { coerceParameterValue, extractSqlParameters, newCellKey } from '@/lib/utils/sql-workbench'
import { MarkdownView } from './MarkdownView'
import { SqlCell } from './SqlCell'
/** A request to add a cell (e.g. from history). */
export interface InsertCellRequest {
sql: string
backendName: string
nonce: number
}
/** Props for {@link NotebookEditor}. */
export interface NotebookEditorProps {
notebookId: number | null
capabilities: SqlWorkbenchCapabilities
insertRequest: InsertCellRequest | null
/** Called once the pending insert request has been applied, so the owner can clear it. */
onInsertConsumed: () => void
onSelect: (id: number | null) => void
}
const PARAMETER_TYPES: NonNullable<SqlNotebookParameter['type']>[] = [
'STRING',
'INTEGER',
'DECIMAL',
'BOOLEAN',
'DATE',
'DATE_TIME',
]
/**
* Loads the notebook (when saved) and renders its editor.
*
* @param props - Component props.
* @returns The editor, or a loading / error state.
*/
export function NotebookEditor(props: NotebookEditorProps) {
const { notebookId, capabilities } = props
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.sqlNotebook(notebookId ?? -1),
queryFn: () => getSqlNotebook(notebookId!),
enabled: notebookId !== null,
meta: HANDLES_OWN_ERRORS,
})
if (notebookId !== null && isLoading) {
return (
<p className="text-sm text-muted-foreground" role="status">
Loading notebook…
</p>
)
}
if (notebookId !== null && (error || !data)) {
return (
<p className="text-sm text-destructive" role="alert">
Could not open the notebook: {getErrorMessage(error, 'not found')}
</p>
)
}
const notebook: SqlNotebook = data ?? {
id: -1,
title: 'Untitled notebook',
description: null,
visibility: 'PERSONAL',
ownerUserId: '',
ownerName: null,
defaultBackendName: capabilities.backends[0]?.name ?? null,
currentVersion: 0,
canEdit: true,
createDate: null,
modifyDate: null,
parameters: [],
cells: [
{ cellKey: newCellKey(), cellType: 'SQL', content: '', title: null, backendName: null },
],
}
return (
<NotebookDraft
key={`${notebookId ?? 'new'}-${notebook.currentVersion}`}
{...props}
notebook={notebook}
saved={notebookId !== null}
/>
)
}
/**
* The editable draft of a notebook.
*
* @param props - Component props.
* @param props.notebook - The loaded (or blank) notebook.
* @param props.saved - Whether it exists on the server.
* @returns The editor.
*/
function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; saved: boolean }) {
const { notebook, saved, capabilities, insertRequest, onInsertConsumed, onSelect } = props
const queryClient = useQueryClient()
const [title, setTitle] = useState(notebook.title)
const [defaultBackendName, setDefaultBackendName] = useState(
notebook.defaultBackendName ?? capabilities.backends[0]?.name ?? ''
)
const [cells, setCells] = useState<SqlNotebookCell[]>(notebook.cells)
const [parameters, setParameters] = useState<SqlNotebookParameter[]>(notebook.parameters ?? [])
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries((notebook.parameters ?? []).map((p) => [p.name, p.defaultValue ?? '']))
)
const [dirty, setDirty] = useState(!saved)
const [saving, setSaving] = useState(false)
const [showVersions, setShowVersions] = useState(false)
const [previewing, setPreviewing] = useState<Record<string, boolean>>({})
const canEdit = notebook.canEdit
// The request is parent state that outlives this draft (it remounts on save, restore,
// duplicate and notebook switch), so apply each nonce once and hand it back to be cleared.
const handledInsertNonce = useRef<number | null>(null)
useEffect(() => {
if (!insertRequest || handledInsertNonce.current === insertRequest.nonce) return
handledInsertNonce.current = insertRequest.nonce
setCells((current) => [
...current,
{
cellKey: newCellKey(),
cellType: 'SQL',
content: insertRequest.sql,
backendName: insertRequest.backendName,
title: null,
},
])
setDirty(true)
onInsertConsumed()
}, [insertRequest, onInsertConsumed])
const { data: snapshots = [] } = useQuery({
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
queryFn: () => listSqlSnapshots(notebook.id),
enabled: saved,
meta: HANDLES_OWN_ERRORS,
})
const { data: versions = [] } = useQuery({
queryKey: queryKeys.sqlNotebookVersions(notebook.id),
queryFn: () => listSqlNotebookVersions(notebook.id),
enabled: saved && showVersions,
meta: HANDLES_OWN_ERRORS,
})
const parameterNames = useMemo(() => {
const names = parameters.map((p) => p.name)
for (const cell of cells) {
if (cell.cellType === 'SQL')
for (const name of extractSqlParameters(cell.content))
if (!names.includes(name)) names.push(name)
}
return names
}, [cells, parameters])
const paramsFor = (sql: string) =>
Object.fromEntries(
extractSqlParameters(sql).map((name) => [
name,
coerceParameterValue(values[name], parameters.find((p) => p.name === name)?.type),
])
)
const updateCells = (next: SqlNotebookCell[]) => {
setCells(next)
setDirty(true)
}
const setParameterType = (name: string, type: SqlNotebookParameter['type']) => {
const existing = parameters.find((p) => p.name === name)
setParameters(
existing
? parameters.map((p) => (p.name === name ? { ...p, type } : p))
: [...parameters, { name, type, defaultValue: values[name] ?? null }]
)
setDirty(true)
}
const input = (): SqlNotebookInput => ({
title: title.trim() || 'Untitled notebook',
defaultBackendName,
parameters: parameterNames.map((name) => ({
name,
type: parameters.find((p) => p.name === name)?.type ?? 'STRING',
defaultValue: values[name] ?? null,
})),
cells: cells.map((c, i) => ({ ...c, sortOrder: i })),
})
const refreshLists = () => {
void queryClient.invalidateQueries({ queryKey: [...queryKeys.sqlWorkbench(), 'notebooks'] })
}
const save = async () => {
setSaving(true)
try {
const result = saved
? await saveSqlNotebook(notebook.id, input())
: await createSqlNotebook(input())
queryClient.setQueryData(queryKeys.sqlNotebook(result.id), result)
void queryClient.invalidateQueries({ queryKey: queryKeys.sqlNotebookVersions(result.id) })
refreshLists()
setDirty(false)
toast.success(`Saved version ${result.currentVersion}`)
if (!saved) onSelect(result.id)
} catch (error) {
toast.error(getErrorMessage(error))
} finally {
setSaving(false)
}
}
const act = async (
action: () => Promise<SqlNotebook | void>,
message: string,
select?: (n: SqlNotebook | void) => number | null
) => {
try {
const result = await action()
if (result) queryClient.setQueryData(queryKeys.sqlNotebook(result.id), result)
refreshLists()
toast.success(message)
if (select) onSelect(select(result))
} catch (error) {
toast.error(getErrorMessage(error))
}
}
const move = (index: number, delta: number) => {
const next = [...cells]
const [cell] = next.splice(index, 1)
next.splice(index + delta, 0, cell)
updateCells(next)
}
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<input
className="min-w-0 flex-1 rounded border px-2 py-1 text-base font-semibold"
value={title}
aria-label="Notebook title"
onChange={(e) => {
setTitle(e.target.value)
setDirty(true)
}}
readOnly={!canEdit}
data-qqq-id="input-sql-notebook-title"
/>
<label className="inline-flex items-center gap-1 text-xs">
Default backend
<select
className="rounded border px-1 py-0.5"
value={defaultBackendName}
disabled={!canEdit}
onChange={(e) => {
setDefaultBackendName(e.target.value)
setDirty(true)
}}
data-qqq-id="select-sql-notebook-backend"
>
{capabilities.backends.map((b) => (
<option key={b.name} value={b.name}>
{b.label}
</option>
))}
</select>
</label>
{canEdit && (
<button
type="button"
className={`${button} bg-primary text-primary-foreground`}
onClick={save}
disabled={saving || !dirty}
data-qqq-id="button-sql-notebook-save"
>
{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
</button>
)}
{saved && canEdit && capabilities.canShare && (
<button
type="button"
className={button}
onClick={() =>
act(
() => publishSqlNotebook(notebook.id, notebook.visibility !== 'SHARED'),
notebook.visibility === 'SHARED'
? 'Removed from the library'
: 'Published to the library'
)
}
data-qqq-id="button-sql-notebook-publish"
>
{notebook.visibility === 'SHARED' ? 'Unpublish' : 'Publish'}
</button>
)}
{saved && (
<button
type="button"
className={button}
onClick={() =>
act(
() => duplicateSqlNotebook(notebook.id),
'Copied to your notebooks',
(n) => (n ? n.id : null)
)
}
data-qqq-id="button-sql-notebook-duplicate"
>
Duplicate
</button>
)}
{saved && (
<button
type="button"
className={button}
onClick={() => setShowVersions(!showVersions)}
aria-expanded={showVersions}
data-qqq-id="button-sql-notebook-versions"
>
Versions
</button>
)}
{saved && canEdit && (
<button
type="button"
className={`${button} text-destructive`}
onClick={() => {
if (window.confirm(`Delete “${notebook.title}” and its versions?`))
void act(
() => deleteSqlNotebook(notebook.id),
'Notebook deleted',
() => null
)
}}
data-qqq-id="button-sql-notebook-delete"
>
Delete
</button>
)}
</div>
{!canEdit && (
<p className="text-xs text-muted-foreground">
Shared by {notebook.ownerName ?? notebook.ownerUserId}. You can run it; duplicate it to
keep changes.
</p>
)}
{showVersions && (
<ul className="space-y-1 rounded-md border p-2 text-xs" aria-label="Versions">
{versions.map((v) => (
<li key={v.versionNumber} className="flex items-center gap-2">
<span className="font-mono">v{v.versionNumber}</span>
<span>{v.savedByName}</span>
<span className="text-muted-foreground">{v.createDate}</span>
{v.comment && <span className="truncate">{v.comment}</span>}
{canEdit && v.versionNumber !== notebook.currentVersion && (
<button
type="button"
className={`${button} ml-auto`}
onClick={() =>
act(
() => restoreSqlNotebookVersion(notebook.id, v.versionNumber),
`Restored version ${v.versionNumber}`
)
}
data-qqq-id={`button-sql-restore-${v.versionNumber}`}
>
Restore
</button>
)}
</li>
))}
</ul>
)}
{parameterNames.length > 0 && (
<fieldset className="flex flex-wrap gap-3 rounded-md border p-2 text-xs">
<legend className="px-1 font-medium">Parameters</legend>
{parameterNames.map((name) => {
const type = parameters.find((p) => p.name === name)?.type ?? 'STRING'
return (
<span key={name} className="inline-flex items-center gap-1">
<label className="inline-flex items-center gap-1">
<span className="font-mono">:{name}</span>
<input
className="w-36 rounded border px-1 py-0.5"
type={
type === 'DATE'
? 'date'
: type === 'DATE_TIME'
? 'datetime-local'
: type === 'INTEGER' || type === 'DECIMAL'
? 'number'
: 'text'
}
value={values[name] ?? ''}
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
data-qqq-id={`input-sql-param-${name}`}
/>
</label>
<select
className="rounded border px-1 py-0.5"
value={type}
aria-label={`Type of :${name}`}
onChange={(e) =>
setParameterType(name, e.target.value as SqlNotebookParameter['type'])
}
data-qqq-id={`select-sql-param-type-${name}`}
>
{PARAMETER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</span>
)
})}
</fieldset>
)}
{cells.map((cell, index) => (
<div key={cell.cellKey} className="group relative">
{canEdit && (
<div className="absolute -right-1 -top-2 z-10 hidden gap-1 group-focus-within:flex group-hover:flex">
<button
type="button"
className="rounded border bg-background p-0.5"
onClick={() => move(index, -1)}
disabled={index === 0}
aria-label={`Move cell ${index + 1} up`}
data-qqq-id={`button-sql-cell-up-${index}`}
>
<span aria-hidden>▲</span>
</button>
<button
type="button"
className="rounded border bg-background p-0.5"
onClick={() => move(index, 1)}
disabled={index === cells.length - 1}
aria-label={`Move cell ${index + 1} down`}
data-qqq-id={`button-sql-cell-down-${index}`}
>
<span aria-hidden>▼</span>
</button>
<button
type="button"
className="rounded border bg-background p-0.5 text-destructive"
onClick={() => updateCells(cells.filter((_, i) => i !== index))}
aria-label={`Delete cell ${index + 1}`}
data-qqq-id={`button-sql-cell-delete-${index}`}
>
<span aria-hidden>✕</span>
</button>
</div>
)}
{cell.cellType === 'MARKDOWN' ? (
<section className="rounded-md border p-3" aria-label={`Markdown cell ${index + 1}`}>
<button
type="button"
className="mb-1 text-xs text-muted-foreground underline"
onClick={() =>
setPreviewing({ ...previewing, [cell.cellKey]: !previewing[cell.cellKey] })
}
data-qqq-id={`button-sql-markdown-toggle-${index}`}
>
{previewing[cell.cellKey] === false ? 'Preview' : 'Edit'}
</button>
{previewing[cell.cellKey] === false ? (
<textarea
className="block h-28 w-full rounded border p-2 font-mono text-sm"
value={cell.content}
aria-label={`Markdown for cell ${index + 1}`}
onChange={(e) =>
updateCells(
cells.map((c, i) => (i === index ? { ...c, content: e.target.value } : c))
)
}
data-qqq-id={`textarea-sql-markdown-${index}`}
/>
) : (
<MarkdownView source={cell.content || '*Empty markdown cell*'} />
)}
</section>
) : (
<SqlCell
cell={cell}
index={index}
capabilities={capabilities}
defaultBackendName={defaultBackendName}
paramsFor={paramsFor}
notebookId={saved ? notebook.id : null}
canEdit={canEdit}
snapshots={snapshots.filter((s) => s.cellKey === cell.cellKey)}
onChange={(next) => updateCells(cells.map((c, i) => (i === index ? next : c)))}
onSnapshotsChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
})
}
/>
)}
</div>
))}
<div className="flex gap-2">
<button
type="button"
className={button}
onClick={() =>
updateCells([
...cells,
{
cellKey: newCellKey(),
cellType: 'SQL',
content: '',
title: null,
backendName: null,
},
])
}
data-qqq-id="button-sql-add-sql-cell"
>
+ SQL cell
</button>
<button
type="button"
className={button}
onClick={() => {
const cellKey = newCellKey()
setPreviewing({ ...previewing, [cellKey]: false })
updateCells([
...cells,
{ cellKey, cellType: 'MARKDOWN', content: '', title: null, backendName: null },
])
}}
data-qqq-id="button-sql-add-markdown-cell"
>
+ Markdown cell
</button>
</div>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file PlanTree — a query plan as a collapsible tree: each node's operation, target, cost range,
* estimated rows and (with ANALYZE) actual time, rows and loops, with a bar scaled to the most
* expensive node. A toggle shows the raw plan text.
*/
'use client'
import React, { useState } from 'react'
import type { SqlExplainResponse, SqlPlanNode } from '@/types'
import { planMax } from '@/lib/utils/sql-workbench'
/**
* Formats a plan number.
*
* @param value - The number.
* @returns Up to two decimals, or an empty string.
*/
function n(value: number | null): string {
return value === null || value === undefined ? '' : Number(value.toFixed(2)).toLocaleString()
}
/**
* Renders one node and its children.
*
* @param props - Component props.
* @param props.node - The node.
* @param props.max - The plan's largest cost or time.
* @param props.useActual - Scale bars by actual time.
* @returns The node as a list item.
*/
function PlanNodeItem({
node,
max,
useActual,
}: {
node: SqlPlanNode
max: number
useActual: boolean
}) {
const value = (useActual ? node.actualTimeMs : node.totalCost) ?? 0
const width = max > 0 ? Math.max(2, Math.round((value / max) * 100)) : 0
const extras = Object.entries(node.extra ?? {})
const summary = (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-0.5">
<span className="font-medium">{node.label}</span>
{node.detail && <span className="text-muted-foreground">{node.detail}</span>}
{node.totalCost !== null && (
<span className="font-mono text-xs">
cost {n(node.startupCost)}..{n(node.totalCost)}
</span>
)}
{node.planRows !== null && <span className="font-mono text-xs">rows {n(node.planRows)}</span>}
{node.actualTimeMs !== null && (
<span className="font-mono text-xs">
actual {n(node.actualTimeMs)} ms · {n(node.actualRows)} rows × {n(node.loops)}
</span>
)}
{width > 0 && (
<span className="inline-block h-1.5 w-24 rounded bg-muted" aria-hidden>
<span className="block h-1.5 rounded bg-primary" style={{ width: `${width}%` }} />
</span>
)}
</span>
)
return (
<li className="my-1">
<details open>
<summary className="cursor-pointer">{summary}</summary>
{extras.length > 0 && (
<dl className="ml-4 grid grid-cols-[auto_1fr] gap-x-2 text-xs text-muted-foreground">
{extras.map(([k, v]) => (
<React.Fragment key={k}>
<dt>{k}</dt>
<dd className="truncate font-mono">
{typeof v === 'string' ? v : JSON.stringify(v)}
</dd>
</React.Fragment>
))}
</dl>
)}
{node.children.length > 0 && (
<ul className="ml-4 border-l pl-3">
{node.children.map((child, i) => (
<PlanNodeItem key={i} node={child} max={max} useActual={useActual} />
))}
</ul>
)}
</details>
</li>
)
}
/**
* Renders an EXPLAIN response.
*
* @param props - Component props.
* @param props.explain - The response.
* @returns The tree, the raw text, or the error.
*/
export function PlanTree({ explain }: { explain: SqlExplainResponse }) {
const [raw, setRaw] = useState(false)
if (explain.status !== 'SUCCEEDED' || !explain.plan) {
return (
<p className="text-sm text-destructive" role="alert">
{explain.error ?? 'The plan could not be produced.'}
</p>
)
}
const useActual = explain.analyze === true
const max = planMax(explain.plan, useActual)
return (
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{explain.analyze ? 'EXPLAIN ANALYZE (executed, rolled back)' : 'EXPLAIN (estimated)'}
</span>
<button
type="button"
className="ml-auto rounded border px-2 py-0.5"
onClick={() => setRaw(!raw)}
data-qqq-id="button-sql-plan-raw"
>
{raw ? 'Tree' : 'Raw'}
</button>
</div>
{raw ? (
<pre className="max-h-96 overflow-auto rounded bg-muted p-2 font-mono text-xs">
{explain.raw}
</pre>
) : (
<ul aria-label="Query plan">
<PlanNodeItem node={explain.plan} max={max} useActual={useActual} />
</ul>
)}
</div>
)
}
+188
View File
@@ -0,0 +1,188 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file QuickChart — a bar, line or pie chart of a result's current rows: pick the category
* column and the value columns. Uses Recharts, as the dashboard chart widgets do.
*/
'use client'
import React, { useMemo, useState } from 'react'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { SqlColumn, SqlValue } from '@/types'
import { numericColumnIndexes, toChartRows } from '@/lib/utils/sql-workbench'
const COLORS = [
'#6366f1',
'#10b981',
'#f59e0b',
'#ef4444',
'#06b6d4',
'#8b5cf6',
'#84cc16',
'#ec4899',
]
/**
* Renders the chart builder.
*
* @param props - Component props.
* @param props.columns - Result columns.
* @param props.rows - Rows to plot (the current page).
* @returns The controls and the chart, or a hint when nothing is plottable.
*/
export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlValue[][] }) {
const numeric = useMemo(() => numericColumnIndexes(columns, rows), [columns, rows])
const firstCategory = columns.findIndex((_, i) => !numeric.includes(i))
const [type, setType] = useState<'bar' | 'line' | 'pie'>('bar')
const [xIndex, setXIndex] = useState(firstCategory >= 0 ? firstCategory : 0)
const [yIndexes, setYIndexes] = useState<number[]>(
numeric.filter((i) => i !== xIndex).slice(0, 1)
)
if (numeric.length === 0 || rows.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Charts need at least one numeric column and one row.
</p>
)
}
const data = toChartRows(columns, rows, xIndex, yIndexes)
const series = yIndexes.map((i) => columns[i].label)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-sm">
<label className="inline-flex items-center gap-1">
Chart
<select
className="rounded border px-1 py-0.5"
value={type}
onChange={(e) => setType(e.target.value as 'bar' | 'line' | 'pie')}
data-qqq-id="select-sql-chart-type"
>
<option value="bar">Bar</option>
<option value="line">Line</option>
<option value="pie">Pie</option>
</select>
</label>
<label className="inline-flex items-center gap-1">
Category
<select
className="rounded border px-1 py-0.5"
value={xIndex}
onChange={(e) => setXIndex(Number(e.target.value))}
data-qqq-id="select-sql-chart-x"
>
{columns.map((c, i) => (
<option key={i} value={i}>
{c.label}
</option>
))}
</select>
</label>
<fieldset className="inline-flex flex-wrap items-center gap-2">
<legend className="sr-only">Values</legend>
<span>Values</span>
{numeric.map((i) => (
<label key={i} className="inline-flex items-center gap-1">
<input
type={type === 'pie' ? 'radio' : 'checkbox'}
name="sql-chart-y"
checked={yIndexes.includes(i)}
onChange={(e) =>
setYIndexes(
type === 'pie'
? [i]
: e.target.checked
? [...yIndexes, i]
: yIndexes.filter((y) => y !== i)
)
}
data-qqq-id={`checkbox-sql-chart-y-${i}`}
/>
{columns[i].label}
</label>
))}
</fieldset>
</div>
<div
className="h-72 w-full"
role="img"
aria-label={`${type} chart of ${series.join(', ') || 'no values'} by ${columns[xIndex]?.label}`}
>
<ResponsiveContainer width="100%" height="100%">
{type === 'pie' ? (
<PieChart>
<Pie data={data} dataKey={series[0] ?? ''} nameKey="x" outerRadius="80%" label>
{data.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
) : type === 'line' ? (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" />
<YAxis />
<Tooltip />
<Legend />
{series.map((s, i) => (
<Line
key={s}
type="monotone"
dataKey={s}
stroke={COLORS[i % COLORS.length]}
dot={false}
/>
))}
</LineChart>
) : (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" />
<YAxis />
<Tooltip />
<Legend />
{series.map((s, i) => (
<Bar key={s} dataKey={s} fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
)}
</ResponsiveContainer>
</div>
</div>
)
}
+281
View File
@@ -0,0 +1,281 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file ResultGrid — typed, sortable, paginated view of a workbench result. The first page comes
* with the run; later pages and sorts are read from the server's cached copy of the result
* (`GET /executions/{uuid}/rows`), so SQL never re-runs. Copies the page as TSV and links the
* CSV / XLSX / JSON exports of the whole (capped) result.
*/
'use client'
import React, { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { SqlColumn, SqlValue } from '@/types'
import { getSqlRows, sqlExportUrl } from '@/lib/api/sql-workbench'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { formatSqlValue, isBinaryValue, prettyJson, rowsToTsv } from '@/lib/utils/sql-workbench'
import { cn } from '@/lib/utils/cn'
/** Props for {@link ResultGrid}. */
export interface ResultGridProps {
executionUuid: string
columns: SqlColumn[]
firstPage: SqlValue[][]
rowCount: number
pageSize: number
truncated?: boolean
truncatedReason?: 'ROWS' | 'BYTES' | null
/** Hide paging/sort/export (pinned snapshots, which have no cached result). */
staticRows?: boolean
}
/**
* Renders one cell value by its column kind.
*
* @param props - Component props.
* @param props.value - The value.
* @param props.column - Its column.
* @returns The cell content.
*/
export function SqlValueCell({ value, column }: { value: SqlValue; column: SqlColumn }) {
if (value === null) return <span className="italic text-muted-foreground">NULL</span>
if (isBinaryValue(value)) {
return (
<span
className="text-muted-foreground"
title={`0x${value.hexPreview}${value.length > value.hexPreview.length / 2 ? '…' : ''}`}
>
{formatSqlValue(value)}
</span>
)
}
if (column.kind === 'json' && typeof value === 'string') {
return (
<details>
<summary className="max-w-xs cursor-pointer truncate font-mono text-xs">{value}</summary>
<pre className="mt-1 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
{prettyJson(value)}
</pre>
</details>
)
}
if (column.kind === 'boolean') return <span className="font-mono">{String(value)}</span>
return (
<span className={cn(column.kind === 'number' && 'font-mono tabular-nums')}>
{formatSqlValue(value)}
</span>
)
}
/**
* Renders the grid.
*
* @param props - Component props.
* @returns The table with paging and export controls.
*/
export function ResultGrid(props: ResultGridProps) {
const {
executionUuid,
columns,
firstPage,
rowCount,
pageSize,
truncated,
truncatedReason,
staticRows = false,
} = props
const [page, setPage] = useState(0)
const [sort, setSort] = useState<{ column: number; dir: 'asc' | 'desc' } | null>(null)
const pages = Math.max(1, Math.ceil(rowCount / pageSize))
const needsFetch = !staticRows && (page > 0 || sort !== null)
const { data, isFetching, error } = useQuery({
queryKey: [
...queryKeys.sqlWorkbench(),
'rows',
executionUuid,
page,
pageSize,
sort?.column ?? null,
sort?.dir ?? null,
],
queryFn: () => getSqlRows(executionUuid, page * pageSize, pageSize, sort?.column, sort?.dir),
enabled: needsFetch,
meta: HANDLES_OWN_ERRORS,
})
const rows = needsFetch ? (data?.rows ?? []) : firstPage
const toggleSort = (index: number) => {
setPage(0)
setSort((current) =>
current?.column !== index
? { column: index, dir: 'asc' }
: current.dir === 'asc'
? { column: index, dir: 'desc' }
: null
)
}
const copy = async () => {
try {
await navigator.clipboard.writeText(rowsToTsv(columns, rows))
toast.success(`Copied ${rows.length} rows`)
} catch {
toast.error('Could not copy to the clipboard')
}
}
if (columns.length === 0) {
return <p className="text-sm text-muted-foreground">The statement returned no rows.</p>
}
const from = rowCount === 0 ? 0 : page * pageSize + 1
const to = Math.min(rowCount, (page + 1) * pageSize)
return (
<div className="space-y-2">
<div className="max-h-[28rem] overflow-auto rounded-md border">
<table
className="w-full border-collapse text-sm"
aria-label="Query result"
aria-busy={isFetching}
>
<thead className="sticky top-0 bg-muted">
<tr>
{columns.map((column, index) => (
<th
key={index}
scope="col"
className="whitespace-nowrap border-b px-2 py-1 text-left font-medium"
aria-sort={
sort?.column === index
? sort.dir === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
>
{staticRows ? (
<span title={column.typeName}>{column.label}</span>
) : (
<button
type="button"
className="inline-flex items-center gap-1 hover:underline"
onClick={() => toggleSort(index)}
title={`${column.typeName} — sort`}
data-qqq-id={`button-sql-sort-${index}`}
>
{column.label}
{sort?.column === index &&
(sort.dir === 'asc' ? (
<span aria-hidden>▲</span>
) : (
<span aria-hidden>▼</span>
))}
</button>
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={r} className="odd:bg-background even:bg-muted/30">
{columns.map((column, c) => (
<td
key={c}
className={cn(
'border-b px-2 py-1 align-top',
column.kind === 'number' && 'text-right'
)}
>
<SqlValueCell value={row[c] ?? null} column={column} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
Could not load rows: {error instanceof Error ? error.message : 'error'}
</p>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
Rows {from}–{to} of {rowCount}
{truncated && (
<span className="ml-1 text-amber-600">
(capped by the {truncatedReason === 'BYTES' ? 'size' : 'row'} limit)
</span>
)}
</span>
{!staticRows && pages > 1 && (
<span className="inline-flex items-center gap-1">
<button
type="button"
className="rounded border px-2 py-0.5 disabled:opacity-50"
disabled={page === 0}
onClick={() => setPage(page - 1)}
data-qqq-id="button-sql-page-prev"
>
Previous
</button>
<span>
Page {page + 1} of {pages}
</span>
<button
type="button"
className="rounded border px-2 py-0.5 disabled:opacity-50"
disabled={page + 1 >= pages}
onClick={() => setPage(page + 1)}
data-qqq-id="button-sql-page-next"
>
Next
</button>
</span>
)}
<span className="ml-auto inline-flex items-center gap-1">
<button
type="button"
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
onClick={copy}
data-qqq-id="button-sql-copy"
>
Copy page
</button>
{!staticRows &&
(['csv', 'xlsx', 'json'] as const).map((format) => (
<a
key={format}
href={sqlExportUrl(executionUuid, format)}
download
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
data-qqq-id={`link-sql-export-${format}`}
>
{format.toUpperCase()}
</a>
))}
</span>
</div>
</div>
)
}
+488
View File
@@ -0,0 +1,488 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file SqlCell — one SQL cell of a notebook: backend and mode, the editor, Run / Cancel /
* Explain / Format, and the result as a grid, a quick chart or a plan. Write mode runs the
* two-step protocol: a preview (rolled-back dry run) opens {@link WriteConfirmDialog}, and only
* its confirmation commits.
*/
'use client'
import React, { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type {
SqlExecuteResponse,
SqlExplainResponse,
SqlNotebookCell,
SqlResultSnapshot,
SqlWorkbenchCapabilities,
} from '@/types'
import {
cancelSqlExecution,
createSqlSnapshot,
executeSql,
explainSql,
getSqlSchema,
} from '@/lib/api/sql-workbench'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { formatDuration, newCellKey } from '@/lib/utils/sql-workbench'
import { cn } from '@/lib/utils/cn'
import { PlanTree } from './PlanTree'
import { QuickChart } from './QuickChart'
import { ResultGrid } from './ResultGrid'
import { SqlEditor } from './SqlEditor'
import { WriteConfirmDialog } from './WriteConfirmDialog'
/** Props for {@link SqlCell}. */
export interface SqlCellProps {
cell: SqlNotebookCell
index: number
capabilities: SqlWorkbenchCapabilities
defaultBackendName: string
/** Parameter values for this run, by name (already typed). */
paramsFor: (sql: string) => Record<string, string | number | boolean | null>
notebookId: number | null
canEdit: boolean
snapshots: SqlResultSnapshot[]
onChange: (cell: SqlNotebookCell) => void
onSnapshotsChanged: () => void
}
/**
* Renders a SQL cell.
*
* @param props - Component props.
* @returns The cell.
*/
export function SqlCell(props: SqlCellProps) {
const {
cell,
index,
capabilities,
defaultBackendName,
paramsFor,
notebookId,
canEdit,
snapshots,
onChange,
onSnapshotsChanged,
} = props
const queryClient = useQueryClient()
const backendName = cell.backendName || defaultBackendName
const backend = capabilities.backends.find((b) => b.name === backendName)
const [writeMode, setWriteMode] = useState(false)
const [runningUuid, setRunningUuid] = useState<string | null>(null)
const [result, setResult] = useState<SqlExecuteResponse | null>(null)
const [explain, setExplain] = useState<SqlExplainResponse | null>(null)
const [preview, setPreview] = useState<SqlExecuteResponse | null>(null)
const [committing, setCommitting] = useState(false)
const [requestError, setRequestError] = useState<string | null>(null)
const [tab, setTab] = useState<'grid' | 'chart' | 'plan'>('grid')
const { data: schema } = useQuery({
queryKey: queryKeys.sqlWorkbenchSchema(backendName),
queryFn: () => getSqlSchema(backendName),
enabled: !!backend,
staleTime: 5 * 60_000,
meta: HANDLES_OWN_ERRORS,
})
const afterRun = () => queryClient.invalidateQueries({ queryKey: queryKeys.sqlHistory() })
const run = async () => {
if (!backend || runningUuid || !cell.content.trim()) return
const executionUuid = newCellKey()
setRunningUuid(executionUuid)
setRequestError(null)
try {
const response = await executeSql({
backendName,
sql: cell.content,
params: paramsFor(cell.content),
mode: writeMode ? 'WRITE' : 'READ_ONLY',
notebookId: notebookId ?? undefined,
cellKey: cell.cellKey,
executionUuid,
})
if (response.status === 'PREVIEW') {
setPreview(response)
} else {
setResult(response)
setTab('grid')
}
} catch (error) {
setRequestError(getErrorMessage(error))
} finally {
setRunningUuid(null)
void afterRun()
}
}
const commit = async (acknowledgeRowGuard: boolean) => {
if (!preview?.confirmToken) return
setCommitting(true)
try {
const response = await executeSql({
backendName,
sql: cell.content,
params: paramsFor(cell.content),
mode: 'WRITE',
notebookId: notebookId ?? undefined,
cellKey: cell.cellKey,
confirmToken: preview.confirmToken,
acknowledgeRowGuard,
})
setResult(response)
setTab('grid')
if (response.status === 'SUCCEEDED')
toast.success(`Committed: ${response.rowsAffected ?? 0} rows affected`)
} catch (error) {
setRequestError(getErrorMessage(error))
} finally {
setCommitting(false)
setPreview(null)
void afterRun()
}
}
const cancel = async () => {
if (!runningUuid) return
try {
await cancelSqlExecution(runningUuid)
} catch (error) {
toast.error(getErrorMessage(error))
}
}
const runExplain = async (analyze: boolean) => {
if (!backend || !cell.content.trim()) return
setRequestError(null)
try {
setExplain(
await explainSql({
backendName,
sql: cell.content,
params: paramsFor(cell.content),
analyze,
})
)
setTab('plan')
} catch (error) {
setRequestError(getErrorMessage(error))
} finally {
void afterRun()
}
}
const format = async () => {
try {
const { formatWorkbenchSql } = await import('@/lib/utils/sql-format')
onChange({ ...cell, content: formatWorkbenchSql(cell.content, backend?.dialect) })
} catch (error) {
toast.error(`Could not format: ${getErrorMessage(error)}`)
}
}
const pin = async () => {
if (!notebookId || !result) return
try {
await createSqlSnapshot(notebookId, {
executionUuid: result.executionUuid,
cellKey: cell.cellKey,
})
toast.success('Result pinned')
onSnapshotsChanged()
} catch (error) {
toast.error(getErrorMessage(error))
}
}
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
return (
<section
className="space-y-2 rounded-md border p-3"
aria-label={`SQL cell ${index + 1}`}
data-qqq-id={`section-sql-cell-${index}`}
>
<div className="flex flex-wrap items-center gap-2 text-xs">
<input
className="min-w-0 flex-1 rounded border-transparent bg-transparent px-1 py-0.5 text-sm font-medium hover:border-border focus:border-border"
value={cell.title ?? ''}
placeholder={`SQL cell ${index + 1}`}
aria-label={`Title of SQL cell ${index + 1}`}
onChange={(e) => onChange({ ...cell, title: e.target.value })}
data-qqq-id={`input-sql-cell-title-${index}`}
/>
<label className="inline-flex items-center gap-1">
Backend
<select
className="rounded border px-1 py-0.5"
value={backendName}
onChange={(e) => onChange({ ...cell, backendName: e.target.value })}
data-qqq-id={`select-sql-backend-${index}`}
>
{capabilities.backends.map((b) => (
<option key={b.name} value={b.name}>
{b.label} ({b.dialect})
</option>
))}
</select>
</label>
{capabilities.canWrite ? (
<label
className={cn(
'inline-flex items-center gap-1 rounded px-1.5 py-0.5',
writeMode ? 'bg-destructive/15 text-destructive' : 'bg-muted'
)}
>
<input
type="checkbox"
checked={writeMode}
onChange={(e) => setWriteMode(e.target.checked)}
data-qqq-id={`checkbox-sql-write-mode-${index}`}
/>
Write mode
</label>
) : (
<span className="rounded bg-muted px-1.5 py-0.5">Read-only</span>
)}
</div>
<SqlEditor
value={cell.content}
onChange={(content) => onChange({ ...cell, content })}
dialect={backend?.dialect}
schema={schema}
onRun={run}
label={`SQL for cell ${index + 1}`}
qqqId={`editor-sql-cell-${index}`}
/>
<div className="flex flex-wrap items-center gap-2">
{runningUuid ? (
<button
type="button"
className={button}
onClick={cancel}
data-qqq-id={`button-sql-cancel-${index}`}
>
Cancel
</button>
) : (
<button
type="button"
className={cn(button, 'bg-primary text-primary-foreground')}
onClick={run}
disabled={!backend}
data-qqq-id={`button-sql-run-${index}`}
>
{writeMode ? 'Preview write' : 'Run'}
</button>
)}
<button
type="button"
className={button}
onClick={() => runExplain(false)}
disabled={!backend || !!runningUuid}
data-qqq-id={`button-sql-explain-${index}`}
>
Explain
</button>
{capabilities.canWrite && (
<button
type="button"
className={button}
onClick={() => runExplain(true)}
disabled={!backend || !!runningUuid}
title="Executes the statement in a transaction that is rolled back"
data-qqq-id={`button-sql-explain-analyze-${index}`}
>
Explain analyze
</button>
)}
<button
type="button"
className={button}
onClick={format}
data-qqq-id={`button-sql-format-${index}`}
>
Format
</button>
{runningUuid && (
<span className="text-xs text-muted-foreground" role="status">
Running…
</span>
)}
{backend && !backend.readOnlyEnforced && !writeMode && (
<span className="text-xs text-amber-600">
This backend does not enforce read-only sessions; only the statement check applies.
</span>
)}
</div>
{requestError && (
<p className="text-sm text-destructive" role="alert">
{requestError}
</p>
)}
{preview && (
<WriteConfirmDialog
preview={preview}
backendName={backendName}
pending={committing}
onConfirm={commit}
onCancel={() => setPreview(null)}
/>
)}
{(result || explain) && (
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs" role="tablist" aria-label="Result views">
{(['grid', 'chart', 'plan'] as const).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
className={cn(
'rounded px-2 py-0.5',
tab === t ? 'bg-muted font-medium' : 'text-muted-foreground'
)}
onClick={() => setTab(t)}
data-qqq-id={`tab-sql-${t}-${index}`}
>
{t === 'grid' ? 'Grid' : t === 'chart' ? 'Chart' : 'Plan'}
</button>
))}
{result && (
<span className="ml-2 text-muted-foreground">
{result.status}
{result.durationMs != null && ` · ${formatDuration(result.durationMs)}`}
{result.rowsAffected != null && ` · ${result.rowsAffected} rows affected`}
</span>
)}
{result?.status === 'SUCCEEDED' &&
notebookId &&
canEdit &&
(result.columns?.length ?? 0) > 0 && (
<button
type="button"
className={cn(button, 'ml-auto')}
onClick={pin}
data-qqq-id={`button-sql-pin-${index}`}
>
Pin result
</button>
)}
</div>
{tab === 'grid' && result && (
<ResultView
key={result.executionUuid}
result={result}
pageSize={capabilities.limits.pageSize}
/>
)}
{tab === 'chart' && result?.status === 'SUCCEEDED' && (
<QuickChart
key={result.executionUuid}
columns={result.columns ?? []}
rows={result.rows ?? []}
/>
)}
{tab === 'plan' &&
(explain ? (
<PlanTree explain={explain} />
) : (
<p className="text-sm text-muted-foreground">Use Explain to see the plan.</p>
))}
</div>
)}
{snapshots.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-xs text-muted-foreground">
Pinned results ({snapshots.length})
</summary>
{snapshots.map((s) => (
<div key={s.id} className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground">
{s.label ?? 'Snapshot'} · {s.createDate ?? ''} · {s.rowCount} rows
{s.truncated ? ' (truncated)' : ''}
</p>
<ResultGrid
executionUuid={s.executionUuid ?? String(s.id)}
columns={s.result.columns ?? []}
firstPage={s.result.rows ?? []}
rowCount={s.rowCount}
pageSize={s.rowCount || 1}
staticRows
/>
</div>
))}
</details>
)}
</section>
)
}
/**
* Renders a run's outcome: the grid, or the failure, drift or rejection message.
*
* @param props - Component props.
* @param props.result - The run.
* @param props.pageSize - Rows per page.
* @returns The outcome.
*/
function ResultView({ result, pageSize }: { result: SqlExecuteResponse; pageSize: number }) {
if (result.status === 'DRIFTED') {
return (
<p className="text-sm text-amber-700" role="alert">
Nothing was committed: the affected rows changed since the preview (
{result.previewAffectedRows} → {result.actualAffectedRows}). Preview again.
</p>
)
}
if (result.status !== 'SUCCEEDED') {
return (
<p className="whitespace-pre-wrap text-sm text-destructive" role="alert">
{result.status === 'TIMED_OUT'
? 'Timed out: '
: result.status === 'CANCELLED'
? 'Cancelled: '
: ''}
{result.error ?? result.status}
</p>
)
}
return (
<ResultGrid
executionUuid={result.executionUuid}
columns={result.columns ?? []}
firstPage={result.rows ?? []}
rowCount={result.rowCount ?? 0}
pageSize={pageSize}
truncated={result.truncated}
truncatedReason={result.truncatedReason}
/>
)
}
+199
View File
@@ -0,0 +1,199 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file SqlEditor — CodeMirror 6 SQL editor for workbench cells: dialect-aware highlighting,
* schema autocomplete (tables, columns, keywords) plus QQQ table/field names that insert their
* SQL names, and Ctrl/Cmd+Enter to run.
*
* Keyboard (WCAG 2.1.2 No Keyboard Trap): Tab is not bound, so Tab and Shift+Tab always move
* focus out of the editor; indentation uses the editor's indent commands (Ctrl+] / Ctrl+[).
* Loaded only inside the lazily imported workbench widget, so CodeMirror stays out of the
* initial bundle.
*/
'use client'
import React, { useEffect, useRef } from 'react'
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
type CompletionSource,
} from '@codemirror/autocomplete'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { MySQL, PostgreSQL, SQLite, StandardSQL, sql, type SQLDialect } from '@codemirror/lang-sql'
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { Compartment, EditorState } from '@codemirror/state'
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder } from '@codemirror/view'
import type { SqlSchema, SqlWorkbenchDialect } from '@/types'
import { editorDialectName, editorSchema, qqqNameCompletions } from '@/lib/utils/sql-workbench'
const DIALECTS: Record<ReturnType<typeof editorDialectName>, SQLDialect> = {
PostgreSQL,
MySQL,
SQLite,
StandardSQL,
}
/** Props for {@link SqlEditor}. */
export interface SqlEditorProps {
value: string
onChange: (value: string) => void
dialect?: SqlWorkbenchDialect
schema?: SqlSchema
readOnly?: boolean
/** Called on Ctrl/Cmd+Enter. */
onRun?: () => void
/** Accessible name of the editor. */
label: string
/** `data-qqq-id` of the editable content. */
qqqId: string
}
/**
* A completion source offering QQQ table and field names that insert SQL names.
*
* @param schema - The backend schema, with its QQQ tables.
* @returns The completion source.
*/
function qqqCompletionSource(schema: SqlSchema | undefined): CompletionSource {
const options = qqqNameCompletions(schema)
return (context) => {
const word = context.matchBefore(/[\w.]+/)
if (!word || (word.from === word.to && !context.explicit)) return null
return { from: word.from, options, validFor: /^[\w.]*$/ }
}
}
/**
* Builds the language extension for a dialect and schema.
*
* @param dialect - Backend dialect.
* @param schema - Backend schema.
* @returns The extensions for the language compartment.
*/
function languageExtensions(
dialect: SqlWorkbenchDialect | undefined,
schema: SqlSchema | undefined
) {
const sqlDialect = DIALECTS[editorDialectName(dialect)]
return [
sql({ dialect: sqlDialect, schema: editorSchema(schema), upperCaseKeywords: true }),
sqlDialect.language.data.of({ autocomplete: qqqCompletionSource(schema) }),
]
}
/**
* Renders the editor.
*
* @param props - Component props.
* @returns The editor container.
*/
export function SqlEditor(props: SqlEditorProps) {
const { value, onChange, dialect, schema, readOnly = false, onRun, label, qqqId } = props
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const languageRef = useRef(new Compartment())
const readOnlyRef = useRef(new Compartment())
const onChangeRef = useRef(onChange)
const onRunRef = useRef(onRun)
onChangeRef.current = onChange
onRunRef.current = onRun
useEffect(() => {
if (!hostRef.current) return
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: value,
extensions: [
lineNumbers(),
highlightActiveLine(),
history(),
bracketMatching(),
closeBrackets(),
autocompletion({ activateOnTyping: true }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
placeholder('SELECT … (Ctrl/Cmd+Enter to run)'),
keymap.of([
{
key: 'Mod-Enter',
run: () => {
onRunRef.current?.()
return true
},
},
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...completionKeymap,
]),
languageRef.current.of(languageExtensions(dialect, schema)),
readOnlyRef.current.of(EditorState.readOnly.of(readOnly)),
EditorView.contentAttributes.of({ 'aria-label': label, 'data-qqq-id': qqqId }),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) onChangeRef.current(update.state.doc.toString())
}),
EditorView.theme({
'&': {
fontSize: '13px',
border: '1px solid var(--border, #d4d4d8)',
borderRadius: '6px',
},
'&.cm-focused': { outline: '2px solid var(--ring, #6366f1)', outlineOffset: '1px' },
'.cm-content': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
minHeight: '5rem',
},
'.cm-gutters': { backgroundColor: 'transparent', border: 'none' },
}),
],
}),
})
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
// The view is created once; later prop changes are applied by the effects below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
viewRef.current?.dispatch({
effects: languageRef.current.reconfigure(languageExtensions(dialect, schema)),
})
}, [dialect, schema])
useEffect(() => {
viewRef.current?.dispatch({
effects: readOnlyRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
})
}, [readOnly])
useEffect(() => {
const view = viewRef.current
if (view && view.state.doc.toString() !== value) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
}
}, [value])
return <div ref={hostRef} className="qqq-sql-workbench-editor min-w-0" />
}
@@ -0,0 +1,254 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the SQL Workbench building blocks: editor, markdown, plan tree, result grid, chart
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { axe } from 'jest-axe'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { sqlExplain, sqlSchema, sqlSelectResult } from '@/mocks/fixtures/sql-workbench'
import { MarkdownView } from './MarkdownView'
import { PlanTree } from './PlanTree'
import { QuickChart } from './QuickChart'
import { ResultGrid } from './ResultGrid'
import { SqlEditor } from './SqlEditor'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
beforeAll(() => {
// Recharts' ResponsiveContainer observes its size; jsdom has no ResizeObserver.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
})
/**
* Renders an element inside a fresh query client.
*
* @param ui - The element.
* @returns The render result.
*/
function renderWithClient(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
describe('SqlEditor', () => {
it('shows the value, reports edits, follows prop changes and runs on Mod-Enter', async () => {
const onChange = vi.fn()
const onRun = vi.fn()
const { rerender } = render(
<SqlEditor
value="SELECT 1"
onChange={onChange}
onRun={onRun}
dialect="postgresql"
schema={sqlSchema}
label="SQL"
qqqId="editor-test"
/>
)
const content = screen.getByLabelText('SQL')
expect(content).toHaveAttribute('data-qqq-id', 'editor-test')
expect(content.textContent).toContain('SELECT 1')
rerender(
<SqlEditor
value="SELECT 2"
onChange={onChange}
onRun={onRun}
dialect="mysql"
label="SQL"
qqqId="editor-test"
readOnly
/>
)
await waitFor(() => expect(screen.getByLabelText('SQL').textContent).toContain('SELECT 2'))
expect(onChange).toHaveBeenCalledWith('SELECT 2')
const user = userEvent.setup()
rerender(
<SqlEditor
value="SELECT 2"
onChange={onChange}
onRun={onRun}
label="SQL"
qqqId="editor-test"
/>
)
screen.getByLabelText('SQL').focus()
await user.keyboard('{Control>}{Enter}{/Control}')
expect(onRun).toHaveBeenCalled()
})
})
describe('MarkdownView', () => {
it('renders blocks and inline markup as elements, never as HTML', () => {
const { container } = render(
<MarkdownView
source={
'# Title\n\nSome **bold**, *em*, `code` and [a link](https://example.com) <b>raw</b>\n\n- one\n- two\n\n1. first\n\n```\nSELECT 1\n```\n###### small'
}
/>
)
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument()
expect(screen.getByText('bold').tagName).toBe('STRONG')
expect(screen.getByText('em').tagName).toBe('EM')
expect(screen.getByText('code').tagName).toBe('CODE')
expect(screen.getByRole('link', { name: 'a link' })).toHaveAttribute(
'href',
'https://example.com'
)
expect(container.querySelector('b')).toBeNull()
expect(screen.getByText(/<b>raw<\/b>/)).toBeInTheDocument()
expect(container.querySelectorAll('ul li')).toHaveLength(2)
expect(container.querySelectorAll('ol li')).toHaveLength(1)
expect(container.querySelector('pre')?.textContent).toBe('SELECT 1')
expect(screen.getByRole('heading', { name: 'small' })).toBeInTheDocument()
})
it('does not link javascript: URLs', () => {
const { container } = render(<MarkdownView source="[x](javascript:alert(1))" />)
expect(container.querySelector('a')).toBeNull()
})
})
describe('PlanTree', () => {
it('renders nodes with costs and toggles the raw plan', async () => {
const user = userEvent.setup()
const { container } = render(<PlanTree explain={sqlExplain} />)
const tree = screen.getByRole('list', { name: 'Query plan' })
expect(within(tree).getByText('Sort')).toBeInTheDocument()
expect(within(tree).getByText('on person')).toBeInTheDocument()
expect(within(tree).getByText('cost 10..12.5')).toBeInTheDocument()
expect(within(tree).getByText('Sort Key')).toBeInTheDocument()
expect(await axe(container)).toHaveNoViolations()
await user.click(screen.getByRole('button', { name: 'Raw' }))
expect(screen.queryByRole('list', { name: 'Query plan' })).not.toBeInTheDocument()
})
it('shows analyze times and errors', () => {
const plan = { ...sqlExplain.plan!, actualTimeMs: 1.5, actualRows: 10, loops: 1 }
const { rerender } = render(<PlanTree explain={{ ...sqlExplain, analyze: true, plan }} />)
expect(screen.getByText(/actual 1.5 ms/)).toBeInTheDocument()
rerender(<PlanTree explain={{ executionUuid: 'x', status: 'FAILED', error: 'syntax error' }} />)
expect(screen.getByRole('alert')).toHaveTextContent('syntax error')
rerender(<PlanTree explain={{ executionUuid: 'x', status: 'FAILED' }} />)
expect(screen.getByRole('alert')).toHaveTextContent('could not be produced')
})
})
describe('ResultGrid', () => {
it('sorts through the server cache and copies the page', async () => {
const user = userEvent.setup()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const { container } = renderWithClient(
<ResultGrid
executionUuid="exec-1"
columns={sqlSelectResult.columns!}
firstPage={sqlSelectResult.rows!}
rowCount={2}
pageSize={10}
truncated
truncatedReason="BYTES"
/>
)
expect(screen.getByText(/capped by the size limit/)).toBeInTheDocument()
expect(screen.getByText('{"k":1}')).toBeInTheDocument()
expect(await axe(container)).toHaveNoViolations()
await user.click(screen.getByRole('button', { name: /Copy page/ }))
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('id\tfirst_name'))
await user.click(screen.getByRole('button', { name: /^id/ }))
expect(await screen.findByText('Tim')).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute(
'aria-sort',
'ascending'
)
await user.click(screen.getByRole('button', { name: /^id/ }))
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute(
'aria-sort',
'descending'
)
await user.click(screen.getByRole('button', { name: /^id/ }))
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute('aria-sort', 'none')
})
it('handles statements without rows, static snapshots and copy failures', async () => {
const user = userEvent.setup()
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn().mockRejectedValue(new Error('no')) },
configurable: true,
})
const { rerender } = renderWithClient(
<ResultGrid executionUuid="x" columns={[]} firstPage={[]} rowCount={0} pageSize={10} />
)
expect(screen.getByText(/returned no rows/)).toBeInTheDocument()
rerender(
<QueryClientProvider client={new QueryClient()}>
<ResultGrid
executionUuid="x"
columns={sqlSelectResult.columns!}
firstPage={sqlSelectResult.rows!}
rowCount={2}
pageSize={2}
staticRows
/>
</QueryClientProvider>
)
expect(screen.queryByRole('link', { name: /CSV/ })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Copy page/ }))
})
})
describe('QuickChart', () => {
it('offers chart types and value columns, or explains why it cannot chart', async () => {
const user = userEvent.setup()
const columns = [
{ name: 'm', label: 'month', typeName: 'text', jdbcType: 12, kind: 'string' as const },
{ name: 'n', label: 'signups', typeName: 'int4', jdbcType: 4, kind: 'number' as const },
]
const { rerender } = render(
<QuickChart
columns={columns}
rows={[
['jan', 3],
['feb', 5],
]}
/>
)
expect(screen.getByRole('img', { name: /bar chart of signups by month/ })).toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Chart'), 'line')
expect(screen.getByRole('img', { name: /line chart/ })).toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Chart'), 'pie')
await user.click(screen.getByLabelText('signups'))
expect(screen.getByRole('img', { name: /pie chart of signups/ })).toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Category'), '1')
rerender(<QuickChart columns={[columns[0]]} rows={[['jan']]} />)
expect(screen.getByText(/need at least one numeric column/)).toBeInTheDocument()
})
})
@@ -0,0 +1,296 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the SQL Workbench widget: notebooks, running, write confirmation, explain
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { axe } from 'jest-axe'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { QWidgetMetaData, SqlExecuteRequest } from '@/types'
import { server } from '@/mocks/node'
import { sqlReadOnlyCapabilities } from '@/mocks/fixtures/sql-workbench'
import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
import SqlWorkbenchWidget from './SqlWorkbenchWidget'
vi.mock('./SqlEditor', () => ({
SqlEditor: ({
value,
onChange,
label,
onRun,
}: {
value: string
onChange: (v: string) => void
label: string
onRun?: () => void
}) => (
<textarea
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) onRun?.()
}}
/>
),
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
beforeAll(() => {
// Recharts' ResponsiveContainer observes its size; jsdom has no ResizeObserver.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
})
const BASE = '/qqq/v1/sql-workbench'
const widgetMetaData = {
name: 'sqlWorkbenchWidget',
label: 'SQL Workbench',
type: 'SQL_WORKBENCH',
} as QWidgetMetaData
/**
* Renders an element inside a fresh query client.
*
* @param ui - The element.
* @returns The render result.
*/
function renderWithClient(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
afterEach(() => {
window.history.replaceState(null, '', '/')
})
describe('SqlWorkbenchWidget', () => {
it('lists notebooks and opens one into the URL', async () => {
const user = userEvent.setup()
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
await user.click(await screen.findByRole('button', { name: /Monthly signups/ }))
expect(await screen.findByDisplayValue('Monthly signups')).toBeInTheDocument()
expect(window.location.search).toBe('?notebook=1')
expect(screen.getByText('month')).toBeInTheDocument()
expect(screen.getByText(':since')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'My notebooks' })).toHaveAttribute(
'aria-pressed',
'true'
)
await user.click(screen.getByRole('button', { name: 'Shared library' }))
expect(screen.getByRole('button', { name: 'Shared library' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(await screen.findByRole('button', { name: /Orphan media/ })).toBeInTheDocument()
})
it('adds a history entry as one cell, and not again on save or notebook switch', async () => {
const user = userEvent.setup()
window.history.replaceState(null, '', '/?notebook=1')
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
await screen.findByDisplayValue('Monthly signups')
const before = screen.getAllByLabelText(/^SQL for cell/).length
await user.click(screen.getByRole('button', { name: /SELECT \* FROM person/ }))
await waitFor(() => expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(before + 1))
// Save bumps the version, which remounts the draft: the request must not re-apply.
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('button', { name: 'Saved' })).toBeInTheDocument()
expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(before + 1)
// Switching notebooks must not inject the old request into the other notebook.
await user.click(screen.getByRole('button', { name: '+ New notebook' }))
expect(await screen.findByDisplayValue('Untitled notebook')).toBeInTheDocument()
expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(1)
expect(screen.getByLabelText('SQL for cell 1')).toHaveValue('')
})
it('shows an error when the notebook list fails to load', async () => {
server.use(
http.get(`${BASE}/notebooks`, () =>
HttpResponse.json({ error: 'Database unavailable.' }, { status: 500 })
)
)
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
expect(await screen.findByText(/Could not load notebooks/)).toBeInTheDocument()
expect(screen.queryByText('No notebooks yet.')).not.toBeInTheDocument()
})
it('locks the default backend and cell controls on a notebook the user cannot edit', async () => {
const { sqlNotebook } = await import('@/mocks/fixtures/sql-workbench')
server.use(
http.get(`${BASE}/notebooks/:id`, () => HttpResponse.json({ ...sqlNotebook, canEdit: false }))
)
window.history.replaceState(null, '', '/?notebook=1')
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
await screen.findByDisplayValue('Monthly signups')
expect(screen.getByLabelText('Default backend')).toBeDisabled()
expect(screen.queryByRole('button', { name: /^Delete cell/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Move cell/ })).not.toBeInTheDocument()
})
it('runs read-only SQL and shows typed results', async () => {
const user = userEvent.setup()
let sent: SqlExecuteRequest | null = null
server.use(
http.post(`${BASE}/execute`, async ({ request }) => {
sent = (await request.json()) as SqlExecuteRequest
const { sqlSelectResult } = await import('@/mocks/fixtures/sql-workbench')
return HttpResponse.json(sqlSelectResult)
}),
http.get(`${BASE}/capabilities`, async () => {
const { sqlCapabilities } = await import('@/mocks/fixtures/sql-workbench')
return HttpResponse.json({
...sqlCapabilities,
limits: { ...sqlCapabilities.limits, pageSize: 2 },
})
})
)
window.history.replaceState(null, '', '/?notebook=1')
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
await user.click(await screen.findByRole('button', { name: 'Run' }))
const table = await screen.findByRole('table', { name: 'Query result' })
expect(within(table).getByText('Darin')).toBeInTheDocument()
expect(within(table).getAllByText('NULL').length).toBeGreaterThan(0)
expect(within(table).getByText('<binary 4 bytes>')).toBeInTheDocument()
expect(sent!.mode).toBe('READ_ONLY')
expect(sent!.params).toEqual({ since: '2026-01-01' })
expect(sent!.cellKey).toBe('c1')
expect(sent!.executionUuid).toMatch(/[0-9a-f-]{36}/)
expect(screen.getByRole('link', { name: /CSV/ })).toHaveAttribute(
'href',
'/qqq/v1/sql-workbench/executions/exec-1/export?format=csv'
)
await user.click(screen.getByRole('button', { name: 'Next' }))
expect(await within(table).findByText('Tim')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Chart' }))
expect(screen.getByLabelText('Chart')).toBeInTheDocument()
})
it('shows SQL errors as results', async () => {
const user = userEvent.setup()
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
const editor = await screen.findByLabelText('SQL for cell 1')
await user.type(editor, 'select broken')
await user.click(screen.getByRole('button', { name: 'Run' }))
expect(await screen.findByText(/column "broken" does not exist/)).toBeInTheDocument()
})
it('previews a write and commits only after confirmation', async () => {
const user = userEvent.setup()
const bodies: SqlExecuteRequest[] = []
server.use(
http.post(`${BASE}/execute`, async ({ request }) => {
const body = (await request.json()) as SqlExecuteRequest
bodies.push(body)
const { sqlWritePreview, sqlWriteCommit } = await import('@/mocks/fixtures/sql-workbench')
return HttpResponse.json(body.confirmToken ? sqlWriteCommit : sqlWritePreview)
})
)
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
await user.type(
await screen.findByLabelText('SQL for cell 1'),
'UPDATE person SET active = false'
)
await user.click(screen.getByLabelText('Write mode'))
await user.click(screen.getByRole('button', { name: 'Preview write' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText(/1204 rows affected/)).toBeInTheDocument()
const commit = within(dialog).getByRole('button', { name: 'Commit write' })
expect(commit).toBeDisabled()
await user.type(within(dialog).getByLabelText(/Type postgres to confirm/), 'postgres')
expect(commit).toBeDisabled()
await user.click(within(dialog).getByLabelText(/I understand/))
await user.click(commit)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(bodies).toHaveLength(2)
expect(bodies[0].confirmToken).toBeUndefined()
expect(bodies[1]).toMatchObject({
mode: 'WRITE',
confirmToken: 'token-1',
acknowledgeRowGuard: true,
})
expect(await screen.findByText(/1204 rows affected/)).toBeInTheDocument()
})
it('explains a statement as a plan tree', async () => {
const user = userEvent.setup()
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
await user.type(await screen.findByLabelText('SQL for cell 1'), 'SELECT 1')
await user.click(screen.getByRole('button', { name: 'Explain' }))
const plan = await screen.findByRole('list', { name: 'Query plan' })
expect(within(plan).getByText('Seq Scan')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Raw' }))
expect(screen.getByText('[{"Plan": {}}]')).toBeInTheDocument()
})
it('saves a new notebook', async () => {
const user = userEvent.setup()
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
const title = await screen.findByLabelText('Notebook title')
await user.clear(title)
await user.type(title, 'Fresh')
await user.click(screen.getByRole('button', { name: '+ Markdown cell' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(window.location.search).toBe('?notebook=9'))
})
it('hides write controls for read-only users', async () => {
server.use(http.get(`${BASE}/capabilities`, () => HttpResponse.json(sqlReadOnlyCapabilities)))
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
expect(await screen.findByText('Read-only')).toBeInTheDocument()
expect(screen.queryByLabelText('Write mode')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Explain analyze' })).not.toBeInTheDocument()
})
it('says when the workbench is unavailable', async () => {
server.use(
http.get(`${BASE}/capabilities`, () =>
HttpResponse.json({ error: 'Permission denied.' }, { status: 403 })
)
)
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
expect(await screen.findByText(/not available to you/)).toBeInTheDocument()
})
it('is rendered by WidgetRenderer for SQL_WORKBENCH and has no axe violations', async () => {
const { container } = renderWithClient(
<WidgetRenderer widgetMetaData={widgetMetaData} data={{}} />
)
await screen.findByRole('button', { name: '+ New notebook' })
expect(await axe(container)).toHaveNoViolations()
})
})
@@ -0,0 +1,245 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file SqlWorkbenchWidget — the qbit-sql-workbench app's `SQL_WORKBENCH` widget: notebook
* library (mine / shared), execution history, and the notebook editor. Its data comes from the
* permission-checked `/qqq/v1/sql-workbench` endpoints (as the ESB overview widget reads the ESB
* endpoints); the open notebook is kept in the URL (`?notebook=<id>`). Loaded lazily by
* WidgetRenderer, so CodeMirror and the formatter are only downloaded here.
*/
'use client'
import React, { useCallback, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import type { QWidgetMetaData, SqlHistoryEntry } from '@/types'
import {
getSqlHistory,
getSqlWorkbenchCapabilities,
listSqlNotebooks,
} from '@/lib/api/sql-workbench'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { formatDuration } from '@/lib/utils/sql-workbench'
import { cn } from '@/lib/utils/cn'
import { NotebookEditor, type InsertCellRequest } from './NotebookEditor'
/**
* The notebook id in the page URL.
*
* @returns The id, or null.
*/
function notebookFromUrl(): number | null {
if (typeof window === 'undefined') return null
const id = Number(new URLSearchParams(window.location.search).get('notebook'))
return Number.isInteger(id) && id > 0 ? id : null
}
/**
* Renders the workbench.
*
* @param props - Component props.
* @param props.widgetMetaData - The widget's metadata, for `data-qqq-id` scoping.
* @returns The workbench, or a loading / unavailable state.
*/
export default function SqlWorkbenchWidget({
widgetMetaData,
}: {
widgetMetaData: QWidgetMetaData
}) {
const [notebookId, setNotebookId] = useState<number | null>(notebookFromUrl)
const [scope, setScope] = useState<'mine' | 'shared'>('mine')
const [insertRequest, setInsertRequest] = useState<InsertCellRequest | null>(null)
const clearInsertRequest = useCallback(() => setInsertRequest(null), [])
const {
data: capabilities,
isLoading,
error,
} = useQuery({
queryKey: queryKeys.sqlWorkbenchCapabilities(),
queryFn: getSqlWorkbenchCapabilities,
meta: HANDLES_OWN_ERRORS,
})
const {
data: notebooks = [],
isLoading: notebooksLoading,
error: notebooksError,
} = useQuery({
queryKey: queryKeys.sqlNotebooks(scope),
queryFn: () => listSqlNotebooks(scope),
enabled: !!capabilities,
meta: HANDLES_OWN_ERRORS,
})
const { data: history = [] } = useQuery({
queryKey: queryKeys.sqlHistory(),
queryFn: () => getSqlHistory(20),
enabled: !!capabilities,
meta: HANDLES_OWN_ERRORS,
})
const select = (id: number | null) => {
setNotebookId(id)
const url = new URL(window.location.href)
if (id === null) url.searchParams.delete('notebook')
else url.searchParams.set('notebook', String(id))
window.history.replaceState(window.history.state, '', url)
}
if (isLoading) {
return (
<p className="text-sm text-muted-foreground" role="status">
Loading SQL Workbench…
</p>
)
}
if (error) {
return (
<p className="text-sm text-destructive" role="alert">
Failed to load the SQL Workbench: {getErrorMessage(error)}
</p>
)
}
if (!capabilities) {
return (
<p className="text-sm text-muted-foreground">
The SQL Workbench is not available to you on this server.
</p>
)
}
if (capabilities.backends.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No SQL backends are configured for the workbench.
</p>
)
}
const openFromHistory = (entry: SqlHistoryEntry) =>
setInsertRequest({ sql: entry.sqlText, backendName: entry.backendName, nonce: Date.now() })
return (
<div
className="grid gap-4 lg:grid-cols-[16rem_1fr]"
data-qqq-id={`widget-${widgetMetaData.name}`}
>
<div className="space-y-4">
<nav aria-label="Notebooks" className="space-y-2">
<div className="flex gap-1 text-xs" role="group" aria-label="Notebook library">
{(['mine', 'shared'] as const).map((s) => (
<button
key={s}
type="button"
aria-pressed={scope === s}
className={cn(
'rounded px-2 py-1',
scope === s ? 'bg-muted font-medium' : 'text-muted-foreground'
)}
onClick={() => setScope(s)}
data-qqq-id={`tab-sql-notebooks-${s}`}
>
{s === 'mine' ? 'My notebooks' : 'Shared library'}
</button>
))}
</div>
<button
type="button"
className="w-full rounded border px-2 py-1 text-left text-sm"
onClick={() => select(null)}
data-qqq-id="button-sql-new-notebook"
>
+ New notebook
</button>
{notebooksLoading ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : notebooksError ? (
<p className="text-xs text-destructive" role="alert">
Could not load notebooks: {getErrorMessage(notebooksError)}
</p>
) : notebooks.length === 0 ? (
<p className="text-xs text-muted-foreground">
{scope === 'mine' ? 'No notebooks yet.' : 'Nothing has been shared yet.'}
</p>
) : (
<ul className="space-y-0.5 text-sm">
{notebooks.map((n) => (
<li key={n.id}>
<button
type="button"
className={cn(
'w-full truncate rounded px-2 py-1 text-left hover:bg-muted',
n.id === notebookId && 'bg-muted font-medium'
)}
onClick={() => select(n.id)}
aria-current={n.id === notebookId ? 'page' : undefined}
title={n.description ?? n.title}
data-qqq-id={`button-sql-notebook-${n.id}`}
>
{n.title}
{n.visibility === 'SHARED' && scope === 'mine' && (
<span className="ml-1 text-xs text-muted-foreground">(shared)</span>
)}
{scope === 'shared' && (
<span className="block text-xs text-muted-foreground">{n.ownerName}</span>
)}
</button>
</li>
))}
</ul>
)}
</nav>
<section aria-label="History" className="space-y-1">
<h3 className="text-xs font-medium text-muted-foreground">Recent runs</h3>
<ul className="space-y-0.5 text-xs">
{history.map((h) => (
<li key={h.executionUuid}>
<button
type="button"
className="w-full rounded px-1 py-0.5 text-left hover:bg-muted"
onClick={() => openFromHistory(h)}
title={`${h.mode} ${h.status} — add as a new cell`}
data-qqq-id={`button-sql-history-${h.executionUuid}`}
>
<span
className={cn(
'mr-1',
h.status === 'SUCCEEDED' ? 'text-emerald-600' : 'text-destructive'
)}
>
{h.status === 'SUCCEEDED' ? '✓' : '✗'}
</span>
<span className="font-mono">{h.sqlText.slice(0, 60)}</span>
<span className="ml-1 text-muted-foreground">{formatDuration(h.durationMs)}</span>
</button>
</li>
))}
</ul>
</section>
</div>
<div className="min-w-0">
<NotebookEditor
notebookId={notebookId}
capabilities={capabilities}
insertRequest={insertRequest}
onInsertConsumed={clearInsertRequest}
onSelect={select}
/>
</div>
</div>
)
}
@@ -0,0 +1,147 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file WriteConfirmDialog — the second step of write mode. Shows every statement with its kind
* and the affected rows from the rolled-back dry run, requires acknowledging the row guard when
* it is exceeded, and requires typing the backend name before the commit is sent.
*/
'use client'
import React, { useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import type { SqlExecuteResponse } from '@/types'
/** Props for {@link WriteConfirmDialog}. */
export interface WriteConfirmDialogProps {
preview: SqlExecuteResponse
backendName: string
pending: boolean
onConfirm: (acknowledgeRowGuard: boolean) => void
onCancel: () => void
}
/**
* Renders the confirmation dialog (open while mounted).
*
* @param props - Component props.
* @returns The dialog.
*/
export function WriteConfirmDialog({
preview,
backendName,
pending,
onConfirm,
onCancel,
}: WriteConfirmDialogProps) {
const [typed, setTyped] = useState('')
const [acknowledged, setAcknowledged] = useState(false)
const guard = preview.rowGuardExceeded === true
const canCommit = typed === backendName && (!guard || acknowledged) && !pending
return (
<Dialog.Root open onOpenChange={(open) => !open && onCancel()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-50 w-[min(42rem,95vw)] -translate-x-1/2 -translate-y-1/2 space-y-3 rounded-lg border bg-background p-4 shadow-lg"
data-qqq-id="dialog-sql-write-confirm"
>
<Dialog.Title className="text-base font-semibold">
Confirm write on “{backendName}”
</Dialog.Title>
<Dialog.Description className="flex gap-2 rounded bg-amber-50 p-2 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100">
<span aria-hidden>⚠</span>
Raw SQL bypasses QQQ record security, audits, automations and search indexing. It runs
in one transaction and is audited.
</Dialog.Description>
<table className="w-full text-sm" aria-label="Statements to commit">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th scope="col">#</th>
<th scope="col">Kind</th>
<th scope="col">Statement</th>
<th scope="col" className="text-right">
Rows
</th>
</tr>
</thead>
<tbody>
{(preview.statements ?? []).map((s) => (
<tr key={s.index} className="align-top">
<td>{s.index + 1}</td>
<td className="font-mono text-xs">{s.kind}</td>
<td className="max-w-md truncate font-mono text-xs" title={s.text}>
{s.text}
</td>
<td className="text-right tabular-nums">{s.affectedRows ?? '—'}</td>
</tr>
))}
</tbody>
</table>
<p className="text-sm">
{preview.dryRunSupported
? `Dry run (rolled back): ${preview.totalAffectedRows ?? 0} rows affected.`
: (preview.dryRunNote ?? 'This write could not be dry-run.')}
</p>
{guard && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
data-qqq-id="checkbox-sql-row-guard"
/>
I understand this affects more than {preview.rowGuardLimit} rows.
</label>
)}
<label className="block text-sm">
Type <span className="font-mono">{backendName}</span> to confirm
<input
className="mt-1 block w-full rounded border px-2 py-1 font-mono"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
data-qqq-id="input-sql-confirm-backend"
/>
</label>
<div className="flex justify-end gap-2">
<Dialog.Close asChild>
<button
type="button"
className="rounded border px-3 py-1 text-sm"
data-qqq-id="button-sql-write-cancel"
>
Cancel
</button>
</Dialog.Close>
<button
type="button"
className="rounded bg-destructive px-3 py-1 text-sm text-destructive-foreground disabled:opacity-50"
disabled={!canCommit}
onClick={() => onConfirm(acknowledged)}
data-qqq-id="button-sql-write-commit"
>
{pending ? 'Committing…' : 'Commit write'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
+4 -1
View File
@@ -25,7 +25,7 @@
* pieChart, quickSightChart, statistics, stackedBarChart, stepper, table, usaMap,
* process, parentWidget, composite, childRecordList, customComponent, cronUI,
* dynamicForm, dataBagViewer, pivotTableSetup, filterAndColumnsSetup, rowBuilder,
* scriptViewer, and the ESB module's ESB_OVERVIEW — plus the demo shapes (recordGrid, quickLinks, processSummary,
* scriptViewer, the ESB module's ESB_OVERVIEW and the SQL Workbench QBit's SQL_WORKBENCH — plus the demo shapes (recordGrid, quickLinks, processSummary,
* block, parent) used by the mocked development API.
*
* Chart components are loaded lazily so Recharts is split out of the initial bundle.
@@ -77,6 +77,7 @@ const LineChartWidget = lazy(() => import('./LineChartWidget').then((m) => ({ de
const PieChartWidget = lazy(() => import('./PieChartWidget').then((m) => ({ default: m.PieChartWidget })))
const FilterAndColumnsSetupWidget = lazy(() => import('./FilterAndColumnsSetupWidget').then((m) => ({ default: m.FilterAndColumnsSetupWidget })))
const PivotTableSetupWidget = lazy(() => import('./PivotTableSetupWidget').then((m) => ({ default: m.PivotTableSetupWidget })))
const SqlWorkbenchWidget = lazy(() => import('@/components/sql-workbench/SqlWorkbenchWidget'))
const RowBuilderWidget = lazy(() => import('./RowBuilderWidget').then((m) => ({ default: m.RowBuilderWidget })))
/** Fallback while a lazy chart chunk loads; matches the default chart height. */
@@ -215,6 +216,8 @@ export function WidgetRenderer({ widgetMetaData, data, recordContext, formContex
return <ScriptViewerWidget {...common} data={data} />
case 'ESB_OVERVIEW':
return <EsbOverviewWidget widgetMetaData={widgetMetaData} />
case 'SQL_WORKBENCH':
return <Suspense fallback={SetupFallback}><SqlWorkbenchWidget widgetMetaData={widgetMetaData} /></Suspense>
case 'recordGrid':
return <RecordGridWidget data={data as unknown as RecordGridWidgetPayload} widgetName={name} />
case 'quickLinks':
+171
View File
@@ -0,0 +1,171 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the SQL Workbench API client
import { http, HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { server } from '@/mocks/node'
import {
sqlCapabilities,
sqlExplain,
sqlNotebook,
sqlSchema,
sqlSelectResult,
sqlWritePreview,
} from '@/mocks/fixtures/sql-workbench'
import {
cancelSqlExecution,
createSqlNotebook,
createSqlSnapshot,
deleteSqlNotebook,
deleteSqlSnapshot,
duplicateSqlNotebook,
executeSql,
explainSql,
getSqlHistory,
getSqlNotebook,
getSqlRows,
getSqlSchema,
getSqlWorkbenchCapabilities,
listSqlNotebooks,
listSqlNotebookVersions,
listSqlSnapshots,
publishSqlNotebook,
restoreSqlNotebookVersion,
saveSqlNotebook,
sqlExportUrl,
} from './sql-workbench'
const BASE = '/qqq/v1/sql-workbench'
describe('getSqlWorkbenchCapabilities', () => {
it('returns the capabilities', async () => {
expect(await getSqlWorkbenchCapabilities()).toEqual(sqlCapabilities)
})
it.each([403, 404])('treats %i as unavailable', async (status) => {
server.use(
http.get(`${BASE}/capabilities`, () => HttpResponse.json({ error: 'x' }, { status }))
)
expect(await getSqlWorkbenchCapabilities()).toBeNull()
})
it('treats a non-workbench body (the SPA fallback) as unavailable', async () => {
server.use(http.get(`${BASE}/capabilities`, () => HttpResponse.text('<html></html>')))
expect(await getSqlWorkbenchCapabilities()).toBeNull()
})
it('throws other errors', async () => {
server.use(
http.get(`${BASE}/capabilities`, () => HttpResponse.json({ error: 'boom' }, { status: 500 }))
)
await expect(getSqlWorkbenchCapabilities()).rejects.toThrow()
})
})
describe('running SQL', () => {
it('loads a schema, with and without refresh', async () => {
let refresh: string | null = null
server.use(
http.get(`${BASE}/schema/:backend`, ({ request }) => {
refresh = new URL(request.url).searchParams.get('refresh')
return HttpResponse.json(sqlSchema)
})
)
expect(await getSqlSchema('postgres')).toEqual(sqlSchema)
expect(refresh).toBeNull()
await getSqlSchema('postgres', true)
expect(refresh).toBe('true')
})
it('executes read-only and write requests', async () => {
expect(await executeSql({ backendName: 'postgres', sql: 'SELECT 1' })).toEqual(sqlSelectResult)
expect(await executeSql({ backendName: 'postgres', sql: 'UPDATE x', mode: 'WRITE' })).toEqual(
sqlWritePreview
)
})
it('explains', async () => {
expect(await explainSql({ backendName: 'postgres', sql: 'SELECT 1' })).toEqual(sqlExplain)
})
it('pages with and without sorting', async () => {
const seen: string[] = []
server.use(
http.get(`${BASE}/executions/:uuid/rows`, ({ request }) => {
seen.push(new URL(request.url).search)
return HttpResponse.json({ rows: [] })
})
)
await getSqlRows('exec-1', 0, 10)
await getSqlRows('exec-1', 10, 10, 2, 'desc')
await getSqlRows('exec-1', 10, 10, 1)
expect(seen).toEqual([
'?offset=0&limit=10',
'?offset=10&limit=10&sort=2&dir=desc',
'?offset=10&limit=10&sort=1&dir=asc',
])
})
it('cancels, exports and lists history', async () => {
expect(await cancelSqlExecution('exec-1')).toBe(true)
expect(sqlExportUrl('exec 1', 'xlsx')).toBe(
'/qqq/v1/sql-workbench/executions/exec%201/export?format=xlsx'
)
expect(await getSqlHistory()).toHaveLength(1)
let params = ''
server.use(
http.get(`${BASE}/history`, ({ request }) => {
params = new URL(request.url).search
return HttpResponse.json({})
})
)
expect(await getSqlHistory(5, 'postgres')).toEqual([])
expect(params).toBe('?limit=5&backendName=postgres')
})
})
describe('notebooks', () => {
it('lists, loads, creates, saves and deletes', async () => {
expect((await listSqlNotebooks('mine'))[0].title).toBe('Monthly signups')
expect((await listSqlNotebooks('shared'))[0].visibility).toBe('SHARED')
expect(await getSqlNotebook(1)).toEqual(sqlNotebook)
expect((await createSqlNotebook({ title: 'New' })).title).toBe('New')
expect((await saveSqlNotebook(1, { title: 'Renamed' })).title).toBe('Renamed')
await expect(deleteSqlNotebook(1)).resolves.toBeUndefined()
server.use(http.get(`${BASE}/notebooks`, () => HttpResponse.json({})))
expect(await listSqlNotebooks('mine')).toEqual([])
})
it('publishes, duplicates, versions and snapshots', async () => {
expect((await publishSqlNotebook(1, true)).visibility).toBe('SHARED')
expect((await duplicateSqlNotebook(1)).id).toBe(10)
expect(await listSqlNotebookVersions(1)).toHaveLength(2)
expect((await restoreSqlNotebookVersion(1, 1)).currentVersion).toBe(3)
expect(await listSqlSnapshots(1)).toEqual([])
expect((await createSqlSnapshot(1, { executionUuid: 'exec-1', cellKey: 'c1' })).id).toBe(5)
await expect(deleteSqlSnapshot(1, 5)).resolves.toBeUndefined()
server.use(
http.get(`${BASE}/notebooks/:id/versions`, () => HttpResponse.json({})),
http.get(`${BASE}/notebooks/:id/snapshots`, () => HttpResponse.json({}))
)
expect(await listSqlNotebookVersions(1)).toEqual([])
expect(await listSqlSnapshots(1)).toEqual([])
})
})
+340
View File
@@ -0,0 +1,340 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file SQL Workbench API — permission-checked `/qqq/v1/sql-workbench` endpoints served by the
* qbit-sql-workbench QBit. SQL errors come back as results (status FAILED / TIMED_OUT /
* CANCELLED), not as request errors; request errors are 400 / 401 / 403 / 404.
*/
import type {
SqlExecuteRequest,
SqlExecuteResponse,
SqlExplainResponse,
SqlExportFormat,
SqlHistoryEntry,
SqlNotebook,
SqlNotebookInput,
SqlNotebookSummary,
SqlNotebookVersionSummary,
SqlResultSnapshot,
SqlRowsPage,
SqlSchema,
SqlWorkbenchCapabilities,
} from '@/types'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import apiClient, { apiUrl } from './client'
/** Base path of the workbench routes under the API base URL. */
export const SQL_WORKBENCH_PATH = '/sql-workbench'
/**
* Loads what the caller may do, the limits and the target backends from `GET /capabilities`.
*
* 403 (no access), 404, and a 2xx body that is not a capabilities object (the SPA fallback when
* the backend does not have the QBit) all mean the workbench is unavailable.
*
* @returns The capabilities, or `null` when the workbench is unavailable to this user.
* @throws The request error for any other failure.
*/
export async function getSqlWorkbenchCapabilities(): Promise<SqlWorkbenchCapabilities | null> {
try {
const data = await apiClient.get<unknown>(`${SQL_WORKBENCH_PATH}/capabilities`)
return isObject(data) && Array.isArray(data.backends) && isObject(data.limits)
? (data as unknown as SqlWorkbenchCapabilities)
: null
} catch (error) {
const status = getErrorStatusCode(error)
if (status === 403 || status === 404) return null
throw error
}
}
/**
* Loads a backend's schema for autocomplete from `GET /schema/{backend}`.
*
* @param backendName - Target backend.
* @param refresh - Re-read instead of using the server's cache.
* @returns The schema.
*/
export async function getSqlSchema(backendName: string, refresh = false): Promise<SqlSchema> {
return apiClient.get<SqlSchema>(
`${SQL_WORKBENCH_PATH}/schema/${encodeURIComponent(backendName)}`,
{
params: refresh ? { refresh: true } : undefined,
}
)
}
/**
* Runs SQL via `POST /execute`: read-only, a write preview (mode WRITE without a token), or a
* write commit (mode WRITE with the preview's `confirmToken`).
*
* @param request - The run.
* @returns The outcome.
*/
export async function executeSql(request: SqlExecuteRequest): Promise<SqlExecuteResponse> {
return apiClient.post<SqlExecuteResponse>(`${SQL_WORKBENCH_PATH}/execute`, request, {
timeout: 0,
})
}
/**
* Explains one statement via `POST /explain`.
*
* @param request - Backend, SQL, parameters, and whether to ANALYZE (executes; rolled back).
* @param request.backendName - Target backend.
* @param request.sql - The statement.
* @param request.params - Parameter values.
* @param request.analyze - Run EXPLAIN ANALYZE.
* @returns The plan.
*/
export async function explainSql(request: {
backendName: string
sql: string
params?: Record<string, string | number | boolean | null>
analyze?: boolean
}): Promise<SqlExplainResponse> {
return apiClient.post<SqlExplainResponse>(`${SQL_WORKBENCH_PATH}/explain`, request, {
timeout: 0,
})
}
/**
* Pages (and optionally sorts) a cached result via `GET /executions/{uuid}/rows`.
*
* @param executionUuid - The run.
* @param offset - Rows to skip.
* @param limit - Rows to return.
* @param sort - Column index to sort by.
* @param dir - Sort direction.
* @returns The page.
*/
export async function getSqlRows(
executionUuid: string,
offset: number,
limit: number,
sort?: number,
dir?: 'asc' | 'desc'
): Promise<SqlRowsPage> {
return apiClient.get<SqlRowsPage>(
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/rows`,
{
params: { offset, limit, ...(sort == null ? {} : { sort, dir: dir ?? 'asc' }) },
}
)
}
/**
* Cancels a running execution via `POST /executions/{uuid}/cancel`.
*
* @param executionUuid - The run.
* @returns Whether a running execution was found and cancelled.
*/
export async function cancelSqlExecution(executionUuid: string): Promise<boolean> {
const data = await apiClient.post<{ cancelled: boolean }>(
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/cancel`,
{}
)
return data.cancelled === true
}
/**
* The download URL of a cached result export (the browser opens it, sending its session cookie).
*
* @param executionUuid - The run.
* @param format - csv, xlsx or json.
* @returns The URL.
*/
export function sqlExportUrl(executionUuid: string, format: SqlExportFormat): string {
return apiUrl(
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/export?format=${format}`
)
}
/**
* The caller's recent executions from `GET /history`.
*
* @param limit - How many (server caps at 200).
* @param backendName - Only this backend.
* @returns Newest first.
*/
export async function getSqlHistory(limit = 50, backendName?: string): Promise<SqlHistoryEntry[]> {
const data = await apiClient.get<{ executions: SqlHistoryEntry[] }>(
`${SQL_WORKBENCH_PATH}/history`,
{
params: { limit, ...(backendName ? { backendName } : {}) },
}
)
return data.executions ?? []
}
/**
* Lists notebooks.
*
* @param scope - The caller's own, or the shared library.
* @returns Summaries, most recently modified first.
*/
export async function listSqlNotebooks(scope: 'mine' | 'shared'): Promise<SqlNotebookSummary[]> {
const data = await apiClient.get<{ notebooks: SqlNotebookSummary[] }>(
`${SQL_WORKBENCH_PATH}/notebooks`,
{
params: { scope },
}
)
return data.notebooks ?? []
}
/**
* Loads one notebook with its cells.
*
* @param id - Notebook id.
* @returns The notebook.
*/
export async function getSqlNotebook(id: number): Promise<SqlNotebook> {
return apiClient.get<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}`)
}
/**
* Creates a personal notebook.
*
* @param input - Title, cells and parameters.
* @returns The new notebook (version 1).
*/
export async function createSqlNotebook(input: SqlNotebookInput): Promise<SqlNotebook> {
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks`, input)
}
/**
* Saves a notebook (its cells are replaced; a new version is recorded).
*
* @param id - Notebook id.
* @param input - The new content.
* @returns The saved notebook.
*/
export async function saveSqlNotebook(id: number, input: SqlNotebookInput): Promise<SqlNotebook> {
return apiClient.put<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}`, input)
}
/**
* Deletes a notebook (owner only).
*
* @param id - Notebook id.
*/
export async function deleteSqlNotebook(id: number): Promise<void> {
await apiClient.delete(`${SQL_WORKBENCH_PATH}/notebooks/${id}`)
}
/**
* Publishes a notebook to, or withdraws it from, the shared library.
*
* @param id - Notebook id.
* @param shared - Publish (true) or withdraw (false).
* @returns The notebook.
*/
export async function publishSqlNotebook(id: number, shared: boolean): Promise<SqlNotebook> {
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/publish`, { shared })
}
/**
* Makes a personal copy of a readable notebook.
*
* @param id - Notebook id.
* @returns The copy.
*/
export async function duplicateSqlNotebook(id: number): Promise<SqlNotebook> {
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/duplicate`, {})
}
/**
* Lists a notebook's versions.
*
* @param id - Notebook id.
* @returns Newest first.
*/
export async function listSqlNotebookVersions(id: number): Promise<SqlNotebookVersionSummary[]> {
const data = await apiClient.get<{ versions: SqlNotebookVersionSummary[] }>(
`${SQL_WORKBENCH_PATH}/notebooks/${id}/versions`
)
return data.versions ?? []
}
/**
* Restores a version (saved as a new version).
*
* @param id - Notebook id.
* @param versionNumber - Version to restore.
* @returns The notebook.
*/
export async function restoreSqlNotebookVersion(
id: number,
versionNumber: number
): Promise<SqlNotebook> {
return apiClient.post<SqlNotebook>(
`${SQL_WORKBENCH_PATH}/notebooks/${id}/versions/${versionNumber}/restore`,
{}
)
}
/**
* Lists a notebook's pinned results.
*
* @param id - Notebook id.
* @returns Newest first.
*/
export async function listSqlSnapshots(id: number): Promise<SqlResultSnapshot[]> {
const data = await apiClient.get<{ snapshots: SqlResultSnapshot[] }>(
`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots`
)
return data.snapshots ?? []
}
/**
* Pins a cached result of the caller to a cell.
*
* @param id - Notebook id.
* @param input - The run, the cell, an optional label.
* @param input.executionUuid - The run to pin.
* @param input.cellKey - The cell it belongs to.
* @param input.label - Optional label.
* @returns The snapshot.
*/
export async function createSqlSnapshot(
id: number,
input: { executionUuid: string; cellKey: string; label?: string }
): Promise<SqlResultSnapshot> {
return apiClient.post<SqlResultSnapshot>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots`, input)
}
/**
* Unpins a snapshot.
*
* @param id - Notebook id.
* @param snapshotId - Snapshot id.
*/
export async function deleteSqlSnapshot(id: number, snapshotId: number): Promise<void> {
await apiClient.delete(`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots/${snapshotId}`)
}
/**
* Whether a value is a plain object.
*
* @param value - Anything.
* @returns True for non-null, non-array objects.
*/
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+55
View File
@@ -463,6 +463,61 @@ export const queryKeys = {
esbMessages: (kind: 'destination' | 'deadLetters', name: string, offset: number) =>
[...queryKeys.esb(), 'messages', kind, name, offset] as const,
// SQL Workbench
/**
* Key for all SQL Workbench queries.
*
* @returns The SQL Workbench namespace query key tuple.
*/
sqlWorkbench: () => [...queryKeys.all(), 'sqlWorkbench'] as const,
/**
* Key for the caller's workbench capabilities (permissions, limits, backends).
*
* @returns The capabilities query key tuple.
*/
sqlWorkbenchCapabilities: () => [...queryKeys.sqlWorkbench(), 'capabilities'] as const,
/**
* Key for a backend's schema (autocomplete).
*
* @param backendName - Target backend.
* @returns The schema query key tuple.
*/
sqlWorkbenchSchema: (backendName: string) => [...queryKeys.sqlWorkbench(), 'schema', backendName] as const,
/**
* Key for a notebook list.
*
* @param scope - The caller's own or the shared library.
* @returns The notebook list query key tuple.
*/
sqlNotebooks: (scope: 'mine' | 'shared') => [...queryKeys.sqlWorkbench(), 'notebooks', scope] as const,
/**
* Key for one notebook.
*
* @param id - Notebook id.
* @returns The notebook query key tuple.
*/
sqlNotebook: (id: number) => [...queryKeys.sqlWorkbench(), 'notebook', id] as const,
/**
* Key for a notebook's versions.
*
* @param id - Notebook id.
* @returns The versions query key tuple.
*/
sqlNotebookVersions: (id: number) => [...queryKeys.sqlNotebook(id), 'versions'] as const,
/**
* Key for a notebook's pinned results.
*
* @param id - Notebook id.
* @returns The snapshots query key tuple.
*/
sqlNotebookSnapshots: (id: number) => [...queryKeys.sqlNotebook(id), 'snapshots'] as const,
/**
* Key for the caller's execution history.
*
* @returns The history query key tuple.
*/
sqlHistory: () => [...queryKeys.sqlWorkbench(), 'history'] as const,
// Search
/**
* Key for all global search queries.
+18
View File
@@ -19,6 +19,7 @@
import { describe, it, expect } from 'vitest'
import {
getProcessesForTable,
getProcessesForRecordView,
getSingleRecordProcesses,
getBulkProcesses,
getProcessesForAllScreens,
@@ -100,6 +101,23 @@ describe('getProcessesForTable', () => {
})
})
describe('getProcessesForRecordView', () => {
it('sorts the table\'s own processes by label, then keeps all-screens processes in declared order', () => {
const instance = {
...makeInstance({
zeta: makeProcess({ name: 'zeta', label: 'Zeta', tableName: 'person' }),
alpha: makeProcess({ name: 'alpha', label: 'Alpha', tableName: 'person' }),
mid: makeProcess({ name: 'mid', label: 'Mid', tableName: 'person' }),
tag: makeProcess({ name: 'tag', label: 'Tag', tableName: '' }),
audit: makeProcess({ name: 'audit', label: 'Audit', tableName: '' }),
}),
supplementalInstanceMetaData: { materialDashboard: { processNamesToAddToAllQueryAndViewScreens: ['tag', 'audit'] } },
}
expect(getProcessesForRecordView(instance, 'person').map((p) => p.name)).toEqual(['alpha', 'mid', 'zeta', 'tag', 'audit'])
expect(getProcessesForTable(instance, 'person').map((p) => p.name)).toEqual(['zeta', 'alpha', 'mid', 'tag', 'audit'])
})
})
describe('processes added to every query and record screen (Material)', () => {
const withMaterial = (processes: Record<string, QProcessMetaData>, names: unknown) => ({
...makeInstance(processes),
+37 -1
View File
@@ -45,9 +45,45 @@ export function getProcessesForTable(
metaData: QInstance,
tableName: string
): QProcessMetaData[] {
const own = Object.values(metaData.processes ?? {}).filter(
return withAllScreensProcesses(metaData, getOwnProcesses(metaData, tableName))
}
/**
* Processes for the record view's action menu. Material sorts only the table's own
* processes by label there (the query page keeps declared order); all-screens processes
* still follow in their configured order.
*
* @param metaData - The full QQQ instance metadata.
* @param tableName - The backend name of the table whose record is being viewed.
* @returns The table's own visible, permitted processes sorted by label, then the
* all-screens processes not already listed.
*/
export function getProcessesForRecordView(metaData: QInstance, tableName: string): QProcessMetaData[] {
const own = getOwnProcesses(metaData, tableName).sort((a, b) => a.label.localeCompare(b.label))
return withAllScreensProcesses(metaData, own)
}
/**
* Processes declared for the table itself, before any all-screens additions.
*
* @param metaData - The full QQQ instance metadata.
* @param tableName - The backend table name to match against `QProcessMetaData.tableName`.
* @returns The table's own processes that are not hidden and that the user may run, in declared order.
*/
function getOwnProcesses(metaData: QInstance, tableName: string): QProcessMetaData[] {
return Object.values(metaData.processes ?? {}).filter(
(p) => p.tableName === tableName && !p.isHidden && p.hasPermission !== false
)
}
/**
* Appends the processes Material adds to every query and record screen.
*
* @param metaData - The full QQQ instance metadata.
* @param own - The table's own processes, already filtered and ordered.
* @returns `own` followed by the all-screens processes it does not already contain.
*/
function withAllScreensProcesses(metaData: QInstance, own: QProcessMetaData[]): QProcessMetaData[] {
const added = getProcessesForAllScreens(metaData).filter((p) => !own.some((o) => o.name === p.name))
return [...own, ...added]
}
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the workbench SQL formatter
import { describe, expect, it } from 'vitest'
import { formatWorkbenchSql } from './sql-format'
describe('formatWorkbenchSql', () => {
it('formats per dialect, upper-cases keywords and keeps :parameters', () => {
expect(formatWorkbenchSql('select id from person where a = :since', 'postgresql')).toBe(
'SELECT\n id\nFROM\n person\nWHERE\n a = :since'
)
expect(formatWorkbenchSql('select `id` from t', 'mysql')).toContain('SELECT')
expect(formatWorkbenchSql('select 1', 'sqlite')).toBe('SELECT\n 1')
expect(formatWorkbenchSql('select 1', undefined)).toBe('SELECT\n 1')
})
it('throws on SQL it cannot parse', () => {
expect(() => formatWorkbenchSql('select "unterminated', 'postgresql')).toThrow()
})
})
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file SQL formatting for the workbench's Format button. Imports only the four dialects the
* workbench uses (sql-formatter's tree-shakable `formatDialect` API) and is loaded with
* `import()` on first use, so the formatter is not part of any page load.
*/
import { formatDialect, mysql, postgresql, sql, sqlite } from 'sql-formatter'
import type { SqlWorkbenchDialect } from '@/types'
import { formatterLanguage } from './sql-workbench'
const DIALECTS = { postgresql, mysql, sqlite, sql }
/**
* Formats SQL for a backend dialect, upper-casing keywords and keeping `:name` parameters.
*
* @param text - The SQL.
* @param dialect - The backend's dialect.
* @returns The formatted SQL.
* @throws When the formatter cannot parse the SQL.
*/
export function formatWorkbenchSql(text: string, dialect: SqlWorkbenchDialect | undefined): string {
return formatDialect(text, {
dialect: DIALECTS[formatterLanguage(dialect)],
keywordCase: 'upper',
paramTypes: { named: [':'] },
})
}
+245
View File
@@ -0,0 +1,245 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the SQL Workbench helpers
import { describe, expect, it, vi } from 'vitest'
import type { SqlColumn, SqlPlanNode, SqlSchema } from '@/types'
import {
coerceParameterValue,
editorDialectName,
editorSchema,
extractSqlParameters,
formatDuration,
formatSqlValue,
formatterLanguage,
isBinaryValue,
newCellKey,
numericColumnIndexes,
planMax,
prettyJson,
qqqNameCompletions,
rowsToTsv,
toChartRows,
} from './sql-workbench'
const column = (label: string, kind: SqlColumn['kind']): SqlColumn => ({
name: label,
label,
typeName: kind,
jdbcType: 0,
kind,
})
describe('formatSqlValue', () => {
it('renders nulls, binaries, arrays and scalars', () => {
expect(formatSqlValue(null)).toBe('NULL')
expect(formatSqlValue(undefined)).toBe('NULL')
expect(formatSqlValue({ $binary: true, length: 4, hexPreview: 'cafebabe' })).toBe(
'<binary 4 bytes>'
)
expect(formatSqlValue(['a', 1])).toBe('["a",1]')
expect(formatSqlValue(true)).toBe('true')
expect(formatSqlValue(1.5)).toBe('1.5')
expect(isBinaryValue({ $binary: true })).toBe(true)
expect(isBinaryValue([])).toBe(false)
expect(isBinaryValue(null)).toBe(false)
})
it('pretty-prints JSON and leaves other text alone', () => {
expect(prettyJson('{"a":1}')).toBe('{\n "a": 1\n}')
expect(prettyJson('not json')).toBe('not json')
})
it('builds tab-separated text', () => {
expect(
rowsToTsv(
[column('a', 'string'), column('b', 'number')],
[
['x\ty', null],
['z', 2],
]
)
).toBe('a\tb\nx y\t\nz\t2')
})
})
describe('extractSqlParameters', () => {
it('finds parameters outside literals, comments and casts', () => {
const sql = `SELECT id::text, ':skip', "x:y", \`a:b\` FROM t /* :c */ WHERE a = :a AND b = :b_2 OR a = :a -- :d
AND e = $$ :e $$ AND f = $tag$ :f $tag$ AND g = :g`
expect(extractSqlParameters(sql)).toEqual(['a', 'b_2', 'g'])
})
it('handles escapes and unterminated text', () => {
expect(extractSqlParameters("SELECT 'it''s :x', :y")).toEqual(['y'])
expect(extractSqlParameters('SELECT 1 /* :open')).toEqual([])
expect(extractSqlParameters('SELECT $$ :open')).toEqual([])
expect(extractSqlParameters('SELECT :1, a:=:b')).toEqual(['b'])
expect(extractSqlParameters('')).toEqual([])
})
})
describe('coerceParameterValue', () => {
it('converts by declared type', () => {
expect(coerceParameterValue('', 'STRING')).toBeNull()
expect(coerceParameterValue(null)).toBeNull()
expect(coerceParameterValue('12', 'INTEGER')).toBe(12)
expect(coerceParameterValue('1.5', 'DECIMAL')).toBe(1.5)
expect(coerceParameterValue('abc', 'INTEGER')).toBe('abc')
expect(coerceParameterValue('yes', 'BOOLEAN')).toBe(true)
expect(coerceParameterValue('no', 'BOOLEAN')).toBe(false)
expect(coerceParameterValue('007')).toBe('007')
})
})
describe('dialects', () => {
it('maps backend dialects to editor and formatter dialects', () => {
expect(editorDialectName('postgresql')).toBe('PostgreSQL')
expect(editorDialectName('mysql')).toBe('MySQL')
expect(editorDialectName('sqlite')).toBe('SQLite')
expect(editorDialectName('h2')).toBe('StandardSQL')
expect(editorDialectName(undefined)).toBe('StandardSQL')
expect(formatterLanguage('postgresql')).toBe('postgresql')
expect(formatterLanguage('mysql')).toBe('mysql')
expect(formatterLanguage('sqlite')).toBe('sqlite')
expect(formatterLanguage('h2')).toBe('sql')
})
})
describe('autocomplete sources', () => {
const schema: SqlSchema = {
backendName: 'pg',
dialect: 'postgresql',
schemas: [
{
name: 'public',
tables: [
{
name: 'person',
type: 'TABLE',
columns: [{ name: 'id', typeName: 'int4', nullable: false }],
},
],
},
{
name: 'audit',
tables: [
{
name: 'person',
type: 'TABLE',
columns: [{ name: 'x', typeName: 'int4', nullable: true }],
},
],
},
],
truncated: false,
keywords: [],
functions: [],
qqqTables: [
{
name: 'person',
label: 'Person',
sqlName: 'person',
fields: [{ name: 'firstName', label: 'First Name', sqlName: 'first_name', type: 'STRING' }],
},
],
cachedAt: '',
}
it('maps tables to columns, bare and qualified', () => {
expect(editorSchema(schema)).toEqual({
'public.person': ['id'],
person: ['id'],
'audit.person': ['x'],
})
expect(editorSchema(undefined)).toEqual({})
})
it('offers QQQ names that insert SQL names', () => {
expect(qqqNameCompletions(schema)).toEqual([
{ label: 'person', apply: 'person', detail: 'QQQ table → person', type: 'class' },
{
label: 'person.firstName',
apply: 'person.first_name',
detail: 'QQQ field → first_name',
type: 'property',
},
])
expect(qqqNameCompletions(undefined)).toEqual([])
})
})
describe('charts', () => {
const columns = [
column('month', 'string'),
column('n', 'number'),
column('s', 'string'),
column('t', 'string'),
]
const rows = [
['jan', 1, '2.5', 'a'],
['feb', null, '3', 'b'],
]
it('finds numeric columns, including numeric text', () => {
expect(numericColumnIndexes(columns, rows)).toEqual([1, 2])
expect(numericColumnIndexes(columns, [])).toEqual([1])
})
it('builds chart rows', () => {
expect(toChartRows(columns, rows, 0, [1, 2])).toEqual([
{ x: 'jan', n: 1, s: 2.5 },
{ x: 'feb', n: 0, s: 3 },
])
})
})
describe('plans and misc', () => {
const node = (
cost: number | null,
time: number | null,
children: SqlPlanNode[] = []
): SqlPlanNode => ({
label: 'n',
detail: null,
startupCost: null,
totalCost: cost,
planRows: null,
actualTimeMs: time,
actualRows: null,
loops: null,
extra: {},
children,
})
it('finds the largest cost or time', () => {
const plan = node(10, 1, [node(25, 0.5), node(null, 3)])
expect(planMax(plan, false)).toBe(25)
expect(planMax(plan, true)).toBe(3)
})
it('makes cell keys and durations', () => {
expect(newCellKey()).toMatch(/[0-9a-f-]{36}/)
vi.stubGlobal('crypto', undefined)
expect(newCellKey()).toMatch(/^cell-/)
vi.unstubAllGlobals()
expect(formatDuration(38)).toBe('38 ms')
expect(formatDuration(1234)).toBe('1.2 s')
expect(formatDuration(null)).toBe('')
})
})
+344
View File
@@ -0,0 +1,344 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file SQL Workbench helpers — value formatting, :parameter detection, dialect mapping,
* autocomplete sources, chart data and plan costs. Pure functions; no React, no CodeMirror.
*/
import type {
SqlBinaryValue,
SqlColumn,
SqlNotebookParameter,
SqlPlanNode,
SqlSchema,
SqlValue,
SqlWorkbenchDialect,
} from '@/types'
/** CodeMirror `@codemirror/lang-sql` dialect export names. */
export type EditorDialectName = 'PostgreSQL' | 'MySQL' | 'SQLite' | 'StandardSQL'
/** `sql-formatter` language names. */
export type FormatterLanguage = 'postgresql' | 'mysql' | 'sqlite' | 'sql'
/** One completion entry for QQQ names that insert SQL names. */
export interface QqqNameCompletion {
label: string
apply: string
detail: string
type: 'class' | 'property'
}
/**
* Whether a value is a binary placeholder from the backend.
*
* @param value - A result value.
* @returns True for `{ $binary: true, ... }`.
*/
export function isBinaryValue(value: unknown): value is SqlBinaryValue {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
(value as SqlBinaryValue).$binary === true
)
}
/**
* Text for one result value in the grid, the clipboard and charts' labels.
*
* @param value - The value.
* @returns `NULL` for null, `<binary N bytes>` for binary, JSON for arrays, else the value as text.
*/
export function formatSqlValue(value: SqlValue | undefined): string {
if (value === null || value === undefined) return 'NULL'
if (isBinaryValue(value)) return `<binary ${value.length} bytes>`
if (Array.isArray(value)) return JSON.stringify(value)
return String(value)
}
/**
* Pretty-prints a JSON column value, or returns it unchanged when it is not valid JSON.
*
* @param value - The JSON text.
* @returns Indented JSON.
*/
export function prettyJson(value: string): string {
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch {
return value
}
}
/**
* Tab-separated text of a result, for copying into a spreadsheet.
*
* @param columns - The columns (header row).
* @param rows - The rows.
* @returns Header and rows, tab-separated; NULL as empty.
*/
export function rowsToTsv(columns: SqlColumn[], rows: SqlValue[][]): string {
const cell = (value: SqlValue) =>
value === null ? '' : formatSqlValue(value).replace(/[\t\r\n]+/g, ' ')
return [columns.map((c) => c.label).join('\t'), ...rows.map((r) => r.map(cell).join('\t'))].join(
'\n'
)
}
/**
* Names of the `:name` parameters in SQL, in order of first appearance. Skips string literals,
* quoted identifiers, comments, dollar quotes and Postgres `::` casts — the same rules as the
* backend's scanner, so the UI asks for exactly the values the server will require.
*
* @param sql - The SQL text.
* @returns Unique parameter names.
*/
export function extractSqlParameters(sql: string): string[] {
const names: string[] = []
const seen = new Set<string>()
let i = 0
const n = sql.length
const isStart = (c: string) => /[A-Za-z_]/.test(c)
const isPart = (c: string) => /[A-Za-z0-9_]/.test(c)
while (i < n) {
const c = sql[i]
const next = sql[i + 1]
if (c === '-' && next === '-') {
while (i < n && sql[i] !== '\n') i++
} else if (c === '/' && next === '*') {
const end = sql.indexOf('*/', i + 2)
i = end < 0 ? n : end + 2
} else if (c === "'" || c === '"' || c === '`') {
i++
while (i < n) {
if (sql[i] === c && sql[i + 1] === c) i += 2
else if (sql[i] === c) break
else i++
}
i++
} else if (c === '$' && /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.test(sql.slice(i))) {
const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i))![0]
const end = sql.indexOf(tag, i + tag.length)
i = end < 0 ? n : end + tag.length
} else if (c === ':' && next === ':') {
i += 2
} else if (c === ':' && next !== undefined && isStart(next) && sql[i - 1] !== ':') {
let j = i + 1
while (j < n && isPart(sql[j])) j++
const name = sql.slice(i + 1, j)
if (!seen.has(name)) {
seen.add(name)
names.push(name)
}
i = j
} else {
i++
}
}
return names
}
/**
* Converts a parameter's text input to the JSON value the backend binds.
*
* @param raw - What the user typed.
* @param type - The notebook parameter's declared type, if any.
* @returns `null` for empty input; a number for numeric types (or numeric-looking untyped input
* is still sent as text, so `'007'` stays a string); a boolean for BOOLEAN; else the text.
*/
export function coerceParameterValue(
raw: string | null | undefined,
type?: SqlNotebookParameter['type']
): string | number | boolean | null {
if (raw === null || raw === undefined || raw === '') return null
if (type === 'INTEGER' || type === 'DECIMAL') {
const number = Number(raw)
return Number.isFinite(number) ? number : raw
}
if (type === 'BOOLEAN') {
return /^(true|1|yes|y)$/i.test(raw.trim())
}
return raw
}
/**
* The CodeMirror dialect for a backend dialect.
*
* @param dialect - The backend's dialect name.
* @returns The `@codemirror/lang-sql` dialect export name.
*/
export function editorDialectName(dialect: SqlWorkbenchDialect | undefined): EditorDialectName {
switch (dialect) {
case 'postgresql':
return 'PostgreSQL'
case 'mysql':
return 'MySQL'
case 'sqlite':
return 'SQLite'
default:
return 'StandardSQL'
}
}
/**
* The `sql-formatter` language for a backend dialect.
*
* @param dialect - The backend's dialect name.
* @returns The formatter language.
*/
export function formatterLanguage(dialect: SqlWorkbenchDialect | undefined): FormatterLanguage {
switch (dialect) {
case 'postgresql':
return 'postgresql'
case 'mysql':
return 'mysql'
case 'sqlite':
return 'sqlite'
default:
return 'sql'
}
}
/**
* The `schema` option for `@codemirror/lang-sql`: every table (bare and schema-qualified) mapped
* to its column names.
*
* @param schema - The backend schema.
* @returns Table name to column names.
*/
export function editorSchema(schema: SqlSchema | undefined): Record<string, string[]> {
const result: Record<string, string[]> = {}
for (const s of schema?.schemas ?? []) {
for (const table of s.tables) {
const columns = table.columns.map((c) => c.name)
result[`${s.name}.${table.name}`] = columns
if (!(table.name in result)) result[table.name] = columns
}
}
return result
}
/**
* Completions that let users type QQQ table and field names and insert the SQL names.
*
* @param schema - The backend schema (with its QQQ tables).
* @returns One entry per QQQ table, and per field as `table.field`.
*/
export function qqqNameCompletions(schema: SqlSchema | undefined): QqqNameCompletion[] {
const completions: QqqNameCompletion[] = []
for (const table of schema?.qqqTables ?? []) {
completions.push({
label: table.name,
apply: table.sqlName,
detail: `QQQ table → ${table.sqlName}`,
type: 'class',
})
for (const field of table.fields) {
completions.push({
label: `${table.name}.${field.name}`,
apply: `${table.sqlName}.${field.sqlName}`,
detail: `QQQ field → ${field.sqlName}`,
type: 'property',
})
}
}
return completions
}
/**
* Indexes of the columns a chart can plot as values (numbers, or every row numeric text).
*
* @param columns - Result columns.
* @param rows - Result rows.
* @returns Column indexes.
*/
export function numericColumnIndexes(columns: SqlColumn[], rows: SqlValue[][]): number[] {
return columns
.map((column, index) => ({ column, index }))
.filter(
({ column, index }) =>
column.kind === 'number' ||
(rows.length > 0 &&
rows.every(
(r) =>
r[index] === null ||
(typeof r[index] === 'string' && r[index] !== '' && Number.isFinite(Number(r[index])))
))
)
.map(({ index }) => index)
}
/**
* Rows for a Recharts chart: the x column's text as `x`, and each y column as a number keyed by its label.
*
* @param columns - Result columns.
* @param rows - Result rows.
* @param xIndex - Category column.
* @param yIndexes - Value columns.
* @returns Chart rows (nulls and non-numbers become 0).
*/
export function toChartRows(
columns: SqlColumn[],
rows: SqlValue[][],
xIndex: number,
yIndexes: number[]
): Record<string, string | number>[] {
return rows.map((row) => {
const point: Record<string, string | number> = { x: formatSqlValue(row[xIndex]) }
for (const y of yIndexes) {
const number = Number(row[y])
point[columns[y].label] = row[y] === null || !Number.isFinite(number) ? 0 : number
}
return point
})
}
/**
* The largest total cost (or actual time) in a plan, to scale per-node cost bars.
*
* @param node - Plan root.
* @param useActual - Use actual time instead of cost.
* @returns The maximum, or 0.
*/
export function planMax(node: SqlPlanNode, useActual: boolean): number {
const own = (useActual ? node.actualTimeMs : node.totalCost) ?? 0
return Math.max(own, ...node.children.map((c) => planMax(c, useActual)))
}
/**
* A new, stable cell key.
*
* @returns A UUID.
*/
export function newCellKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
return crypto.randomUUID()
return `cell-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
/**
* A human-readable duration.
*
* @param ms - Milliseconds.
* @returns e.g. `38 ms`, `1.2 s`.
*/
export function formatDuration(ms: number | null | undefined): string {
if (ms === null || ms === undefined) return ''
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`
}
+279
View File
@@ -0,0 +1,279 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Fixtures for the SQL Workbench endpoints, shaped as qbit-sql-workbench answers.
*/
import type {
SqlExecuteResponse,
SqlExplainResponse,
SqlHistoryEntry,
SqlNotebook,
SqlNotebookSummary,
SqlRowsPage,
SqlSchema,
SqlWorkbenchCapabilities,
} from '@/types'
export const sqlCapabilities: SqlWorkbenchCapabilities = {
version: '1.0.0-RC.1',
canWrite: true,
canShare: true,
writeModeEnabled: true,
limits: {
maxRows: 10000,
pageSize: 500,
maxPageSize: 2000,
maxResultBytes: 20971520,
statementTimeoutSeconds: 30,
writeStatementTimeoutSeconds: 60,
maxAffectedRowsWithoutAcknowledgement: 1000,
maxAffectedRows: 100000,
maxSqlLength: 100000,
maxStatementsPerRun: 50,
},
backends: [
{
name: 'postgres',
label: 'postgres',
vendor: 'postgres',
dialect: 'postgresql',
readOnlyCredentials: true,
readOnlyEnforced: true,
transactionalDdl: true,
writeAllowed: true,
},
],
}
export const sqlReadOnlyCapabilities: SqlWorkbenchCapabilities = {
...sqlCapabilities,
canWrite: false,
canShare: false,
backends: sqlCapabilities.backends.map((b) => ({ ...b, writeAllowed: false })),
}
export const sqlSchema: SqlSchema = {
backendName: 'postgres',
dialect: 'postgresql',
schemas: [
{
name: 'public',
tables: [
{
name: 'person',
type: 'TABLE',
columns: [
{ name: 'id', typeName: 'int4', nullable: false },
{ name: 'first_name', typeName: 'text', nullable: true },
],
},
],
},
],
truncated: false,
keywords: ['SELECT', 'FROM'],
functions: ['count'],
qqqTables: [
{
name: 'person',
label: 'Person',
sqlName: 'person',
fields: [{ name: 'firstName', label: 'First Name', sqlName: 'first_name', type: 'STRING' }],
},
],
cachedAt: '2026-10-11T00:00:00Z',
}
export const sqlSelectResult: SqlExecuteResponse = {
executionUuid: 'exec-1',
status: 'SUCCEEDED',
mode: 'READ_ONLY',
backendName: 'postgres',
durationMs: 12,
statements: [
{ index: 0, kind: 'READ', leadingKeyword: 'SELECT', text: 'SELECT ...', affectedRows: null },
],
columns: [
{ name: 'id', label: 'id', typeName: 'int4', jdbcType: 4, kind: 'number' },
{ name: 'first_name', label: 'first_name', typeName: 'text', jdbcType: 12, kind: 'string' },
{ name: 'doc', label: 'doc', typeName: 'jsonb', jdbcType: 1111, kind: 'json' },
{ name: 'photo', label: 'photo', typeName: 'bytea', jdbcType: -2, kind: 'binary' },
],
rows: [
[1, 'Darin', '{"k":1}', { $binary: true, length: 4, hexPreview: 'cafebabe' }],
[2, null, null, null],
],
rowCount: 3,
hasMore: true,
truncated: false,
truncatedReason: null,
}
export const sqlRowsPage: SqlRowsPage = {
executionUuid: 'exec-1',
columns: sqlSelectResult.columns!,
rows: [[3, 'Tim', null, null]],
offset: 2,
rowCount: 3,
truncated: false,
truncatedReason: null,
}
export const sqlWritePreview: SqlExecuteResponse = {
executionUuid: 'exec-2',
status: 'PREVIEW',
mode: 'WRITE_PREVIEW',
backendName: 'postgres',
durationMs: 41,
statements: [
{
index: 0,
kind: 'WRITE',
leadingKeyword: 'UPDATE',
text: 'UPDATE person SET active = false',
affectedRows: 1204,
},
],
dryRunSupported: true,
dryRunNote: null,
totalAffectedRows: 1204,
rowGuardExceeded: true,
rowGuardLimit: 1000,
confirmToken: 'token-1',
expiresAt: '2026-10-11T00:05:00Z',
}
export const sqlWriteCommit: SqlExecuteResponse = {
executionUuid: 'exec-3',
status: 'SUCCEEDED',
mode: 'WRITE_COMMIT',
backendName: 'postgres',
durationMs: 50,
statements: [
{
index: 0,
kind: 'WRITE',
leadingKeyword: 'UPDATE',
text: 'UPDATE person SET active = false',
affectedRows: 1204,
},
],
columns: [],
rows: [],
rowCount: 0,
hasMore: false,
truncated: false,
rowsAffected: 1204,
}
export const sqlExplain: SqlExplainResponse = {
executionUuid: 'exec-4',
status: 'SUCCEEDED',
dialect: 'postgresql',
analyze: false,
raw: '[{"Plan": {}}]',
plan: {
label: 'Sort',
detail: null,
startupCost: 10,
totalCost: 12.5,
planRows: 100,
actualTimeMs: null,
actualRows: null,
loops: null,
extra: { 'Sort Key': '["id"]' },
children: [
{
label: 'Seq Scan',
detail: 'on person',
startupCost: 0,
totalCost: 7.1,
planRows: 100,
actualTimeMs: null,
actualRows: null,
loops: null,
extra: {},
children: [],
},
],
},
}
export const sqlHistory: SqlHistoryEntry[] = [
{
executionUuid: 'exec-1',
backendName: 'postgres',
mode: 'READ_ONLY',
status: 'SUCCEEDED',
sqlText: 'SELECT * FROM person',
notebookId: 1,
cellKey: 'c1',
startTime: '2026-10-11T14:02:00Z',
durationMs: 12,
rowsReturned: 3,
rowsAffected: null,
errorMessage: null,
},
]
export const sqlNotebookSummary: SqlNotebookSummary = {
id: 1,
title: 'Monthly signups',
description: null,
visibility: 'PERSONAL',
ownerUserId: 'user-1',
ownerName: 'User One',
defaultBackendName: 'postgres',
currentVersion: 2,
canEdit: true,
createDate: '2026-10-01T00:00:00Z',
modifyDate: '2026-10-11T00:00:00Z',
}
export const sqlSharedNotebookSummary: SqlNotebookSummary = {
...sqlNotebookSummary,
id: 2,
title: 'Orphan media',
visibility: 'SHARED',
ownerUserId: 'user-2',
ownerName: 'User Two',
canEdit: false,
}
export const sqlNotebook: SqlNotebook = {
...sqlNotebookSummary,
parameters: [{ name: 'since', type: 'DATE', defaultValue: '2026-01-01' }],
cells: [
{
cellKey: 'c0',
cellType: 'MARKDOWN',
title: null,
content: '## Signups by **month**',
backendName: null,
sortOrder: 0,
},
{
cellKey: 'c1',
cellType: 'SQL',
title: 'Signups',
content: 'SELECT * FROM person WHERE create_date >= :since',
backendName: null,
sortOrder: 1,
},
],
}
+2
View File
@@ -25,6 +25,7 @@ import { possibleValuesHandlers } from './possible-values'
import { processHandlers } from './processes'
import { widgetHandlers } from './widgets'
import { esbHandlers } from './esb'
import { sqlWorkbenchHandlers } from './sql-workbench'
export const handlers = [
...authHandlers,
@@ -35,4 +36,5 @@ export const handlers = [
...processHandlers,
...widgetHandlers,
...esbHandlers,
...sqlWorkbenchHandlers,
]
+145
View File
@@ -0,0 +1,145 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file MSW handlers for the SQL Workbench endpoints (`/qqq/v1/sql-workbench/*`).
*/
import { http, HttpResponse } from 'msw'
import type { SqlExecuteRequest } from '@/types'
import {
sqlCapabilities,
sqlExplain,
sqlHistory,
sqlNotebook,
sqlNotebookSummary,
sqlRowsPage,
sqlSchema,
sqlSelectResult,
sqlSharedNotebookSummary,
sqlWriteCommit,
sqlWritePreview,
} from '../fixtures/sql-workbench'
const BASE = '/qqq/v1/sql-workbench'
export const sqlWorkbenchHandlers = [
http.get(`${BASE}/capabilities`, () => HttpResponse.json(sqlCapabilities)),
http.get(`${BASE}/schema/:backend`, ({ params }) =>
params.backend === 'postgres'
? HttpResponse.json(sqlSchema)
: HttpResponse.json(
{ error: `Unknown SQL Workbench backend: ${String(params.backend)}` },
{ status: 404 }
)
),
http.post(`${BASE}/execute`, async ({ request }) => {
const body = (await request.json()) as SqlExecuteRequest
if (body.mode === 'WRITE') {
return HttpResponse.json(body.confirmToken ? sqlWriteCommit : sqlWritePreview)
}
if (/^\s*select\s+broken/i.test(body.sql)) {
return HttpResponse.json({
...sqlSelectResult,
status: 'FAILED',
error: 'column "broken" does not exist',
columns: undefined,
rows: undefined,
})
}
return HttpResponse.json(sqlSelectResult)
}),
http.post(`${BASE}/explain`, () => HttpResponse.json(sqlExplain)),
http.get(`${BASE}/executions/:uuid/rows`, () => HttpResponse.json(sqlRowsPage)),
http.post(`${BASE}/executions/:uuid/cancel`, () => HttpResponse.json({ cancelled: true })),
http.get(`${BASE}/history`, () => HttpResponse.json({ executions: sqlHistory })),
http.get(`${BASE}/notebooks`, ({ request }) => {
const scope = new URL(request.url).searchParams.get('scope')
return HttpResponse.json({
notebooks: scope === 'shared' ? [sqlSharedNotebookSummary] : [sqlNotebookSummary],
})
}),
http.post(`${BASE}/notebooks`, async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
...sqlNotebook,
id: 9,
title: body.title,
cells: body.cells ?? [],
currentVersion: 1,
})
}),
http.get(`${BASE}/notebooks/:id`, ({ params }) =>
params.id === '1'
? HttpResponse.json(sqlNotebook)
: HttpResponse.json({ error: 'Notebook not found.' }, { status: 404 })
),
http.put(`${BASE}/notebooks/:id`, async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
...sqlNotebook,
...body,
currentVersion: sqlNotebook.currentVersion + 1,
})
}),
http.delete(`${BASE}/notebooks/:id`, () => HttpResponse.json({ deleted: true })),
http.post(`${BASE}/notebooks/:id/publish`, async ({ request }) => {
const body = (await request.json()) as { shared: boolean }
return HttpResponse.json({ ...sqlNotebook, visibility: body.shared ? 'SHARED' : 'PERSONAL' })
}),
http.post(`${BASE}/notebooks/:id/duplicate`, () =>
HttpResponse.json({ ...sqlNotebook, id: 10, title: 'Copy of Monthly signups' })
),
http.get(`${BASE}/notebooks/:id/versions`, () =>
HttpResponse.json({
versions: [
{
versionNumber: 2,
savedByName: 'User One',
comment: null,
createDate: '2026-10-11T00:00:00Z',
},
{
versionNumber: 1,
savedByName: 'User One',
comment: null,
createDate: '2026-10-01T00:00:00Z',
},
],
})
),
http.post(`${BASE}/notebooks/:id/versions/:n/restore`, () =>
HttpResponse.json({ ...sqlNotebook, currentVersion: 3 })
),
http.get(`${BASE}/notebooks/:id/snapshots`, () => HttpResponse.json({ snapshots: [] })),
http.post(`${BASE}/notebooks/:id/snapshots`, async ({ request }) => {
const body = (await request.json()) as { cellKey: string; executionUuid: string }
return HttpResponse.json({
id: 5,
cellKey: body.cellKey,
executionUuid: body.executionUuid,
label: null,
backendName: 'postgres',
rowCount: 2,
truncated: false,
createDate: null,
result: { columns: sqlSelectResult.columns, rows: sqlSelectResult.rows },
})
}),
http.delete(`${BASE}/notebooks/:id/snapshots/:snapshotId`, () =>
HttpResponse.json({ deleted: true })
),
]
+31
View File
@@ -128,3 +128,34 @@ export type {
EsbMessage,
EsbMessagePage,
} from './esb'
export type {
SqlWorkbenchDialect,
SqlWorkbenchBackend,
SqlWorkbenchLimits,
SqlWorkbenchCapabilities,
SqlColumnKind,
SqlColumn,
SqlBinaryValue,
SqlValue,
SqlStatementView,
SqlExecutionStatus,
SqlExecuteRequest,
SqlExecuteResponse,
SqlRowsPage,
SqlPlanNode,
SqlExplainResponse,
SqlSchemaColumn,
SqlSchemaTable,
SqlQqqTable,
SqlSchema,
SqlHistoryEntry,
SqlNotebookParameter,
SqlNotebookCell,
SqlNotebookSummary,
SqlNotebook,
SqlNotebookInput,
SqlNotebookVersionSummary,
SqlResultSnapshot,
SqlExportFormat,
} from './sql-workbench'
+316
View File
@@ -0,0 +1,316 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file SQL Workbench types — the `/qqq/v1/sql-workbench` contract of the qbit-sql-workbench
* QBit (see that repository's docs/DESIGN.md, section 5).
*/
/** Dialect names the backend reports; drive the editor and formatter. */
export type SqlWorkbenchDialect = 'postgresql' | 'mysql' | 'sqlite' | 'h2' | 'sql'
/** One target backend. */
export interface SqlWorkbenchBackend {
name: string
label: string
vendor: string | null
dialect: SqlWorkbenchDialect
/** Read-only sessions use dedicated (least-privilege) credentials. */
readOnlyCredentials: boolean
/** The database enforces read-only sessions (false on H2 without dedicated credentials). */
readOnlyEnforced: boolean
/** DDL can be dry-run (rolled back) on this backend. */
transactionalDdl: boolean
writeAllowed: boolean
}
/** Server limits. */
export interface SqlWorkbenchLimits {
maxRows: number
pageSize: number
maxPageSize: number
maxResultBytes: number
statementTimeoutSeconds: number
writeStatementTimeoutSeconds: number
maxAffectedRowsWithoutAcknowledgement: number
maxAffectedRows: number
maxSqlLength: number
maxStatementsPerRun: number
}
/** `GET /capabilities`. */
export interface SqlWorkbenchCapabilities {
version: string
canWrite: boolean
canShare: boolean
writeModeEnabled: boolean
limits: SqlWorkbenchLimits
backends: SqlWorkbenchBackend[]
}
/** How the UI renders a result column. */
export type SqlColumnKind =
| 'number'
| 'string'
| 'boolean'
| 'date'
| 'datetime'
| 'time'
| 'json'
| 'binary'
| 'array'
| 'other'
/** One result column. */
export interface SqlColumn {
name: string
label: string
typeName: string
jdbcType: number
kind: SqlColumnKind
}
/** A binary value as the backend sends it. */
export interface SqlBinaryValue {
$binary: true
length: number
hexPreview: string
}
/** One result value (already JSON-safe). */
export type SqlValue = string | number | boolean | null | SqlBinaryValue | SqlValue[]
/** One statement of a run, as the backend classified it. */
export interface SqlStatementView {
index: number
kind: 'READ' | 'EXPLAIN_ANALYZE' | 'WRITE' | 'DDL' | 'FORBIDDEN'
leadingKeyword: string | null
text: string
affectedRows: number | null
}
/** Outcome of a run. */
export type SqlExecutionStatus =
| 'SUCCEEDED'
| 'FAILED'
| 'TIMED_OUT'
| 'CANCELLED'
| 'REJECTED'
| 'PREVIEW'
| 'DRIFTED'
/** `POST /execute` request. */
export interface SqlExecuteRequest {
backendName: string
sql: string
params?: Record<string, string | number | boolean | null>
mode?: 'READ_ONLY' | 'WRITE'
notebookId?: number
cellKey?: string
pageSize?: number
confirmToken?: string
acknowledgeRowGuard?: boolean
/** Client-chosen id (a UUID), so a running statement can be cancelled before it answers. */
executionUuid?: string
}
/** `POST /execute` response (fields depend on the status). */
export interface SqlExecuteResponse {
executionUuid: string
status: SqlExecutionStatus
mode: string
backendName: string
durationMs?: number | null
statements?: SqlStatementView[]
columns?: SqlColumn[]
rows?: SqlValue[][]
rowCount?: number
hasMore?: boolean
truncated?: boolean
truncatedReason?: 'ROWS' | 'BYTES' | null
rowsAffected?: number
error?: string
sqlState?: string | null
dryRunSupported?: boolean
dryRunNote?: string | null
totalAffectedRows?: number | null
rowGuardExceeded?: boolean
rowGuardLimit?: number
confirmToken?: string
expiresAt?: string
previewAffectedRows?: number
actualAffectedRows?: number
}
/** `GET /executions/{uuid}/rows`. */
export interface SqlRowsPage {
executionUuid: string
columns: SqlColumn[]
rows: SqlValue[][]
offset: number
rowCount: number
truncated: boolean
truncatedReason: 'ROWS' | 'BYTES' | null
}
/** One node of a normalized query plan. */
export interface SqlPlanNode {
label: string
detail: string | null
startupCost: number | null
totalCost: number | null
planRows: number | null
actualTimeMs: number | null
actualRows: number | null
loops: number | null
extra: Record<string, unknown>
children: SqlPlanNode[]
}
/** `POST /explain` response. */
export interface SqlExplainResponse {
executionUuid: string
status: SqlExecutionStatus
dialect?: SqlWorkbenchDialect
analyze?: boolean
plan?: SqlPlanNode
raw?: string
error?: string
durationMs?: number | null
}
/** Schema column. */
export interface SqlSchemaColumn {
name: string
typeName: string
nullable: boolean
}
/** Schema table. */
export interface SqlSchemaTable {
name: string
type: string
columns: SqlSchemaColumn[]
}
/** A QQQ table on a backend, with its SQL names. */
export interface SqlQqqTable {
name: string
label: string | null
sqlName: string
fields: { name: string; label: string | null; sqlName: string; type: string | null }[]
}
/** `GET /schema/{backend}`. */
export interface SqlSchema {
backendName: string
dialect: SqlWorkbenchDialect
schemas: { name: string; tables: SqlSchemaTable[] }[]
truncated: boolean
keywords: string[]
functions: string[]
qqqTables: SqlQqqTable[]
cachedAt: string
}
/** One audit row from `GET /history`. */
export interface SqlHistoryEntry {
executionUuid: string
backendName: string
mode: string
status: string
sqlText: string
notebookId: number | null
cellKey: string | null
startTime: string | null
durationMs: number | null
rowsReturned: number | null
rowsAffected: number | null
errorMessage: string | null
}
/** A notebook parameter definition. */
export interface SqlNotebookParameter {
name: string
type?: 'STRING' | 'INTEGER' | 'DECIMAL' | 'BOOLEAN' | 'DATE' | 'DATE_TIME'
defaultValue?: string | null
label?: string | null
}
/** One notebook cell. */
export interface SqlNotebookCell {
cellKey: string
cellType: 'SQL' | 'MARKDOWN'
title?: string | null
content: string
backendName?: string | null
sortOrder?: number
}
/** Notebook list entry. */
export interface SqlNotebookSummary {
id: number
title: string
description: string | null
visibility: 'PERSONAL' | 'SHARED'
ownerUserId: string
ownerName: string | null
defaultBackendName: string | null
currentVersion: number
canEdit: boolean
createDate: string | null
modifyDate: string | null
}
/** A full notebook. */
export interface SqlNotebook extends SqlNotebookSummary {
parameters: SqlNotebookParameter[]
cells: SqlNotebookCell[]
}
/** Notebook save / create body. */
export interface SqlNotebookInput {
title: string
description?: string | null
defaultBackendName?: string | null
parameters?: SqlNotebookParameter[]
cells?: SqlNotebookCell[]
comment?: string
}
/** One notebook version (list entry). */
export interface SqlNotebookVersionSummary {
versionNumber: number
savedByName: string | null
comment: string | null
createDate: string
}
/** A pinned result. */
export interface SqlResultSnapshot {
id: number
cellKey: string | null
executionUuid: string | null
label: string | null
backendName: string | null
rowCount: number
truncated: boolean
createDate: string | null
result: { columns?: SqlColumn[]; rows?: SqlValue[][] }
}
/** Export formats. */
export type SqlExportFormat = 'csv' | 'xlsx' | 'json'
@@ -8,6 +8,7 @@
import type { Locator, Page } from '@playwright/test'
import { expect, open, test } from '../../support/fixtures'
import { expectNoHorizontalScroll, expectTouchReady, expectTouchTargets, undersizedTargets } from '../../support/touch'
import { waitForSampleDashboard } from './sample-dashboard-navigation'
import { tabKey } from '../security/support/ui'
import { appNavigation, expectBreadcrumbs, recordCollection, waitForShell } from './nav-helpers'
@@ -39,6 +40,7 @@ test.describe('mobile navigation', () => {
if (!navBox || !titleBox) return 0
return Math.min(navBox.x + navBox.width, titleBox.x + titleBox.width) - Math.max(navBox.x, titleBox.x)
}).toBeGreaterThan(100)
await waitForSampleDashboard(page)
await open(page, '/app')
await waitForShell(page)
const drawer = page.locator('[data-qqq-id="sidebar-mobile-drawer"]')
@@ -0,0 +1,15 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import type { Page } from '@playwright/test'
import { expect } from '../../support/fixtures'
import { sampleDashboardReady } from './sample-dashboard-readiness'
/** Wait for this fixed fixture's actual request and renderer outputs before leaving it. */
export async function waitForSampleDashboard(page: Page) {
await expect.poll(() => page.evaluate(sampleDashboardReady), { message: 'Sample dashboard widgets and renderers complete before departure' }).toBe(true)
}
@@ -0,0 +1,26 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/** Fixed sample dashboard dependency boundary for NAV026. Runs in the document. */
export function sampleDashboardReady(): boolean {
const home = document.querySelector('[data-qqq-id="app-home-SampleWidgetsDashboard"]')
if (!home || home.querySelectorAll('[data-qqq-id^="widget-grid-item-"]').length !== 11) return false
if (home.querySelector('[role="alert"], [aria-busy="true"], [aria-label="Loading widget"], [data-qqq-id^="widget-error-"], [data-qqq-id^="widget-no-permission-"], [data-qqq-id^="widget-permission-denied-"], [data-qqq-id^="widget-needs-selection-"]')) return false
const outputs: Array<[string, string]> = [
['BigNumberBlocks', '[data-block-type="BIG_NUMBER"]'],
['MultiStatistics', '[data-qqq-id="multi-statistics-group-SampleMultiStatisticsWidget-0"]'],
['Statistics', '[data-qqq-id="statistics-count-SampleStatisticsWidget"]'],
['Table', 'tbody tr td'],
['Stepper', '[data-qqq-id="stepper-step-SampleStepperWidget-0"]'],
['HTML', '[data-qqq-id="html-widget-SampleHTMLWidget"]'],
...['PieChart', 'StackedBarChart', 'SmallLineChart', 'LineChart', 'BarChart'].map(name => [name, `[data-qqq-id="chart-data-Sample${name}Widget"] tbody tr td`] as [string, string]),
]
return outputs.every(([name, selector]) => {
const card = home.querySelector(`[data-qqq-id="widget-Sample${name}Widget"][aria-busy="false"]`)
return Boolean(card?.querySelector(selector)?.textContent?.trim())
})
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { afterEach, expect, test } from 'vitest'
import { sampleDashboardReady } from '../specs/navigation/sample-dashboard-readiness'
afterEach(() => { document.body.innerHTML = '' })
test('shell and breadcrumb without AppHome are not a ready dashboard', () => {
document.body.innerHTML = '<main id="main-content"><nav aria-label="Breadcrumb">Sample Widgets Dashboard</nav></main>'
expect(sampleDashboardReady()).toBe(false)
})
test('an empty mounted AppHome cannot pass vacuously', () => {
document.body.innerHTML = '<div data-qqq-id="app-home-SampleWidgetsDashboard"><section data-qqq-id="widget-grid"></section></div>'
expect(sampleDashboardReady()).toBe(false)
})
/** Fixture output declared by the sample dashboard and existing WID renderer assertions. */
function renderedDashboard() {
const contents: Record<string, string> = {
SampleBigNumberBlocksWidget: '<div data-block-type="BIG_NUMBER">123</div>',
SampleMultiStatisticsWidget: '<div data-qqq-id="multi-statistics-group-SampleMultiStatisticsWidget-0">Statistics</div>',
SamplePieChartWidget: '<table data-qqq-id="chart-data-SamplePieChartWidget"><tbody><tr><td>Pie</td></tr></tbody></table>',
SampleStatisticsWidget: '<span data-qqq-id="statistics-count-SampleStatisticsWidget">123</span>',
SampleTableWidget: '<table><tbody><tr><td>Row 1</td></tr></tbody></table>',
SampleStackedBarChartWidget: '<table data-qqq-id="chart-data-SampleStackedBarChartWidget"><tbody><tr><td>Stack</td></tr></tbody></table>',
SampleStepperWidget: '<li data-qqq-id="stepper-step-SampleStepperWidget-0">Step 1</li>',
SampleHTMLWidget: '<div data-qqq-id="html-widget-SampleHTMLWidget">Purely Custom</div>',
SampleSmallLineChartWidget: '<table data-qqq-id="chart-data-SampleSmallLineChartWidget"><tbody><tr><td>Small</td></tr></tbody></table>',
SampleLineChartWidget: '<table data-qqq-id="chart-data-SampleLineChartWidget"><tbody><tr><td>Line</td></tr></tbody></table>',
SampleBarChartWidget: '<table data-qqq-id="chart-data-SampleBarChartWidget"><tbody><tr><td>Bar</td></tr></tbody></table>',
}
document.body.innerHTML = `<div data-qqq-id="app-home-SampleWidgetsDashboard"><section data-qqq-id="widget-grid">${Object.entries(contents).map(([name, html]) => `<div data-qqq-id="widget-grid-item-${name}"><section data-qqq-id="widget-${name}" aria-busy="false">${html}</section></div>`).join('')}</section></div>`
}
test('all eleven actual fixture renderers are ready without scrolling offscreen cards', () => {
renderedDashboard()
expect(sampleDashboardReady()).toBe(true)
})
test('eleven wrappers cannot substitute for the exact missing widget card', () => {
renderedDashboard()
document.querySelector('[data-qqq-id="widget-SampleTableWidget"]')!.remove()
expect(sampleDashboardReady()).toBe(false)
})
test('an actual held table request remains unready even if old rows are present', () => {
renderedDashboard()
document.querySelector('[data-qqq-id="widget-SampleTableWidget"]')!.setAttribute('aria-busy', 'true')
expect(sampleDashboardReady()).toBe(false)
})
test('busy false with an actual widget error is not readiness', () => {
renderedDashboard()
document.querySelector('[data-qqq-id="widget-SampleTableWidget"]')!.insertAdjacentHTML('beforeend', '<div role="alert" data-qqq-id="widget-error-SampleTableWidget">Failed</div>')
expect(sampleDashboardReady()).toBe(false)
})
test('a lazy chart fallback below a nonbusy card is not a rendered chart', () => {
renderedDashboard()
document.querySelector('[data-qqq-id="widget-SamplePieChartWidget"]')!.innerHTML = '<div class="h-60 animate-pulse rounded bg-muted"></div>'
expect(sampleDashboardReady()).toBe(false)
})
test('held table completion requires real rows, not only a cleared busy flag', () => {
renderedDashboard()
const card = document.querySelector('[data-qqq-id="widget-SampleTableWidget"]')!
card.setAttribute('aria-busy', 'true')
card.innerHTML = '<div role="status" aria-label="Loading widget"></div>'
expect(sampleDashboardReady()).toBe(false)
card.setAttribute('aria-busy', 'false')
card.innerHTML = ''
expect(sampleDashboardReady()).toBe(false)
card.innerHTML = '<table><tbody><tr><td>Forwarded row</td></tr></tbody></table>'
expect(sampleDashboardReady()).toBe(true)
})
test('one missing declared widget is not accepted as a smaller completed dashboard', () => {
renderedDashboard()
document.querySelector('[data-qqq-id="widget-grid-item-SampleTableWidget"]')!.remove()
expect(sampleDashboardReady()).toBe(false)
})
+241
View File
@@ -0,0 +1,241 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// E2E: SQL Workbench — the SQL_WORKBENCH widget on the qbit's app page, with a mocked backend
import { test, expect, type Page } from '@playwright/test'
import { METADATA, setupApiMocks } from './api-mocks'
import { checkA11y } from './a11y-helpers'
const BASE = '**/qqq/v1/sql-workbench'
const CAPABILITIES = {
version: '1.0.0-RC.1',
canWrite: true,
canShare: true,
writeModeEnabled: true,
limits: {
maxRows: 10000,
pageSize: 500,
maxPageSize: 2000,
maxResultBytes: 20971520,
statementTimeoutSeconds: 30,
writeStatementTimeoutSeconds: 60,
maxAffectedRowsWithoutAcknowledgement: 1000,
maxAffectedRows: 100000,
maxSqlLength: 100000,
maxStatementsPerRun: 50,
},
backends: [
{
name: 'postgres',
label: 'postgres',
vendor: 'postgres',
dialect: 'postgresql',
readOnlyCredentials: true,
readOnlyEnforced: true,
transactionalDdl: true,
writeAllowed: true,
},
],
}
const RESULT = {
executionUuid: 'exec-1',
status: 'SUCCEEDED',
mode: 'READ_ONLY',
backendName: 'postgres',
durationMs: 7,
statements: [
{ index: 0, kind: 'READ', leadingKeyword: 'SELECT', text: 'SELECT', affectedRows: null },
],
columns: [
{ name: 'id', label: 'id', typeName: 'int4', jdbcType: 4, kind: 'number' },
{ name: 'first_name', label: 'first_name', typeName: 'text', jdbcType: 12, kind: 'string' },
],
rows: [
[1, 'Alice'],
[2, null],
],
rowCount: 2,
hasMore: false,
truncated: false,
truncatedReason: null,
}
const PREVIEW = {
executionUuid: 'exec-2',
status: 'PREVIEW',
mode: 'WRITE_PREVIEW',
backendName: 'postgres',
durationMs: 5,
statements: [
{
index: 0,
kind: 'WRITE',
leadingKeyword: 'DELETE',
text: 'DELETE FROM person WHERE id = 2',
affectedRows: 1,
},
],
dryRunSupported: true,
totalAffectedRows: 1,
rowGuardExceeded: false,
rowGuardLimit: 1000,
confirmToken: 'tok',
expiresAt: '',
}
/**
* Adds the workbench app, widget and endpoints to the mocked backend.
*
* @param page - The page.
* @returns The list of execute request bodies, filled as the page sends them.
*/
async function setupWorkbench(page: Page): Promise<Record<string, unknown>[]> {
const executes: Record<string, unknown>[] = []
const metaData = {
...METADATA,
apps: {
...METADATA.apps,
sqlWorkbench: {
name: 'sqlWorkbench',
label: 'SQL Workbench',
iconName: 'terminal',
widgets: ['sqlWorkbenchWidget'],
children: [],
sections: [],
},
},
appTree: [
...METADATA.appTree,
{
name: 'sqlWorkbench',
label: 'SQL Workbench',
type: 'APP',
iconName: 'terminal',
children: [],
},
],
widgets: {
sqlWorkbenchWidget: {
name: 'sqlWorkbenchWidget',
label: 'SQL Workbench',
type: 'SQL_WORKBENCH',
gridColumns: 12,
isCard: false,
hasPermission: true,
},
},
}
await page.route('**/qqq/v1/metaData**', (route) => {
if (new URL(route.request().url()).pathname.endsWith('/metaData/authentication'))
return route.fallback()
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(metaData) })
})
await page.route('**/qqq/v1/widget/sqlWorkbenchWidget**', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ type: 'SQL_WORKBENCH' }),
})
)
await page.route(`${BASE}/capabilities`, (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(CAPABILITIES) })
)
await page.route(`${BASE}/schema/**`, (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
backendName: 'postgres',
dialect: 'postgresql',
schemas: [],
truncated: false,
keywords: [],
functions: [],
qqqTables: [],
cachedAt: '',
}),
})
)
await page.route(`${BASE}/notebooks**`, (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ notebooks: [] }) })
)
await page.route(`${BASE}/history**`, (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ executions: [] }) })
)
await page.route(`${BASE}/execute`, (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>
executes.push(body)
const response =
body.mode === 'WRITE'
? body.confirmToken
? { ...RESULT, mode: 'WRITE_COMMIT', columns: [], rows: [], rowCount: 0, rowsAffected: 1 }
: PREVIEW
: RESULT
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(response) })
})
return executes
}
test.describe('SQL Workbench', () => {
test('runs read-only SQL from the app page and shows the result grid', async ({ page }) => {
await setupApiMocks(page)
const executes = await setupWorkbench(page)
await page.goto('/app/sqlWorkbench')
await expect(page.getByRole('button', { name: '+ New notebook' })).toBeVisible({
timeout: 30000,
})
const editor = page.getByRole('textbox', { name: 'SQL for cell 1' })
await editor.click()
await page.keyboard.type('SELECT id, first_name FROM person')
await page.getByRole('button', { name: 'Run' }).click()
const grid = page.getByRole('table', { name: 'Query result' })
await expect(grid.getByText('Alice')).toBeVisible()
await expect(grid.getByText('NULL')).toBeVisible()
expect(executes[0]).toMatchObject({
backendName: 'postgres',
mode: 'READ_ONLY',
sql: 'SELECT id, first_name FROM person',
})
await expect(page.getByRole('link', { name: /XLSX/ })).toHaveAttribute(
'href',
/executions\/exec-1\/export\?format=xlsx/
)
await checkA11y(page)
})
test('write mode commits only after the confirmation dialog', async ({ page }) => {
await setupApiMocks(page)
const executes = await setupWorkbench(page)
await page.goto('/app/sqlWorkbench')
await page.getByRole('textbox', { name: 'SQL for cell 1' }).click()
await page.keyboard.type('DELETE FROM person WHERE id = 2')
await page.getByLabel('Write mode').check()
await page.getByRole('button', { name: 'Preview write' }).click()
const dialog = page.getByRole('dialog')
await expect(dialog.getByText('Dry run (rolled back): 1 rows affected.')).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Commit write' })).toBeDisabled()
await dialog.getByLabel(/Type postgres to confirm/).fill('postgres')
await dialog.getByRole('button', { name: 'Commit write' }).click()
await expect(page.getByText(/SUCCEEDED · .* · 1 rows affected/)).toBeVisible()
expect(executes.map((e) => e.confirmToken ?? null)).toEqual([null, 'tok'])
})
})