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 d81b7ed5ae chore(#1002): integrate current main for date-default verification 2026-10-06 07:25:30 -05:00
james.maes b8313b6819 chore(#1001): integrate current main for readiness verification 2026-10-06 07:25:21 -05:00
james.maes 22c9e02f20 test: consolidate browser capture and security review follow-ups (#42)
* test(#1003): await complete chart label geometry

Keep the original strict overlap check on the same completed measurement sample. Refs QRun-IO/qqq#1003; historical rendering cause remains unproved.

(cherry picked from commit 4aa95cc363)

* test(acceptance): capture selected navigation request ownership

(cherry picked from commit bd7716fe8d)

* test(acceptance): integrate session capture with selective navigation

(cherry picked from commit 6c5d6d92e14536ed472b2c8357a00c652718198b)

* docs(#696): reconcile merged security qualifications

(cherry picked from commit b6106062bb09381407e92bdb55352175062941d2)
2026-10-04 18:48:11 -05:00
james.maes 138c3c6db5 fix: derive standalone CSP from application metadata (QRun-IO/qqq#1017) (#40)
* fix: derive standalone CSP from application metadata (#1017)

Consume optional bounded CSP source metadata through the existing auth cache. Remove fixture-derived policy masking and qualify the actual analytics hook.

* docs(qqq#696): reconcile current security integration evidence

* test(qqq#1022): close owned analytics fixture connections
2026-10-04 17:07:09 -05:00
james.maes 70f26e9c9a Merge pull request #38 from QRun-IO/feature/GH-1009-standalone-branding
fix: complete standalone security and branding acceptance
2026-10-04 14:19:35 -05:00
james.maes 2bc75d779e Merge qualified Auth0 fixture correction into branding acceptance 2026-10-04 12:55:09 -05:00
james.maes 17e22edaee Merge reviewed NAV-018 readiness correction into branding acceptance 2026-10-04 12:54:47 -05:00
james.maes 66d02c7b6a test: align owned Auth0 issuer with trusted provider contract (#1016) 2026-10-04 12:53:20 -05:00
james.maes 20fe7b7b9b test(GH-1014): await process response completion before navigation 2026-10-04 11:55:45 -05:00
james.maes 4190ee01ba test: restrict acceptance javac to owned fixture sources (QQQ #1013) 2026-10-04 10:35:06 -05:00
james.maes 62d1e77847 fix: route declared standalone branding images through fixed backend roles 2026-10-04 10:21:43 -05:00
james.maes a23fd997db test: qualify standalone security variants against real Node origins 2026-10-04 10:06:27 -05:00
james.maes f367756b8c Merge pull request #36 from QRun-IO/feature/GH-1006-next-query-cleanup
fix(qqq#1006): clear private query state between users
2026-10-04 09:06:18 -05:00
james.maes 4a055463bd fix(GH-1006): clear private query state between users
Refs QRun-IO/qqq#1006 and QRun-IO/qqq#696.
2026-10-02 09:41:42 -05:00
james.maes c1a4efd7d7 test(navigation): integrate current main readiness (#1001) 2026-10-02 07:58:11 -05:00
james.maes 0222f0dcad test(widgets): await resized chart label geometry (#31) 2026-10-02 06:58:12 -05:00
james.maes 4fea636f24 Merge reviewed main before widget acceptance qualification (#31) 2026-10-02 06:56:43 -05:00
james.maes 5ca3e0b17a Merge pull request #30 from QRun-IO/feature/GH-995-cell-focus-investigation
fix(qqq#995): preserve grid body-cell focus and state
2026-10-02 05:40:58 -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
james.maes a32ef74a9d chore(GH-995): integrate reviewed main for cell focus 2026-10-02 04:21:31 -05:00
james.maes d292c76530 Merge pull request #28: preserve report-copy keyboard focus
Fixes QRun-IO/qqq#994. Source-specific required checks verified.
2026-10-02 04:18:17 -05:00
james.maes e3fc4b2f52 chore(qqq#994): integrate current main for verification 2026-10-02 03:00:53 -05:00
james.maes b5f8be7645 Merge pull request #27 from QRun-IO/feature/GH-716-menu-viewport
fix(query): keep column menus reachable after scrolling
2026-10-02 02:57:12 -05:00
james.maes 8f552e2e29 Merge current acceptance evidence baseline for date fix (#31) 2026-10-02 02:21:54 -05:00
james.maes 1f18575abe test(widgets): await default selection request (#31) 2026-10-02 02:15:08 -05:00
james.maes 6a5c1bb237 fix(widgets): preserve date request normalization (#31) 2026-10-02 02:09:21 -05:00
james.maes 58a896345f Merge pull request #29 from QRun-IO/feature/GH-904-document-capture
test(qqq#904): capture document and XHR boundaries
2026-10-02 02:00:41 -05:00
james.maes bd904fe4ac fix(widgets): apply untouched date defaults (#31) 2026-10-02 01:41:59 -05:00
james.maes 09c271384c test(qqq#996): wait for report setup before pivot input 2026-10-02 01:39:17 -05:00
james.maes 05763450ff fix(qqq#995): preserve grid body-cell focus and state
Invoke locally owned pure cell renderers as content so selection and refresh retain focused children and current callbacks.
2026-10-02 00:32:46 -05:00
james.maes aa0129f024 test(qqq#904): capture document and XHR boundaries
Add opt-in bounded evidence for QRun-IO/qqq#904 and #960. Preserve native outcomes and mark ambiguous request reuse incomplete.
2026-10-02 00:31:09 -05:00
james.maes 2bdc048440 fix(query): preserve report column copy focus (#994)
Keep current header callbacks and child identity across preview updates.
2026-10-02 00:02:07 -05:00
james.maes 7bd157dd81 fix(query): reveal column anchor before opening menu (#716)
Restore keyboard menu reachability after scrolling a focused header away.
2026-10-01 23:24:21 -05:00
james.maes 2535e98c7e Merge pull request #25 from QRun-IO/feature/GH-904-transport-capture
test(browser): retain transport and query retry evidence
2026-10-01 22:44:07 -05:00
james.maes 2853b9b148 Merge pull request #26 from QRun-IO/feature/GH-712-next-rc11
chore(#712): prepare Next UI 1.0.0-RC.11
2026-10-01 22:19:50 -05:00
james.maes 29cb5584fc chore(#712): prepare Next UI 1.0.0-RC.11 2026-10-01 22:14:30 -05:00
james.maes f36d8709c9 test(GH-900): cover production query retry lifecycle 2026-10-01 22:05:22 -05:00
james.maes f7b2aa52a8 test(GH-904): capture acceptance response write boundaries 2026-10-01 22:05:08 -05:00
james.maes a1d3e48419 Merge pull request #24 from QRun-IO/feature/GH-970-browser-input-races
fix(browser): stabilize selector and script completion input races
2026-10-01 21:56:06 -05:00
james.maes ea73118fca docs(GH-985): record browser input race corrections
Refs QRun-IO/qqq#970 and QRun-IO/qqq#954.
2026-10-01 21:09:34 -05:00
james.maes c629eb0b6c fix(GH-954): preserve suggestions after deferred scroll events
Dismiss completions only when the textarea moves from the position used to place them. Cover deferred reset and both real scroll axes.

Refs QRun-IO/qqq#954
2026-10-01 21:09:31 -05:00
james.maes 571ee7bc88 fix(GH-970): invalidate stale possible values when search changes
Close the debounce interval in which an old option can disappear during a pointer selection. Cover loaded and in-flight prior searches with deterministic regressions.

Refs QRun-IO/qqq#970
2026-10-01 20:59:51 -05:00
james.maes 12cf7745f6 Merge pull request #23 from QRun-IO/feature/GH-990-esb-touch-coverage
test(GH-990): include all ESB touch acceptance scenarios
2026-10-01 20:52:56 -05:00
james.maes 0f6581b6d7 chore(GH-990): reconcile touch coverage with RC10 preparation 2026-10-01 20:43:46 -05:00
james.maes 46a1d5d7ec Merge pull request #22 from QRun-IO/feature/GH-712-next-rc10
chore(release): prepare Next UI 1.0.0-RC.10
2026-10-01 20:36:14 -05:00
james.maes 226efa841f Merge pull request #21 from QRun-IO/feature/QQQ-989-pr15-reconciliation
fix(records): preserve immediate URL state (QQQ#989)
2026-10-01 20:35:01 -05:00
james.maes dd130238be test(GH-990): include ESB in touch acceptance
Closes QRun-IO/qqq#990. Preserve real-backend assertions and record the original coverage failure.
2026-10-01 20:18:27 -05:00
james.maes d8c803ef5f chore(release): prepare Next UI 1.0.0-RC.10
Record verified candidate scope and disclosed coverage limits. Refs QRun-IO/qqq#712.
2026-10-01 20:15:37 -05:00
james.maes 5e3738b73f fix(records): preserve immediate record view URL state
Closes QRun-IO/qqq#989
2026-10-01 20:00:16 -05:00
james.maes 289eca19d4 Merge pull request #18 from QRun-IO/feature/browser-stability
fix(browser): stabilize Firefox and WebKit interactions and acceptance
2026-10-01 19:44:38 -05:00
james.maes 7ff90f1a63 chore(GH-985): integrate ESB acceptance with browser fixes 2026-10-01 18:46:40 -05:00
james.maes 50a935a4a5 Merge pull request #19 from QRun-IO/feature/GH-10-esb-acceptance
fix(esb): finish management and real-broker acceptance (#10)
2026-10-01 18:30:11 -05:00
james.maes 40e987d4d0 Merge pull request #20 from QRun-IO/feature/GH-985-next-tracking
docs(tracking): manage Next UI through the QQQ project
2026-10-01 18:09:28 -05:00
james.maes 71f5a39b09 docs(tracking): centralize Next UI delivery and unreleased fixes 2026-10-01 18:04:34 -05:00
james.maes 45f99f27ce fix(auth): finish logout before allowing another sign-in
Keep authentication loading through cookie expiry and provider departure (QRun-IO/qqq#984).
2026-10-01 17:40:38 -05:00
james.maes d17e880e79 test(widgets): recognize proven missing script responses 2026-10-01 17:25:44 -05:00
james.maes f8fdb8e997 fix(esb): finish management and real-broker acceptance (#10) 2026-10-01 17:23:24 -05:00
james.maes ad154b5b56 test(acceptance): observe browser framing refusal (#981) 2026-10-01 17:22:43 -05:00
james.maes c6a58933a6 test(#979): account for Linux WebKit download filenames
Verify original application filenames and preserve downloaded data checks while matching the pinned libsoup space sanitization.
2026-10-01 17:16:51 -05:00
james.maes 26e3a0d004 test(acceptance): await initial document fonts before redirect
Complete the rendered initial document before NAV016 tears it down (QRun-IO/qqq#951).
2026-10-01 17:08:48 -05:00
james.maes c9d7fcd393 fix(forms): preserve option pointer targets in WebKit 2026-10-01 17:06:35 -05:00
james.maes b3e1ddda8b test(browser): adopt fixed WebKit network library and retain crash diagnostics
Refs QRun-IO/qqq#904, QRun-IO/qqq#960, QRun-IO/qqq#973. Preserve final RC9 tag outcomes separately from current verification.
2026-10-01 17:06:23 -05:00
james.maes 8d4668cdab test(acceptance): await breadcrumb destination iframe
Require the owned chart heading before leaving Greetings App so navigation does not cancel its pending document. Refs QRun-IO/qqq#972.
2026-10-01 16:53:35 -05:00
james.maes 254a0a2050 test(reports): await summary metadata before navigation
Refs QRun-IO/qqq#961.
2026-10-01 16:39:39 -05:00
james.maes 52cc038fa6 test(acceptance): observe transient Firefox feedback reliably
Capture toast visibility before actions and verify navigation-cancelled legacy fonts in the final document. Refs QRun-IO/qqq#951, QRun-IO/qqq#953, QRun-IO/qqq#967.
2026-10-01 16:39:23 -05:00
james.maes 6090f2f9b4 fix(search): preserve focus after early keyboard interaction
Refs QRun-IO/qqq#950.
2026-10-01 16:32:48 -05:00
james.maes 32a9560970 Merge pull request #17 from QRun-IO/feature/rc9-publication-notes
docs: record verified RC9 public delivery
2026-10-01 14:29:27 -05:00
james.maes 96085c72e2 docs(release): record verified RC9 public delivery
Refs QRun-IO/qqq#712. Preserve immutable source and distinguish active tag acceptance from completed checks.
2026-10-01 14:28:27 -05:00
james.maes c029ad2a70 Merge pull request #14 from QRun-IO/feature/next-1.0
Next UI 1.0 RC9 integration: compatibility, verification and issue tracking
2026-10-01 14:12:28 -05:00
james.maes 32a3b281ff chore(release): prepare Next UI 1.0.0-RC.9
Refs QRun-IO/qqq#712 and QRun-IO/qqq#713. Preserve known issue disclosures and the final release hold.
2026-10-01 14:11:48 -05:00
james.maes a0a18cfa76 Merge pull request #16 from QRun-IO/feature/next-issue-tracking
docs: make GitHub the Next UI source of truth
2026-10-01 14:06:09 -05:00
james.maes 354ee2709d docs(tracking): make GitHub the Next UI source of truth
Reconcile the roadmap and issue evidence under QRun-IO/qqq#713; point guides and ledgers to the live tracker.
2026-10-01 07:21:51 -05:00
james.maes 3c7c0e88b7 docs: announce verified RC8 artifacts and known issues 2026-10-01 06:41:25 -05:00
james.maes 0679672aec docs: preserve RC8 secondary browser outcomes 2026-09-30 21:26:58 -05:00
james.maes 6d0862284a docs: record RC8 acceptance and public preview evidence 2026-09-30 21:08:56 -05:00
james.maes cd3d4731d1 docs: record RC8 corrections and process visual comparison (#14) 2026-09-30 19:39:44 -05:00
james.maes 2eb075610e fix: fit API specification labels to application fonts (#14) 2026-09-30 19:38:27 -05:00
james.maes a317f0292d fix: keep search dialog keyboard focus contained (#14) 2026-09-30 19:38:27 -05:00
james.maes 14d69667dc docs: record RC8 sleep interruption and final RC7 results (#14) 2026-09-30 19:01:54 -05:00
james.maes f0f0eae98e chore: prepare RC8 stability candidate with open gate evidence (#14) 2026-09-30 16:21:06 -05:00
james.maes 66ccd34642 fix: validate bounded long values without rounding or throwing (#14)
Gate range checks behind integer validation and preserve exact integer-string bounds. Refs QRun-IO/qqq#728.
2026-09-30 16:13:51 -05:00
james.maes c954005332 test: scope process choice label lookup to its view field (#14) 2026-09-30 16:13:51 -05:00
james.maes 33cfb0b322 fix: keep API specification controls visible in narrow panels (#14)
Scope responsive button rules to the overview while preserving desktop layout. Refs QRun-IO/qqq#724.
2026-09-30 16:13:51 -05:00
james.maes b9b1f3da84 docs: record verified RC7 publication and final RC6 results (#14) 2026-09-30 16:11:44 -05:00
james.maes f65cf6bfbd chore: prepare verified local RC7 candidate (#14) 2026-09-30 15:16:17 -05:00
james.maes d5f275f85e test: check accessible sidebar labels after icons load (#728) 2026-09-30 15:12:20 -05:00
james.maes d4795c80e6 fix: retain second precision in time input blocks (#728) 2026-09-30 15:12:20 -05:00
james.maes b8b361d8da fix: improve API playground theme contrast (#724) 2026-09-30 15:12:20 -05:00
james.maes 12c21a7560 docs: record terminal RC6 candidate browser results (#14) 2026-09-30 15:11:00 -05:00
james.maes d4e3289c61 docs: record verified RC6 publication and visual review (#14) 2026-09-30 15:08:06 -05:00
188 changed files with 12624 additions and 520 deletions

No files matched your search

+5
View File
@@ -72,6 +72,11 @@ jobs:
- name: Acceptance (${{ matrix.browser }})
env:
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
# Fixed NAV023/NAV056 ownership capture only; original assertions and diagnostics remain strict.
QQQ_ACCEPTANCE_BROWSER_TRACE: ${{ (matrix.browser == 'webkit' || matrix.browser == 'mobile,tablet') && 'navigation' || '0' }}
# Retain native browser crashes alongside traces (QQQ#904, QQQ#960).
DEBUG: pw:browser
run: node scripts/acceptance.mjs --skip-build
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
+5 -3
View File
@@ -2,7 +2,9 @@
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
[Next UI 1.0.0-RC.4 is published](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.4) for application testing. Installation requirements, verified artifacts and known gaps are in the [RC4 notes](docs/releases/1.0.0-RC.4.md). Final 1.0 work remains open.
**This checkout targets Next UI 1.0.0-RC.11**, with searchable-selector and script-completion input-race corrections. Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing; see the [RC11 notes](docs/releases/1.0.0-RC.11.md) for the preparation checkpoint and verification scope. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
**Current roadmap and known issues:** [Next UI 1.0 tracker](https://github.com/QRun-IO/qqq/issues/713). GitHub issues are the source of truth for status and remaining work; see [issue tracking](docs/ISSUE-TRACKING.md).
## Use it in a QQQ application
@@ -12,11 +14,11 @@ The dashboard is the default admin UI for QQQ 4.1+. Add the jar (managed by `qqq
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.4</version>
<version>1.0.0-RC.11</version>
</dependency>
```
The explicit version overrides an older BOM-managed frontend. RC4 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
The explicit version overrides an older BOM-managed frontend. RC11 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
The jar holds the static export (`pnpm build:export`) under `next-dashboard/`. Deep links such as `/app/person/1` are served from placeholder pages and read their route from the browser path, so no Node.js server is involved. The Material Dashboard remains available: select it with `withServeFrontendMaterialDashboard(true)` or `-Dqqq.javalin.frontend=material`. Material routes are `/<app>/<table>/<id>`; Next routes are `/app/<table>/<id>`.
+1 -1
View File
@@ -18,7 +18,7 @@ COPY --from=frontend --chown=node:node /app/.next/static ./.next/static
COPY --from=frontend --chown=node:node /app/public ./public
# server.js wrapped with the dashboard security headers (QRun-IO/qqq#734); add origins the
# dashboard must reach (QuickSight, custom component bundles) with QQQ_DASHBOARD_CSP_SOURCES
COPY --from=frontend --chown=node:node /app/standalone/qqq-server.mjs /app/standalone/security-headers.mjs /app/standalone/backend-prefixes.json ./
COPY --from=frontend --chown=node:node /app/standalone/qqq-server.mjs /app/standalone/security-headers.mjs /app/standalone/branding-assets.mjs /app/standalone/backend-prefixes.json ./
COPY LICENSE ./LICENSE
USER node
EXPOSE 3000
+61
View File
@@ -0,0 +1,61 @@
# Next UI issue tracking
GitHub issues are the source of truth for scope, current status, remaining work and release decisions. Start at [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713), which links the release baseline, known failures, visual review, external-provider checks and existing feature tickets.
## Where work lives
| Area | Authoritative issue |
|---|---|
| Roadmap and complete issue index | [qqq#713](https://github.com/QRun-IO/qqq/issues/713) |
| Feature acceptance and default adoption | [qqq#649](https://github.com/QRun-IO/qqq/issues/649) |
| Behavior and metadata compatibility | [qqq#714](https://github.com/QRun-IO/qqq/issues/714) |
| Original Next visual review and owner approval | [qqq#711](https://github.com/QRun-IO/qqq/issues/711) |
| Browser failures and incomplete run evidence | [qqq#985](https://github.com/QRun-IO/qqq/issues/985) |
| Real application soak and final release hold | [qqq#712](https://github.com/QRun-IO/qqq/issues/712) |
| Cross-application launch/restart acceptance | [qqq#650](https://github.com/QRun-IO/qqq/issues/650) |
| ESB integration and acceptance | [qqq#986](https://github.com/QRun-IO/qqq/issues/986) |
| Paired runtime provenance and CI handoff | [qqq#905](https://github.com/QRun-IO/qqq/issues/905) |
Create **all Next UI tickets in [QRun-IO/qqq](https://github.com/QRun-IO/qqq/issues)** and add them to the [QQQ Roadmap, Project 12](https://github.com/orgs/QRun-IO/projects/12). Use existing issues for the same requirement or failure, and link each focused ticket to #713 and its feature or investigation parent. Frontend issues #12 and #10 were transferred to central issues #985 and #986; use the central identifiers and preserve their report history.
## Required ticket fields
Every new ticket needs these fields on the issue and its Project 12 item; complete them before work advances to **Ready**:
| Field | Required value |
|---|---|
| Issue Type | Native issue: Bug, Feature, Task or Epic. Mirror it in the Project field, using Planning Epic for Epic; Improvement is a Project-only category. |
| Assignees | The person accountable for completing or coordinating the ticket |
| Milestone | The agreed delivery milestone; keep it consistent with the target version |
| Priority | Critical, High, Medium or Low, with an impact-based rationale in the issue |
| Component | The owning implementation component: `qqq-frontend-next` for Next source, or the actual backend/sample component for a dependency. |
| Target Version | The agreed version of the owning component or an explicit pending release decision. A backend dependency in the Next UI 1.0 milestone does not imply a backend 1.0 release. Never imply delivery in an already published RC. |
| Status | The current workflow state below |
Record unresolved ownership or release decisions explicitly while a ticket remains in **Backlog**. Do not invent dates, estimates or a new release version to fill a field. A browser assertion failure alone does not establish high product severity.
## Workflow and evidence
- **Backlog / Ready:** capture the expected behavior, scope, acceptance criteria, required fields and dependencies; Ready means actionable work with an owner.
- **In progress:** link the active branch or work and record reproduction, confirmed cause, implementation progress and remaining blockers.
- **In review:** link the reviewable PR, source commit and verification evidence; keep unmet acceptance criteria visible. A local fix or passing subset does not imply merge or release.
- **Done:** close only after the accepted fix is integrated and every acceptance criterion is verified, or an explicit approved disposition is recorded. Link the merged PR/commit and evidence. Identify whether the change is unreleased or delivered, with the version and release link when published.
## Updating an issue
- Put current status and remaining acceptance criteria at the top. Preserve the original report as history.
- Record expected/actual behavior, reproduction, environment, source commit, run and artifact evidence. State whether the cause is established.
- Separate implementation evidence, passing scoped tests, owner visual approval and final release approval. They are distinct requirements.
- Resolve all original acceptance criteria before closing, or document an explicit approved disposition. A later passing run alone does not explain an intermittent failure.
- Keep external-provider exclusions visible. Controlled provider fixtures do not prove a real Auth0 tenant, Google account or QuickSight dashboard.
- Update issue links after a fix or new finding. Repository plans, session notes, release notes and test/visual ledgers support the issues; they are not competing live backlogs.
## Release and design constraints
Preserve the original Next design at `e42ad2b2bcdc76311e13002a22dd70e3ec437192`. Material Dashboard is the behavior and metadata compatibility reference. Prioritize primary-browser functional depth and disclose secondary-browser failures.
[RC9](releases/1.0.0-RC.9.md) is the published prerelease for application testing. Its immutable-tag acceptance run has known Firefox/tablet failures; consult the release notes and #985 for the exact evidence. The browser fixes in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) are [unreleased](releases/unreleased.md).
Every release and changelog entry must link its central QQQ issues and distinguish product changes, test/infrastructure corrections, verification and unresolved work. A published RC's tag and artifacts remain immutable; subsequent fixes require a separately authorized version and release. Update tickets and Project 12 with the actual delivery version and release link after publication.
Final 1.0 remains held for the owner's requested application soak and renewed, explicit final approval ([#712](https://github.com/QRun-IO/qqq/issues/712)). Passing CI, an issue marked Done or elapsed time does not release that hold. This project does not authorize releasing QQQ 4.1.
+22
View File
@@ -0,0 +1,22 @@
# Plan: API playground contrast (#724)
## Reproduction and scope
Fresh, fingerprinted desktop captures preserve the original Next composition. RapiDoc's Expand all text in both the original design and RC6 renders RGB(29, 78, 216) on RGB(42, 43, 44), a contrast ratio of 2.12:1 at 16px normal weight. This inherited defect fails the 4.5:1 requirement. Browser regression checks also found light-mode contact-link contrast of 2.36:1 and authentication-status contrast of 3.00:1.
## Implementation and verification
- [x] Retain failing browser contrast checks inside RapiDoc's shadow DOM.
- [x] Apply existing dashboard primary-text and error tokens at the component boundary. Pair dark accent backgrounds with inverse button text. Preserve layout and dependencies.
- [x] Verify normal and hovered controls in light/dark Chromium desktop and mobile, plus live Preferences theme switching.
- [x] Run types, lint, production export, all 1,901 unit tests and existing bundle budgets.
- [x] Directly inspect four fresh screenshots after color transitions settle.
- [x] Independent review approved with no remaining findings; prepare signed isolated commit.
Evidence: `test-results/visual-review/api-playground-contrast/` in the original checkout. Final focused report: four passed, zero failures, skips, flaky outcomes or report errors. These are targeted checks, not a complete acceptance gate. Initial toggle verification incorrectly asserted an HTML attribute: React 19 sets RapiDoc's non-reflected Lit property. A subsequent audit sampled active color transitions. Both runs are retained; the corrected test awaits the property, Lit render and actual shadow-root animations without fixed sleeps.
## Remaining limits
This proves the fixture's visible playground content, hover states and theme switching, not all expanded endpoint/authentication states or arbitrary custom palettes. The existing narrow-screen inner horizontal clipping remains open; this color correction does not redesign the playground.
This branch is based on RC6 source `7ddf45f` and excludes the separate TIME fix `276b65b`. Neither follow-up is part of the immutable RC6 tag. Final 1.0 remains on hold.
+17
View File
@@ -0,0 +1,17 @@
# Sidebar icon label assertion (#728)
## Evidence
RC6 candidate WebKit failed NAV-002 because raw link text included decorative legacy icon codepoints. The same assertion was reproduced against the public RC6 JAR after explicitly awaiting the fixture's legacy glyph. This is a test assertion defect: accessible link names should match metadata labels, while decorative SVG text remains hidden from assistive technology.
## Correction
Await the legacy glyph, then assert exact link count and each accessible label in backend order. Check that rendered legacy SVGs remain `aria-hidden`. Retain nested-app, parent expansion and child label assertions. No product code or release artifact changes.
## Verification
Retain red and focused Chromium/WebKit/mobile reports under `test-results/visual-review/sidebar-icon-labels/` in the original checkout. The focused check exercises the actual public RC6 JAR with backend `e0d57dd`. Targeted ESLint and diff checks are required; independent review precedes integration. Original CI failure remains recorded, not erased by a later focused pass.
Focused result: three passed, zero failures, skips, flaky outcomes or report errors. Targeted ESLint and diff checks passed.
Independent review approved with no findings. This change verifies navigation semantics; glyph appearance remains covered by the separate legacy-icon workflows.
+10
View File
@@ -0,0 +1,10 @@
# Plan: TIME block seconds compatibility (#728)
Preserve Next layout while making standalone and process TIME inputs accept and submit seconds, matching the shared form editor and QQQ TIME values.
1. Reproduce native step validation failure with standalone and process tests; retain a regular form control.
2. Apply the existing one-second input step to both plain block paths.
3. Verify edited/seeded values through actual Chromium workflows, including backend process readback; run affected tests and relevant quality gates.
4. Keep this follow-up separate from the unchanged RC6 candidate while its hosted CI runs.
Verification: two focused component regressions failed before the fix (regular form control passed); three real-backend Chromium scenarios failed at native step assertions. After the two-line correction, all 66 affected component tests and eight desktop/mobile backend workflows passed, including independent SQL checks. Full unit suite, type/lint, production build and budgets passed. This is isolated follow-up work, not part of RC6 candidate `7ddf45f`.
+6 -4
View File
@@ -1,5 +1,7 @@
# Browser matrix — real-backend acceptance (QRun-IO/qqq#649, #708)
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
## September 30 — post-RC4 report and menu checkpoint
Local production export at `a8e4d3b`:583 Chromium tests and all391 required feature rows passed, zero failures/skips/flaky results, three approved external-service exclusions. This includes the uniform table backgrounds, record overview spacing and hidePreview compatibility fixes. Retained full report/gate: `report-preview-full-chromium/` in local visual-review evidence.
@@ -69,11 +71,11 @@ QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet \
QQQ_ACCEPTANCE_BROWSERS=mobile,tablet node scripts/acceptance.mjs --skip-build
```
| Project | Playwright device | Engine (Playwright 1.58.2) | Runs |
| Project | Playwright device | Engine (Playwright 1.64.0-alpha-2026-10-01) | Runs |
|----------|-------------------|----------------------------|------|
| chromium | Desktop Chrome | Chrome for Testing 145 | every spec |
| firefox | Desktop Firefox | Firefox 146 | every spec |
| webkit | Desktop Safari | WebKit 26.0 | every spec |
| chromium | Desktop Chrome | Chrome for Testing 155 | every spec |
| firefox | Desktop Firefox | Firefox 156 | every spec |
| webkit | Desktop Safari | WebKit 26.6 | every spec |
| mobile | Pixel 7 (412 × 839, touch) | Chromium | tests tagged `@mobile` |
| tablet | iPad (gen 7) (810 × 1080, touch) | WebKit | tests tagged `@mobile` or `@tablet` |
+5
View File
@@ -0,0 +1,5 @@
# ESB integration acceptance
The ten required [ESB matrix scenarios](../../tests/acceptance/matrix/esb.json) pass in Chromium, Firefox and WebKit: 30 cases with no failures, skips or retries. They exercise the stock sample’s embedded Artemis broker, table-event delivery into `syncPerson`, service/table/process views, permission denial, subscription browsing, trigger pause/resume/restart and confirmed dead-letter replay. A further 38 Chromium navigation, permission and table Developer-view cases, plus 12 security-variant and search cases, pass without skips or retries. The main and security fixtures use separate broker ports. These are focused ESB results; general Next browser/parity and final release acceptance remain tracked separately.
The backend integration fixes are [QQQ PR #980](https://github.com/QRun-IO/qqq/pull/980), based on `5bf02d54`: normal sample ESB registration, API documentation route isolation and nullable subscription/dead-letter pause state. Frontend verification passes 105 focused ESB tests, all 1,951 unit tests with coverage thresholds, type checking, the production static export and bundle budgets. The [acceptance README](../../tests/acceptance/README.md) gives the commands and fixture boundaries; [Next #10](https://github.com/QRun-IO/qqq-frontend-next/issues/10) and [QQQ #739](https://github.com/QRun-IO/qqq/issues/739) track delivery. RabbitMQ and broker management behavior are covered by the backend conformance suite; these browser tests use Artemis without an HTTP management endpoint, so unavailable broker actions remain hidden.
+11 -7
View File
@@ -1,14 +1,18 @@
# Next UI feature acceptance matrix
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
Generated by `node scripts/acceptance-matrix-md.mjs` from `tests/acceptance/matrix/*.json` and the
acceptance gate results. Each row is a supported QQQ UI feature or use case with executable
real-backend acceptance coverage (QRun-IO/qqq#649). See `tests/acceptance/README.md` to run it.
Rows: 394. Required rows passing in the recorded runs: 391.
Recorded runs: `test-results/acceptance/gate.json` (2026-09-30T19:05:47.175Z).
Recorded runs: `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-results/visual-review/rc8-corrected-full-chromium/gate.json` (2026-10-01T01:03:05.420Z), `/Users/james.maes/Git.Local/QRunIO/qqq-frontend-next/test-results/visual-review/rc8-corrected-ci-chromium/gate.json` (2026-10-01T01:23:18.171Z).
Local unpublished icon follow-up on ac22946, backend e0d57dd31b979b17215370546ef4e26cd8a38d21. Command: QQQ_ACCEPTANCE_BROWSERS=chromium node scripts/acceptance.mjs --skip-build (isolated port 18842). 589 tests passed; zero failures, skips, flaky outcomes or report errors. Three approved external-service exclusions remain. This does not establish other-browser or whole-product visual approval.
Corrected RC8 source cd3d473: 602 local and 602 hosted Chromium cases passed, with zero failed/skipped/flaky cases or report errors. Hosted run 36798799502. Three approved external-service exclusions remain. Earlier failed runs are retained and described in docs/releases/1.0.0-RC.8.md; this matrix records the corrected primary runs only. Other browser profiles and whole-product compatibility/visual approval remain separate.
The additional ESB integration scenarios and their focused three-browser results are recorded in [ESB acceptance](esb.md). The historical full-suite counts above predate those ten new matrix rows.
## Navigation, application structure, branding and icons
@@ -38,7 +42,7 @@ Local unpublished icon follow-up on ac22946, backend e0d57dd31b979b17215370546ef
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | /app/person/1/no/such and /no-such-page respond 404 and render the 404 page; Go Home reaches the dashboard | only the document 404 is reported | stock sample | pass (chromium) | |
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | ?code=NAV-B2 opens record 2 (Tide Chart); ?email=... opens that person | no match, several matches (?shelf=North), unknown field, no parameters: error message, no navigation | NavigationFixtures navDeepItem (unique key code); sample person emails | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context, filters, and navigates by keyboard | filter 'pet' shows Pet, Pet Note, Pet Species with type and app; Enter opens the first; '.' opens the palette; Escape closes it | hidden objects are not listed | stock sample + NavigationFixtures | pass (chromium) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by label (matched locally, alongside record search when the backend offers it: NAV-030) | typing a label lists matching pages with app context; choosing one navigates; a viewed record is offered as a recent record; Enter without a selection opens /app/search?q= with the matches, surviving refresh | no search request fails (regression: every keystroke produced a 404 from a missing /search endpoint); with no searchable tables no request is made at all (NAV-032) | stock sample | pass (chromium) | [#653](https://github.com/QRun-IO/qqq/issues/653) |
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by label (matched locally, alongside record search when the backend offers it: NAV-030) | typing a label lists matching pages with app context; choosing one navigates; a viewed record is offered as a recent record; Enter without a selection opens /app/search?q= with the matches, surviving refresh; search dialog contains Tab and Shift+Tab with empty or populated results; Clear returns to the input; Escape and backdrop dismissal restore keyboard and pointer openers | no search request fails (regression: every keystroke produced a 404 from a missing /search endpoint); with no searchable tables no request is made at all (NAV-032) | stock sample | pass (chromium) | [#653](https://github.com/QRun-IO/qqq/issues/653) |
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | hamburger opens the drawer; choosing a table navigates and closes it; close button and a backdrop tap close it; drawer controls are 44 px touch targets | | stock sample, 412x839 touch viewport | pass (chromium) | |
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | metadata counts equal the backend's; framework shows the bundled Next.js version (regression: hardcoded 'Next.js 15') | | stock sample | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report page | sidebar entry with its icon opens /app/navDeepItemReport with the report heading, title and breadcrumbs; app home Reports group lists and opens it | | NavigationFixtures navDeepItemReport (child of Nav Level Three, icon assessment) | pass (chromium) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
@@ -53,7 +57,7 @@ Local unpublished icon follow-up on ac22946, backend e0d57dd31b979b17215370546ef
| NAV-037 | Mobile navigation drawer is a keyboard-usable modal dialog: focus moves in on open, Tab stays inside, and Escape, the close button or a chosen link close it and return focus to the menu button | Enter on the menu button opens the Navigation dialog with focus inside; Tab and Shift+Tab visit every drawer control and never reach the page behind; Escape, the close button and choosing Carrier close it; focus returns to the menu button; Carrier opens | Tab never leaves the open drawer (QRun-IO/qqq#708: focus stayed on the menu button; Firefox could not reach the drawer) | stock sample (Miscellaneous app), 412x839 touch viewport | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| NAV-038 | Shell on phones and tablets: skip link, header controls, breadcrumbs, navigation entries, user menu, app homes and not-found links are 44 px touch targets, and the page never scrolls sideways | skip link is the first Tab stop and 44 px tall when shown; header, breadcrumbs, sidebar or drawer entries and the user menu meet the 44 px target; no sideways page scroll with a four-level breadcrumb trail | under a mouse (pointer: fine) the desktop sizes are unchanged | stock sample + NavigationFixtures (navDeepItem three apps deep); mobile (Pixel 7) and tablet (iPad, desktop layout with touch) projects | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
| NAV-055 | Material Dashboard CSS and test hooks remain available on navigation, banners, app homes, forms, record views and query controls | Material data-qqq-id and class selectors identify corresponding Next UI controls; application CSS keyed to those selectors changes the rendered style; record and query actions retain the expected behavior and button variants | | stock sample with NavigationFixtures and RecordsFixtures; application CSS applied in css-hooks.spec.ts | pass (chromium) | [QRun-IO/qqq-frontend-next#731](https://github.com/QRun-IO/qqq-frontend-next/issues/731) |
| NAV-056 | The Material Dashboard theme from versioned QQQ metadata changes Next UI tokens, application CSS and the branded header | v1 metaData publishes the selected Material theme settings; the rendered dashboard applies its primary token, branded header colors and tagline, and custom CSS; Every theme scenario resets instance metadata before navigation; Preferences exposes accessible Light and Dark choices; keyboard changes, real reload persistence, reset and light/dark dialog accessibility are verified; Application theme override is explained in Preferences without erasing the saved browser appearance choice; Pivot validation meets text contrast in default light, default dark and a configured orange error theme | | AcceptanceSampleServer /acceptance/theme; reset removes the theme before each test | pass (chromium) | [QRun-IO/qqq-frontend-next#719](https://github.com/QRun-IO/qqq-frontend-next/issues/719) |
| NAV-056 | The Material Dashboard theme from versioned QQQ metadata changes Next UI tokens, application CSS and the branded header | v1 metaData publishes the selected Material theme settings; the rendered dashboard applies its primary token, branded header colors and tagline, and custom CSS; Every theme scenario resets instance metadata before navigation; Preferences exposes accessible Light and Dark choices; keyboard changes, real reload persistence, reset and light/dark dialog accessibility are verified; Application theme override is explained in Preferences without erasing the saved browser appearance choice; Pivot validation meets text contrast in default light, default dark and a configured orange error theme; API playground visible text and hovered controls meet contrast in light/dark themes; Preferences changes update the rendered palette without reload | | AcceptanceSampleServer /acceptance/theme; reset removes the theme before each test | pass (chromium) | [QRun-IO/qqq-frontend-next#719](https://github.com/QRun-IO/qqq-frontend-next/issues/719) |
## Performance budgets on the production export: large tables, heavy dashboards and long processes (QRun-IO/qqq#710). Bundle budgets are checked separately by scripts/perf-budget.mjs.
@@ -126,7 +130,7 @@ Local unpublished icon follow-up on ac22946, backend e0d57dd31b979b17215370546ef
| PRC-061 | Typed bulk load default values, searchable value mapping and the grouped, searchable Add Fields menu (Material QHierarchyAutoComplete) | the Person default value is chosen by searching person possible values and shows its label; Birth Date takes a date; Is Employed takes Yes or No; the Add Fields menu groups Pet and Pet Note, searches, disables mapped fields with a tooltip and adds the WIDE note field twice (Pet Note: Note (1) and (2)); species values are mapped by searching the species possible values; the pets, their person, species and birth date and both notes each are inserted (SQL) | a default value left empty is refused with A value is required. before any step request | enricher pet.bulkInsert (pet with its notes association, WIDE layout) and person.bulkInsert | pass (chromium) | [#726](https://github.com/QRun-IO/qqq/issues/726) |
| PRC-062 | Composite INPUT_FIELD metadata editors share process validation and multipart submission | Choose a labeled possible value, edit currency and multiline code, update a widget filter without changing columns, upload BLOB and FILE_UPLOAD data and verify backend-read content; Desktop wheel scrolling over a plain numeric block preserves its value; intentional keyboard edits and touch input changes are returned by the backend process review | Required uploads block submission; code-editor Enter does not submit the process | prcBlockEditors | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-063 | INPUT_FIELD autofocus and trimmed Enter values on subsequent process screens | Plain and metadata editors receive configured focus, placeholders and previous process values; Enter saves trimmed text verified in SQL; ordinary result screens receive heading focus | First blur does not show premature validation; required whitespace cannot advance either input screen | prcInputFocus | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | Unchanged seeded timestamps preserve seconds and instant across plain/shared blocks and regular form fields, including the repeated autumn DST hour; Edited timestamps crossing daylight saving submit UTC values verified from backend SQL | Zoned values must not disappear from datetime-local controls or lose their timezone on submission | prcInputDateTimes with browser timezone America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-064 | DATE_TIME process inputs display local time and submit UTC instants | Unchanged seeded timestamps preserve seconds and instant across plain/shared blocks and regular form fields, including the repeated autumn DST hour; Edited timestamps crossing daylight saving submit UTC values verified from backend SQL; TIME inputs in plain/shared blocks and forms retain seconds for unchanged and edited submissions, verified through SQL readback. | Zoned values must not disappear from datetime-local controls or lose their timezone on submission | prcInputDateTimes with browser timezone America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| PRC-065 | Configured Google Drive shared-folder selection and process submission | Select a shared-drive folder and submit ID, name, token and other required input through the real process; independently verify database outcome | Reject a file and retry; cancel a replacement selection clears the destination and token; missing required note cannot advance | prcDrive with owned Google SDK script responses and real configured backend CSP | pass (chromium) | [#704](https://github.com/QRun-IO/qqq/issues/704) |
| PRC-066 | INPUT_FIELD case behaviors preserve caret and submitted process values | Backend uppercase/lowercase metadata transforms a middle-of-value edit without moving the caret; Enter and Submit persist exact values verified through independent SQL; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Submit persists the exact transformed text through independent SQL; Unicode case expansion preserves continued typing in STRING and TEXT fields; submitted values are verified through independent SQL; Explicitly configured PASSWORD case behaviors preserve masking and show/hide values; Enter and Submit persist Unicode-expanded values verified through independent SQL | Typing alone does not submit or persist the process; Multiline Enter does not submit or navigate away | prcInputCase / prcTextCase | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
@@ -272,7 +276,7 @@ Local unpublished icon follow-up on ac22946, backend e0d57dd31b979b17215370546ef
| REC-056 | Record view hash links: #audit, #/launchProcess={process} and #{section} anchors | #audit opens the audit dialog; closing it clears the hash and a reload does not reopen it; #/launchProcess=person.bulkEdit replaces the URL with the run for the record (init recordIds=2); Cancel returns to the record; #employmentInfo selects the Employment Info tab and scrolls it into view; a later hash change selects Dates | | sample person, person.bulkEdit | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-057 | Create presets from links: #/createChild= opens a child create dialog over the record; /create#/defaultValues=/disabledFields= prefills and locks | the backend-format #/createChild=pet link on person 3 opens Add Pet with Person preset and disabled; Create closes the dialog, clears the hash, submits personId=3 and SQL shows the pet linked to person 3; /app/pet/create#/defaultValues={name, personId}/disabledFields={personId} prefills both, locks Person and saves them (SQL); /app/pet/create#defaultValues={speciesId} (no slash) prefills Species without locking | locked fields are disabled; unlocked preset fields stay editable | sample person -> pet (personId), petSpecies | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the form's current values | every search posts the form values as the values map of the v1 JSON body; create: choices narrow to the chosen Category and change when it changes; edit: the stored Item label shows, a new Category changes the choices; the chosen id persists (SQL) and the record view shows its label | Category empty: the unset variable makes no matches for a form, shown as No options found; an item outside the filter is not offered, even when searched by name; the backend refuses an out-of-filter id lookup and returns only the filtered choices | recPvPick itemId (recPvItem source filtered on categoryId = ${input.categoryId}) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | developer page for a table keeps its metadata sections and shows the API Docs & Playground section; with no application APIs it shows "This table is not available in any APIs." and no API or Version selectors | a viewer persona sees the same empty state and cannot fetch any table spec | stock sample tables (the sample classpath has no qqq-middleware-api, so no application APIs) | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | developer page for a table keeps its metadata sections and shows the API Docs & Playground section; with no application APIs it shows "This table is not available in any APIs." and no API or Version selectors; OpenAPI download and new-tab buttons remain fully visible before interaction at desktop, phone and 320px widths in light/dark modes; pointer/touch downloads yield the real specification and keyboard navigation opens its URL; specification labels fit wider application font metrics at desktop and phone widths without content clipping | a viewer persona sees the same empty state and cannot fetch any table spec | recordLab has no application APIs; person is exposed through the owned Acceptance API | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script creation | versions list with CURRENT on the current revision and per-file code for the selected version; an older version offers Edit and Activate; save a new version with a commit message (SQL: new script_revision row, its file and script.current_script_revision_id; persists after reload); test run output and log lines equal the backend testScript response; a failing run shows the error the backend returns; logs list the script_log rows of the selected version with their lines; docs show the script type help text and sample code; create a script for an empty associated script field; local QQQ helper and keyword suggestions support keyboard and pointer/touch insertion, native undo/redo, both-theme accessibility, and SQL-verified revision persistence | a viewer cannot save or test a revision (no Edit or Test offered; the backend refuses storeScriptRevision and testScript); a viewer cannot create a script (no Create Script offered; the backend refuses the create route); a table without associated scripts shows none | scriptLab (RecordsFixtures: Alpha with script 101, revisions 101-102, logs 101-102; Beta without a script) on the scripts tables from WidgetsFixtures | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | e, c and n open edit, copy and create with the right values; a opens the audit history; d opens the delete confirmation, Escape keeps the row (SQL), confirming deletes it (SQL); ? lists Create a New Record, Edit/Copy/Delete/Audit the current Record with their keys | no shortcut fires while the audit or keyboard help dialog is open; keys typed in the Jump to input are text; viewer persona: n, e, c, d do nothing (a still works); the backend refuses insert and delete (403, SQL unchanged) | sample person | pass (chromium) | [#714](https://github.com/QRun-IO/qqq/issues/714) |
| REC-058 | Record view on phones and tablets: the title stays readable, the header controls wrap onto their own row, and the phone action sheet fits, scrolls and keeps focus | The title stays on one line and never overlaps the view toggle, Audit or record actions; neither the page nor its main region scrolls sideways; The back link, value links and header controls are touch-sized (44 x 44 px) under a coarse pointer; The phone Actions sheet fits the screen, takes focus, keeps Tab and Shift+Tab inside, closes by Escape or a backdrop tap with focus back on its trigger, and runs Edit | A read-only user gets no phone Actions trigger (asserted in REC-046) | recordLab 1 (Lab: Alpha); phone (412 px touch), tablet (810 px touch) and desktop viewports | pass (chromium) | [#708](https://github.com/QRun-IO/qqq/issues/708) |
@@ -436,5 +440,5 @@ Local unpublished icon follow-up on ac22946, backend e0d57dd31b979b17215370546ef
| WID-069 | chart widget Material extras: theme color names, legend toggles, single-series legends (horizontal bars, line badges), full pie with percent tooltip, stacked backgroundColor, stacked tooltip and right integer axis, rotating category labels, small line y ticks and grid, bar chart As of line | Pie slices named info and success fill with the Material theme colors; the pie is full (no inner radius); Hovering a slice shows its label, value and percent of the total; Clicking a legend entry hides that slice or series and clicking again shows it; Stacked datasets fill from their singular backgroundColor (a theme name or a CSS color); the tooltip names only the hovered dataset; the y axis sits on the right with whole-number ticks; long category labels turn; A single-series horizontal bar chart has a legend; a line chart names its series in badges above the chart; A small line chart shows y-axis values and a horizontal grid; A barChart widget ends with an As of line naming today's date | A hidden series draws no bars or points; No y-axis tick of the stacked chart is fractional | WidgetTableChartFixtures.java widgetTableCharts/accPieNamed, accStackedExtras, accLineBadges, accHorizontalLegend, accSmallLineTicks, accBarAsOf | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| RPT-019 | render report input step shows only its fields; a report without variables adds no no-fields message | Report Format, Email To and Email Subject are shown; the values widget renders nothing | no "No fields" text on the input step | savedReport 1 (Pet Species Report, no variables) | pass (chromium) | [#707](https://github.com/QRun-IO/qqq/issues/707) |
| RPT-020 | Report files download from the v1 download and report routes | a process report links its file at /qqq/v1/download/... and the file has every row; a report without a process streams from /qqq/v1/reports/{name}?format=csv with every row | an unregistered server file or storage reference is refused (403); a restricted report is 403, an unknown one 404, a missing format 400 | accPersonReport, accStreamedReport, accRestrictedReport | pass (chromium) | [#699](https://github.com/QRun-IO/qqq/issues/699) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| WID-074 | Standalone composite scalar controls and metadata editors | Seeded text, number, date, boolean and native file controls; currency prefix, named possible values and multiline code editor; Choice width and center-click behavior with system and monospace fonts, including a 320px viewport; Backend uppercase/lowercase metadata preserves middle-of-value edits and caret position in standalone blocks; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Desktop wheel scrolling preserves plain and shared numeric block values; intentional ArrowUp edits still work and text inputs retain focus | Code-editor Enter leaves the widget on the same page; Multiline Enter does not submit or navigate away | widgetInputEditors / accTypedInputs | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| WID-074 | Standalone composite scalar controls and metadata editors | Seeded text, number, date, boolean and native file controls; currency prefix, named possible values and multiline code editor; Choice width and center-click behavior with system and monospace fonts, including a 320px viewport; Backend uppercase/lowercase metadata preserves middle-of-value edits and caret position in standalone blocks; Multiline TEXT case conversion preserves the middle-edit caret and inserted newline; Desktop wheel scrolling preserves plain and shared numeric block values; intentional ArrowUp edits still work and text inputs retain focus; Native standalone TIME controls increment by one second and retain second-level values. | Code-editor Enter leaves the widget on the same page; Multiline Enter does not submit or navigate away | widgetInputEditors / accTypedInputs | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | UTC timestamp displays the correct local date, time and seconds | Do not slice UTC digits into a local-time field | accTypedInputs in America/New_York | pass (chromium) | [#728](https://github.com/QRun-IO/qqq/issues/728) |
+4 -2
View File
@@ -1,5 +1,7 @@
# Material Dashboard parity (QRun-IO/qqq#714)
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
This is the full inventory of what the Material Dashboard can do and where each ability stands in Next. The audit covered Material `origin/develop` at `8ec1be7a`: every route, page, component, widget, block, process component, supplemental metadata class and theme property. The ledger is being reconciled against `feature/next-1.0` after the integrated parity work; the Query, theme and form adjuster sections include the 2026-09-26 implementation.
Each row gives the Material source (`material:` is the Material repo root; `qqq:` is the backend repo root), the Next implementation (paths relative to this repo), the acceptance rows in `tests/acceptance/matrix/*.json` that cover it, and a status. Every Partial or Missing row names the issue that tracks it. A row that came up in more than one area is listed once, in the most specific area.
@@ -791,13 +793,13 @@ Dashboard and record-view widgets: widget types, dashboard layout and data flow,
| DIVIDER block | material:src/qqq/components/widgets/blocks/DividerBlock.tsx:30-33 | src/components/widgets/blocks/QqqBlocks.tsx:DividerBlock | WID-057 | Done |
| BIG_NUMBER (heading, number, context, numberColor, width) | material:src/qqq/components/widgets/blocks/BigNumberBlock.tsx:32-66 | src/components/widgets/blocks/QqqBlocks.tsx:BigNumberBlock | WID-057, WID-023 | Done |
| INPUT_FIELD: label, placeholder, initial value, autoFocus | material:src/qqq/components/widgets/blocks/InputFieldBlock.tsx:38-46, 114-134 | src/components/widgets/blocks/QqqBlocks.tsx:InputFieldBlock | WID-057, PRC-063 | Done: configured focus and placeholders survive process transitions; process and standalone initial values retain their respective sources |
| INPUT_FIELD: typed field from QDynamicFormField (all field types) | material:src/qqq/components/widgets/blocks/InputFieldBlock.tsx:38-39, 123-131 | src/components/widgets/blocks/QqqBlocks.tsx; src/components/widgets/blocks/BlockInputEditor.tsx; src/components/process/ProcessBlocks.tsx | PRC-062, PRC-063, PRC-064, PRC-066, WID-074, WID-075 | Partial: shared choice/currency/code/file/widget editors are integrated; process field context, multipart uploads, hidden/read-only flags and scalar presentation have regression coverage; later-screen autofocus, placeholders, prior values and trimmed Enter are covered by PRC-063; zoned timestamp display/submission and unchanged DST instants by PRC-064/WID-075; backend-provided upper/lowercase behavior for STRING and TEXT, middle-edit caret, multiline Enter and persisted submission values by PRC-066/WID-074; post-RC3 source adds explicitly configured PASSWORD case behavior, masking/show-hide and Enter/Submit persistence through PRC-066; RC4 candidate adds numeric wheel protection with targeted desktop value/keyboard and tablet submission coverage; remaining field configurations and visual review remain; #728 |
| INPUT_FIELD: typed field from QDynamicFormField (all field types) | material:src/qqq/components/widgets/blocks/InputFieldBlock.tsx:38-39, 123-131 | src/components/widgets/blocks/QqqBlocks.tsx; src/components/widgets/blocks/BlockInputEditor.tsx; src/components/process/ProcessBlocks.tsx | PRC-062, PRC-063, PRC-064, PRC-066, WID-074, WID-075 | Partial: shared choice/currency/code/file/widget editors are integrated; process field context, multipart uploads, hidden/read-only flags and scalar presentation have regression coverage; later-screen autofocus, placeholders, prior values and trimmed Enter are covered by PRC-063; zoned timestamp display/submission and unchanged DST instants by PRC-064/WID-075; backend-provided upper/lowercase behavior for STRING and TEXT, middle-edit caret, multiline Enter and persisted submission values by PRC-066/WID-074; post-RC3 source adds explicitly configured PASSWORD case behavior, masking/show-hide and Enter/Submit persistence through PRC-066; RC4 candidate adds numeric wheel protection with targeted desktop value/keyboard and tablet submission coverage; RC7 adds one-second native stepping to plain process/standalone blocks, with unchanged and edited seconds verified through SQL alongside shared/form controls; remaining field configurations and visual review remain; #728 |
| INPUT_FIELD: submitOnEnter, required-blank guard, `->actionCode` | material:src/qqq/components/widgets/blocks/InputFieldBlock.tsx:72-110 | src/components/widgets/blocks/QqqBlocks.tsx:341-351 | none (unit test only) | Done |
| BUTTON: click to actionCallback, format outlined/text/filled, default label, icons | material:src/qqq/components/widgets/blocks/ButtonBlock.tsx:37-81 | src/components/widgets/blocks/QqqBlocks.tsx:ButtonBlock | WID-057 | Done |
| AUDIO (path, autoPlay, showControls) | material:src/qqq/components/widgets/blocks/AudioBlock.tsx:33-39 | src/components/widgets/blocks/QqqBlocks.tsx:AudioBlock | WID-057 | Done: autoPlay is not asserted |
| IMAGE (path, alt, width, height, bordered) | material:src/qqq/components/widgets/blocks/ImageBlock.tsx:33-58 | src/components/widgets/blocks/QqqBlocks.tsx:ImageBlock | WID-057 | Done: Only width is asserted |
| ICON (name, color, fontSize) | material:src/qqq/components/widgets/blocks/IconBlock.tsx:32-40 | src/components/widgets/blocks/QqqBlocks.tsx:IconBlock | WID-057 | Done |
| Any Material icon name renders (MUI ligature) in blocks, multiStatistics, fieldValueList | material:src/qqq/components/widgets/blocks/IconBlock.tsx:37; material:src/qqq/components/widgets/statistics/MultiStatisticsCard.tsx:89; material:src/qqq/components/widgets/misc/FieldValueListWidget.tsx:90 | src/components/widgets/WidgetIcon.tsx; src/components/ui/LegacyMaterialIcon.tsx; src/lib/utils/legacy-material-icons.ts | WID-057, WID-012, WID-005 | Partial: integrated follow-up, pending RC6 publication, retains existing Lucide mappings and supplies the pinned 2,234-name legacy inventory through a local font. Six formerly unmapped names plus navigation and main-tile glyphs pass focused light/dark desktop/mobile checks; Full local Chromium verification passed 589 tests and all 391 required rows; the packaged development JAR passed four light/dark desktop/mobile workflows. Publication remains pending. Unknown names retain fallbacks; distinct font variants and arbitrary Material Symbols names are not claimed. RC5 retains the prior limitation; #728 |
| Any Material icon name renders (MUI ligature) in blocks, multiStatistics, fieldValueList | material:src/qqq/components/widgets/blocks/IconBlock.tsx:37; material:src/qqq/components/widgets/statistics/MultiStatisticsCard.tsx:89; material:src/qqq/components/widgets/misc/FieldValueListWidget.tsx:90 | src/components/widgets/WidgetIcon.tsx; src/components/ui/LegacyMaterialIcon.tsx; src/lib/utils/legacy-material-icons.ts | WID-057, WID-012, WID-005 | Partial: published RC6 compatibility support retains existing Lucide mappings and supplies the pinned 2,234-name legacy inventory through a local font. Six formerly unmapped names plus navigation and main-tile glyphs pass focused light/dark desktop/mobile checks; Full local Chromium verification passed 589 tests and all 391 required rows; the packaged development JAR passed four light/dark desktop/mobile workflows. The public RC6 JAR signature, fresh Maven resolution and actual-JAR workflows are verified; hosted secondary-browser font download failures remain under investigation. Unknown names retain fallbacks; distinct font variants and arbitrary Material Symbols names are not claimed. RC5 retains the prior limitation; #728 |
### F. Statistics, stepper, fieldValueList, usaMap
+53 -1
View File
@@ -1,10 +1,18 @@
# Next UI visual review
> **Current status lives in GitHub:** [Next UI 1.0 roadmap](https://github.com/QRun-IO/qqq/issues/713). This document records scoped evidence and historical checkpoints; older open/closed wording and summary counts do not override current issue status. See [tracking policy](../ISSUE-TRACKING.md). Final 1.0 remains held for owner testing and explicit approval.
Status: in progress, September 30, 2026. This review is a release requirement. Captured screenshots are not approvals or evidence that a surface has passed review.
The visual reference is the working Next UI before the September 26 parity integrations, commit `e42ad2b2bcdc76311e13002a22dd70e3ec437192`. Material Dashboard remains the reference for supported workflows and metadata contracts. The user's September 27–28 correction requires preserving the earlier Next design while completing those workflows.
## Latest checkpoint — September 30, reachable column menus
## Latest checkpoint — September 30, phone process states
Compared original Next `e42ad2b2bcdc76311e13002a22dd70e3ec437192` with the packaged RC8 candidate `f0f0eae98e4b6c883475956a7c59e060d2e5f0a5`, using the same fresh fixture classes and backend. All 24 phone images were inspected: light/dark, original/current, bulk-load mapping at four scroll positions, process progress and expanded internal error. All requested scroll positions were reached, with no setup errors, page errors or document horizontal overflow. Evidence: `rc8-phone-process-review/` manifests and review.
Progress and expanded-error composition retain the original arrangement. Bulk-load field cards and footer retain their arrangement, but saved-profile/reset controls, metadata help and larger touch targets make the upper section taller. Grouped Add Fields and the red removal icon also differ from the original. These are recorded differences, not owner approval. Header breadcrumb crowding remains open. This checkpoint covers these phone states only; whole-product visual and interaction approval remain open.
## Earlier checkpoint — September 30, reachable column menus
VIS-031: column menus were not constrained to available viewport height. A browser probe on the report preview measured a 352px menu starting at y=-147 in a 320px window in both Chromium and WebKit. The shared menu now uses Radix's available-height limit, an 8px collision margin and vertical scrolling, matching existing query menus. Its labels, ordering, typography and normal-height presentation are preserved.
@@ -500,3 +508,47 @@ Inventory completion is **not** whole-product visual approval. Fifty-two gallery
Existing Next Lucide mappings, widget overrides and image priority are preserved. Additional legacy names use the pinned local Material Icons inventory. Six formerly unmapped block icons, navigation and the raised main tile were checked in light/dark desktop/mobile views. Four workflows passed against the actual development JAR; its font and exported files were verified byte-for-byte. Saved desktop/mobile tile regions contain identical light/dark glyph pixels; the earlier suspected blank tile was an inspection error, and no repaint workaround was introduced.
The full local Chromium suite passed 589 tests and all 391 required rows, with zero failures, skips, flaky outcomes or report errors and three approved external-service exclusions. Independent code review approved the change. Evidence is retained under `test-results/visual-review/icon-compatibility-investigation/` in the original checkout. This checkpoint covers the icon change; whole-product visual approval remains open. RC5 is immutable and does not include this follow-up.
## September 30: fresh RC6 dark desktop developer and miscellaneous review
Twelve original/current captures across app home, table developer, record developer and not-found pages were directly reviewed, including lower scroll positions. The fresh manifest records capture times and export fingerprints. No new layout regression was found in those states. Evidence: `test-results/visual-review/rc6-dark-developer-review/review.md` in the original checkout.
An inherited RapiDoc dark-link contrast defect is confirmed at 2.12:1 in both original and RC6; subsequent browser checks also find light-mode contact/authentication text below 4.5:1. A separate correction is being tested. This closes the missing desktop dark capture evidence for the four entry states; phone/tablet dark coverage, expanded endpoint interactions and whole-product approval remain open.
## September 30 — phone developer view comparison
Eight fresh original/RC6 phone screenshots in light/dark were directly inspected (initial and lower scroll positions). Four capture entries had no runtime/state errors or body horizontal overflow. Evidence: `test-results/visual-review/rc6-phone-developer-review/` in the original checkout, including export fingerprints and provenance.
The main composition matches the original. RapiDoc's inner clipping is inherited: its 360px minimum exceeds the 343px phone panel, and hidden overflow clips controls. This remains open for a separate responsive correction. The added command button also reduces breadcrumb space; the trail scrolls to the current page, but header crowding remains a visual-review consideration. This is scoped evidence, not full visual approval.
## September 30 — API playground contrast follow-up
Original Next and RC6 both have an inherited dark playground link contrast defect (2.12:1). Focused browser checks also reproduced light contact/status contrast defects. An isolated follow-up applies existing theme tokens without changing layout. Four final desktop/mobile light/dark cases pass, including hovered controls and Preferences changes, with no failures, skips, flaky outcomes or report errors. All four final screenshots were directly inspected after transitions settled. Evidence is retained under `test-results/visual-review/api-playground-contrast/` in the original checkout.
This is scoped color verification, not whole-product visual approval. Narrow-screen inner clipping, expanded endpoint states and additional application palettes remain open. RC6 is unchanged.
## September 30 — API specification controls in narrow panels
Reproduced clipped specification buttons against the packaged RC7 candidate before any focus/click could conceal overflow by scrolling hidden ancestors. The follow-up lets RapiDoc fit its panel and uses a named overview container plus public CSS parts to wrap only the specification actions at narrow widths. It adds no dependency and does not mutate the shadow DOM.
Twelve real-backend desktop/mobile workflows pass: default device and 320px widths, light/dark themes, complete visibility, actual specification download, keyboard new-tab action, contrast and live Preferences changes. Final phone screenshots were directly inspected. At 1440px, the full playground screenshot is byte-identical to RC7 (SHA256 `ec6d108d5d632d1ee0b3a3ecd6c61ae9279bee705facd2a3b9e422d2e0f1ae37`), and overview/button geometry matches exactly.
All 1,904 units, types, lint/license, default production export and unchanged bundle budgets pass. Evidence, including earlier test/setup failures, is retained under original-checkout `test-results/visual-review/api-playground-width/`. This is a separate follow-up, not part of frozen RC7. Expanded endpoint states, additional application palettes and whole-product visual approval remain open.
## September 30 — desktop and tablet process comparison
Compared original Next `e42ad2b2` with the actual RC8 candidate JAR in 32 light/dark desktop/tablet screenshots: bulk file mapping at two scroll positions, running process progress, and expanded internal-error details. Both builds used fresh isolated instances of the same fixture/backend. Capture manifests include export fingerprints and report zero runtime/state errors or body/main horizontal overflow.
Progress and error panel composition are retained. Bulk mapping visibly changes the saved-profile actions and Add Fields control and introduces explanatory text. Main scroll height increases from 1330 to 1390px on desktop and 1556 to 1748px on tablet. This added density/height remains an owner-review item. Tablet breadcrumb ancestors are more crowded with the added command control; the current page remains visible. Dark mode retains the existing composition with revised readable accent colors. No screen redesign was applied during this review.
Evidence: original-checkout `test-results/visual-review/rc8-desktop-tablet-process-review/`, including four inspected contact sheets and all 32 source images. This is scoped process evidence, not whole-product approval; the existing uncaptured states and compatibility gaps remain open.
## RC8 lower widget review: desktop, tablet and phone
Original Next `e42ad2b2` and corrected RC8 `cd3d473` were compared on Widget Gallery and Widget Tables And Charts using the actual candidate JAR, matching fixtures and backend `e0d57dd31`. Evidence: `rc8-widget-lower-review/` in the visual-review archive. Desktop supplied 28 images; tablet/phone supplied 86 more, for 114 images across 24 entries in both themes. Twelve contact sheets were directly inspected. Every lower capture records and verifies its actual scroll position. All entries had zero runtime/state errors and no body/main horizontal overflow. This closes the earlier repeated-near-top capture gap for these two entry states across all three viewport sizes.
The card structure is retained. Recorded changes include map geography, formatted table cells, per-table export/footers, paging controls, metadata chart colors, readable long category labels, legends, connected line data and As Of timestamps. These additions increase page height, including an extra phone viewport on Tables And Charts. Long phone header text now truncates alongside the added command control. Dark map/plot contrast remains a review concern; this comparison did not measure contrast ratios or verify horizontally scrolled table columns. Expanded interaction states, other widget surfaces and owner visual approval remain open. No product code changed during this review.
+18 -4
View File
@@ -1,6 +1,10 @@
# Next UI agent guide
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-09-30; the latest published prerelease is [1.0.0-RC.5](../releases/1.0.0-RC.5.md) at `ac22946`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) at `12cf774`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
## Operating rule: metadata is the product boundary
@@ -42,6 +46,7 @@ Use the September 25 Next design (`e42ad2b2`) as the visual reference. Use Mater
- Preserve inline naming in `SavedBulkLoadProfiles`: New, Save As and Rename belong inside the original profile section. Prevent Enter from submitting the enclosing process form; retain ownership rules, backend validation and overwrite/delete confirmations.
- Dialogs launched from the phone sidebar must use the existing `--qqq-z-overlay` and `--qqq-z-modal` layers. A hardcoded `z-50` puts them behind the drawer. Return focus to a persistent control when the opening menu item unmounts.
- For Radix menus, check keyboard navigation, collision placement, available-height scrolling and native touch focus. Returning focus to the same trigger must not dismiss its newly opened menu; outside interactions must still dismiss it. Record a dialog's actual event opener because WebKit can activate buttons without focusing them.
- Search-dialog checks must exercise empty and populated Tab cycles, Clear removing its own button, Escape and backdrop dismissal, keyboard reopening, pointer openers that browsers do not automatically focus, and close-before-autofocus cleanup. Build `pnpm build:export` before Javalin acceptance; `pnpm build` produces a separate Node artifact.
- Browser keyboard checks must await the resulting focus before sending the next dependent key; Radix defers roving focus. Testing Library `getByRole` uses an exact string name by default and does not accept Playwright's `exact` option.
- Standard report input-then-download flows use `compactReportInputs` in `ProcessStepScreen`: preserve the compact card, inline Generate Report action, and accessible form name. Keep the shared form and cancellation; do not replace metadata-driven fields with primitive inputs. Custom components, section labels, process fields and longer flows retain normal step presentation. `DynamicForm.compactInputs` changes ordinary flat-list defaults while preserving explicit grid widths and rich editors.
- Test report editor tabs at a viewport short enough to make their panel scroll. Firefox can add a native focus stop on an overflowing container unless it has an explicit `tabIndex`; the report panel uses `-1` so Tab enters the first control. Verify draft preservation and metadata that hides Columns. Check validation contrast on the actual tinted alert background in both themes.
@@ -132,7 +137,7 @@ For `pivotTableSetup`, the record view reads `pivotTableJson`; a form supplies t
For complete record submissions, call `wireValuesFromForm(table, values, originalValues)`: EntityForm passes `record.values`, association editors pass the existing row values, and process child editors pass the original dialog defaults. Ordinary updates still omit unchanged DATE_TIME fields. Preserve this distinction for copies and child-row edits; omitting the original can shift a repeated DST hour and truncate fractional seconds. REC-065 verifies the copy wire value and SQL source/copy; both child-editor paths have component regressions.
Use `toLocalDateTimeInput` for seeded process/standalone DATE_TIME values and `fromLocalDateTimeInput(value, originalValue)` at submission. Supplying the original preserves an unchanged instant in the repeated DST hour and its hidden subsecond precision. Normalize the final process payload after contributors have added values, so bulk-edit fields use the same contract. Do not slice UTC text into datetime-local controls or send local text as an instant. PRC-064 and WID-075 exercise America/New_York dates and seconds.
Use `toLocalDateTimeInput` for seeded process/standalone DATE_TIME values and `fromLocalDateTimeInput(value, originalValue)` at submission. Supplying the original preserves an unchanged instant in the repeated DST hour and its hidden subsecond precision. Normalize the final process payload after contributors have added values, so bulk-edit fields use the same contract. Do not slice UTC text into datetime-local controls or send local text as an instant. PRC-064 and WID-075 exercise America/New_York dates and seconds. Published RC7 sets a one-second step on plain TIME blocks, matching regular/shared fields. Keep TIME values as local clock strings; do not apply instant/timezone conversion. PRC-064 checks unchanged and edited seconds with SQL readback, and WID-074 checks standalone native stepping. This correction is published in RC7 at `f65cf6b`; RC6 remains unchanged.
`ProcessRun` preserves focus already held inside a configured `[data-qqq-autofocus="true"]` editor; other later screens still focus their heading. Preserve this precedence when changing screen lifecycle. On scalar Enter, normalize text in the hosting form before validation/submission; do not trim unrelated fields or convert typed values to display strings. PRC-063 covers later-screen focus, placeholders, prior values, first blur, whitespace validation and backend-stored text.
@@ -156,7 +161,7 @@ The developer guide's [widget configuration recipes](./next-ui-developer-guide.m
- Compare theme properties with `THEME_PROPERTY_KINDS`, including generated typography and radius keys. Keep browser-public `environmentValues` separate from build variables and backend secrets.
- Trace dropdown behavior through `ConnectedWidget`: lists pair by position, payload names are request keys, response defaults must be offered options, and `dropdownNeedsSelectedText` gates the body. The interface's `isRequired` and `defaultValue` fields do not by themselves implement those behaviors.
- Preserve `gridCols:sizeClass:*` overrides in widget metadata defaults. Their Material breakpoint widths differ from ordinary Tailwind defaults; explicit overrides can keep widgets side by side on smaller screens.
- Document `table` cell helper values and `subRows`, chart dataset/URL arrays, and host-specific composite behavior when changing these families. Modal control codes work locally; process `conditional` and interpolation depend on the composite host. The unpublished icon follow-up preserves existing Lucide mappings, then lazily resolves valid legacy names through `LegacyMaterialIcon` and the pinned local font. Unknown names retain their fallback. Keep the codepoint inventory, font and license together; verify real JAR font delivery, both themes, existing mapped icons, image precedence, and bundle budgets before integration. Do not attribute this follow-up to RC5 or claim support for every Material Symbols name/style.
- Document `table` cell helper values and `subRows`, chart dataset/URL arrays, and host-specific composite behavior when changing these families. Modal control codes work locally; process `conditional` and interpolation depend on the composite host. RC6 icon compatibility preserves existing Lucide mappings, then lazily resolves valid legacy names through `LegacyMaterialIcon` and the pinned local font. Unknown names retain their fallback. Keep the codepoint inventory, font and license together; verify real JAR font delivery, both themes, existing mapped icons, image precedence, and bundle budgets before integration. Do not attribute this behavior to RC5 or claim support for every Material Symbols name/style.
- Check both table-scoped aliases (`/app/{table}/{process}` and `/app/{table}/{report}`) before record GET behavior. Process aliases take precedence over reports; matching names can shadow record IDs. Preserve form hash presets, record tab/view/return parameters, process selection/default inputs, and safe return-path validation.
- Preserve the distinction between a displayed preview, a host-form draft, and persisted data. A payload example demonstrates the contract shape; it does not establish backend acceptance for every combination of options.
@@ -220,8 +225,17 @@ Google Drive picker changes span the frontend component/SDK loader and Javalin's
and v1 public-environment allow-list (#704; backend PR #908 merged to develop as `bac663044`). The frontend needs those backend changes to receive its public configuration and load under the configured CSP. PRC-065 tests controlled SDK responses with
real process execution and SQL; PRC-039 is the approved real-account exclusion. Preserve
the shared-drive view, folder-only validation, cancellation and token expiry handling.
Never log or persist the access token, and never publish OAuth client secrets in metadata. Javalin conditional CSP covers its static export only; standalone Node/container deployments also require runtime `QQQ_DASHBOARD_CSP_SOURCES` using the [documented Google directives](./next-ui-developer-guide.md#google-drive-process-configuration). Check the actual dashboard HTML response from the selected host. RC1 remains immutable and retains its historical placeholder; these changes belong to the ongoing final 1.0 work.
Never log or persist the access token, and never publish OAuth client secrets in metadata. Javalin applies conditional CSP to its static export; standalone Node/container deployments derive the same sources from backend `dashboardCspSources` authentication metadata. Older backends that omit that optional field require runtime `QQQ_DASHBOARD_CSP_SOURCES` using the [documented Google directives](./next-ui-developer-guide.md#google-drive-process-configuration). Check the actual dashboard HTML response from the selected host. RC1 remains immutable and retains its historical placeholder; these changes belong to the ongoing final 1.0 work.
### Script editor suggestions
`CodeEditor` autocomplete is opt-in from `ScriptEditorDialog`; keep other metadata code fields unchanged unless their contract calls for it. QQQ helpers, language keywords and file identifiers are local suggestions. Preserve native undo/redo, composition input, read-only behavior, caret/selection validation, and Escape-then-Tab focus escape. `records/script-completions.spec.ts` verifies loaded-revision undo/redo, prefix and pointer/touch insertion, both-theme accessibility, and SQL-backed save/reload. Native browsers group consecutive edits differently; do not claim every insertion creates a separate undo transaction.
### RC8 implementation notes
Public JAR checksum/signature/fresh-consumer checks and 28 affected desktop/mobile workflows passed. Full tag Chromium passed 602 and mobile 563; Firefox, WebKit and tablet retain failures, including tablet Clear-search focus. Consult the release notes before claiming cross-browser completion.
- Search must contain the entire Tab/Shift+Tab cycle, handle Escape from every dialog control, restore the opener after dismissal, and move focus to the input before Clear removes its own button. Explicitly focus a phone pointer opener because WebKit does not necessarily focus clicked buttons. Cancel delayed autofocus when closing. `navigation/search-focus.spec.ts` covers light/dark and pointer/keyboard openings; preserve the existing layout.
- Keep RapiDoc width handling on its public CSS parts. The named `qqq-api-overview` and `qqq-api-overview-wide` containers restrict narrow wrapping and wide content sizing to overview specification actions; do not apply it to authentication or endpoint controls. Test full visibility before focus can scroll a clipped control into view, real JSON downloads, keyboard new-tab behavior, both themes, and label fit with wider application fonts at desktop and phone widths.
- LONG range validation must run only after integer syntax succeeds. Preserve integer-string bounds as `BigInt` values; converting through `Number` rounds large bounds. Keep the mixed numeric comparison for fractional numeric bounds. Test `safeParse` error results without exceptions, exact positive/negative boundaries, optional blanks, and correction/submission through block and shared process forms. These changes do not introduce a backend range-metadata contract.
+29 -7
View File
@@ -1,6 +1,12 @@
# Next UI developer guide
This guide describes the `feature/next-1.0` release worktree as of 2026-09-30. It explains the current frontend and backend metadata contract. The latest published prerelease is [1.0.0-RC.5](../releases/1.0.0-RC.5.md) (commit `ac22946`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
> **RC9:** application code is unchanged from RC8. See [RC9 notes](../releases/1.0.0-RC.9.md) and [published releases](https://github.com/QRun-IO/qqq-frontend-next/releases) for versioned verification and public delivery status. Earlier RC8 receipts below remain historical evidence.
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) (commit `12cf774`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
The visual baseline is the September 25 Next UI design (`e42ad2b2`). Material remains the reference for backend contracts and functional behavior. The layout defaults below describe current source; they do not certify a completed visual review.
@@ -29,7 +35,7 @@ The preferred QQQ-hosted distribution is the `com.kingsrook.qqq:qqq-frontend-nex
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.5</version>
<version>1.0.0-RC.11</version>
</dependency>
```
@@ -42,7 +48,7 @@ pnpm build:export
pnpm build:jar
```
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). Use version `1.0.0-RC.5` for the published prerelease, overriding an older BOM-managed frontend. It requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for its verified backend commit. Use a matching local backend/frontend build to test later branch changes.
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). This checkout targets `1.0.0-RC.11`, overriding an older BOM-managed frontend; confirm its availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. It requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for its verified backend commit. Use a matching local backend/frontend build to test later branch changes.
| Setting | Behavior in this checkout |
|---|---|
@@ -88,7 +94,7 @@ Permissions apply to metadata and data: check table capabilities and the corresp
| `/app/search?q=...` | Navigation, recent-record, and available record search results. |
| `/app/developer` | Developer information available to permitted users. |
The header search and `/` dialog locate navigation targets and recent records; record search is offered when the backend supports it. The command palette opens with `.` or Cmd/Ctrl+K and has a header button; `?` opens keyboard help. Keyboard shortcuts on query and record pages are listed in the [feature matrix](../acceptance/feature-matrix.md). The sidebar has a phone drawer, nested app groups, banners, and a user menu. Branding can add a company footer and a themed bar above the shell. `?helpHelp` reveals help-slot keys for content authors.
The header search and `/` dialog locate navigation targets and recent records; record search is offered when the backend supports it. RC8 includes the search-focus correction: Tab/Shift+Tab remain within the search dialog, Clear returns focus to the search input, and Escape or backdrop dismissal restores focus to the opener. A pointer-opened phone search restores focus to its actual button in verified Chromium/mobile cases. RC8 tablet WebKit has two open Clear-search focus failures; see the release notes for remaining browser issues. RC7 does not include this correction. The command palette opens with `.` or Cmd/Ctrl+K and has a header button; `?` opens keyboard help. Keyboard shortcuts on query and record pages are listed in the [feature matrix](../acceptance/feature-matrix.md). The sidebar has a phone drawer, nested app groups, banners, and a user menu. Branding can add a company footer and a themed bar above the shell. `?helpHelp` reveals help-slot keys for content authors.
Material's nested `/{app}/{table}/{id}` URLs are not the native Next route. A not-found page now checks the signed-in instance metadata, applies exact or `/*` redirect rules, and resolves nested Material app paths to `/app/...` while preserving query/hash ([`legacy-paths.ts`](../../src/lib/utils/legacy-paths.ts), [`LegacyPathRedirect.tsx`](../../src/components/layout/LegacyPathRedirect.tsx)). Verify a specific old bookmark rather than assuming arbitrary legacy URLs match. For links generated by widgets use [`material-links.ts`](../../src/lib/utils/material-links.ts), which normalizes supported legacy links and preserves a trailing slash for static-export routes.
@@ -376,7 +382,7 @@ A host evaluates `conditional` as the truthiness of the named host value and int
{"type":"composite","blocks":[{"blockTypeName":"BUTTON","values":{"label":"Details","controlCode":"showModal:details"}},{"blockTypeName":"COMPOSITE","blockId":"details","modalMode":"dialog","blocks":[{"blockTypeName":"TEXT","values":{"text":"Details appear here."}}]}]}
```
See [`QqqComposite`](../../src/components/widgets/blocks/QqqComposite.tsx), [`QqqBlocks`](../../src/components/widgets/blocks/QqqBlocks.tsx), and [`BlockSlot`](../../src/components/widgets/blocks/BlockSlot.tsx). The unpublished icon compatibility follow-up preserves existing Lucide mappings and adds a locally served glyph for valid unmapped names from the pinned 2,234-name legacy Material Icons inventory. Examples include `3d_rotation`, `account_balance_wallet`, `battery_6_bar`, and `60fps`. Unknown names retain their fallback; this does not include every Material Symbols name or distinct outlined/rounded font variants. Metadata image paths still take precedence. The font and Apache license ship with the static export/JAR; no Google Fonts request is needed at runtime. See the [font provenance](../../public/fonts/material-icons/README.md). This behavior is not in RC5. The parity ledger also retains open INPUT_FIELD configuration and visual review work.
See [`QqqComposite`](../../src/components/widgets/blocks/QqqComposite.tsx), [`QqqBlocks`](../../src/components/widgets/blocks/QqqBlocks.tsx), and [`BlockSlot`](../../src/components/widgets/blocks/BlockSlot.tsx). RC6 icon compatibility preserves existing Lucide mappings and adds a locally served glyph for valid unmapped names from the pinned 2,234-name legacy Material Icons inventory. Examples include `3d_rotation`, `account_balance_wallet`, `battery_6_bar`, and `60fps`. Unknown names retain their fallback; this does not include every Material Symbols name or distinct outlined/rounded font variants. Metadata image paths still take precedence. The font and Apache license ship with the static export/JAR; no Google Fonts request is needed at runtime. See the [font provenance](../../public/fonts/material-icons/README.md). This behavior first ships in RC6. The parity ledger also retains open INPUT_FIELD configuration and visual review work.
## 8. Theme, branding, and customization
@@ -453,8 +459,9 @@ Client secrets are not browser configuration and are not published in v1 metadat
The Next UI middleware must include the #704 changes (backend PR #908, merged to develop as `bac663044`) that publish these two settings
and permit Google's SDK and picker origins in CSP only when both are configured.
Those conditional headers apply to the Javalin-hosted static export. The standalone
Node/container host must receive `QQQ_DASHBOARD_CSP_SOURCES` at runtime; it does not derive
Google allowances from instance metadata. Merge these sources with any other deployment
Node/container host also derives them when the backend publishes `dashboardCspSources`
in authentication metadata. With an older backend that omits that optional field, supply
`QQQ_DASHBOARD_CSP_SOURCES` at runtime. Merge these sources with any other deployment
additions and start through [`standalone/qqq-server.mjs`](../../standalone/qqq-server.mjs):
```bash
@@ -479,3 +486,18 @@ Google SDK responses; PRC-039 retains the approved real-account testing exclusio
### Post-RC4 table appearance correction
RC5 restores the original Next uniform row background; RC4 has alternating row shading. Selection highlighting and opaque pinned cells are retained. Applications can target `data-row-parity="odd"` or `"even"` through their configured CSS if they explicitly want alternating colors.
### API playground theme integration (RC7)
The embedded RapiDoc viewer uses its own shadow DOM. Next applies existing dashboard color tokens to its dark accent/inverse pair and light contact/status colors, preserving the surrounding layout. Preferences theme changes apply without reloading the page. Focused Chromium desktop/mobile checks cover the fixture's visible text, hovered controls and live theme changes; expanded endpoint states, arbitrary custom palettes and narrow-screen inner layout still require review. This correction is not included in RC6.
### TIME block correction (RC7)
Plain process and standalone TIME blocks now use a one-second native input step, matching regular/shared form fields. This permits second-level clock values without a minute-step validation error and makes native stepping advance one second. TIME values remain local clock strings, with no timezone conversion. This correction is published in RC7 at `f65cf6b`; earlier RCs remain unchanged.
### Narrow API controls and LONG validation (RC8)
The API specification Download and View actions fit narrow panels, including 320px phones, and wrap their labels when necessary. Their download/new-tab behavior is preserved. On wide panels, buttons grow to fit the rendered font instead of clipping labels at a fixed width. This correction is not included in RC7.
When the frontend receives LONG range bounds, invalid integer text produces a field error before range comparisons run. Integer-string bounds retain exact precision beyond JavaScript's safe-number range; optional blanks and required-field messages retain their existing behavior. This describes the frontend schema's supported inputs, not a new backend metadata contract. RC8 includes this correction; RC7 does not.
@@ -0,0 +1,26 @@
# API playground controls in narrow panels — GH-724
## Requirement
Keep the original Next developer view and make its API specification actions fully visible and usable in narrow panels, including 320px phones. Preserve the wide desktop layout, real spec download/new-tab actions, light/dark themes and keyboard access.
## Evidence and implementation
- The packaged RC7 JAR fails the new real-backend visibility test: the Download button is partially clipped before any focus/click can horizontally scroll hidden ancestors. The View button extends outside the panel too.
- RapiDoc 9.3.8 has a 360px host minimum, a negative overview info margin and two fixed-width, non-wrapping buttons.
- Allow the host to fit its panel. Use the public overview/button CSS parts under a panel-width container query to provide safe inset and share available button width with wrapped labels. A named overview container limits button adjustments to specification actions, excluding authentication and endpoint controls. No dependency changes or shadow DOM mutation.
## Verification plan
1. Retain the failing Chromium phone trace and screenshot.
2. Run light/dark, normal-width and 320px workflows with desktop and touch input. Check complete visibility before interaction, download the real OpenAPI JSON, and open the same spec using keyboard navigation.
3. Inspect screenshots and compare wide desktop before/after layout.
4. Run existing API contrast/theme workflows, unit suite, types, lint, export and bundle budgets. Obtain independent review before integration.
Evidence: original checkout `test-results/visual-review/api-playground-width/`. This follow-up is separate from frozen RC7 candidate `f65cf6b`.
## Results
The final production export passed 12 focused desktop/mobile workflows, with zero failures, skips, flaky outcomes or report errors. All 1,904 unit tests, types, lint/license checks and the default Turbopack export passed. The unchanged bundle budget passed at 963.3KB/1050KB. The 1440px overview/button geometry and entire RapiDoc screenshot are identical to RC7; final phone screenshots were inspected.
Earlier evidence is retained: the first green attempt exposed a relative-versus-absolute URL assertion error; a later run used a running Java server with a replaced export, invalidating its startup asset/CSP registry. The server was restarted against the finished export before the passing run. A Webpack build exceeded the size budget; the release/CI Turbopack pipeline passed without changing budgets. The incompatible old font-response mock was removed from the build command; the final build uses the normal font pipeline.
@@ -0,0 +1,17 @@
# Bounded LONG validation — GH-728
## Defect and correction
Configured LONG ranges exposed two defects in the shared metadata schema: chained Zod refinements called `BigInt` after format validation had already failed, and exact integer-string bounds were rounded through `Number`. Seven new schema cases reproduced exceptions or wrongly accepted values before the fix.
The schema now pipes successfully validated integer text into its range checks. Integer-string bounds remain BigInt values; numeric fractional bounds use native mixed numeric comparisons. Optional blanks, required-field errors, signs, and existing field messages are preserved. No rendering changes or dependencies are introduced.
## Verification
- Seven schema regressions passed after failing on the previous source.
- Block and shared process-form component tests verify invalid fractional text, a below-minimum error, blocked submission, and correction to exact digits beyond the safe-number range.
- An unrelated intermittent unit failure expected a globally unique “Green” label although both view and edit fields display it. Its separate correction targets the read-only value being tested.
- All 1,913 units across 180 files passed; the 95 affected tests, types, lint/license and diff checks passed. Independent review approved both corrections.
- Evidence: original checkout `test-results/visual-review/long-range-validation/`. The initial full-run failure remains retained in `units.log`; the final full run is `units-final.log`.
This follow-up is not included in immutable RC7 `f65cf6b`. Production export and real-backend verification of the next integrated candidate remain required; these results establish schema/component behavior, not a new backend range-metadata contract.
+4 -11
View File
@@ -10,23 +10,16 @@ RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-nex
The owner authorizes publishing new RCs at verified stability or daily milestones. Each RC gets a new immutable version and notes describing its fixes, backend prerequisites, verification, and known gaps. RC publication does not authorize final 1.0.
Latest published candidate: **[1.0.0-RC.5](1.0.0-RC.5.md)**. Its notes record installation, backend requirements, exact artifact verification and known failures. RC1, RC2, RC3 and RC4 remain available and unchanged.
Previously verified public candidate at the RC11 preparation checkpoint: **[1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC9 remain available and unchanged.
## Fixes during testing
Development and verification continue on `feature/next-1.0`, tracked in [PR #14](https://github.com/QRun-IO/qqq-frontend-next/pull/14). The branch can include changes beyond the latest published RC. Check the version of each preview separately; a local preview may intentionally preserve an earlier design checkpoint. Branch changes are **not retroactive changes to published RC artifacts**.
This checkout targets [RC11](1.0.0-RC.11.md); confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. The candidate adds searchable-selector and script-completion input-race corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24). These changes are absent from immutable RC10. Candidate notes distinguish versioned local checks from public delivery and retain the historical browser failures and remaining RC limits. Local previews may intentionally preserve an earlier version.
The branch includes subsequent report editor fixes, script completions, and corrections found in the original Next UI visual review. The latest visual corrections restore Copy form alignment and selected-section defaults, and expose the full table checkbox target on touch devices. Verification details are in the [visual review](../acceptance/next-ui-visual-review.md). The review remains open for other screen families.
For the complete change history, compare the immutable RC1 tag with the branch:
```sh
git fetch origin tag v1.0.0-RC.1
git log --oneline v1.0.0-RC.1..feature/next-1.0
```
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
## Reporting application issues
Include the exact UI artifact/version or branch commit, QQQ backend version, page/workflow, expected and actual results, reproduction steps, and relevant screenshots or sanitized errors. This distinguishes an RC1 issue already fixed on the branch from a new regression. Never include credentials or sensitive application records.
Create or update the central QQQ ticket and Project 12 item using the [tracking policy](../ISSUE-TRACKING.md). Include the exact UI artifact/version or branch commit, QQQ backend version, page/workflow, expected and actual results, reproduction steps, and relevant screenshots or sanitized errors. This distinguishes an issue in a published RC from a later branch regression. Never include credentials or sensitive application records.
The compatibility ledger and remaining acceptance work still govern final readiness. The testing hold does not waive those requirements or authorize a QQQ 4.1 release.
+30
View File
@@ -0,0 +1,30 @@
# Next UI 1.0.0-RC.10 — preparation
RC10 is being prepared for application testing and **is not yet published**. [RC9](1.0.0-RC.9.md) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.10` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.10`. Public checksums, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Changes from RC9
- Fix section-hash loss and stale immediate URLs when changing record tabs or views (#989), preserving URL context, return links, focus and history. The baseline eventually restored the tab; this is not a claim of proven tab loss.
- Preserve search-dialog keyboard focus (#950), WebKit possible-value selections (#978), and authentication loading through logout/provider departure (#984).
- Add ESB developer views and overview/management controls, with real-backend acceptance. See the [ESB contract](../acceptance/esb.md).
- Improve browser readiness and diagnostic checks while preserving assertions. The pinned Playwright runner contains the corrected Linux WebKit library; stable-runner migration remains #973. See [change details](unreleased.md).
## Verification
Verification uses source `5e3738b73f14d7ed25580560f3c97b91aed58fce` ([PR21](https://github.com/QRun-IO/qqq-frontend-next/pull/21)) with RC10 metadata and QQQ backend `55b33e6bd7f8cc2aef3b41546ef8409ad307ff05`.
- **1,979 unit tests / 184 files**, typecheck, lint/license (zero errors, five existing warnings), static export, Maven packaging and bundle budgets pass.
- The actual RC10 JAR passes **85/85** auth/focus/selection/ESB real-backend cases: Chromium, Firefox and WebKit 21 each; mobile and tablet 11 each.
- Four navigation regressions pass **8/8 in Firefox/WebKit**, using the same JAR's assets with mocked APIs. These are separate from real-backend API coverage.
Both browser reports have zero failures, skips, flaky outcomes or report errors, with retries disabled. All 192 dashboard files are packaged without a mock service worker; Maven and compiled frontend versions are RC10. Served JavaScript and font bytes match the JAR. The primary fixture serves the JAR directly; authentication variants use verified assets extracted from it. These are local candidate checks, not public delivery receipts. PR23 changes test selection and documentation only; production code and test assertions are unchanged from this verified candidate.
## Browser coverage and remaining RC limits
The historical [PR18 run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) failed its touch-coverage gate despite all 2,972 executed cases passing: ESB-001–010 were required but not selected on phone and tablet. The original failed result is retained under [#990](https://github.com/QRun-IO/qqq/issues/990).
[PR23](https://github.com/QRun-IO/qqq-frontend-next/pull/23) corrects selection by adding touch markers to the ten existing ESB tests, preserving production code and every assertion. Its separate focused real-broker run on the PR18 export and QQQ `55b33e6` passed all 20 phone/tablet cases with zero retries, failures, skips, flaky outcomes or report errors. This focused result is not a fresh full hosted matrix pass; full hosted verification remains pending at this preparation checkpoint.
The secondary-browser/tablet RC exception remains documented in [#985](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5943724086) and [#798](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5943457468). No gate, test requirement or exclusion was weakened. This exception does not waive product defects or authorize GA.
Remaining parity, real Auth0/Drive/QuickSight checks, browser investigations, visual review and application soak stay tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#985](https://github.com/QRun-IO/qqq/issues/985), [#711](https://github.com/QRun-IO/qqq/issues/711) and [#712](https://github.com/QRun-IO/qqq/issues/712). The container backend origin is fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin. This candidate does not itself publish QQQ 4.1.
+19
View File
@@ -0,0 +1,19 @@
# Next UI 1.0.0-RC.11 — preparation
At this preparation checkpoint, RC11 is being prepared for application testing and **is not yet published**. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.11` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.11`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Changes from RC10
[PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24), merged source `a1d3e48419812fcb8d47091cb4af315cad4e76e9` (tree-identical to reviewed head `ea73118fcabdf3c08ea8614c8bd0ad63749fb598`), invalidates stale searchable-selector results immediately when input changes (#970), and keeps script-completion suggestions open when a delayed scroll event reports an unchanged position (#954). Actual position changes still dismiss suggestions. No acceptance assertion or gate is weakened.
## Verification checkpoint
PR24's [hosted primary acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36954433088) passes on its exact source: 614 cases and 401 required rows. The actual merged source above has the same tree. With RC11 metadata, fresh verification passes **1,984 unit tests / 184 files**, typecheck, lint/license (zero errors, five existing warnings), static export, Maven packaging and bundle budgets. All **192 dashboard files** match the export byte-for-byte, with no mock service worker and matching RC11 frontend/Maven versions.
The actual RC11 JAR passes **125/125 real-backend cases** against QQQ `55b33e6bd7f8cc2aef3b41546ef8409ad307ff05`: 25 each in Chromium, Firefox, WebKit, phone and tablet. These include the 105 auth/focus/selection/ESB cases and 20 selector/completion regression cases. A separate **8/8 record-navigation regressions in Firefox/WebKit** use the same JAR assets with mocked APIs. Both reports have zero failures, skips, flaky outcomes or report errors, with retries disabled. Served versioned JavaScript and font bytes match the JAR. The scoped matrix check passes only the exercised rows; it is not a full hosted matrix result. These local candidate checks are separate from eventual public-artifact verification; no RC10 test count or artifact digest is attributed to RC11.
## Remaining RC limits
Historical observations remain preserved: REC-054 on mobile light expected three completion options but found none; PRC-061 in WebKit left the Casey selection at its placeholder. See [#954](https://github.com/QRun-IO/qqq/issues/954), [#970](https://github.com/QRun-IO/qqq/issues/970) and [RC10 delivery notes](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10). The original [PR18 touch-coverage failure](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) remains historical evidence. [#990 is complete](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159): PR23’s complete touch report covers all 401 required IDs on each profile, with zero missing, and all ten ESB scenarios pass first attempt on both phone and tablet. That report records **1,149 passes and one unrelated tablet NAV-056 failure**; its full touch gate and whole workflow remain **failed**. [NAV-056 diagnostics remain under investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383), provisionally tracked in [#904](https://github.com/QRun-IO/qqq/issues/904#issuecomment-5944558037), without claiming a shared historical cause. Scoped #990 completion does not imply an all-browser pass or waive that failure.
The documented secondary-browser/tablet RC exception remains under [#985](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5943724086) and [#798](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5943457468); it does not waive new product defects or authorize GA. Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711) and [#712](https://github.com/QRun-IO/qqq/issues/712). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin. This candidate does not publish QQQ 4.1.
+2 -2
View File
@@ -41,11 +41,11 @@ Container manifest digest:
## Known limitations and continuing checks
Candidate WebKit finished with **586 passed and one failed**: REC-046 encountered an internal WebKit navigation error opening a read-only user's copy route. Mobile passed **548 tests**. Tablet passed **547 tests and failed one**: NAV-014 encountered an internal WebKit navigation error opening the Person create route during breadcrumb coverage. These completed profiles had zero skips, flaky outcomes or report errors. Both failures remain open. Candidate Firefox completed with **586 passed and one failed**: WID-073 did not observe the initial report-variant preview row within 15 seconds. It had zero skips, flaky outcomes or report errors. The candidate run is complete and failed overall; all failures remain open. The independent [tag acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36754867847) also passed **587 Chromium tests and all 391 required feature rows**, with zero failures, skips, flaky results or report errors; its WebKit profile completed with **585 passed and two failed**: NAV-026 reported widget-request access-control errors during navigation-drawer coverage, and NAV-015 timed out after an internal WebKit navigation error opening the unknown-route page. WebKit had zero skips, flaky outcomes or report errors. Tag Firefox completed with **586 passed and one failed**: INT-010 did not observe the failed-query error panel within 30 seconds. It had zero skips, flaky outcomes or report errors. The tag touch profiles are still running. Pending jobs are not counted as passes. Reports and traces are retained, and notes will be updated when these jobs finish. Prior secondary-browser failures remain under investigation.
Candidate WebKit finished with **586 passed and one failed**: REC-046 encountered an internal WebKit navigation error opening a read-only user's copy route. Mobile passed **548 tests**. Tablet passed **547 tests and failed one**: NAV-014 encountered an internal WebKit navigation error opening the Person create route during breadcrumb coverage. These completed profiles had zero skips, flaky outcomes or report errors. Both failures remain open. Candidate Firefox completed with **586 passed and one failed**: WID-073 did not observe the initial report-variant preview row within 15 seconds. It had zero skips, flaky outcomes or report errors. The candidate run is complete and failed overall; all failures remain open. The independent [tag acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36754867847) also passed **587 Chromium tests and all 391 required feature rows**, with zero failures, skips, flaky results or report errors; its WebKit profile completed with **585 passed and two failed**: NAV-026 reported widget-request access-control errors during navigation-drawer coverage, and NAV-015 timed out after an internal WebKit navigation error opening the unknown-route page. WebKit had zero skips, flaky outcomes or report errors. Tag Firefox completed with **586 passed and one failed**: INT-010 did not observe the failed-query error panel within 30 seconds. It had zero skips, flaky outcomes or report errors. Tag mobile passed **548 tests**; tablet passed **546 tests and failed two**: NAV-014 hit an internal WebKit navigation error opening the interactive greeting process, and SEC-043 hit an internal WebKit error reloading during different-user recent-record isolation coverage. Both profiles had zero skips, flaky outcomes or report errors. The tag run is complete and failed overall. Reports and traces for every failed scenario are retained; causes remain under investigation. Prior secondary-browser failures remain under investigation.
Use the QQQ **4.1.0-SNAPSHOT development backend** including report-variant and configured Google Drive support, and record your application's exact backend commit. This frontend RC does not release QQQ 4.1.
Complete embedded report/query equivalence, remaining input configurations, unsupported Material icon names, hosting under an origin sub-path, real Google Drive provider verification and whole-product visual approval remain open. The original Next UI is the visual reference. A separate icon follow-up is not part of this immutable RC5 release.
Complete embedded report/query equivalence, remaining input configurations, unsupported Material icon names, hosting under an origin sub-path, real Google Drive provider verification and whole-product visual approval remain open. The original Next UI is the visual reference. A separate icon follow-up is not part of this immutable RC5 release. A subsequent regression check also found that plain TIME blocks use minute increments: process blocks can mark second-level values invalid. A separate correction is being verified; it is not included in RC5.
Final 1.0 requires renewed owner approval after application testing.
+42 -12
View File
@@ -1,22 +1,52 @@
# Next UI 1.0.0-RC.6 candidate
# Next UI 1.0.0-RC.6
**Not published.** RC5 remains the latest published candidate. Final 1.0 remains on hold for owner application testing and renewed explicit approval.
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and renewed explicit approval.** Earlier RCs remain immutable.
## Changes since RC5
- Legacy Material icon names absent from the existing Next maps render using a pinned, locally bundled Material Icons font with 2,234 unique names.
- Existing Next Lucide mappings, widget-specific overrides, image priority, colors, sizes and layout are preserved. Unknown names retain generic fallback icons.
- The extra icon inventory loads only when needed. No external font service or new package dependency is required.
- Developer and agent guides explain icon resolution and limits. Accessibility regression coverage verifies that decorative glyph text does not enter option names.
- Legacy metadata icon names missing from the existing Next maps now render from a pinned, locally bundled Material Icons inventory of 2,234 unique names.
- Existing Next Lucide mappings, widget overrides, custom image priority, colors, sizing and layout are preserved. Unknown names keep generic fallback icons.
- The extra inventory loads only when needed, without an external font service or new package dependency.
- Developer and agent guides document icon resolution and its limits.
## Verification so far
## Install
The integrated icon implementation passed 1,901 unit tests, type checking, lint, license checks, production export and existing bundle budgets. Its full local real-backend Chromium run passed 589 tests and all 391 required feature rows, with zero failures, skips, flaky outcomes or report errors and three approved external-service exclusions. Backend: `e0d57dd31b979b17215370546ef4e26cd8a38d21`.
Maven coordinate: `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.6`. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required.
Four light/dark desktop/mobile workflows passed against the packaged development JAR; the package font and export contents were checked byte-for-byte. These checks preceded the RC6 version change. Independent code review approved the icon implementation. After the RC6 version change, the release branch again passed types, lint and all 1,901 unit tests, production JAR packaging and existing bundle budgets. All 193 packaged export files match the build output; version markers and the pinned font checksum are correct. The actual RC6 candidate JAR passed all four light/dark desktop/mobile icon workflows with zero failures, skips, flaky outcomes or report errors. Hosted candidate CI and public artifact verification remain pending; no tag or public artifacts have been created.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.6`, AMD64 and ARM64. Its build-time backend origin is `http://host.docker.internal:8000`; use the Maven JAR or rebuild for a different origin.
## Remaining requirements
Both public artifacts are available and verified. If Maven cached a missing-artifact result from an earlier attempt, retry with `-U`.
Complete embedded report/query equivalence, remaining input and association-editing configurations, developer-tool gaps, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. The legacy inventory does not claim arbitrary Material Symbols names or distinct font-style variants. The original Next design remains the visual reference.
## Verified candidate
Use the QQQ 4.1.0-SNAPSHOT development backend including report-variant and configured Google Drive support. Record the application's exact backend commit. This frontend candidate does not release QQQ 4.1.
Source: `7ddf45fa4d7c94627bafb1ee40916c3f6c47536f`; signed tag `v1.0.0-RC.6`. Tested PR merge `f52387f10885da1d8ad22961915b76fa9e08ab72` has the same source tree.
- [Hosted candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36764199293): **589 real-backend Chromium tests and all 391 required feature rows passed**, with zero failures, skips, flaky results or report errors. Three approved external-service exclusions remain. Backend checkout: `e0d57dd31b979b17215370546ef4e26cd8a38d21`.
- [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36764199430): **1,901 unit tests**, **104 mocked Chromium tests**, types, lint, coverage, Storybook and bundle budgets passed.
- Both candidate container architecture builds passed. The local RC6 JAR passed four light/dark desktop/mobile icon workflows; its 193 static export files and bundled font were verified against the build output.
Candidate WebKit completed with **586 passed and three failed**, with zero skips, flaky outcomes or report errors: NAV-034 encountered an internal WebKit navigation error opening a record process; NAV-002 expected raw sidebar text without decorative legacy icon glyphs; WID-073 reported an application-metadata request access-control error. Reports and traces are retained; these failures remain recorded. A subsequent check reproduced NAV-002 against the public JAR and confirmed its raw-text assertion counted decorative SVG characters. A separately reviewed test correction verifies accessible names, order and hidden glyphs; it passed focused Chromium/WebKit/mobile checks and is not part of the original run. The other failures remain under investigation. The candidate run is now complete and failed overall. Firefox finished **586 passed / 3 failed**: NAV-014 and NAV-015 reported legacy icon font download errors; INT-010 timed out waiting for the failed-query error panel. Mobile finished **549 passed / 1 failed** (NAV-002 decorative-glyph raw-text assertion). Tablet finished **548 passed / 2 failed** (the same NAV-002 assertion and NAV-034 internal WebKit session/navigation failure). These profiles had zero skips, flaky outcomes or report errors. All reports and traces are retained. The independent [tag-triggered acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36767986263) passed **589 Chromium tests** with zero failures, skips or flaky results. Its WebKit job finished **585 passed / 4 failed**: NAV-026 application-request access-control errors, NAV-016 missing Person grid after site-root navigation, NAV-002 the decorative-glyph raw-text assertion, and SEC-043 an internal WebKit reload error. Firefox finished **586 passed / 3 failed**: NAV-002 the decorative-glyph raw-text assertion, and REC-056/SEC-043 legacy icon font download errors. Mobile finished **548 passed / 2 failed**: NAV-002 and REC-054 a missing script-suggestion popup bounding box in light mode. Tablet finished **549 passed / 1 failed**: NAV-002. The tag run is complete and failed overall; all profiles had zero skips, flaky outcomes or report-level errors. Reports and traces are retained. Earlier secondary-browser failures remain open; Chromium success does not establish full browser compatibility.
## Public artifact verification
- [Maven publication](https://circleci.com/gh/QRun-IO/qqq-frontend-next/135) built the exact tagged source. The downloaded JAR checksum and PGP signature were verified; a clean consumer resolved identical bytes through ordinary Maven dependency resolution.
- The actual public JAR passed **four affected light/dark desktop/mobile icon workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 dashboard files, three version markers, pinned font and license were checked; no mock worker is packaged.
- [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36767986139) succeeded. Anonymous inspection verified both architectures; the downloaded ARM64 image served the app and all 21 JavaScript assets. Backend behavior was verified separately through the JAR.
Public JAR SHA-256: `81ef0a869afb3caeb356704bd8b0345a7601b7cf908d042f66d5c3c9835b2697`.
Container manifest digest: `sha256:8591aaf10946eb1b44da5a4a5f45a9782dac9afc734bdd5187630c5b1351a5d3`.
## Backend and known limitations
Use the QQQ **4.1.0-SNAPSHOT development backend** with report-variant and configured Google Drive support. Record your application's exact backend commit. This frontend RC does not release QQQ 4.1.
Complete embedded report/query equivalence, remaining input and association-editing configurations, developer-tool gaps, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next is the visual reference. Arbitrary Material Symbols names and distinct icon font-style variants are not claimed.
Plain TIME blocks retain a known seconds-handling defect in this RC. A separate correction is verified and committed, but not included. API playground contrast corrections are also being verified separately and are not included.
## Guides and feedback
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/7ddf45fa4d7c94627bafb1ee40916c3f6c47536f/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/7ddf45fa4d7c94627bafb1ee40916c3f6c47536f/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
Include the exact frontend and backend versions/commits, browser, workflow, relevant metadata, reproduction steps, expected/actual results and sanitized screenshots or errors.
+69
View File
@@ -0,0 +1,69 @@
# Next UI 1.0.0-RC.7
Release candidate for application testing. **Final 1.0 remains on hold for owner testing and renewed explicit approval.** Earlier RCs remain immutable.
## Changes since RC6
- Plain process and standalone TIME inputs support seconds, matching shared forms. Seeded values remain valid, native stepping advances one second, and saved values retain seconds. Existing timestamp timezone and daylight-saving behavior is preserved.
- API playground text and button contrast use the dashboard theme in light/dark mode. Preferences changes apply immediately; layout is preserved.
- Sidebar acceptance checks wait for legacy icons and verify accessible labels, order and decorative glyph hiding. This corrects a test assertion; no sidebar product behavior changes.
## Install
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.7</version>
</dependency>
```
Override an older BOM-managed frontend version. Select Next with `-Dqqq.javalin.frontend=next`. The JAR serves the UI at `/` alongside `/qqq/v1`; no Node server is required. Use Maven `-U` if an earlier lookup cached a missing artifact.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.7` (AMD64/ARM64). This quickstart image uses build-time backend origin `http://host.docker.internal:8000`; use the Maven artifact or rebuild for a different origin.
## Verified candidate
Source: `f65cf6bfbd1583538afe1b66f8764812980880c3`. Tested PR merge `f11bb1a6656de71e5a60d5781ac244e5117daa81` has the identical source tree `623efded5c6de13cbe389dc27c9227468a7419c9`.
- [Hosted candidate acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36771453216): **592 real-backend Chromium tests and all 391 required feature rows passed**, with zero failures, skips, flaky outcomes or report errors. Three approved external-service exclusions remain. Backend checkout: `e0d57dd31b979b17215370546ef4e26cd8a38d21`. WebKit completed with **591 passed / 1 failed**: NAV-026 navigation-drawer coverage reported widget-request access-control errors. There were zero skips, flaky outcomes or report-level errors. The cause remains under investigation; original reports and traces are retained. Mobile completed with **553 passed / 0 failed**. Tablet completed with **552 passed / 1 failed**: NAV-014 breadcrumb navigation reported API request access-control errors. Both had zero skips, flaky outcomes or report-level errors. Firefox completed with **590 passed / 2 failed**: NAV-016 legacy icon font download failure and WID-041 missing plain widget. The candidate run is complete and failed overall; all profiles had zero skips, flaky outcomes or report-level errors.
- [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36771452885) passed, including unit/coverage, types, lint, mocked Chromium, Storybook and bundle budgets. Fresh local checks passed **1,904 unit tests**, types, lint, export and Maven packaging.
- The actual local candidate JAR passed **14 affected desktop/mobile workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 export files match the build output, version markers and pinned icon font are correct, and no mock service worker is packaged.
## Publication verification
- [Maven publication](https://circleci.com/gh/QRun-IO/qqq-frontend-next/138) built the exact tagged source. The public JAR checksum and QQQ publishing signature were verified. A clean consumer resolved identical bytes through ordinary Maven dependency resolution.
- The actual downloaded public JAR passed **14 affected desktop/mobile workflows**, with zero failures, skips, flaky outcomes or report errors. Its 193 dashboard files, three version markers, pinned icon font and license were verified; no mock worker is packaged. An initial run used stale compiled test fixtures and failed four missing-clock assertions; that evidence was retained, the fixtures were compiled from tagged source, and the complete affected suite passed.
- [Container publication](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36775272526) succeeded. Anonymous manifest verification confirmed AMD64 and ARM64. The downloaded ARM64 image served the application and all 21 referenced JavaScript assets. This verifies image delivery; backend workflows were verified through the public JAR.
Public JAR SHA-256: `f23f8ede0d82d30f47621efacd14119afb7c00a6961a1936a17ffa2203bebdf0`.
Container manifest digest: `sha256:6d6fc39afd3f6e00aebe619b49a0350a64802f4e6f7cebdacb8aac20284fdba3`.
The independent [tag-triggered acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36775272542) is complete and failed overall. Original reports and traces are retained; there were zero skips, flaky outcomes or report-level errors.
| Profile | Passed | Failed | Failure evidence |
| --- | ---: | ---: | --- |
| Chromium | 592 | 0 | All 391 required feature rows passed |
| Mobile | 553 | 0 | All tagged phone scenarios passed |
| WebKit | 591 | 1 | NAV-015 incorrect not-found title with a session-request internal error |
| Firefox | 590 | 2 | NAV-016 legacy icon font download failure; WID-024 missing record heading during child export |
| Tablet | 551 | 2 | NAV-014 and NAV-015 internal navigation errors |
These failures remain open. Passing primary workflows does not establish complete browser compatibility.
## Backend and known gaps
Use QQQ 4.1.0-SNAPSHOT development backend including report-variant and configured Google Drive support. Record your application's exact backend commit. This RC does not release QQQ 4.1.
Complete embedded report/query equivalence, remaining input and association-editing configurations, developer-tool gaps, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next remains the visual reference.
The API playground's inherited narrow-screen specification-button clipping is not fixed in RC7; a separately reviewed follow-up addresses it. A newly reproduced bounded LONG validation issue can throw for non-integer text or round large string bounds; its separate follow-up passed schema/component tests and independent review, with combined candidate verification underway. Neither follow-up is included here. A separate unit test also has an ambiguous duplicate-label assertion; its correction is outside this immutable source.
Final 1.0 requires renewed owner approval after application testing; passing the current matrix does not establish complete compatibility.
## Guides and issue reports
[Developer guide](https://github.com/QRun-IO/qqq-frontend-next/blob/b9b1f3da84f49eec16ead0da3fda3af3e5f16b98/docs/guides/next-ui-developer-guide.md) · [Agent guide](https://github.com/QRun-IO/qqq-frontend-next/blob/b9b1f3da84f49eec16ead0da3fda3af3e5f16b98/docs/guides/next-ui-agent-guide.md) · [Report an issue](https://github.com/QRun-IO/qqq-frontend-next/issues/new).
Include frontend RC/commit, backend version/commit, browser, page/workflow, relevant metadata, reproduction steps, expected/actual results, and sanitized screenshots or errors. Never include credentials or sensitive records.
+46
View File
@@ -0,0 +1,46 @@
# Next UI 1.0.0-RC.8
**Public Maven and container delivery verified; RC8 prerelease published for application testing.** Tag `v1.0.0-RC.8` points to `cd3d4731d1910e68e994da9c74a8c28a2f1b7c1c`. RC8 supersedes RC7 as the current application-testing prerelease. Final 1.0 remains on hold for owner application testing and renewed explicit approval.
## Changes since RC7
- API specification Download and View controls remain fully visible and usable in narrow panels, including 320px phones. Labels wrap when needed; wide-panel buttons grow to fit the application font. Real specification download and keyboard new-tab behavior are covered in light/dark and touch workflows.
- Search keeps keyboard focus inside the dialog, returns focus to its opener on close, handles Escape from all controls, and returns focus to the input after clearing.
- Bounded LONG fields reject invalid integer text with a field error instead of throwing during range validation. Exact integer-string bounds retain precision beyond JavaScript's safe-number range. Optional blanks and existing required-field messages are preserved.
- A process unit test scopes its choice-label lookup to the read-only field under test, avoiding ambiguity when an editable field displays the same label. This changes no product behavior.
## Historical verification before version change
Both product fixes passed independent review. API layout verification passed 12 affected desktop/mobile workflows; its 1440px RapiDoc screenshot was identical to RC7. The API layout source passed 596 real-backend Chromium tests and all 391 required feature rows, with zero failures/skips/flaky outcomes/report errors and three approved external-service exclusions.
The combined fixes passed all 1,913 unit tests across 180 files, types, lint/license, a clean production export and existing bundle budgets. The first full combined Chromium run finished with 595 passed and one failed (NAV-025 search navigation), zero skips/flaky outcomes/report errors. Its report and trace are retained; investigation remains open. The trace requested the correct search route successfully and the final snapshot contained the expected results despite the URL assertion reporting the prior record route. Twenty focused repetitions against the RC8 JAR passed; they do not erase the original failure. No timeout, retry or assertion changes were made. Fresh RC8-version checks passed all 1,913 units, types, lint/license, production export, Maven packaging and existing bundle budgets. All 193 packaged dashboard files match the export; version markers and pinned font are verified. The actual RC8 JAR passed 16 affected desktop/mobile workflows with zero failures/skips/flaky outcomes/report errors: API specification visibility/download/keyboard actions, contrast/live theme changes, exact LONG/DECIMAL persistence and composite metadata-editor submission. The fresh full RC8 run at source `f0f0eae98e4b6c883475956a7c59e060d2e5f0a5` finished with **588 passed / 8 failed**, zero skips/flaky outcomes/report errors. The failed cases were QRY-007, QRY-009, two QRY-001 theme cases, QRY-052, QRY-053, REC-063 and QRY-071. macOS power events overlap 98–100% of each failure interval, including a browser-launch timeout; test and teardown timers advanced during host sleep. The run is retained as failed, not converted to passing evidence. This explains its abnormal duration but does not prove every affected workflow correct. An uninterrupted hosted run is required, along with public delivery checks. The earlier NAV-025 search failure remains independently recorded. At that historical checkpoint, no RC8 tag or public artifacts existed.
LONG regression coverage establishes frontend schema/component behavior, including correction and submission of exact digits. It does not establish a new backend range-metadata contract.
## Historical September 30 follow-up verification
The first hosted RC8 candidate at `14d6966` completed its primary Chromium job with **594 passed / 2 failed** (REC-053, desktop API label clipping in both themes). General CI and image build passed. Secondary acceptance jobs remain in progress. Publication is blocked until the corrected combined candidate has fresh release evidence.
A wider-font regression reproduced label overflow at 210px content versus 198px available. The reviewed correction uses RapiDoc public CSS parts scoped to the wide overview. It passed **14 desktop/mobile API workflows**, 1,913 units, types, lint/license (five existing warnings), clean export and bundle budgets. Desktop/mobile light/dark screenshots were inspected. The reviewed search correction independently passed **601 full Chromium workflows and all 391 required feature rows**, with three approved external-service exclusions and zero failures/skips/flaky outcomes/report errors; its unit total is 1,919 and it also passed 34 focused browser cases. These are separate source checks; combined candidate verification is next. Earlier failed runs remain retained.
## Corrected candidate verification and publication
Corrected source `cd3d473` passed **1,919 unit tests**, types, lint/license (five existing warnings), clean static export, Maven packaging and bundle budgets. All 193 packaged dashboard files match the export. The actual local JAR passed **24 affected desktop/mobile workflows**, with zero failures/skips/flaky outcomes/report errors. Local full Chromium and [hosted primary acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799502) each passed **602 cases and all 391 required feature rows**, with three approved external-service exclusions and zero failures/skips/flaky outcomes/report errors. [General CI](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799444) and [image build](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36798799498) passed. Corrected WebKit finished **600 passed / 2 failed** (NAV-034 legacy process-URL redirect timeout; REL-005 internal navigation error), zero skips/flaky/report errors. Reports/traces are retained and causes remain unproven. Corrected Firefox finished **600 passed / 2 failed** (NAV-014 font-download diagnostics during breadcrumbs; REC-009 save-success toast not observed after updated record display), zero skips/flaky/report errors. The corrected touch step is marked cancelled; its retained artifact has no final report or gate. No passing touch total is claimed. At artifact collection, GitHub still reported the job in progress despite every exposed step being completed. The cancellation cause is unconfirmed; all four candidate artifacts were archived before the later documentation push.
The original candidate finished WebKit 596/0, mobile 557/0 and tablet 556/1 (REL-005 internal WebKit navigation error). Its unfinished Firefox job was cancelled by the owner-requested candidate push; no passing Firefox result is claimed. Earlier failures remain retained as history and are not erased by the corrected primary passes.
Signed RC8 tag publication started CircleCI Maven jobs 141 and 142, [image publication36800964056](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36800964056), and [tag acceptance36800964105](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36800964105). CircleCI jobs 141 and 142 and image publication 36800964056 completed successfully. Maven Central initially returned HTTP 404 after upload; the public JAR is now available and verified (October 1). Its SHA-1 matches Central, the signature matches publishing key `68A49476754103988EA3ED924B88CBB3D6DCB237`, and a fresh Maven consumer resolves identical bytes. The public JAR passed all **28 affected desktop/mobile workflows**, zero failures/skips/flaky outcomes/report errors. SHA-256: `238c9ae80beea336717be759219f0d65a1d614e8e7d6a145a16374cc392a73a1`. The anonymous public image manifest/pull passed (AMD64/ARM64); the downloaded ARM64 image served its route, all 21 scripts, both stylesheets and the pinned font successfully. Manifest digest `sha256:2be3b74f614b1d038d79aba2894b210b988025000e86a572364a902fb0106c25`. This is an image delivery/assets check, not a replacement for backend workflows.
The immutable tag's Chromium job in run 36800964105 also passed **602 cases and all 391 required rows**, with three approved exclusions and zero failures/skips/flaky outcomes/report errors. Its complete artifact is retained separately in `rc8-tag-ci-chromium/`; tag WebKit completed **601 passed / 1 failed** (PRC-021 process display after a failed `/qqq/v1/manageSession` request with a WebKit internal error), with zero skips/flaky outcomes/report errors. Its complete artifact is retained in `rc8-tag-ci-webkit/`; cause unproven. Tag Firefox finished **600 passed / 2 failed** (NAV-015 and SEC-043 font-download diagnostics), with zero skips/flaky outcomes/report errors. No user-isolation assertion failure is reported for SEC-043; the font diagnostics still fail the test and their cause is unproven. The complete artifact is retained in `rc8-tag-ci-firefox/`. Tag mobile passed **563 cases**; tablet passed **559 and failed 4**: NAV-025 Clear-search focus in light/dark, REL-005 WebKit internal navigation error, and SEC-043 recent-record visibility after same-user reload before the user switch. Zero skips/flaky outcomes/report errors. Full artifacts are retained in `rc8-tag-ci-touch/`; these failures remain open.
## Remaining requirements
Complete embedded report/query equivalence, remaining input configurations, origin sub-path hosting, real Google Drive provider verification, secondary-browser failures and whole-product visual approval remain open. Original Next remains the visual reference. Do not infer full compatibility from the current acceptance matrix.
Use the QQQ 4.1.0-SNAPSHOT development backend with report-variant and configured Google Drive support. Record the application's exact backend commit. This candidate does not release QQQ 4.1.
## Local owner preview
A separate RC8 preview is running at `http://127.0.0.1:18781/app/person/` using the public ARM64 container and an isolated sample backend. Entry/grid/branding/custom-component checks passed with five Person rows and zero page errors in light mode. The fixture proxy routes acceptance assets and sample branding; the container uses explicit fixture CSP sources. This preview check does not claim full workflow coverage. RC7 remains separately available at port 18779.
Additional diagnostic evidence: candidate Firefox REC-009 captured the save-success toast before an approximately 7.45-second automation delay preceding later checks. Its four-second toast had expired before the subsequent visibility check; save/refetch requests succeeded quickly. This establishes that the notification appeared, while the automation delay remains unexplained. A separate Chromium probe of the local RC8 JAR verified that a preview popup carries temporary sort and stored criteria, matches SQL row order, and leaves the saved report unchanged. These findings do not alter the failed-run totals or the 602-case gate.
+41
View File
@@ -0,0 +1,41 @@
# Next UI 1.0.0-RC.9
Release candidate for application testing, October 1, 2026. The [GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) records public artifact availability and final delivery verification; a source tag alone is not delivery confirmation. Final 1.0 remains held for owner application testing and explicit approval.
## Changes from RC8
RC9 integrates the GitHub tracking policy, current roadmap links, and updated developer/agent documentation. Application code and dependencies are unchanged from RC8. This candidate does not claim fixes for the open functional or visual findings.
[QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713) is the source of truth for current status and remaining work. Original issue reports and historical acceptance evidence remain available. See the [tracking policy](../ISSUE-TRACKING.md).
## Verification baseline and known issues
The documentation source `354ee2709d8ee553de5e3ad823ce9d1190887d4c`, merged through PR #16, passed general CI (typecheck, lint, unit coverage, mocked Chromium E2E, Storybook and bundle budgets) and both container architecture builds. Real-backend [run 36861609503](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36861609503) passed all 602 Chromium cases and all 391 required rows, with three approved real-provider exclusions.
Its secondary reports remain failed: Firefox 599 passed / 3 failed (NAV-014, NAV-016 and REC-056 legacy-font diagnostics, [#951](https://github.com/QRun-IO/qqq/issues/951)), WebKit 601 / 1 (WID-073 metadata access-control diagnostic, [#961](https://github.com/QRun-IO/qqq/issues/961)), and combined touch 1,124 / 2 (tablet NAV-025 and REL-005). Reports contain zero skips/flaky outcomes/report errors. These are pre-RC9-version source results; final tag results and public artifact receipts are recorded separately below. No all-browser pass is claimed.
Current open work includes embedded report/query equivalence, remaining input configurations, subpath hosting, real Auth0/Google Drive/QuickSight checks, browser investigations, original-Next visual approval, real-application soak and final release approval. See [#713](https://github.com/QRun-IO/qqq/issues/713) and [browser evidence](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual issues. Material remains the behavior reference; the original Next design remains the visual reference.
## Versioned local verification
RC9 passes all 1,919 unit tests across 181 files, typechecking, lint/license (zero errors, five existing warnings), production export, Maven packaging and unchanged bundle budgets. All 193 packaged dashboard files are present; the Maven version is RC9 and no mock service worker is included. The actual locally packaged RC9 JAR passes 28 affected desktop/mobile browser cases with zero failures/skips/flaky outcomes/report errors. These checks cover API controls/theme/search focus, exact numeric persistence and composite field submission against backend e0d57dd31. They do not replace public artifact verification or certify other profiles.
## Installation
Maven coordinate: `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.9`.
Container: `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.9` (AMD64/ARM64 verified). The container backend origin is fixed at build time; the published image targets `host.docker.internal:8000`. Use the JAR or rebuild for another origin.
As with RC8, verification uses the QQQ 4.1 development backend. The locally verified backend source is `e0d57dd31b979b17215370546ef4e26cd8a38d21`; hosted workflows resolve their configured QQQ ref separately. This release does not publish QQQ 4.1 or certify every real consumer/backend combination.
## Public delivery — October 1, 2026
[RC9](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) was published at 19:25:28 UTC as a prerelease, latest=false. PR16 and PR14 are merged. The signed immutable tag points to `c029ad2a70948aac6469d8a71ccefc80c6e00d74`.
- Public JAR: 1,864,121 bytes; SHA256 `969034566c6b30a5034680dcf2d46aec327240db6cdd3fb7b31f1fae7cb9f17d`; SHA1 `3943c8641c14554999206cf52357c32b8f397b7b`.
- Release signature verified against fingerprint `68A49476754103988EA3ED924B88CBB3D6DCB237`; a clean ordinary Maven consumer resolved identical bytes without manual installation.
- Actual public JAR: all 28 targeted desktop/mobile cases passed, zero failures/skips/flaky outcomes/report errors. All 193 dashboard files and RC9 version markers are present, without the mock service worker.
- Public image: `sha256:c2c684733deb98fd8355d4a9e2a53bf4af429bd16d9e7802641f9ef9d44e1017`. Anonymous AMD64/ARM64 pulls and both architecture startup, route, JavaScript, pinned-font and version checks passed.
- Merged-source general CI36912597438 passed. Fresh full dependency audit reports zero advisories across1,038dependencies; GitHub marked all four outstanding alerts fixed, none dismissed. Issues #647/#698 are closed with that evidence.
Full immutable-tag acceptance [36912641399](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36912641399) completed after publication: Chromium 602 passed / 0 failed; desktop WebKit 602 / 0; Firefox 600 / 2; mobile 563 / 0; tablet 560 / 3. All reports contain zero skips, flaky outcomes and report errors. Firefox failed NAV-016 legacy-font diagnostics and REC-048 validation-toast visibility; tablet failed NAV-015 JavaScript loading, NAV-016 root navigation (both WebKit internal errors), and NAV-025 Clear-search focus in dark mode. The overall run failed; later passing cases do not resolve older investigations. Follow [frontend#12](https://github.com/QRun-IO/qqq-frontend-next/issues/12) for individual failure tracking and [#713](https://github.com/QRun-IO/qqq/issues/713) for the live backlog. Final 1.0 remains held.
+62
View File
@@ -0,0 +1,62 @@
# Next UI — unreleased
[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)
- In searchable possible-value fields, invalidate old results immediately when the search term changes, so a debounced request cannot remove an option during its selection. [#970](https://github.com/QRun-IO/qqq/issues/970)
- Keep code suggestions open when a delayed textarea scroll event reports the position already used for the popup; dismiss them when the scroll position actually changes. [#954](https://github.com/QRun-IO/qqq/issues/954)
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
## Browser testing and infrastructure
- Pin the official Playwright runner containing the fixed Linux WebKit network library, and retain native browser diagnostics for navigation failures. The previous bundle's heap corruption was reproduced; migration to a stable runner remains tracked separately. [#904](https://github.com/QRun-IO/qqq/issues/904), [#973](https://github.com/QRun-IO/qqq/issues/973)
- Correlate interrupted Firefox font diagnostics with real request and decode evidence, wait for initial fonts before the deliberate redirect, and observe transient feedback during the action that produces it. [#951](https://github.com/QRun-IO/qqq/issues/951), [#953](https://github.com/QRun-IO/qqq/issues/953), [#967](https://github.com/QRun-IO/qqq/issues/967)
- Wait for nested report metadata and the destination's owned iframe before advancing acceptance navigation. [#961](https://github.com/QRun-IO/qqq/issues/961), [#972](https://github.com/QRun-IO/qqq/issues/972)
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
- Select all ten ESB scenarios for both phone and tablet acceptance, retaining real broker delivery, lifecycle, replay, SQL and permission checks. Matrix references now use the central QQQ tickets. [#986](https://github.com/QRun-IO/qqq/issues/986), [#990](https://github.com/QRun-IO/qqq/issues/990)
## Verification and remaining work
Current main `5ca3e0b` has a complete [3,010-case first-attempt acceptance pass](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36989591498): 620 per desktop browser and 575 per touch profile. That run qualifies the main tree before the query-state correction above. The earlier checkpoints below are retained separately.
The [combined acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) on head `7ff90f1` (the same tree as merge `289eca19`) passed all 2,972 executed cases: 614 each in Chromium, Firefox and WebKit, and 565 each on phone and tablet. All desktop gates covered 401 required rows. The workflow still failed its touch coverage gate because ten ESB rows were not selected; that original failure remains retained under [#990](https://github.com/QRun-IO/qqq/issues/990). After adding the selection markers, a separate native Linux run passed all 20 ESB phone/tablet cases, with zero retries, skips, flaky outcomes or report errors. Its frozen frontend export is unchanged; its freshly rebuilt backend is `55b33e6`. Complete touch collection is 575 cases per profile. The subsequent [complete PR23 outcome](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159) covers all 401 required IDs on each profile with zero missing and all 20 ESB touch cases passing, completing #990. Touch totals are 1,149 passes and one unrelated tablet NAV-056 failure; the full gate and workflow remain failed. [NAV-056 investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383) remains separate, with no shared-cause claim or new waiver.
Each linked issue and PR records the source commit, reproduction and scoped verification. Preserve failed full-run snapshots separately from later focused passes. The broader historical navigation inventory [#904](https://github.com/QRun-IO/qqq/issues/904), same-user session stall [#952](https://github.com/QRun-IO/qqq/issues/952), legacy process-navigation stall [#960](https://github.com/QRun-IO/qqq/issues/960), and original toast-paint uncertainty [#967](https://github.com/QRun-IO/qqq/issues/967) remain open. The [RC10 preparation notes](1.0.0-RC.10.md) retain their historical checkpoint; its [public release entry](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) records final delivery verification. See [RC11 notes](1.0.0-RC.11.md) for the successor candidate. No final-release approval is claimed by these results.
Final 1.0 remains held for real-application soak and explicit owner approval under [#712](https://github.com/QRun-IO/qqq/issues/712). This work does not authorize a QQQ 4.1 release. See the [testing-period policy](1.0-testing.md) and [issue lifecycle](../ISSUE-TRACKING.md).
## Pending standalone branding compatibility (QQQ #1009)
The standalone Node server can route the exact root-relative `branding.logo` and
`branding.icon` paths published by authentication metadata through fixed backend
image roles. Local Node `public` files keep precedence; external image URLs and
Java dashboard serving are unchanged. This does not expose the whole application
overlay or add a general proxy.
This requires **a QQQ middleware-javalin build containing QQQ #1009's
`/qqq/branding/logo` and `/qqq/branding/icon` endpoints**. They are absent from
published **4.1.0-RC.1**. The minimum numbered release is not yet assigned; a
source build must record the exact backend commit/JAR hashes. Older deployments
must supply branding through their Node `public` files or an existing external
image URL. No automatic compatibility with old relative backend assets is claimed.
The preserved standalone qualification against public RC1 remains **5 passed,
38 failed** out of 43 cases because of missing branding assets. The original
staging failure is also retained. The corrected cross-repository pairing has
not yet received its browser qualification; neither #1009 nor #734 is closed by
focused unit/HTTP results.
+44
View File
@@ -1,5 +1,49 @@
# Next UI security review (QRun-IO/qqq#696)
## Current status — October 4, 2026
This addendum supersedes the historical disposition statements below where noted.
The September 25 review remains intact as evidence of its original source and scope.
Current findings and release decisions are tracked in
[QQQ #696](https://github.com/QRun-IO/qqq/issues/696#issuecomment-5982040326).
This is a scoped security review, not a certification of ASVS Level 2 compliance.
| Finding | Current disposition |
| --- | --- |
| NUI-04: dashboard headers | [#695](https://github.com/QRun-IO/qqq/issues/695) is closed for the Javalin static-export scope, with current-source provider tests and browser evidence. Standalone qualification is separate. |
| NUI-05: readable session cookie | [#733](https://github.com/QRun-IO/qqq/issues/733) is closed. Next resumes through the backend-owned cookie; the legacy endpoint correction is merged. Material's explicit readable-cookie compatibility mode remains supported. |
| NUI-06: previous-user browser state | The additional query-filter and saved-view-pointer retention finding is fixed in [#1006](https://github.com/QRun-IO/qqq/issues/1006), merged in frontend `f367756`. Global visual preferences are preserved. |
| NUI-09: standalone headers | [#734](https://github.com/QRun-IO/qqq/issues/734) and the metadata-derived CSP correction [#1017](https://github.com/QRun-IO/qqq/issues/1017) are closed after scoped qualification and integration. The paired implementation passed 48 standalone first-attempt cases across five profiles. [Frontend PR #40](https://github.com/QRun-IO/qqq-frontend-next/pull/40) merged as `138c3c6d`, tree `328bc322`, exactly matching the qualified combined candidate. Its separate full Javalin acceptance passed 3,015/3,015 first attempts against backend `1d16a70c`. HEAD/304, bounded metadata and Java-customizer limits remain documented in the closeout. |
| NUI-11: custom CSS | Current code treats application `customCss` as trusted developer configuration and inserts it with `textContent`. It is not filtered by the historical deny-list or sanitized by DOMPurify. CSP and application configuration remain separate controls. |
| Auth0 token verification | [#1008](https://github.com/QRun-IO/qqq/issues/1008) confirms issuer/audience validation and cache-boundary findings. [Backend PR #1012](https://github.com/QRun-IO/qqq/pull/1012) contains the correction; 164 focused tests passed on its combined backend tree. The exact-head CI passed and the correction merged into backend develop as `fc67393b`. An unconfigured audience remains supported; no audience enforcement is claimed in that configuration. |
| Signed back-channel logout | [#909](https://github.com/QRun-IO/qqq/issues/909) remains open for stable release delivery. [Backend PR #1021](https://github.com/QRun-IO/qqq/pull/1021) merged the qualified correction into stable main as `9168e434`; its merge tree equals the qualified candidate and the automatic post-merge reactor/sample/static gates passed. Published 4.0.0 remains affected; planned 4.0.1 is not published or artifact-qualified. PR #1018 is closed as superseded, not merged; its original Mongo failure is retained. The corrected 4.1 RC line remains distinct. |
The other scoped finding dispositions remain as reconciled in #696. NUI-10's
runtime-style exception remains deliberate; it does not allow inline scripts.
Negative security tests intentionally produce denied-resource and CSP diagnostics.
A passing test run does not mean those diagnostic arrays are all empty.
### Evidence limits
The reference remains ASVS 4.0.3. Owned identity-provider fixtures do not qualify a
real Auth0 tenant, MFA or step-up policy, provider-grant revocation, or deployment
TLS, HSTS and proxy trust. The approved real-provider exclusion remains explicit.
Configurable TABLE_BASED idle expiration does not prove a 12-hour active / 30-minute
idle policy. Cookie-prefix and shared-host suitability, generic CSRF/Origin
protection, all-session revocation, and application-sensitive storage/cache policy
also remain outside the demonstrated guarantees. These limits are not newly
confirmed High/Critical exploits and must not be presented as universal compliance.
The 48-case standalone result covers its selected security scope and paired
candidate backend. Its analytics case uses an owned plugin through the real
application hook, not a vendor SDK or real account. It does not
replace full-browser acceptance. The separate 3,015-case Javalin gate qualifies
the merged PR40 tree; neither result resolves separately tracked browser failures
or certifies a real provider or deployment. Final 1.0 still requires owner visual
acceptance and real-application soak.
## Historical review — September 25, 2026
Independent review of the QQQ Next admin UI against OWASP ASVS 4.0.3 Level 2, for the
Next UI 1.0 milestone (epic QRun-IO/qqq#713). Reviewed on 2026-09-25 at
`feature/GH-649-next-acceptance` (`2b6569f`, release 0.2.0) with the backend at
+10 -2
View File
@@ -1,6 +1,6 @@
{
"name": "qqq-frontend-next",
"version": "1.0.0-RC.6",
"version": "1.0.0-RC.11",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
@@ -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,13 +61,14 @@
"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"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@eslint/eslintrc": "^3.3.4",
"@playwright/test": "^1.58.2",
"@playwright/test": "1.64.0-alpha-2026-10-01",
"@storybook/addon-docs": "10.6.0",
"@storybook/nextjs-vite": "10.6.0",
"@storybook/react": "10.6.0",
+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
}
}
}
+203 -40
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)
@@ -72,7 +93,7 @@ importers:
version: 0.485.0(react@19.2.4)
next:
specifier: 16.3.6
version: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rapidoc:
specifier: 9.3.8
version: 9.3.8
@@ -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
@@ -100,19 +124,19 @@ importers:
devDependencies:
'@axe-core/playwright':
specifier: ^4.11.1
version: 4.11.1(playwright-core@1.58.2)
version: 4.11.1(playwright-core@1.64.0-alpha-2026-10-01)
'@eslint/eslintrc':
specifier: ^3.3.4
version: 3.3.4
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
specifier: 1.64.0-alpha-2026-10-01
version: 1.64.0-alpha-2026-10-01
'@storybook/addon-docs':
specifier: 10.6.0
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
'@storybook/nextjs-vite':
specifier: 10.6.0
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
'@storybook/react':
specifier: 10.6.0
version: 10.6.0(@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)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
@@ -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:
@@ -1200,9 +1254,9 @@ packages:
cpu: [x64]
os: [win32]
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
'@playwright/test@1.64.0-alpha-2026-10-01':
resolution: {integrity: sha512-N/mTE29hNg/pw/ega+N+cEvPmAFwrprMxoacDbaO0fDlpyQ6n/SvCRF9dv6mLa1tjcy9oPStW2o7C1HP2Mh8lQ==}
engines: {node: '>=20'}
hasBin: true
'@polka/url@1.0.0-next.29':
@@ -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'}
@@ -3250,11 +3310,6 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3886,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'}
@@ -3923,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'}
@@ -4101,14 +4163,14 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
playwright-core@1.64.0-alpha-2026-10-01:
resolution: {integrity: sha512-Dk7ENTj8unFs3o5KNTIhFXO+JN/YGU4FrQvPmxI0Uri9j/5r8Mk0IzmFkNpvx6n83Kcls38qRdkD/fTdAfWvtA==}
engines: {node: '>=20'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
playwright@1.64.0-alpha-2026-10-01:
resolution: {integrity: sha512-Nbe+dTDPKcZjI+lIUe6WLUb3KQ3Wll/SPkf7oCzytUoPZo+wzZY6J2DaD8+7dXMsq+8lUB2dZOMimouiYwpGuw==}
engines: {node: '>=20'}
hasBin: true
possible-typed-array-names@1.1.0:
@@ -4168,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'}
@@ -4177,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'}
@@ -4319,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'}
@@ -4475,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==}
@@ -4564,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'}
@@ -4918,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'}
@@ -5066,10 +5149,10 @@ snapshots:
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
'@axe-core/playwright@4.11.1(playwright-core@1.58.2)':
'@axe-core/playwright@4.11.1(playwright-core@1.64.0-alpha-2026-10-01)':
dependencies:
axe-core: 4.11.1
playwright-core: 1.58.2
playwright-core: 1.64.0-alpha-2026-10-01
'@babel/code-frame@7.29.0':
dependencies:
@@ -5212,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)':
@@ -5656,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
@@ -5884,9 +6022,9 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.21.2':
optional: true
'@playwright/test@1.58.2':
'@playwright/test@1.64.0-alpha-2026-10-01':
dependencies:
playwright: 1.58.2
playwright: 1.64.0-alpha-2026-10-01
'@polka/url@1.0.0-next.29': {}
@@ -6386,18 +6524,18 @@ snapshots:
dependencies:
react: 19.2.4
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))':
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))':
dependencies:
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
'@storybook/react': 10.6.0(@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)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
'@storybook/react-vite': 10.6.0(@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)(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4)
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2))
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
@@ -7707,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: {}
@@ -7720,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
@@ -7853,6 +7992,8 @@ snapshots:
diff-sequences@29.6.3: {}
discontinuous-range@1.0.0: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -8369,9 +8510,6 @@ snapshots:
fraction.js@5.3.4: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -9004,6 +9142,8 @@ snapshots:
module-alias@2.3.4: {}
moo@0.5.3: {}
mrmime@2.0.1: {}
ms@2.0.0: {}
@@ -9043,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
@@ -9053,7 +9200,7 @@ snapshots:
neotraverse@1.0.1: {}
next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.3.6
'@swc/helpers': 0.5.23
@@ -9072,7 +9219,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.3.6
'@next/swc-win32-arm64-msvc': 16.3.6
'@next/swc-win32-x64-msvc': 16.3.6
'@playwright/test': 1.58.2
'@playwright/test': 1.64.0-alpha-2026-10-01
sharp: 0.35.4(@types/node@22.19.11)
transitivePeerDependencies:
- '@babel/core'
@@ -9269,13 +9416,11 @@ snapshots:
picomatch@4.0.7: {}
playwright-core@1.58.2: {}
playwright-core@1.64.0-alpha-2026-10-01: {}
playwright@1.58.2:
playwright@1.64.0-alpha-2026-10-01:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
playwright-core: 1.64.0-alpha-2026-10-01
possible-typed-array-names@1.1.0: {}
@@ -9337,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
@@ -9520,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: {}
@@ -9729,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:
@@ -9852,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
@@ -10180,13 +10341,13 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-plugin-storybook-nextjs@10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)):
vite-plugin-storybook-nextjs@10.6.0(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.63.5)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)):
dependencies:
'@next/env': 16.3.6
'@rollup/pluginutils': 5.4.0(rollup@4.63.5)
magic-string: 0.30.21
module-alias: 2.3.4
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 16.3.6(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pathe: 2.0.3
probe-image-size: 7.4.0
semver: 7.8.5
@@ -10255,6 +10416,8 @@ snapshots:
transitivePeerDependencies:
- msw
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
+1 -1
View File
@@ -47,7 +47,7 @@
</developers>
<properties>
<revision>1.0.0-RC.6</revision>
<revision>1.0.0-RC.11</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
+2 -1
View File
@@ -18,7 +18,7 @@ rmSync(classes, { recursive: true, force: true })
mkdirSync(classes, { recursive: true })
const fixtureDirectory = path.resolve('tests/acceptance/fixture')
const sources = readdirSync(fixtureDirectory).filter((name) => name.endsWith('.java')).map((name) => path.join(fixtureDirectory, name))
const javac = spawnSync('javac', ['-proc:none', '-encoding', 'UTF-8', '-cp', fixtureClasspath, '-d', classes, ...sources], { stdio: 'inherit' })
const javac = spawnSync('javac', ['-proc:none', '-sourcepath', '', '-encoding', 'UTF-8', '-cp', fixtureClasspath, '-d', classes, ...sources], { stdio: 'inherit' })
if (javac.status !== 0) {
console.error('Acceptance fixture compilation failed.')
process.exit(1)
@@ -29,6 +29,7 @@ const server = spawn('java', [
'-Dqqq.sample.mockAuthentication=true',
'-Dqqq.sample.sharing=true',
`-Dqqq.sample.port=${port}`,
`-Dqqq.sample.esb.port=${process.env.QQQ_ACCEPTANCE_ESB_PORT ?? '61616'}`,
'-Duser.timezone=UTC',
// javalin mode: the fresh export shadows any dashboard bundled in the sample jar
'-cp', [...(ACCEPTANCE_MODE === 'javalin' ? [EXPORT_CLASSPATH] : []), classes, fixtureClasspath].join(path.delimiter),
+8 -6
View File
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process'
import { cpSync, existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { BUILD_MARKER } from './acceptance-paths.mjs'
import { assertExplicitCspSources } from './acceptance-standalone.mjs'
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
const standalone = path.resolve('.next/standalone')
@@ -19,16 +20,16 @@ if (!existsSync(path.join(standalone, 'server.js')) || !existsSync(BUILD_MARKER)
console.error(`No production build for ${backend}; run node scripts/acceptance.mjs.`)
process.exit(1)
}
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true })
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true })
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'backend-prefixes.json']) {
// Use the JS copy path for owner-readable files on Docker Desktop mounts.
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true, verbatimSymlinks: true, filter: () => true })
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true, verbatimSymlinks: true, filter: () => true })
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'branding-assets.mjs', 'backend-prefixes.json']) {
cpSync(path.resolve('standalone', file), path.join(standalone, file))
}
/**
* The application's policy additions (its QuickSight embed, custom component bundles and
* the fixture's loopback service), which the QQQ server derives in javalin mode and a
* container deployment sets in QQQ_DASHBOARD_CSP_SOURCES.
* Only the fixture's explicit owned frame/image/media overrides. Application
* metadata sources must arrive through the production metadata contract.
*
* @returns {Promise<string>} The sources, once the acceptance backend is up.
*/
@@ -47,6 +48,7 @@ async function dashboardCspSources() {
}
const sources = await dashboardCspSources()
assertExplicitCspSources(sources)
const server = spawn('node', ['qqq-server.mjs'], {
cwd: standalone,
stdio: 'inherit',
+8
View File
@@ -40,3 +40,11 @@ export const BUILD_MARKER = path.resolve('.next/acceptance-backend.txt')
export const EXPORT_CLASSPATH = path.resolve('test-results/acceptance/export-classpath')
export const ACCEPTANCE_MODE = process.env.QQQ_ACCEPTANCE_MODE === 'standalone' ? 'standalone' : 'javalin'
/** Separate backend, browser and provider origins for authentication variants. */
export const SECURITY_BACKEND_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT ?? Number(process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? 18765) + 10)
export const SECURITY_FRONTEND_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_FRONTEND_PORT ?? Number(process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? 13765) + 20)
export const SECURITY_IDP_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_IDP_PORT ?? Number(process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? 13765) + 10)
export const SECURITY_BACKEND_URL = `http://127.0.0.1:${SECURITY_BACKEND_PORT}`
export const SECURITY_UI_URL = ACCEPTANCE_MODE === 'standalone' ? `http://127.0.0.1:${SECURITY_FRONTEND_PORT}` : SECURITY_BACKEND_URL
export const SECURITY_STANDALONE = path.resolve('test-results/acceptance/security-standalone')
+58
View File
@@ -0,0 +1,58 @@
/*
* 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
*/
/** @file Stages and validates an immutable standalone build for an authentication backend. */
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
const prefixes = createRequire(path.resolve('package.json'))('./standalone/backend-prefixes.json')
/** Rejects a missing or differently routed build, regardless of any retained Javalin export. */
export function assertStandaloneTarget(directory, backend) {
if (!existsSync(path.join(directory, 'server.js')) || !existsSync(path.join(directory, 'qqq-server.mjs'))
|| readFileSync(path.join(directory, 'acceptance-backend.txt'), 'utf8').trim() !== backend) {
throw new Error(`No standalone security build for ${backend}; run acceptance without --skip-build.`)
}
const { rewrites } = JSON.parse(readFileSync(path.join(directory, '.next/routes-manifest.json'), 'utf8'))
const routes = Array.isArray(rewrites) ? rewrites : [...rewrites.beforeFiles, ...rewrites.afterFiles, ...rewrites.fallback]
for (const prefix of prefixes) {
if (!routes.some(({ source, destination }) => source === `/${prefix}/:path*` && destination === `${backend}/${prefix}/:path*`)) {
throw new Error(`Standalone security build has the wrong backend rewrite for ${prefix}.`)
}
}
}
/** Copies the same standalone payload as the container image, before the next clean build. */
export function stageStandalone(directory, backend, source = process.cwd()) {
rmSync(directory, { recursive: true, force: true })
// The JS copy path preserves readable files on Docker Desktop mounts; relative links
// must remain relative so deleting the original build cannot invalidate this stage.
cpSync(path.join(source, '.next/standalone'), directory, { recursive: true, verbatimSymlinks: true, filter: () => true })
cpSync(path.join(source, '.next/static'), path.join(directory, '.next/static'), { recursive: true, verbatimSymlinks: true, filter: () => true })
cpSync(path.join(source, 'public'), path.join(directory, 'public'), { recursive: true, verbatimSymlinks: true, filter: () => true })
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'branding-assets.mjs', 'backend-prefixes.json']) {
cpSync(path.join(source, 'standalone', file), path.join(directory, file))
}
writeFileSync(path.join(directory, 'acceptance-backend.txt'), `${backend}\n`)
assertStandaloneTarget(directory, backend)
}
/** Prevents the fixture from supplying the metadata admissions the production wrapper must derive. */
export function assertExplicitCspSources(text) {
for (const part of text.split(';')) {
const [directive, ...sources] = part.trim().split(/\s+/).filter(Boolean)
if (!directive) continue
if (!['frame-src', 'img-src', 'media-src'].includes(directive) || sources.length !== 1) {
throw new Error('Only explicit owned-service CSP overrides are allowed')
}
let origin
try { origin = new URL(sources[0]) } catch { /* rejected below */ }
if (!origin || !['http:', 'https:'].includes(origin.protocol) || origin.origin !== sources[0] || sources[0].includes('*')) {
throw new Error('Only explicit owned-service CSP overrides are allowed')
}
}
}
+9 -2
View File
@@ -10,7 +10,9 @@
import { spawnSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar } from './acceptance-paths.mjs'
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar, SECURITY_BACKEND_URL, SECURITY_STANDALONE } from './acceptance-paths.mjs'
import { stageStandalone } from './acceptance-standalone.mjs'
const args = process.argv.slice(2)
const skipBuild = args.includes('--skip-build')
@@ -38,7 +40,12 @@ if (ACCEPTANCE_MODE === 'javalin') {
cpSync('out', path.join(EXPORT_CLASSPATH, 'next-dashboard'), { recursive: true, filter: (source) => !source.endsWith('mockServiceWorker.js') })
}
if (!skipBuild && ACCEPTANCE_MODE === 'standalone') {
const status = run('pnpm', ['build'], { QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
// A second immutable build is required: Next bakes the rewrite destination at build time.
const securityStatus = run('pnpm', ['build'], { QQQ_NEXT_OUTPUT: 'standalone', QQQ_BACKEND_URL: SECURITY_BACKEND_URL, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
if (securityStatus !== 0) process.exit(securityStatus)
stageStandalone(SECURITY_STANDALONE, SECURITY_BACKEND_URL)
rmSync('.next', { recursive: true, force: true })
const status = run('pnpm', ['build'], { QQQ_NEXT_OUTPUT: 'standalone', QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
if (status !== 0) process.exit(status)
writeFileSync(BUILD_MARKER, backend + '\n')
}
@@ -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 (
<>
+6 -1
View File
@@ -17,7 +17,7 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { QContextProvider } from '@/lib/context/q-context'
import { qInstance } from '@/mocks/fixtures/q-instance'
@@ -124,6 +124,11 @@ const greeting = {
}
describe('SlugPage process initialization', () => {
beforeAll(async () => {
// Keep cold screen compilation outside the metadata-readiness assertion deadline.
await import('@/components/process/ProcessRun')
})
beforeEach(() => {
vi.restoreAllMocks()
params.slug = greeting.name
+135 -13
View File
@@ -24,11 +24,16 @@ import { toast } from 'sonner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EsbCapabilities, EsbPermissions } from '@/types'
import { processInit } from '@/lib/api/processes'
import { processInit, processStatus } from '@/lib/api/processes'
import { orderEsb } from '@/mocks/fixtures/esb'
import { EsbQueueActions, EsbTriggerActions, destinationQueue, type EsbQueueRef } from './EsbActions'
import {
EsbQueueActions,
EsbTriggerActions,
destinationQueue,
type EsbQueueRef,
} from './EsbActions'
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn() }))
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
vi.mock('sonner', async (importOriginal) => ({
...(await importOriginal<typeof import('sonner')>()),
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() },
@@ -102,28 +107,44 @@ describe('EsbActions', () => {
it('needs esbOperate for pausing a queue and esbDelete for purging and deleting', () => {
const { unmount } = render(
<QueryClientProvider client={new QueryClient()}>
<EsbQueueActions queue={fulfillmentQueue()} permissions={{ canOperate: true, canDelete: false }} />
<EsbQueueActions
queue={fulfillmentQueue()}
permissions={{ canOperate: true, canDelete: false }}
/>
</QueryClientProvider>
)
expect(screen.getByRole('button', { name: 'Pause queue orderFulfillment' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Purge orderFulfillment' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete older messages in orderFulfillment' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Delete older messages in orderFulfillment' })
).not.toBeInTheDocument()
unmount()
renderWithClient(
<EsbQueueActions queue={fulfillmentQueue()} permissions={{ canOperate: false, canDelete: true }} />
<EsbQueueActions
queue={fulfillmentQueue()}
permissions={{ canOperate: false, canDelete: true }}
/>
)
expect(screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Purge orderFulfillment' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })
).toBeInTheDocument()
})
it('offers resume instead of pause for a paused queue', () => {
renderWithClient(
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: true }} permissions={ALL} />
)
expect(screen.getByRole('button', { name: 'Resume queue orderFulfillment' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Resume queue orderFulfillment' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Pause queue orderFulfillment' })
).not.toBeInTheDocument()
})
it('offers pause for a running trigger and resume for a paused one', () => {
@@ -150,10 +171,29 @@ describe('EsbActions', () => {
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Done.'))
})
it('replays all dead letters with esbReplayDeadLetters', async () => {
it.each([1, null])('confirms replay-all with a safe count of %s', async (count) => {
const user = userEvent.setup()
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
renderWithClient(
<EsbTriggerActions
trigger={{
...fulfillTrigger,
deadLetter: { ...fulfillTrigger.deadLetter, messageCount: count },
}}
permissions={ALL}
/>
)
await user.click(screen.getByRole('button', { name: 'Replay dead letters for Fulfill Order' }))
let dialog = await screen.findByRole('alertdialog')
expect(dialog).toHaveTextContent('orderFulfillment.dlq')
expect(dialog).toHaveTextContent(
count === null ? 'all dead letters (count unknown)' : '1 message'
)
expect(processInit).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
expect(processInit).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Replay dead letters for Fulfill Order' }))
dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'Replay dead letters' }))
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith('esbReplayDeadLetters', {
values: { triggerName: 'fulfillOrder.orderFulfillment', all: true },
@@ -188,7 +228,9 @@ describe('EsbActions', () => {
const user = userEvent.setup()
renderWithClient(<EsbQueueActions queue={fulfillmentQueue()} permissions={ALL} />)
await user.click(screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' }))
await user.click(
screen.getByRole('button', { name: 'Delete older messages in orderFulfillment' })
)
const dialog = await screen.findByRole('alertdialog')
expect(dialog).toHaveTextContent('orderFulfillment')
expect(dialog).toHaveTextContent('7 messages')
@@ -227,6 +269,86 @@ describe('EsbActions', () => {
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Broker unavailable'))
})
it.each([
['Resume Cancel Order', 'esbResumeTrigger', 'cancelOrder.orderEvents'],
['Restart Fulfill Order', 'esbRestartTrigger', 'fulfillOrder.orderFulfillment'],
])('sends the exact trigger input for %s', async (label, processName, triggerName) => {
const user = userEvent.setup()
renderWithClient(
<EsbTriggerActions
trigger={triggerName === cancelTrigger.name ? cancelTrigger : fulfillTrigger}
permissions={ALL}
/>
)
await user.click(screen.getByRole('button', { name: label }))
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith(processName, { values: { triggerName } })
)
})
it('sends provider and broker queue when resuming delivery', async () => {
const user = userEvent.setup()
renderWithClient(
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: true }} permissions={ALL} />
)
await user.click(screen.getByRole('button', { name: 'Resume queue orderFulfillment' }))
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith('esbResumeQueue', {
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
})
)
})
it('hides replay for an empty dead-letter queue and pause when queue state is unknown', () => {
renderWithClient(
<>
<EsbTriggerActions
trigger={{
...fulfillTrigger,
deadLetter: { ...fulfillTrigger.deadLetter, messageCount: 0 },
}}
permissions={ALL}
/>
<EsbQueueActions queue={{ ...fulfillmentQueue(), paused: null }} permissions={ALL} />
</>
)
expect(screen.queryByRole('button', { name: /Replay/ })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Pause queue|Resume queue/ })
).not.toBeInTheDocument()
})
it.each(['COMPLETE', 'ERROR'] as const)(
'keeps controls pending and reports only the final async %s outcome',
async (type) => {
vi.mocked(processInit).mockResolvedValue({
type: 'JOB_STARTED',
processUUID: 'uuid',
jobUUID: 'job',
})
vi.mocked(processStatus).mockResolvedValue(
type === 'COMPLETE'
? { type, processUUID: 'uuid', values: { message: 'Restarted 2 consumers.', count: 2 } }
: { type, error: 'Restart failed.' }
)
const user = userEvent.setup()
renderWithClient(<EsbTriggerActions trigger={fulfillTrigger} permissions={ALL} />)
await user.click(screen.getByRole('button', { name: 'Restart Fulfill Order' }))
expect(screen.getByRole('button', { name: 'Restart Fulfill Order' })).toBeDisabled()
expect(toast.success).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
await waitFor(
() =>
expect(type === 'COMPLETE' ? toast.success : toast.error).toHaveBeenCalledWith(
type === 'COMPLETE' ? 'Restarted 2 consumers.' : 'Restart failed.'
),
{ timeout: 2500 }
)
expect(type === 'COMPLETE' ? toast.error : toast.success).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Restart Fulfill Order' })).toBeEnabled()
}
)
it('treats a topic as having no broker queue', () => {
expect(destinationQueue(orderEsb.publications[0].destination)).toBeNull()
})
+42 -6
View File
@@ -78,7 +78,7 @@ export function deadLetterQueue(trigger: EsbTrigger): EsbQueueRef {
brokerQueueName: trigger.deadLetter.brokerName,
capabilities: trigger.destination.capabilities,
messageCount: trigger.deadLetter.messageCount,
paused: null,
paused: trigger.deadLetter.paused ?? null,
}
}
@@ -144,14 +144,19 @@ export function EsbTriggerActions({
* @returns A group of buttons.
*/
function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
const action = useEsbAction()
const [confirmReplay, setConfirmReplay] = useState(false)
const action = useEsbAction(() => setConfirmReplay(false))
const run = (processName: string, values: EsbActionValues = {}) =>
action.mutate({ processName, values: { triggerName: trigger.name, ...values } })
const paused = trigger.state === 'PAUSED'
const id = trigger.name
return (
<div className="flex flex-wrap gap-1" role="group" aria-label={`Actions for ${trigger.processLabel}`}>
<div
className="flex flex-wrap gap-1"
role="group"
aria-label={`Actions for ${trigger.processLabel}`}
>
{paused ? (
<ActionButton
label="Resume"
@@ -182,9 +187,25 @@ function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
ariaLabel={`Replay dead letters for ${trigger.processLabel}`}
qqqId={`button-esb-replay-dead-letters-${id}`}
disabled={action.isPending}
onClick={() => run('esbReplayDeadLetters', { all: true })}
onClick={() => setConfirmReplay(true)}
/>
)}
<EsbConfirmDialog
open={confirmReplay}
title="Replay dead letters"
confirmLabel="Replay dead letters"
destructive={false}
pending={action.isPending}
onCancel={() => setConfirmReplay(false)}
onConfirm={() => run('esbReplayDeadLetters', { all: true })}
>
Replay{' '}
{trigger.deadLetter.messageCount === null
? 'all dead letters (count unknown)'
: `all ${messageCountText(trigger.deadLetter.messageCount)}`}{' '}
from queue {trigger.deadLetter.brokerName} through {trigger.processLabel}? Successful
messages will be removed.
</EsbConfirmDialog>
</div>
)
}
@@ -196,14 +217,17 @@ function TriggerButtons({ trigger }: { trigger: EsbTrigger }) {
* @param props - Component props.
* @param props.queue - The broker queue.
* @param props.permissions - The current user's ESB permissions.
* @param props.onDone - Called after success to clear any browsed selection.
* @returns The buttons, or `null` when none is available.
*/
export function EsbQueueActions({
queue,
permissions,
onDone,
}: {
queue: EsbQueueRef
permissions: EsbPermissions
onDone?: () => void
}) {
const canPause = permissions.canOperate && queue.capabilities.pauseQueue && queue.paused !== null
const canPurge = permissions.canDelete && queue.capabilities.purge
@@ -215,6 +239,7 @@ export function EsbQueueActions({
canPause={canPause}
canPurge={canPurge}
canDeleteOlder={canDeleteOlder}
onDone={onDone}
/>
)
}
@@ -227,6 +252,7 @@ export function EsbQueueActions({
* @param props.canPause - Whether to offer pause or resume.
* @param props.canPurge - Whether to offer purge.
* @param props.canDeleteOlder - Whether to offer deleting messages older than a time.
* @param props.onDone - Called after an action succeeds.
* @returns A group of buttons.
*/
function QueueButtons({
@@ -234,11 +260,13 @@ function QueueButtons({
canPause,
canPurge,
canDeleteOlder,
onDone,
}: {
queue: EsbQueueRef
canPause: boolean
canPurge: boolean
canDeleteOlder: boolean
onDone?: () => void
}) {
const [dialog, setDialog] = useState<'purge' | 'deleteOlder' | null>(null)
const [olderThan, setOlderThan] = useState('')
@@ -247,7 +275,10 @@ function QueueButtons({
setDialog(null)
setOlderThan('')
}
const action = useEsbAction(close)
const action = useEsbAction(() => {
close()
onDone?.()
})
const target = { providerName: queue.providerName, brokerQueueName: queue.brokerQueueName }
const name = queue.brokerQueueName
const inQueue =
@@ -613,7 +644,12 @@ export function EsbConfirmDialog({
onCancel: () => void
}) {
return (
<AlertDialogPrimitive.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
<AlertDialogPrimitive.Root
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) onCancel()
}}
>
<AlertDialogPrimitive.Portal>
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<AlertDialogPrimitive.Content
+93 -5
View File
@@ -17,7 +17,7 @@
// Tests for browsing ESB messages and acting on selected ones
import React from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
@@ -72,7 +72,7 @@ describe('EsbMessageList', () => {
})
})
it('lists a queue\'s messages with their event type and subject', async () => {
it("lists a queue's messages with their event type and subject", async () => {
renderWithClient(
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
)
@@ -80,12 +80,24 @@ describe('EsbMessageList', () => {
const rows = within(table).getAllByRole('row').slice(1)
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveTextContent('ID:msg-1')
expect(rows[0]).toHaveTextContent('qqq.table.order.INSERT')
expect(rows[0]).toHaveTextContent('qqq.table.order.inserted')
expect(rows[0]).toHaveTextContent('1001')
expect(rows[1]).toHaveTextContent('ID:msg-2')
})
it('shows a dead letter\'s error and raw body when it is not a CloudEvent', async () => {
it('browses CloudEvents with the backend event type and source in both parsed and raw bodies', async () => {
renderWithClient(
<EsbMessageList source={QUEUE_SOURCE} queue={fulfillmentQueue()} permissions={NONE} />
)
const table = await screen.findByRole('table', { name: 'Messages' })
expect(table).toHaveTextContent('qqq.table.order.inserted')
for (const message of orderFulfillmentMessages.messages) {
expect(message.event?.source).toBe('qqq://demo/table/order')
expect(JSON.parse(message.rawBody)).toEqual(message.event)
}
})
it("shows a dead letter's error and raw body when it is not a CloudEvent", async () => {
renderWithClient(
<EsbMessageList
source={DEAD_LETTER_SOURCE}
@@ -166,7 +178,11 @@ describe('EsbMessageList', () => {
const dialog = await screen.findByRole('alertdialog')
const confirm = within(dialog).getByRole('button', { name: 'Move messages' })
expect(confirm).toBeDisabled()
await user.type(within(dialog).getByLabelText('Target queue'), 'orderFulfillment.hold')
await user.type(within(dialog).getByLabelText('Target queue'), ' ')
expect(confirm).toBeDisabled()
expect(processInit).not.toHaveBeenCalled()
await user.clear(within(dialog).getByLabelText('Target queue'))
await user.type(within(dialog).getByLabelText('Target queue'), ' orderFulfillment.hold ')
await user.click(confirm)
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith('esbMoveMessages', {
@@ -199,6 +215,78 @@ describe('EsbMessageList', () => {
)
})
it.each(['purge', 'deleteOlder'])('clears the selection after %s succeeds', async (operation) => {
const user = userEvent.setup()
renderWithClient(
<EsbMessageList
source={DEAD_LETTER_SOURCE}
queue={deadLetterQueue(fulfillTrigger)}
permissions={ALL}
/>
)
await user.click(await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' }))
await user.click(
screen.getByRole('button', {
name:
operation === 'purge'
? 'Purge orderFulfillment.dlq'
: 'Delete older messages in orderFulfillment.dlq',
})
)
const dialog = await screen.findByRole('alertdialog')
if (operation === 'deleteOlder') {
fireEvent.change(within(dialog).getByLabelText('Older than'), {
target: { value: '2026-09-25T10:00' },
})
}
await user.click(
within(dialog).getByRole('button', {
name: operation === 'purge' ? 'Purge queue' : 'Delete messages',
})
)
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: 'Select ID:dlq-1' })).not.toBeChecked()
)
expect(screen.getByRole('button', { name: 'Replay selected' })).toBeDisabled()
})
it('offers no selection when move and delete are unsupported even with permissions', async () => {
renderWithClient(
<EsbMessageList
source={QUEUE_SOURCE}
queue={{
...fulfillmentQueue(),
capabilities: { ...fulfillmentQueue().capabilities, move: false, deleteSelected: false },
}}
permissions={ALL}
/>
)
await screen.findByRole('table', { name: 'Messages' })
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /selected/ })).not.toBeInTheDocument()
})
it('retains selection and confirmation after a failed purge', async () => {
vi.mocked(processInit).mockResolvedValue({ type: 'ERROR', error: 'Purge refused.' })
const user = userEvent.setup()
renderWithClient(
<EsbMessageList
source={DEAD_LETTER_SOURCE}
queue={deadLetterQueue(fulfillTrigger)}
permissions={ALL}
/>
)
const checkbox = await screen.findByRole('checkbox', { name: 'Select ID:dlq-1' })
await user.click(checkbox)
await user.click(screen.getByRole('button', { name: 'Purge orderFulfillment.dlq' }))
const dialog = await screen.findByRole('alertdialog')
await user.click(within(dialog).getByRole('button', { name: 'Purge queue' }))
await waitFor(() => expect(processInit).toHaveBeenCalled())
expect(dialog).toBeInTheDocument()
expect(checkbox).toBeChecked()
})
it('opens the list in a dialog from the browse button', async () => {
const user = userEvent.setup()
renderWithClient(
+19 -6
View File
@@ -41,6 +41,7 @@ import {
/** What to browse: the queue behind a destination, or a trigger's dead letters. */
export type EsbMessageSource =
| { kind: 'destination'; name: string }
| { kind: 'subscription'; name: string; triggerName: string }
| { kind: 'deadLetters'; triggerName: string }
/** Props for {@link EsbMessageList}. */
@@ -70,11 +71,19 @@ export function EsbMessageList({ source, queue, permissions }: EsbMessageListPro
const selectable = available.replay || available.move || available.delete
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.esbMessages(source.kind, name, offset),
queryKey:
source.kind === 'subscription'
? [...queryKeys.esbMessages('destination', source.name, offset), source.triggerName]
: queryKeys.esbMessages(source.kind, name, offset),
queryFn: () =>
source.kind === 'destination'
? getEsbMessages(source.name, offset)
: getEsbDeadLetters(source.triggerName, offset),
source.kind === 'deadLetters'
? getEsbDeadLetters(source.triggerName, offset)
: getEsbMessages(
source.name,
offset,
ESB_PAGE_SIZE,
source.kind === 'subscription' ? source.triggerName : undefined
),
meta: HANDLES_OWN_ERRORS,
})
@@ -84,12 +93,16 @@ export function EsbMessageList({ source, queue, permissions }: EsbMessageListPro
}
const toggle = (messageId: string) =>
setSelected((current) =>
current.includes(messageId) ? current.filter((id) => id !== messageId) : [...current, messageId]
current.includes(messageId)
? current.filter((id) => id !== messageId)
: [...current, messageId]
)
return (
<div className="space-y-3" data-qqq-id={`esb-message-list-${name}`}>
{source.kind === 'deadLetters' && <EsbQueueActions queue={queue} permissions={permissions} />}
{source.kind !== 'destination' && (
<EsbQueueActions queue={queue} permissions={permissions} onDone={() => setSelected([])} />
)}
{selectable && (
<EsbSelectionActions
queue={queue}
+217 -10
View File
@@ -17,12 +17,18 @@
// Tests for the table Developer-view ESB section
import React from 'react'
import { act, render, screen, within } from '@testing-library/react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { axe } from 'jest-axe'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { processInit } from '@/lib/api/processes'
import { fulfillOrderEsb, orderEsb } from '@/mocks/fixtures/esb'
vi.mock('@/lib/api/processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
import { fulfillOrderEsb, orderEsb, orderFulfillmentMessages } from '@/mocks/fixtures/esb'
import { EsbSection } from './EsbSection'
/**
@@ -47,6 +53,201 @@ function bodyRows(name: string): HTMLElement[] {
}
describe('EsbSection', () => {
beforeEach(() => {
vi.mocked(processInit).mockReset()
vi.mocked(processInit).mockResolvedValue({
type: 'COMPLETE',
processUUID: 'queue-run',
values: { message: 'Done.' },
})
})
it.each([
['subscription', false],
['subscription', true],
['subscription', null],
['subscription', undefined],
['deadLetter', false],
['deadLetter', true],
['deadLetter', null],
['deadLetter', undefined],
] as const)('uses %s paused=%s for broker queue controls', async (kind, paused) => {
const user = userEvent.setup()
const original = orderEsb.subscribers[kind === 'subscription' ? 1 : 0]
const brokerName = 'serialized.custom.queue'
const brokerQueue = { brokerName, messageCount: 9, ...(paused === undefined ? {} : { paused }) }
const trigger = { ...original, [kind]: brokerQueue }
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<EsbSection
data={{
...orderEsb,
publications: [],
subscribers: [trigger],
permissions: { canOperate: true, canDelete: true },
}}
/>
</QueryClientProvider>
)
if (kind === 'deadLetter') {
await user.click(
screen.getByRole('button', { name: `Browse dead letters for ${trigger.processLabel}` })
)
await screen.findByRole('dialog')
}
if (paused == null) {
expect(
screen.queryByRole('button', { name: `Pause queue ${brokerName}` })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: `Resume queue ${brokerName}` })
).not.toBeInTheDocument()
expect(processInit).not.toHaveBeenCalled()
} else {
const action = paused ? 'Resume' : 'Pause'
expect(
screen.queryByRole('button', { name: `${paused ? 'Pause' : 'Resume'} queue ${brokerName}` })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: `${action} queue ${brokerName}` }))
await waitFor(() =>
expect(processInit).toHaveBeenCalledWith(paused ? 'esbResumeQueue' : 'esbPauseQueue', {
values: { providerName: 'artemis', brokerQueueName: brokerName },
})
)
}
})
it.each([
['subscription', 'permission'],
['subscription', 'capability'],
['deadLetter', 'permission'],
['deadLetter', 'capability'],
] as const)(
'hides %s pause/resume without %s even when state is known',
async (kind, missing) => {
const user = userEvent.setup()
const original = orderEsb.subscribers[kind === 'subscription' ? 1 : 0]
const brokerName = 'restricted.queue'
const trigger = {
...original,
[kind]: { brokerName, messageCount: 9, paused: false },
destination: {
...original.destination,
capabilities: {
...original.destination.capabilities,
pauseQueue: missing !== 'capability',
},
},
}
render(
<QueryClientProvider client={new QueryClient()}>
<EsbSection
data={{
...orderEsb,
publications: [],
subscribers: [trigger],
permissions: { canOperate: missing !== 'permission', canDelete: true },
}}
/>
</QueryClientProvider>
)
if (kind === 'deadLetter') {
await user.click(
screen.getByRole('button', { name: `Browse dead letters for ${trigger.processLabel}` })
)
await screen.findByRole('dialog')
}
expect(
screen.queryByRole('button', { name: `Pause queue ${brokerName}` })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: `Resume queue ${brokerName}` })
).not.toBeInTheDocument()
expect(processInit).not.toHaveBeenCalled()
}
)
it('uses the serialized subscription name for controls and trigger for browsing', async () => {
let requestedTrigger: string | null = null
server.use(
http.get('/qqq/v1/esb/messages/orderEvents', ({ request }) => {
requestedTrigger = new URL(request.url).searchParams.get('trigger')
return HttpResponse.json(orderFulfillmentMessages)
})
)
const user = userEvent.setup()
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<EsbSection
data={{
...orderEsb,
subscribers: [
{
...orderEsb.subscribers[1],
subscription: { brokerName: 'custom.subscription.queue', messageCount: 9 },
},
],
permissions: { canOperate: true, canDelete: true },
}}
/>
</QueryClientProvider>
)
expect(screen.getByText('custom.subscription.queue')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Purge custom.subscription.queue' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Pause queue custom.subscription.queue' })
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Browse subscription for Cancel Order' }))
const dialog = await screen.findByRole('dialog', {
name: 'Messages in custom.subscription.queue',
})
await within(dialog).findByRole('table', { name: 'Messages' })
await waitFor(() => expect(requestedTrigger).toBe('cancelOrder.orderEvents'))
expect(
within(dialog).queryByRole('button', { name: 'Replay selected' })
).not.toBeInTheDocument()
})
it('hides browsing and broker controls when the trigger destination has no capabilities', () => {
const trigger = orderEsb.subscribers[1]
render(
<QueryClientProvider client={new QueryClient()}>
<EsbSection
data={{
...orderEsb,
publications: [],
subscribers: [
{
...trigger,
destination: {
...trigger.destination,
capabilities: {
browse: false,
pauseQueue: false,
purge: false,
deleteSelected: false,
deleteOlderThan: false,
move: false,
},
},
},
],
permissions: { canOperate: true, canDelete: true },
}}
/>
</QueryClientProvider>
)
expect(screen.queryByRole('button', { name: /Browse/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Purge/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
})
it('renders nothing when the table has no ESB section', () => {
const { container } = render(<EsbSection data={null} />)
expect(container).toBeEmptyDOMElement()
@@ -88,7 +289,7 @@ describe('EsbSection', () => {
const fulfillCells = within(fulfill).getAllByRole('cell')
expect(within(fulfillCells[0]).getByRole('link', { name: 'Fulfill Order' })).toHaveAttribute(
'href',
'/app/fulfillOrder'
'/app/fulfillOrder/dev'
)
expect(fulfillCells[0]).toHaveTextContent('Single, concurrency 2, 3 attempts')
expect(fulfillCells[1]).toHaveTextContent('orderFulfillment')
@@ -107,7 +308,7 @@ describe('EsbSection', () => {
const cancelCells = within(cancel).getAllByRole('cell')
expect(within(cancelCells[0]).getByRole('link', { name: 'Cancel Order' })).toHaveAttribute(
'href',
'/app/cancelOrder'
'/app/cancelOrder/dev'
)
expect(cancelCells[1]).toHaveTextContent('orderEvents')
expect(cancelCells[2]).toHaveTextContent('Paused')
@@ -124,11 +325,13 @@ describe('EsbSection', () => {
).toBeInTheDocument()
})
it('offers browsing a queue and a trigger\'s dead letters to anyone who sees them', () => {
it("offers browsing a queue and a trigger's dead letters to anyone who sees them", () => {
render(<EsbSection data={orderEsb} />)
const [topic, queue] = bodyRows('Publications')
expect(within(topic).queryByRole('button')).not.toBeInTheDocument()
expect(within(queue).getByRole('button', { name: 'Browse orderFulfillment' })).toBeInTheDocument()
expect(
within(queue).getByRole('button', { name: 'Browse orderFulfillment' })
).toBeInTheDocument()
expect(within(queue).queryByRole('button', { name: /^Purge/ })).not.toBeInTheDocument()
const [fulfill] = bodyRows('Subscribers')
expect(
@@ -145,7 +348,9 @@ describe('EsbSection', () => {
)
const [topic, queue] = bodyRows('Publications')
expect(within(topic).queryByRole('button')).not.toBeInTheDocument()
expect(within(queue).getByRole('button', { name: 'Purge orderFulfillment' })).toBeInTheDocument()
expect(
within(queue).getByRole('button', { name: 'Purge orderFulfillment' })
).toBeInTheDocument()
expect(
within(queue).getByRole('button', { name: 'Pause queue orderFulfillment' })
).toBeInTheDocument()
@@ -154,7 +359,7 @@ describe('EsbSection', () => {
expect(within(cancel).getByRole('button', { name: 'Resume Cancel Order' })).toBeInTheDocument()
})
it('renders a process\'s publications and the triggers that start it', () => {
it("renders a process's publications and the triggers that start it", () => {
render(<EsbSection data={fulfillOrderEsb} />)
expect(bodyRows('Publications')).toHaveLength(1)
expect(bodyRows('Triggers')).toHaveLength(1)
@@ -163,7 +368,9 @@ describe('EsbSection', () => {
it('says so when a process has no publications or triggers', () => {
render(<EsbSection data={{ ...fulfillOrderEsb, publications: [], triggers: [] }} />)
expect(screen.getByText('This process does not publish to any destination.')).toBeInTheDocument()
expect(
screen.getByText('This process does not publish to any destination.')
).toBeInTheDocument()
expect(screen.getByText('No destinations trigger this process.')).toBeInTheDocument()
})
+45 -11
View File
@@ -25,7 +25,7 @@ import Link from 'next/link'
import type { EsbPermissions, EsbTrigger, EsbTriggerMode, EsbTriggerState } from '@/types'
import { CHIP_COLOR_CLASSES, type ChipColor } from '@/lib/utils/adornment-utils'
import { cn } from '@/lib/utils/cn'
import { EsbTriggerActions, deadLetterQueue } from './EsbActions'
import { EsbTriggerActions, EsbQueueActions, deadLetterQueue, type EsbQueueRef } from './EsbActions'
import { EsbCounters } from './EsbCounters'
import { EsbBrowseButton } from './EsbMessageList'
@@ -59,6 +59,15 @@ export function EsbTriggerRow({
trigger: EsbTrigger
permissions: EsbPermissions
}) {
const subscription: EsbQueueRef | null = trigger.subscription
? {
providerName: trigger.destination.provider,
brokerQueueName: trigger.subscription.brokerName,
capabilities: trigger.destination.capabilities,
messageCount: trigger.subscription.messageCount,
paused: trigger.subscription.paused ?? null,
}
: null
const state = STATES[trigger.state] ?? { label: trigger.state, color: 'default' }
const attempts = `${trigger.maxAttempts} ${trigger.maxAttempts === 1 ? 'attempt' : 'attempts'}`
@@ -69,7 +78,7 @@ export function EsbTriggerRow({
>
<td className="px-2 py-2">
<Link
href={`/app/${encodeURIComponent(trigger.processName)}`}
href={`/app/${encodeURIComponent(trigger.processName)}/dev`}
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
>
{trigger.processLabel}
@@ -78,7 +87,11 @@ export function EsbTriggerRow({
{MODES[trigger.mode] ?? trigger.mode}, concurrency {trigger.concurrency}, {attempts}
</p>
</td>
<td className="px-2 py-2 font-mono text-xs text-foreground">{trigger.destination.name}</td>
<td className="px-2 py-2 font-mono text-xs text-foreground">
{trigger.destination.name}
{subscription && <p className="text-muted-foreground">{subscription.brokerQueueName}</p>}
{subscription && <p>Subscription depth: {subscription.messageCount ?? '—'}</p>}
</td>
<td className="px-2 py-2">
<span
className={cn(
@@ -97,14 +110,35 @@ export function EsbTriggerRow({
</td>
<td className="space-y-1 px-2 py-2">
<EsbTriggerActions trigger={trigger} permissions={permissions} />
<EsbBrowseButton
label="Browse dead letters"
ariaLabel={`Browse dead letters for ${trigger.processLabel}`}
title={`Dead letters for ${trigger.processLabel}`}
source={{ kind: 'deadLetters', triggerName: trigger.name }}
queue={deadLetterQueue(trigger)}
permissions={permissions}
/>
{subscription && (
<>
{subscription.capabilities.browse && (
<EsbBrowseButton
label="Browse subscription"
ariaLabel={`Browse subscription for ${trigger.processLabel}`}
title={`Messages in ${subscription.brokerQueueName}`}
source={{
kind: 'subscription',
name: trigger.destination.name,
triggerName: trigger.name,
}}
queue={subscription}
permissions={permissions}
/>
)}
<EsbQueueActions queue={subscription} permissions={permissions} />
</>
)}
{trigger.destination.capabilities.browse && (
<EsbBrowseButton
label="Browse dead letters"
ariaLabel={`Browse dead letters for ${trigger.processLabel}`}
title={`Dead letters for ${trigger.processLabel}`}
source={{ kind: 'deadLetters', triggerName: trigger.name }}
queue={deadLetterQueue(trigger)}
permissions={permissions}
/>
)}
</td>
</tr>
)
@@ -0,0 +1,146 @@
/*
* 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.
*/
import React, { useState } from 'react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { act, render, screen, waitFor } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { SearchDialog } from './SearchDialog'
/** Uses real dialog state and search components; no searchable backend tables are needed for focus. */
function Harness() {
const [open, setOpen] = useState(false)
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }))
return (
<QueryClientProvider client={client}>
<button onClick={() => setOpen(true)}>Open search</button>
<SearchDialog open={open} onClose={() => setOpen(false)} navTargets={[]} />
<button>Outside action</button>
</QueryClientProvider>
)
}
describe('SearchDialog keyboard access', () => {
beforeEach(() => localStorage.clear())
it('keeps both Tab directions in an empty dialog', async () => {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: 'Open search' }))
const input = screen.getByRole('combobox')
await waitFor(() => expect(input).toHaveFocus())
await user.tab()
expect(input).toHaveFocus()
await user.tab({ shift: true })
expect(input).toHaveFocus()
})
it('wraps between the first and last controls when the query adds buttons', async () => {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: 'Open search' }))
const input = screen.getByRole('combobox')
await user.type(input, 'no matches')
await user.tab({ shift: true })
expect(screen.getByRole('button', { name: 'Show all matches' })).toHaveFocus()
await user.tab()
expect(input).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'Clear search' })).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'Show all matches' })).toHaveFocus()
})
it('dismisses with Escape from a button and returns focus to the opener', async () => {
const user = userEvent.setup()
render(<Harness />)
const trigger = screen.getByRole('button', { name: 'Open search' })
await user.click(trigger)
await user.type(screen.getByRole('combobox'), 'no matches')
await user.tab()
expect(screen.getByRole('button', { name: 'Clear search' })).toHaveFocus()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: 'Search' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
it('returns to the input when clearing removes the focused button', async () => {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: 'Open search' }))
const input = screen.getByRole('combobox')
await user.type(input, 'no matches')
await user.tab()
await user.keyboard('{Enter}')
expect(input).toHaveValue('')
expect(input).toHaveFocus()
await user.tab()
expect(input).toHaveFocus()
await user.tab({ shift: true })
expect(input).toHaveFocus()
})
it('cancels delayed autofocus if the dialog unmounts before its frame', async () => {
const request = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(42)
const cancel = vi.spyOn(window, 'cancelAnimationFrame')
try {
const user = userEvent.setup()
const { unmount } = render(<Harness />)
await user.click(screen.getByRole('button', { name: 'Open search' }))
expect(request).toHaveBeenCalledOnce()
unmount()
expect(cancel).toHaveBeenCalledWith(42)
} finally {
request.mockRestore()
cancel.mockRestore()
}
})
it('does not steal focus after the user reaches another dialog control before autofocus', async () => {
let autofocus: FrameRequestCallback | undefined
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
autofocus = callback
return 42
})
try {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: 'Open search' }))
await user.type(screen.getByRole('combobox'), 'no matches')
await user.tab()
const clear = screen.getByRole('button', { name: 'Clear search' })
expect(clear).toHaveFocus()
act(() => autofocus?.(0))
expect(clear).toHaveFocus()
} finally {
request.mockRestore()
}
})
it('returns focus after Escape from the input and can reopen', async () => {
const user = userEvent.setup()
render(<Harness />)
const trigger = screen.getByRole('button', { name: 'Open search' })
await user.click(trigger)
await waitFor(() => expect(screen.getByRole('combobox')).toHaveFocus())
await user.keyboard('{Escape}')
expect(trigger).toHaveFocus()
await user.keyboard('{Enter}')
await waitFor(() => expect(screen.getByRole('combobox')).toHaveFocus())
})
})
+39 -8
View File
@@ -68,6 +68,7 @@ interface SearchDialogProps {
export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEARCH_TABLES }: SearchDialogProps) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const [searchTerm, setSearchTerm] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -76,10 +77,17 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
// Load recent records when the dialog opens; reset state
useEffect(() => {
if (open) {
const returnTo = document.activeElement
setRecentRecords(getRecentRecords().slice(0, 10))
setSearchTerm('')
setSelectedIndex(-1)
requestAnimationFrame(() => inputRef.current?.focus())
const frame = requestAnimationFrame(() => {
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
})
return () => {
cancelAnimationFrame(frame)
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
}
}
}, [open])
@@ -130,15 +138,34 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
}
break
case 'Escape':
e.preventDefault()
onClose()
break
}
},
[selectedIndex, items, searchTerm, handleNavigate, onClose]
[selectedIndex, items, searchTerm, handleNavigate]
)
/**
* Handles dismissal from every control and keeps keyboard focus inside the modal.
*
* @param event - The key pressed in the dialog.
*/
const handleDialogKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab' || !dialogRef.current) return
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('input, button, [href], [tabindex]'))
.filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0)
if (focusable.length === 0) return
// Handle each step: WebKit's native Tab order can omit buttons and leave the modal.
const current = focusable.indexOf(document.activeElement as HTMLElement)
const next = current < 0 ? (event.shiftKey ? focusable.length - 1 : 0)
: (current + (event.shiftKey ? -1 : 1) + focusable.length) % focusable.length
event.preventDefault()
focusable[next].focus()
}
if (!open) return null
const hasTerm = searchTerm.trim().length > 0
@@ -155,11 +182,13 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
/>
<div
ref={dialogRef}
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg overflow-hidden"
data-qqq-id="search-dialog"
role="dialog"
aria-label="Search"
aria-modal="true"
onKeyDown={handleDialogKeyDown}
>
{/* Search input */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
@@ -172,7 +201,6 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
onKeyDown={handleKeyDown}
placeholder={recordSearch.available ? 'Search pages and records...' : 'Jump to a page or recent record...'}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autoFocus
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
aria-expanded={true}
aria-controls="search-dialog-results"
@@ -184,7 +212,10 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
onClick={() => {
inputRef.current?.focus()
setSearchTerm('')
}}
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
@@ -18,7 +18,7 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { useForm } from 'react-hook-form'
@@ -88,6 +88,25 @@ beforeEach(() => {
mockFetchStandalone.mockResolvedValue(OPTIONS)
})
describe('PossibleValueSelect — pointer selection', () => {
it('keeps search focus while pressing an option until its click selects the value', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox', { name: 'Person' }))
const search = screen.getByRole('textbox', { name: 'Search Person options' })
await waitFor(() => expect(search).toHaveFocus())
const option = await screen.findByRole('option', { name: 'Bob' })
await user.pointer({ target: option, keys: '[MouseLeft>]' })
expect(search).toHaveFocus()
await user.pointer({ target: option, keys: '[/MouseLeft]' })
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('Bob')
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveFocus()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
})
describe('PossibleValueSelect — initial value label', () => {
it('looks up and shows the label of a value it did not select (e.g. after process Back)', async () => {
mockFetchProcess.mockResolvedValue([{ id: 2, label: 'Bob' }])
@@ -397,6 +416,38 @@ describe('PossibleValueSelect — option selection', () => {
})
describe('PossibleValueSelect — search', () => {
it.each(['loaded', 'in flight'])('offers only the current search results when the prior request is %s', async (prior) => {
const user = userEvent.setup()
let finishPrior!: (values: QPossibleValue[]) => void
let finishSearch!: (values: QPossibleValue[]) => void
mockFetchTable.mockReset()
.mockImplementationOnce(() => new Promise((resolve) => { finishPrior = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { finishSearch = resolve }))
render(<Wrapper />)
await user.click(screen.getByRole('combobox', { name: 'Person' }))
const search = screen.getByRole('textbox', { name: 'Search Person options' })
if (prior === 'loaded') await act(async () => finishPrior(OPTIONS))
vi.useFakeTimers()
try {
fireEvent.change(search, { target: { value: 'Bo' } })
if (prior === 'in flight') await act(async () => finishPrior(OPTIONS))
// Old options must not be clickable while a new search is debouncing:
// starting its request would remove the option between pointer down/up.
expect(screen.queryAllByRole('option')).toEqual([])
fireEvent.keyDown(search, { key: 'Enter' })
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('-- Select Person --')
await act(async () => vi.advanceTimersByTimeAsync(300))
expect(mockFetchTable).toHaveBeenLastCalledWith('person', 'person', { searchTerm: 'Bo', formValues: { testField: null } })
await act(async () => finishSearch([OPTIONS[1]]))
fireEvent.click(screen.getByRole('option', { name: 'Bob' }))
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('Bob')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
} finally {
vi.useRealTimers()
}
})
it('renders a search input when the dropdown is open', async () => {
const user = userEvent.setup()
render(<Wrapper />)
@@ -204,6 +204,10 @@ export function PossibleValueSelect({
const debouncedFetch = useCallback(
(term: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
// Old results must not remain selectable until the debounce removes them mid-click.
requestSequence.current += 1
setOptions([])
setIsLoading(true)
debounceRef.current = setTimeout(() => fetchOptions(term), 300)
},
[fetchOptions]
@@ -480,6 +484,9 @@ export function PossibleValueSelect({
id={`${id}-option-${index}`}
role="option"
aria-selected={isSelected}
// Keep search focus until click: WebKit can scroll on blur
// between mousedown and mouseup, moving the option away.
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelect(option)}
className={cn(
'flex cursor-pointer items-center justify-between px-3 py-2 text-sm',
+5 -1
View File
@@ -110,7 +110,11 @@ export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, on
<div className="ml-2 md:ml-3 flex flex-shrink-0 items-center gap-0 md:gap-3">
{/* Mobile search icon — only visible below md breakpoint */}
<button
onClick={onSearchOpen}
onClick={(event) => {
// WebKit can activate a button without focusing it; preserve this dialog's opener.
event.currentTarget.focus({ preventScroll: true })
onSearchOpen?.()
}}
className="flex md:hidden items-center justify-center rounded-lg p-2 hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Open search"
data-qqq-id="button-mobile-search"
+1 -1
View File
@@ -213,7 +213,7 @@ function ProcessInputField({ block, widgetName }: HostInputFieldProps) {
<input
id={inputId}
type={isBoolean ? 'checkbox' : inputType(field.type)}
step={field.type === 'DECIMAL' ? 'any' : field.type === 'DATE_TIME' ? 1 : undefined}
step={field.type === 'DECIMAL' ? 'any' : field.type === 'DATE_TIME' || field.type === 'TIME' ? 1 : undefined}
placeholder={typeof values.placeholder === 'string' ? values.placeholder : undefined}
autoFocus={values.autoFocus === true}
data-qqq-autofocus={values.autoFocus === true || undefined}
@@ -118,7 +118,7 @@ describe('ProcessStepScreen', () => {
formFields: [field('category', 'Category'), field('color', 'Color', { possibleValueSourceName: 'colors' })],
}
renderScreen({ step: pick, values: { category: 'warm', color: 'green', records: [{ id: 1 }] } })
expect(await screen.findByText('Green')).toBeInTheDocument()
expect(await screen.findByText('Green', { selector: '[data-qqq-id="process-view-value-color"]' })).toBeInTheDocument()
expect(fetchProcessPossibleValues).toHaveBeenCalledWith('lab', 'color', { ids: 'green', formValues: { category: 'warm', color: 'green' } })
vi.mocked(fetchProcessPossibleValues).mockClear()
await user.click(screen.getByRole('combobox', { name: /Color/ }))
@@ -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)
@@ -18,7 +18,7 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -202,6 +202,24 @@ describe('process screens: blocks (#725)', () => {
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ stamp: instant }, undefined))
})
it.each(['block', 'form'])('accepts and submits seconds through a %s time editor', async (host) => {
const user = userEvent.setup()
const clock = field('clock', 'Clock', { type: 'TIME' })
const step: QFrontendStepMetaData = host === 'block'
? { name: 'edit', label: 'Edit', components: [{ type: 'WIDGET', values: { isAdHocWidget: true, blocks: [
{ blockTypeName: 'INPUT_FIELD', values: { fieldMetaData: clock } },
] } }] }
: { name: 'edit', label: 'Edit', components: [{ type: 'EDIT_FORM' }], formFields: [clock] }
const { onSubmit } = renderStep(step, { clock: '09:30:07' })
const input = screen.getByLabelText('Clock') as HTMLInputElement
expect(input.value).toBe('09:30:07')
expect(input.validity.stepMismatch).toBe(false)
fireEvent.change(input, { target: { value: '14:25:43' } })
expect(input.checkValidity()).toBe(true)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ clock: '14:25:43' }, undefined))
})
describe.each(['block', 'form'])('password behavior in a %s editor', (host) => {
it.each([
['TO_UPPER_CASE', 'ABCD', 'ß', 'ASSCD', 'ASSXCD'],
@@ -239,6 +257,27 @@ describe('process screens: blocks (#725)', () => {
})
})
it.each(['block', 'form'])('bounded LONG in a %s editor reports invalid text and submits exact corrected digits', async (host) => {
const user = userEvent.setup()
const quantity = field('quantity', 'Quantity', { type: 'LONG', minValue: '9007199254740993', maxValue: '9007199254740995' })
const step: QFrontendStepMetaData = host === 'block'
? { name: 'edit', label: 'Edit', components: [{ type: 'WIDGET', values: { isAdHocWidget: true, blocks: [
{ blockTypeName: 'INPUT_FIELD', values: { fieldMetaData: quantity } },
] } }] }
: { name: 'edit', label: 'Edit', components: [{ type: 'EDIT_FORM' }], formFields: [quantity] }
const { onSubmit } = renderStep(step, { quantity: '1.5' })
await user.click(screen.getByRole('button', { name: 'Submit' }))
expect(await screen.findByText('Quantity must be a whole number')).toBeVisible()
expect(onSubmit).not.toHaveBeenCalled()
fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '9007199254740992' } })
await user.click(screen.getByRole('button', { name: 'Submit' }))
expect(await screen.findByText('Quantity must be at least 9007199254740993')).toBeVisible()
expect(onSubmit).not.toHaveBeenCalled()
fireEvent.change(screen.getByLabelText('Quantity'), { target: { value: '9007199254740993' } })
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ quantity: '9007199254740993' }, undefined))
})
it.each([
['TO_UPPER_CASE', 'ABXD', 'Enter'],
['TO_LOWER_CASE', 'abxd', 'Enter'],
+8 -1
View File
@@ -72,12 +72,19 @@ const itemClass = cn(
* @returns The menu.
*/
export function ColumnHeaderMenu({ column, sort, pinned, hasRows, onSort, onFilter, onHide, onPin, onCopyPageValues, onCopyFullQueryValues, onColumnStats }: ColumnHeaderMenuProps) {
const [open, setOpen] = React.useState(false)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const noRows = hasRows ? undefined : 'There are no rows to copy from'
const id = (action: string) => `column-menu-${action}`
return (
<DropdownMenuPrimitive.Root modal={false}>
<DropdownMenuPrimitive.Root modal={false} open={open} onOpenChange={(nextOpen) => {
// Focus survives scrolling; restore the reference before Radix measures its menu.
if (nextOpen) triggerRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
setOpen(nextOpen)
}}>
<DropdownMenuPrimitive.Trigger asChild>
<button
ref={triggerRef}
type="button"
onClick={(e) => e.stopPropagation()}
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-60 hover:text-foreground hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring group-hover:opacity-100 data-[state=open]:opacity-100 pointer-coarse:opacity-100"
+93 -6
View File
@@ -16,18 +16,19 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { QRecord, QTableMetaData } from '@/types'
import { DataGrid } from './DataGrid'
const cellRenders = vi.fn()
vi.mock('./DataCell', () => ({
DataCell: ({ value }: { value: unknown }) => {
vi.mock('./DataCell', async (importOriginal) => {
const actual = await importOriginal<typeof import('./DataCell')>()
return { DataCell: (props: React.ComponentProps<typeof actual.DataCell>) => {
cellRenders()
return <span>{String(value ?? '')}</span>
},
}))
return <actual.DataCell {...props} />
} }
})
const router = { push: vi.fn() }
vi.mock('next/navigation', () => ({ useRouter: () => router }))
@@ -137,3 +138,89 @@ describe('DataGrid compact headers (QRun-IO/qqq#728)', () => {
expect(screen.getByRole('button', { name: 'Column statistics for Name' })).toBeInTheDocument()
})
})
describe('DataGrid stable headers (QRun-IO/qqq#994)', () => {
const scrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
afterAll(() => {
if (scrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', scrollIntoView)
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
})
const menu = () => ({ onHide: vi.fn(), onPin: vi.fn(), onCopyPageValues: vi.fn(), onCopyFullQueryValues: vi.fn() })
it('retains a focused menu trigger across nonstructural preview updates', () => {
const { rerender } = render(grid({ columnMenu: menu() }))
const trigger = screen.getByRole('button', { name: 'Name column menu' })
trigger.focus()
rerender(grid({ columnMenu: menu(), isFetching: true }))
expect(screen.getByRole('button', { name: 'Name column menu' })).toBe(trigger)
expect(trigger).toHaveFocus()
})
it('keeps the same header while updating metadata, sort and menu callbacks', () => {
const initialMenu = menu()
const updatedMenu = menu()
const onSortChange = vi.fn()
const { rerender } = render(grid({ columnMenu: initialMenu }))
const trigger = screen.getByRole('button', { name: 'Name column menu' })
const tableMetaData = { ...TABLE, fields: { ...TABLE.fields, name: { ...TABLE.fields.name, label: 'Current name' } } }
rerender(grid({ tableMetaData, columnMenu: updatedMenu, sortOrder: [{ fieldName: 'name', isAscending: true }], onSortChange }))
expect(screen.getByRole('button', { name: 'Current name column menu' })).toBe(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Sort by Current name' }))
expect(onSortChange).toHaveBeenCalledWith([{ fieldName: 'name', isAscending: false }])
fireEvent.keyDown(trigger, { key: 'Enter' })
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy page values' }))
expect(updatedMenu.onCopyPageValues).toHaveBeenCalledWith('name')
expect(initialMenu.onCopyPageValues).not.toHaveBeenCalled()
})
it('retains the selection header and updates its current selection handler', () => {
const onRowSelectionChange = vi.fn()
const { rerender } = render(grid())
const checkbox = screen.getByRole('checkbox', { name: 'Select all rows on this page' })
checkbox.focus()
rerender(grid({ rowSelection: { 1: true, 2: true, 3: true }, onRowSelectionChange }))
expect(screen.getByRole('checkbox', { name: 'Select all rows on this page' })).toBe(checkbox)
expect(checkbox).toHaveFocus()
expect(checkbox).toBeChecked()
fireEvent.click(checkbox)
expect(onRowSelectionChange).toHaveBeenCalledWith({})
})
})
describe('DataGrid stable cells (QRun-IO/qqq#995)', () => {
const menu = () => ({ onHide: vi.fn(), onPin: vi.fn(), onCopyPageValues: vi.fn(), onCopyFullQueryValues: vi.fn() })
it('retains the focused selection checkbox and uses current selection callbacks', () => {
const initialHandler = vi.fn()
const currentHandler = vi.fn()
const { rerender } = render(grid({ columnMenu: menu(), onRowSelectionChange: initialHandler }))
const checkbox = screen.getAllByRole('checkbox', { name: 'Select record' })[0]
checkbox.focus()
rerender(grid({ columnMenu: menu(), rowSelection: { 1: true }, onRowSelectionChange: currentHandler }))
expect(screen.getAllByRole('checkbox', { name: 'Select record' })[0]).toBe(checkbox)
expect(checkbox).toHaveFocus()
expect(checkbox).toBeChecked()
fireEvent.click(checkbox)
expect(currentHandler).toHaveBeenCalledWith({})
expect(initialHandler).not.toHaveBeenCalled()
})
it('retains revealed cell state and focus while using updated field values', () => {
const tableMetaData = { ...TABLE, fields: { ...TABLE.fields, name: { ...TABLE.fields.name, adornments: [{ type: 'REVEAL' }] } } } as QTableMetaData
const { rerender } = render(grid({ tableMetaData, columnMenu: menu() }))
const reveal = screen.getAllByRole('button', { name: 'Reveal name' })[0]
reveal.focus()
fireEvent.click(reveal)
expect(reveal).toHaveAccessibleName('Hide name')
expect(reveal).toHaveTextContent('Ada')
rerender(grid({ tableMetaData, columnMenu: menu(), isFetching: true }))
expect(screen.getAllByRole('button', { name: 'Hide name' })[0]).toBe(reveal)
expect(reveal).toHaveFocus()
rerender(grid({ tableMetaData, columnMenu: menu(), records: [record(1, 'Current value'), ...RECORDS.slice(1)] }))
expect(reveal).toHaveTextContent('Current value')
expect(reveal).toHaveAccessibleName('Hide name')
})
})
+10 -3
View File
@@ -26,7 +26,6 @@ import React, { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useSta
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
type Row,
type SortingState,
@@ -818,9 +817,13 @@ export function DataGrid({
style={{ width: `${header.getSize()}px`, ...(pinned?.style ?? {}) }}
aria-sort={ariaSortValue}
>
{/* These locally defined headers are pure render callbacks. Calling them
preserves their child identity when current props rebuild the columns. */}
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
: typeof header.column.columnDef.header === 'function'
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
{/* Column resize handle */}
{!isSelectCol && (() => {
@@ -998,7 +1001,11 @@ const GridRow = React.memo(function GridRow({ row, isSelected, rowClass, cellCla
onCellKeyDown(e)
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{/* Local pure render callbacks must not become new component types when
current grid props rebuild columns: retain focused and stateful children. */}
{typeof cell.column.columnDef.cell === 'function'
? cell.column.columnDef.cell(cell.getContext())
: cell.column.columnDef.cell}
</td>
)
})}
+5 -5
View File
@@ -414,7 +414,6 @@ function RecordViewContent({
}) {
const searchParams = useSearchParams()
const pathname = usePathname()
const router = useRouter()
const { preferences } = useUserPreferences()
// Persist tab and view mode in URL so back navigation restores state
@@ -441,15 +440,16 @@ function RecordViewContent({
* @param defaultValue - The "default" value; when matched the key is removed.
*/
const updateUrlParam = useCallback((key: string, value: string, defaultValue: string) => {
const params = new URLSearchParams(searchParams.toString())
const url = new URL(window.location.href)
const params = url.searchParams
if (value === defaultValue) {
params.delete(key)
} else {
params.set(key, value)
}
const qs = params.toString()
router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false })
}, [searchParams, pathname, router])
// Make URL state durable before another click, reload, or departure, without an RSC navigation.
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
}, [])
/**
* Switches the active tab by updating the `tab` URL parameter.
+9 -2
View File
@@ -121,7 +121,7 @@ export function TableApiDocs({ tableName, primaryColor }: TableApiDocsProps) {
return (
<section
className="overflow-hidden rounded-xl border border-border bg-card"
className="@container overflow-hidden rounded-xl border border-border bg-card"
aria-labelledby="table-dev-api-docs-heading"
data-qqq-id="table-dev-api-docs"
>
@@ -240,6 +240,13 @@ function RapiDocViewer({ specUrl, primaryColor }: { specUrl: string; primaryColo
'load-fonts': 'false',
'regular-font': "var(--font-inter, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif)",
'mono-font': 'Monaco, Menlo, Consolas, source-code-pro, monospace',
style: { display: 'block', height: '75vh', width: '100%' },
style: {
display: 'block', height: '75vh', width: '100%', minWidth: 0,
// RapiDoc's shadow theme cannot inherit dashboard utility text colors.
// Keep its accent and inverse button text paired when adapting dark mode.
...(isDarkMode
? { '--primary-color': 'var(--text-color-primary)', '--primary-color-invert': 'var(--color-background)' }
: { '--blue': 'var(--text-color-primary)', '--red': 'var(--qqq-error-dark)' }),
},
})
}
@@ -99,6 +99,42 @@ describe('script code completions', () => {
expect(editor).toHaveValue('api.qu')
})
it('keeps current suggestions when a delayed scroll event reports their existing position', async () => {
const user = userEvent.setup()
render(<Editor initial={'const result = api.query("person");'} />)
const editor = screen.getByRole('textbox', { name: 'Code' }) as HTMLTextAreaElement
await user.click(editor)
editor.scrollLeft = 4
fireEvent.scroll(editor)
editor.scrollLeft = 0
fireEvent.change(editor, { target: { value: 'api.bu' } })
editor.setSelectionRange(6, 6)
await user.keyboard('{Control>} {/Control}{ArrowDown}')
expect(screen.getAllByRole('option')).toHaveLength(3)
fireEvent.scroll(editor)
expect(screen.getAllByRole('option')).toHaveLength(3)
expect(screen.getByRole('option', { name: /api.bulkUpdate/ })).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{Tab}')
expect(editor).toHaveValue('api.bulkUpdate(')
expect(editor).toHaveFocus()
})
it.each(['scrollLeft', 'scrollTop'] as const)('dismisses suggestions when %s changes after they open', async (direction) => {
const user = userEvent.setup()
render(<Editor />)
const editor = screen.getByRole('textbox', { name: 'Code' }) as HTMLTextAreaElement
await user.type(editor, 'api.bu')
expect(screen.getAllByRole('option')).toHaveLength(3)
editor[direction] = 4
fireEvent.scroll(editor)
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(editor).toHaveValue('api.bu')
})
it.each([[0, 0], [0, 6]])('rejects a stale suggestion after selection changes to %s–%s', async (start, end) => {
const user = userEvent.setup()
render(<Editor />)
+7 -3
View File
@@ -174,7 +174,7 @@ export function CodeEditor({
}: CodeEditorProps) {
const movesFocus = useSyncExternalStore(subscribeTabMode, readTabMode, readServerTabMode)
const [released, setReleased] = useState(false)
const [completion, setCompletion] = useState<(CodeCompletionMatch & { selected: number; top: number; left: number; height: number; caret: number; source: string }) | null>(null)
const [completion, setCompletion] = useState<(CodeCompletionMatch & { selected: number; top: number; left: number; height: number; caret: number; source: string; scrollTop: number; scrollLeft: number }) | null>(null)
const composing = useRef(false)
const metricsRef = useRef<HTMLSpanElement>(null)
const listRef = useRef<HTMLDivElement>(null)
@@ -211,7 +211,7 @@ export function CodeEditor({
const top = caretTop + lineHeight + height <= textarea.clientHeight ? caretTop + lineHeight : Math.max(0, caretTop - height)
const column = before.at(-1)!.replace(/\t/g, ' ').length
const left = Math.min(Math.max(0, 12 + column * characterWidth - textarea.scrollLeft), Math.max(0, textarea.clientWidth - 320))
setCompletion({ ...match, selected: 0, top, left, height, caret: textarea.selectionStart, source: textarea.value })
setCompletion({ ...match, selected: 0, top, left, height, caret: textarea.selectionStart, source: textarea.value, scrollTop: textarea.scrollTop, scrollLeft: textarea.scrollLeft })
}
/**
@@ -382,7 +382,11 @@ export function CodeEditor({
value={value}
onChange={(event) => { onChange?.(event.target.value); suggest(event.currentTarget) }}
onKeyDown={handleKeyDown}
onScroll={() => { syncScroll(); setCompletion(null) }}
onScroll={(event) => {
syncScroll()
// A deferred browser event can report the position already used to place suggestions.
if (shown && (shown.scrollTop !== event.currentTarget.scrollTop || shown.scrollLeft !== event.currentTarget.scrollLeft)) setCompletion(null)
}}
onClick={() => setCompletion(null)}
onCompositionStart={() => { composing.current = true; setCompletion(null) }}
onCompositionEnd={(event) => { composing.current = false; suggest(event.currentTarget) }}
@@ -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>
)
}
+102 -1
View File
@@ -18,7 +18,7 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -30,6 +30,7 @@ vi.mock('@/lib/api/widgets', async (importOriginal) => {
import type { QWidgetMetaData, WidgetData } from '@/types'
import { fetchWidgetData, WidgetRequestError } from '@/lib/api/widgets'
import { ConnectedWidget, SeededWidget } from './ConnectedWidget'
import * as widgetUtils from './widget-utils'
const fetchMock = vi.mocked(fetchWidgetData)
@@ -69,6 +70,106 @@ describe('ConnectedWidget', () => {
fetchMock.mockImplementation(async (_name, params) => controlsPayload(params))
})
it('hydrates backend date and choice defaults without persisting caller defaults', async () => {
fetchMock.mockImplementation(async (_name, params) => ({
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
}))
renderWidget({ ...controls, storeDropdownSelections: false })
const day = await screen.findByLabelText('Select Day')
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha')
const sent = new Date(2026, 0, 15).toLocaleDateString()
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: sent }))
expect(await screen.findByText(`choice=alpha; day=${sent}`)).toBeInTheDocument()
expect(localStorage.length).toBe(0)
})
it('uses the configured date wire normalization for a backend default', async () => {
const normalize = vi.spyOn(widgetUtils, 'normalizeDropdownDate').mockImplementation((value) => value)
try {
fetchMock.mockImplementation(async (_name, params) => ({
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
}))
renderWidget({ ...controls, storeDropdownSelections: false })
await waitFor(() => expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-15'))
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: '2026-01-15' }))
expect(await screen.findByText('choice=alpha; day=2026-01-15')).toBeInTheDocument()
expect(localStorage.length).toBe(0)
} finally {
normalize.mockRestore()
}
})
it.each([undefined, null, '', 'not-a-date'])('leaves a missing or invalid date default blank (%s)', async (value) => {
fetchMock.mockImplementation(async (_name, params) => ({
...controlsPayload(params), dropdownDefaultValueList: ['alpha', value],
}))
renderWidget({ ...controls, storeDropdownSelections: false })
await waitFor(() => expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha'))
expect(screen.getByLabelText('Select Day')).toHaveValue('')
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha' }))
})
it('keeps a user date when a late reload supplies a different default', async () => {
const user = userEvent.setup()
fetchMock.mockImplementation(async (_name, params) => ({
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
}))
renderWidget({ ...controls, storeDropdownSelections: false })
const day = await screen.findByLabelText('Select Day')
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
fireEvent.change(day, { target: { value: '2026-01-20' } })
const sent = new Date(2026, 0, 20).toLocaleDateString()
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: sent }))
expect(await screen.findByText(`choice=alpha; day=${sent}`)).toBeInTheDocument()
let finishReload!: (value: WidgetData) => void
fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finishReload = resolve }))
await user.click(screen.getByLabelText('Reload Owned Controls'))
await waitFor(() => expect(finishReload).toBeDefined())
finishReload({ ...controlsPayload({ accChoice: 'alpha', accDate: sent }), dropdownDefaultValueList: ['alpha', '2026-01-25'] })
await waitFor(() => expect(screen.getByLabelText('Reload Owned Controls')).not.toBeDisabled())
expect(day).toHaveValue('2026-01-20')
})
it('preserves an explicit clear when the backend still supplies a date default', async () => {
fetchMock.mockImplementation(async (_name, params) => ({
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
}))
renderWidget({ ...controls, storeDropdownSelections: false })
const day = await screen.findByLabelText('Select Day')
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
fireEvent.change(day, { target: { value: '' } })
await waitFor(() => expect(screen.getByText('Please select a Day')).toBeInTheDocument())
expect(day).toHaveValue('')
expect(localStorage.length).toBe(0)
})
it('preserves a stored date instead of replacing it with a server default', async () => {
localStorage.setItem('qqq.widgets.dropdownData.accControls.accDate', JSON.stringify({ id: '2026-01-10' }))
fetchMock.mockImplementation(async (_name, params) => ({
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
}))
renderWidget()
await waitFor(() => expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha'))
expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-10')
expect(JSON.parse(localStorage.getItem('qqq.widgets.dropdownData.accControls.accDate')!)).toEqual({ id: '2026-01-10' })
expect(localStorage.getItem('qqq.widgets.dropdownData.accControls.accChoice')).toBeNull()
})
it('does not carry one caller default into a fresh caller widget without saved dates', async () => {
fetchMock.mockImplementation(async (_name, params) => ({
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
}))
const first = renderWidget({ ...controls, storeDropdownSelections: false })
await waitFor(() => expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-15'))
first.unmount()
fetchMock.mockImplementation(async (_name, params) => controlsPayload(params))
renderWidget({ ...controls, storeDropdownSelections: false })
expect(await screen.findByText('Please select a Choice and Day')).toBeInTheDocument()
expect(screen.getByLabelText('Select Day')).toHaveValue('')
expect(localStorage.length).toBe(0)
})
it('renders payload dropdowns, sends selections under their parameter names and persists them', async () => {
const user = userEvent.setup()
renderWidget()
+9 -3
View File
@@ -39,7 +39,7 @@ import { WidgetRenderer } from './WidgetRenderer'
import { ChildRecordExportButton } from './ChildRecordListWidget'
import type { ChildRecordListPayload } from './ChildRecordListWidget'
import {
CUSTOM_TIMEFRAME, TIMEFRAME_DROPDOWN, downloadText, dropdownStorageKey, storedDropdownParams, widgetCsvToString,
CUSTOM_TIMEFRAME, TIMEFRAME_DROPDOWN, downloadText, dropdownStorageKey, normalizeDropdownDate, parseDropdownDate, storedDropdownParams, widgetCsvToString,
widgetExportFileName, writeStoredSelection,
} from './widget-utils'
import { tableExportCsv } from './table-widget-utils'
@@ -174,9 +174,15 @@ export function ConnectedWidget({
names.forEach((name, index) => {
if (typeof name !== 'string') return
const meta = widgetMetaData.dropdowns?.[index]
if (meta?.type === 'DATE_PICKER') return
const ids = (Array.isArray(lists[index]) ? lists[index] : []).map((option: { id?: unknown }) => String(option?.id))
const selected = current[name]
if (meta?.type === 'DATE_PICKER') {
const value = defaults[index]
if (selected === undefined && typeof value === 'string' && parseDropdownDate(value)) {
next = { ...next, [name]: normalizeDropdownDate(value) }
}
return
}
const ids = (Array.isArray(lists[index]) ? lists[index] : []).map((option: { id?: unknown }) => String(option?.id))
// a custom timeframe range (custom,<start>,<end>) is offered by the timeframe's custom option
const offered = selected && (ids.includes(selected) || (name === TIMEFRAME_DROPDOWN && ids.includes(CUSTOM_TIMEFRAME) && selected.startsWith(`${CUSTOM_TIMEFRAME},`)))
if (selected && !offered) {
@@ -29,7 +29,11 @@ import { server } from '@/mocks/node'
import { EsbOverviewWidget } from './EsbOverviewWidget'
import { WidgetRenderer } from './WidgetRenderer'
const widgetMetaData = { name: 'esbOverview', label: 'ESB', type: 'ESB_OVERVIEW' } as QWidgetMetaData
const widgetMetaData = {
name: 'esbOverview',
label: 'ESB',
type: 'ESB_OVERVIEW',
} as QWidgetMetaData
/**
* Renders an element inside a fresh query client.
@@ -73,13 +77,16 @@ describe('EsbOverviewWidget', () => {
expect(bodyRows('Destinations')).toHaveLength(3)
expect(events).toHaveTextContent('orderEvents')
expect(events).toHaveTextContent('Topic')
expect(within(events).getByRole('link', { name: 'order' })).toHaveAttribute('href', '/app/order')
expect(within(events).getByRole('link', { name: 'order' })).toHaveAttribute(
'href',
'/app/order'
)
expect(events).toHaveTextContent('INSERT, UPDATE, DELETE')
expect(fulfillment).toHaveTextContent('Queue')
expect(within(fulfillment).getAllByRole('cell')[3]).toHaveTextContent(/^7$/)
expect(within(results).getByRole('link', { name: 'fulfillOrder' })).toHaveAttribute(
'href',
'/app/fulfillOrder'
'/app/fulfillOrder/dev'
)
})
@@ -105,7 +112,9 @@ describe('EsbOverviewWidget', () => {
)
renderWithClient(<EsbOverviewWidget widgetMetaData={widgetMetaData} />)
await screen.findByRole('table', { name: 'Triggers' })
expect(screen.queryByRole('button', { name: /^(Pause|Resume|Restart|Purge)/ })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^(Pause|Resume|Restart|Purge)/ })
).not.toBeInTheDocument()
})
it('says so when the ESB status is not available', async () => {
@@ -119,7 +128,9 @@ describe('EsbOverviewWidget', () => {
})
it('is rendered for the ESB_OVERVIEW widget type', async () => {
renderWithClient(<WidgetRenderer widgetMetaData={widgetMetaData} data={{ type: 'ESB_OVERVIEW' }} />)
renderWithClient(
<WidgetRenderer widgetMetaData={widgetMetaData} data={{ type: 'ESB_OVERVIEW' }} />
)
expect(await screen.findByRole('table', { name: 'Destinations' })).toBeInTheDocument()
})
+15 -4
View File
@@ -25,7 +25,12 @@ import React from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import type { EsbOverviewDestination, EsbPermissions, EsbProviderType, QWidgetMetaData } from '@/types'
import type {
EsbOverviewDestination,
EsbPermissions,
EsbProviderType,
QWidgetMetaData,
} from '@/types'
import { getEsbOverview } from '@/lib/api/esb'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { CHIP_COLOR_CLASSES } from '@/lib/utils/adornment-utils'
@@ -126,7 +131,11 @@ export function EsbOverviewWidget({ widgetMetaData }: { widgetMetaData: QWidgetM
))}
</EsbList>
<EsbList title="Triggers" columns={TRIGGER_COLUMNS} empty="No triggers on processes you can access.">
<EsbList
title="Triggers"
columns={TRIGGER_COLUMNS}
empty="No triggers on processes you can access."
>
{triggers.map((trigger) => (
<EsbTriggerRow key={trigger.name} trigger={trigger} permissions={data.permissions} />
))}
@@ -174,12 +183,14 @@ function DestinationRow({
{publisher.kind === 'TABLE' ? 'Table' : 'Process'}{' '}
</span>
<Link
href={`/app/${encodeURIComponent(publisher.name)}`}
href={`/app/${encodeURIComponent(publisher.name)}${publisher.kind === 'PROCESS' ? '/dev' : ''}`}
className="font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
>
{publisher.name}
</Link>
<span className="text-xs text-muted-foreground">: {publisher.events.join(', ')}</span>
<span className="text-xs text-muted-foreground">
: {publisher.events.join(', ')}
</span>
</li>
))}
</ul>
@@ -15,10 +15,18 @@
*/
import React, { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, beforeAll, afterAll } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// jsdom does not implement native viewport scrolling; real menu geometry is covered in acceptance.
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
afterAll(() => {
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
})
import type { QTableMetaData, QWidgetMetaData } from '@/types'
import type { WidgetFormContext } from './widget-types'
@@ -15,10 +15,18 @@
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// jsdom does not implement native viewport scrolling; real menu geometry is covered in acceptance.
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
afterAll(() => {
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
})
const { previewRecords } = vi.hoisted(() => ({ previewRecords: [] as QRecord[] }))
vi.mock('@/lib/hooks/use-metadata', () => ({ useTableMetaData: vi.fn(), useMetaData: () => ({ data: undefined }) }))
+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':
+1 -1
View File
@@ -440,7 +440,7 @@ function PlainInputFieldBlock({ block, widgetName, actionCallback }: LeafBlockPr
{...common}
type={control}
value={value}
step={control === 'number' && field.type === 'DECIMAL' ? 'any' : control === 'datetime-local' ? 1 : undefined}
step={control === 'number' && field.type === 'DECIMAL' ? 'any' : control === 'datetime-local' || control === 'time' ? 1 : undefined}
placeholder={text(values.placeholder)}
onChange={(event) => setValue(event.target.value)}
onKeyDown={onKeyDown}
@@ -432,6 +432,24 @@ describe('QqqComposite', () => {
await waitFor(() => expect(actionCallback).toHaveBeenCalledWith(expect.anything(), { stamp: instant }))
})
it('accepts seconds in a standalone time block and submits the entered time', async () => {
const { actionCallback } = renderComposite({ blocks: [
{ blockTypeName: 'INPUT_FIELD', values: { value: '09:30:07', submitOnEnter: true, fieldMetaData: {
name: 'clock', label: 'Clock', type: 'TIME', isEditable: true,
} } },
] })
const input = screen.getByLabelText('Clock') as HTMLInputElement
expect(input.value).toBe('09:30:07')
expect(input.validity.stepMismatch).toBe(false)
fireEvent.change(input, { target: { value: '14:25:43' } })
expect(input.checkValidity()).toBe(true)
const beforeStep = input.valueAsNumber
input.stepUp()
expect(input.valueAsNumber).toBe(beforeStep + 1000)
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(actionCallback).toHaveBeenCalledWith(expect.anything(), { clock: expect.stringMatching(/^14:25:44(?:\.000)?$/) }))
})
it('uses metadata choice and formatted number editors in standalone input blocks', async () => {
const user = userEvent.setup()
const action = vi.fn()
+111 -10
View File
@@ -27,7 +27,7 @@ import {
orderFulfillmentMessages,
} from '@/mocks/fixtures/esb'
import { server } from '@/mocks/node'
import { processInit } from './processes'
import { processInit, processStatus } from './processes'
import {
getEsbDeadLetters,
getEsbForProcess,
@@ -37,7 +37,7 @@ import {
runEsbAction,
} from './esb'
vi.mock('./processes', () => ({ processInit: vi.fn() }))
vi.mock('./processes', () => ({ processInit: vi.fn(), processStatus: vi.fn() }))
describe('getEsbForTable', () => {
it('returns the table ESB section from GET /qqq/v1/esb/table/{table}', async () => {
@@ -166,13 +166,15 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
return HttpResponse.json(orderFulfillmentMessages)
})
)
await expect(getEsbMessages('orderFulfillment', 50, 25)).resolves.toEqual(orderFulfillmentMessages)
await expect(getEsbMessages('orderFulfillment', 50, 25)).resolves.toEqual(
orderFulfillmentMessages
)
expect(url.pathname).toBe('/qqq/v1/esb/messages/orderFulfillment')
expect(url.searchParams.get('offset')).toBe('50')
expect(url.searchParams.get('limit')).toBe('25')
})
it('browse a trigger\'s dead letters, 50 at a time from the start by default', async () => {
it("browse a trigger's dead letters, 50 at a time from the start by default", async () => {
let url = new URL('http://localhost')
server.use(
http.get('/qqq/v1/esb/deadLetters/:trigger', ({ request }) => {
@@ -188,6 +190,33 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
expect(url.searchParams.get('limit')).toBe('50')
})
it('browses the named topic subscription with the backend trigger parameter', async () => {
let url = new URL('http://localhost')
server.use(
http.get('/qqq/v1/esb/messages/:destination', ({ request }) => {
url = new URL(request.url)
return HttpResponse.json(orderFulfillmentMessages)
})
)
await expect(getEsbMessages('orderEvents', 50, 25, 'cancelOrder.orderEvents')).resolves.toEqual(
orderFulfillmentMessages
)
expect(url.pathname).toBe('/qqq/v1/esb/messages/orderEvents')
expect(url.searchParams.get('trigger')).toBe('cancelOrder.orderEvents')
expect(url.searchParams.get('offset')).toBe('50')
expect(url.searchParams.get('limit')).toBe('25')
})
it('serves the demo topic subscription and rejects missing or unrelated triggers', async () => {
await expect(getEsbMessages('orderEvents', 0, 50, 'cancelOrder.orderEvents')).resolves.toEqual(
orderFulfillmentMessages
)
await expect(getEsbMessages('orderEvents')).rejects.toThrow()
await expect(
getEsbMessages('orderEvents', 0, 50, 'fulfillOrder.orderFulfillment')
).rejects.toThrow()
})
it('rejects a body that is not a message page', async () => {
server.use(http.get('/qqq/v1/esb/messages/orderFulfillment', () => HttpResponse.json({})))
await expect(getEsbMessages('orderFulfillment')).rejects.toThrow('Invalid ESB message page')
@@ -206,6 +235,7 @@ describe('getEsbMessages and getEsbDeadLetters', () => {
describe('runEsbAction', () => {
beforeEach(() => {
vi.mocked(processInit).mockReset()
vi.mocked(processStatus).mockReset()
})
it('runs the operate process with the values and returns its count and message', async () => {
@@ -215,7 +245,10 @@ describe('runEsbAction', () => {
values: { count: 7, message: 'Purged 7 messages.' },
})
await expect(
runEsbAction('esbPurgeQueue', { providerName: 'artemis', brokerQueueName: 'orderFulfillment' })
runEsbAction('esbPurgeQueue', {
providerName: 'artemis',
brokerQueueName: 'orderFulfillment',
})
).resolves.toEqual({ count: 7, message: 'Purged 7 messages.' })
expect(processInit).toHaveBeenCalledWith('esbPurgeQueue', {
values: { providerName: 'artemis', brokerQueueName: 'orderFulfillment' },
@@ -243,11 +276,79 @@ describe('runEsbAction', () => {
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('The broker refused the purge.')
})
it('says the action is still running when it continues as a job', async () => {
vi.mocked(processInit).mockResolvedValue({ type: 'JOB_STARTED', processUUID: 'uuid', jobUUID: 'job' })
await expect(runEsbAction('esbRestartTrigger', {})).resolves.toEqual({
count: null,
message: 'The action is still running.',
it.each(['process error', 'request failure'])(
'rejects an async %s instead of reporting success',
async (failure) => {
vi.useFakeTimers()
try {
vi.mocked(processInit).mockResolvedValue({
type: 'JOB_STARTED',
processUUID: 'uuid',
jobUUID: 'job',
})
if (failure === 'process error') {
vi.mocked(processStatus).mockResolvedValue({
type: 'ERROR',
error: 'Technical failure',
userFacingError: 'Replay failed; messages remain.',
})
} else {
vi.mocked(processStatus).mockRejectedValue(new Error('Status unavailable'))
}
const assertion = expect(
runEsbAction('esbReplayDeadLetters', { all: true })
).rejects.toThrow(
failure === 'process error' ? 'Replay failed; messages remain.' : 'Status unavailable'
)
await vi.runAllTimersAsync()
await assertion
} finally {
vi.useRealTimers()
}
}
)
it('does not report success for a running response with no job identifier', async () => {
vi.mocked(processInit).mockResolvedValue({ type: 'RUNNING', processUUID: 'uuid' })
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('no job identifier')
})
it('does not report a completed intermediate step as the final action outcome', async () => {
vi.mocked(processInit).mockResolvedValue({
type: 'COMPLETE',
processUUID: 'uuid',
nextStep: 'confirm',
values: {},
})
await expect(runEsbAction('esbPurgeQueue', {})).rejects.toThrow('additional step')
})
it('waits for the final outcome through running job statuses', async () => {
vi.useFakeTimers()
try {
vi.mocked(processInit).mockResolvedValue({
type: 'JOB_STARTED',
processUUID: 'uuid',
jobUUID: 'job',
})
vi.mocked(processStatus)
.mockResolvedValueOnce({ type: 'RUNNING', processUUID: 'uuid' })
.mockResolvedValueOnce({
type: 'COMPLETE',
processUUID: 'uuid',
values: { count: 3, message: 'Replayed 3 messages.' },
})
const settled = vi.fn()
const result = runEsbAction('esbReplayDeadLetters', { all: true }).then(settled)
await Promise.resolve()
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
await vi.runAllTimersAsync()
await result
expect(processStatus).toHaveBeenCalledWith('esbReplayDeadLetters', 'uuid', 'job')
expect(settled).toHaveBeenCalledWith({ count: 3, message: 'Replayed 3 messages.' })
} finally {
vi.useRealTimers()
}
})
})
+31 -9
View File
@@ -26,7 +26,7 @@ import type {
} from '@/types'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import apiClient from './client'
import { processInit } from './processes'
import { processInit, processStatus } from './processes'
/** Messages per page when browsing, the backend's default. */
export const ESB_PAGE_SIZE = 50
@@ -87,15 +87,22 @@ export async function getEsbOverview(): Promise<EsbOverviewResponse | null> {
* @param destinationName - QQQ destination name.
* @param offset - Messages to skip.
* @param limit - Messages to return.
* @param triggerName - Required for a topic: the trigger whose subscription to browse.
* @returns The page of messages.
* @throws The request error, or an error when the body is not a message page.
*/
export async function getEsbMessages(
destinationName: string,
offset = 0,
limit = ESB_PAGE_SIZE
limit = ESB_PAGE_SIZE,
triggerName?: string
): Promise<EsbMessagePage> {
return getMessagePage(`/esb/messages/${encodeURIComponent(destinationName)}`, offset, limit)
return getMessagePage(
`/esb/messages/${encodeURIComponent(destinationName)}`,
offset,
limit,
triggerName
)
}
/**
@@ -120,19 +127,26 @@ export async function getEsbDeadLetters(
*
* @param processName - The operate process, e.g. `esbPurgeQueue`.
* @param values - Its input values, e.g. `providerName` and `brokerQueueName`.
* @returns The process's `count` and `message`; a generic message while it continues as a job.
* @returns The completed process's `count` and `message`.
* @throws An error with the process's user-facing message when it fails or is refused.
*/
export async function runEsbAction(
processName: string,
values: Record<string, string | boolean>
): Promise<EsbActionResult> {
const response = await processInit(processName, { values })
let response = await processInit(processName, { values })
let jobUUID: string | undefined
while (response.type === 'JOB_STARTED' || response.type === 'RUNNING') {
if (response.type === 'JOB_STARTED') jobUUID = response.jobUUID
if (!jobUUID) throw new Error('The action returned no job identifier to check its outcome.')
await new Promise((resolve) => setTimeout(resolve, 1000))
response = await processStatus(processName, response.processUUID, jobUUID)
}
if (response.type === 'ERROR') {
throw new Error(response.userFacingError ?? response.error)
}
if (response.type !== 'COMPLETE') {
return { count: null, message: 'The action is still running.' }
if (response.nextStep) {
throw new Error('The action requires an unexpected additional step.')
}
const count = Number(response.values.count)
const message = response.values.message
@@ -165,11 +179,19 @@ async function getOrNull(path: string): Promise<unknown> {
* @param path - Path under the API base URL.
* @param offset - Messages to skip.
* @param limit - Messages to return.
* @param triggerName - Optional topic trigger name.
* @returns The page.
* @throws The request error, or an error when the body is not a message page.
*/
async function getMessagePage(path: string, offset: number, limit: number): Promise<EsbMessagePage> {
const data = await apiClient.get<unknown>(path, { params: { offset, limit } })
async function getMessagePage(
path: string,
offset: number,
limit: number,
triggerName?: string
): Promise<EsbMessagePage> {
const data = await apiClient.get<unknown>(path, {
params: { offset, limit, ...(triggerName ? { trigger: triggerName } : {}) },
})
if (!hasArrays<EsbMessagePage>(data, 'messages')) {
throw new Error('Invalid ESB message page')
}
+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)
}
@@ -30,9 +30,10 @@ vi.mock('@/lib/api/auth', () => ({
clearAuthMetadataCache: vi.fn(),
}))
import { createPasswordSession, logout, manageSession, resumeSession } from '@/lib/api/auth'
import { createPasswordSession, getAuthenticationMetaData, logout, manageSession, resumeSession } from '@/lib/api/auth'
import { AuthProvider } from './auth-provider'
import { useAuth } from './use-auth'
import * as oidc from './oidc'
import { storeUser, getStoredUser, hasSessionHint, setSessionHint } from './auth-storage'
let auth: ReturnType<typeof useAuth> | null = null
@@ -102,6 +103,62 @@ describe('AuthProvider with TABLE_BASED authentication', () => {
write.mockRestore()
})
it.each([false, true])('keeps authentication loading until logout settles (failure: %s)', async (fails) => {
let finishLogout!: () => void
vi.mocked(logout).mockReturnValue(new Promise<void>((resolve, reject) => {
finishLogout = () => fails ? reject(new Error('Logout unavailable')) : resolve()
}))
vi.mocked(createPasswordSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('signed out: no error')
await act(async () => { await auth?.signInWithPassword('tess.table', 'table:pass-2026') })
let pendingLogout: Promise<void> | undefined
await act(async () => { pendingLogout = auth?.logout() })
expect(screen.getByText('loading')).toBeInTheDocument()
expect(auth?.isAuthenticated).toBe(false)
expect(auth?.user).toBeNull()
await act(async () => { finishLogout(); await pendingLogout })
expect(screen.getByText('signed out: no error')).toBeInTheDocument()
expect(auth?.isLoading).toBe(false)
})
it('leaves loading when provider logout falls back to the local login page', async () => {
vi.mocked(getAuthenticationMetaData).mockResolvedValueOnce({ name: 'oauth', type: 'OAUTH2' })
vi.mocked(resumeSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
vi.mocked(logout).mockResolvedValue(undefined)
setSessionHint(true)
const endSession = vi.spyOn(oidc, 'buildEndSessionUrl').mockRejectedValue(new Error('Provider unavailable'))
try {
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('Tess Table <tess.table>')
await act(async () => { await auth?.logout() })
expect(endSession).toHaveBeenCalled()
expect(screen.getByText('signed out: no error')).toBeInTheDocument()
expect(auth?.isLoading).toBe(false)
} finally {
endSession.mockRestore()
}
})
it('keeps loading after provider logout starts an external navigation', async () => {
vi.mocked(getAuthenticationMetaData).mockResolvedValueOnce({ name: 'oauth', type: 'OAUTH2' })
vi.mocked(resumeSession).mockResolvedValue({ values: { user: { name: 'Tess Table', username: 'tess.table' } } })
vi.mocked(logout).mockResolvedValue(undefined)
setSessionHint(true)
const endSession = vi.spyOn(oidc, 'buildEndSessionUrl').mockResolvedValue('https://idp.example/logout')
try {
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('Tess Table <tess.table>')
// jsdom leaves the document in place when location.assign starts navigation.
await act(async () => { await auth?.logout() })
expect(endSession).toHaveBeenCalled()
expect(screen.getByText('loading')).toBeInTheDocument()
expect(auth?.isLoading).toBe(true)
} finally {
endSession.mockRestore()
}
})
it('reports refused credentials and stays signed out', async () => {
vi.mocked(createPasswordSession).mockRejectedValue(refused('Incorrect username or password.'))
render(<AuthProvider><Probe /></AuthProvider>)
+49 -41
View File
@@ -102,7 +102,7 @@ export interface AuthUser {
export interface AuthContextType {
/** Whether the current user has an active, validated session. */
isAuthenticated: boolean
/** Whether an auth check or sign-in is in flight. */
/** True while checking authentication, signing in or signing out. */
isLoading: boolean
/** The authenticated user, or `null` while unauthenticated. */
user: AuthUser | null
@@ -385,50 +385,58 @@ export function AuthProvider({ children, onAuthError }: AuthProviderProps) {
}, [applySession])
const handleLogout = useCallback(async () => {
const metadata = authMetadata
const loginPath = `/login?returnTo=${encodeURIComponent(currentReturnTo())}`
const providerLogout = Boolean(metadata && PROVIDER_TYPES.has(metadata.type))
// Signed out first: protected pages unmount (no refetch with an ending session) and the
// login page shows the signed-out state instead of signing in again. Provider logouts
// leave the page for the identity provider instead, so no in-app navigation is started.
redirectingToLogin.current = true
setSignedOut(true)
setSessionHint(false)
if (!providerLogout) {
// Do not offer another sign-in until the old response has expired its cookies (#984).
let leavingForProvider = false
setIsLoading(true)
try {
const metadata = authMetadata
const loginPath = `/login?returnTo=${encodeURIComponent(currentReturnTo())}`
const providerLogout = Boolean(metadata && PROVIDER_TYPES.has(metadata.type))
// Signed out first: protected pages unmount (no refetch with an ending session) and the
// login page shows the signed-out state instead of signing in again. Provider logouts
// leave the page for the identity provider instead, so no in-app navigation is started.
redirectingToLogin.current = true
setSignedOut(true)
setSessionHint(false)
if (!providerLogout) {
setSignedOutState(true)
setIsAuthenticated(false)
setUser(null)
}
try {
// the backend also expires both session cookies (QRun-IO/qqq#674)
await apiLogout()
} catch (error) {
// The local sign-out still happens; the backend session is left to expire, and the
// next sign-in replaces its cookies (QRun-IO/qqq#733).
console.warn('[Auth] Backend logout failed:', error instanceof Error ? error.message : error)
}
resetReauthAttempts()
clearUserClientData()
// Forget the analytics identity with the session (Material resets its providers on logout)
analytics.reset()
clearAuthMetadataCache()
queryClient.clear()
if (metadata && PROVIDER_TYPES.has(metadata.type)) {
try {
const endSession = await buildEndSessionUrl(metadata, `${window.location.origin}/login`)
if (endSession) {
window.location.assign(endSession)
leavingForProvider = true
return
}
} catch (error) {
console.warn('[Auth] Identity provider sign-out unavailable:', error instanceof Error ? error.message : error)
}
}
setSignedOutState(true)
setIsAuthenticated(false)
setUser(null)
router.push(loginPath)
} finally {
if (!leavingForProvider) setIsLoading(false)
}
try {
// the backend also expires both session cookies (QRun-IO/qqq#674)
await apiLogout()
} catch (error) {
// The local sign-out still happens; the backend session is left to expire, and the
// next sign-in replaces its cookies (QRun-IO/qqq#733).
console.warn('[Auth] Backend logout failed:', error instanceof Error ? error.message : error)
}
resetReauthAttempts()
clearUserClientData()
// Forget the analytics identity with the session (Material resets its providers on logout)
analytics.reset()
clearAuthMetadataCache()
queryClient.clear()
if (metadata && PROVIDER_TYPES.has(metadata.type)) {
try {
const endSession = await buildEndSessionUrl(metadata, `${window.location.origin}/login`)
if (endSession) {
window.location.assign(endSession)
return
}
} catch (error) {
console.warn('[Auth] Identity provider sign-out unavailable:', error instanceof Error ? error.message : error)
}
}
setSignedOutState(true)
setIsAuthenticated(false)
setUser(null)
router.push(loginPath)
}, [authMetadata, router])
const handleOAuthCallback = useCallback(async (code: string, state: string, codeVerifier?: string) => {
+57
View File
@@ -15,6 +15,12 @@
*/
import { afterEach, describe, expect, it } from 'vitest'
import {
readCurrentSavedViewId,
readStoredQueryView,
writeCurrentSavedViewId,
writeStoredQueryView,
} from '@/lib/utils/query-view-storage'
import {
claimClientData,
clearUserClientData,
@@ -97,6 +103,57 @@ describe('auth-storage', () => {
expect(localStorage.getItem('accessToken')).toBeNull()
})
it('clears every table\'s remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)', () => {
for (const table of ['person', 'pet']) {
writeStoredQueryView(table, {
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
viewIdentity: 'empty',
})
writeCurrentSavedViewId(table, 7)
expect(readStoredQueryView(table)).toMatchObject({
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
})
expect(readCurrentSavedViewId(table)).toBe(7)
}
const preferences = {
'qqq.density': 'compact',
'qqq-dark-mode': 'true',
'qqq-person-column-widths': '{"name":240}',
'qqq.recordQueryViews.person': 'unrelated plural key',
'another-application': 'keep',
}
for (const [key, value] of Object.entries(preferences)) localStorage.setItem(key, value)
clearUserClientData()
for (const table of ['person', 'pet']) {
expect(readStoredQueryView(table)).toBeNull()
expect(readCurrentSavedViewId(table)).toBeNull()
}
for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value)
})
it('keeps remembered query state for the same identity and clears it before another identity claims the browser (QRun-IO/qqq#1006)', () => {
claimClientData({ name: 'Alice', email: 'alice@example.test' })
writeStoredQueryView('person', {
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
viewIdentity: 'savedView:7',
})
writeCurrentSavedViewId('person', 7)
claimClientData({ name: 'Alice', email: 'alice@example.test' })
expect(readStoredQueryView('person')).toMatchObject({
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
viewIdentity: 'savedView:7',
})
expect(readCurrentSavedViewId('person')).toBe(7)
claimClientData({ name: 'Bob', email: 'bob@example.test' })
expect(readStoredQueryView('person')).toBeNull()
expect(readCurrentSavedViewId('person')).toBeNull()
expect(localStorage.getItem('qqq.clientDataOwner')).toBe('bob@example.test')
})
it('clears the previous user\'s data when someone else signs in in this browser (QRun-IO/qqq#696)', () => {
claimClientData({ name: 'Alice', email: 'alice@example.com' })
localStorage.setItem('qqq-recent-records', JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }]))
+3 -1
View File
@@ -19,6 +19,7 @@
* re-authentication loop guard, and clearing per-user client data on sign-out.
*/
import { clearAllStoredQueryState } from '@/lib/utils/query-view-storage-cleanup'
import { clearRecentRecords } from '@/lib/utils/recent-records'
const SIGNED_OUT_KEY = 'qqq.signedOut'
@@ -192,9 +193,10 @@ export function resetReauthAttempts(): void {
storage('session')?.removeItem(REAUTH_KEY)
}
/** Removes per-user data kept in the browser (recently viewed records, stored identity and session values). */
/** Removes per-user browser data: recent records, query views, stored identity and session values. */
export function clearUserClientData(): void {
clearRecentRecords()
clearAllStoredQueryState()
storeUser(null)
storage('local')?.removeItem(SESSION_VALUES_KEY)
storage('local')?.removeItem('accessToken')
@@ -0,0 +1,193 @@
/*
* 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 useRecordQuery hook
import { setTimeout as realSetTimeout } from 'node:timers'
import { act, cleanup, fireEvent, render, renderHook, screen, within } from '@testing-library/react'
import { QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { QTableMetaData } from '@/types'
import { RecordQueryContent } from '@/components/query/RecordQueryContent'
import { queryClient, queryKeys } from '@/lib/query-client'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import { useRecordQuery } from './use-record-query'
const table: QTableMetaData = { ...qInstance.tables.person, capabilities: ['TABLE_QUERY', 'TABLE_COUNT'] }
const options = { tableName: table.name, tableMetaData: table, allTables: { [table.name]: table } }
const records = [{ tableName: table.name, recordLabel: 'Recovered row', values: { id: 1, firstName: 'Recovered row' } }]
function Wrapper({ children }: { children: React.ReactNode }) {
// Use the production client, including its retry predicate, delay and error cache callbacks.
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
function QueryContent() {
const rq = useRecordQuery(options)
return <RecordQueryContent
tableName={table.name} tableMetaData={table} viewMode="card" activeFilterCount={0}
{...rq.data} {...rq.pagination} {...rq.columns}
sortOrder={rq.filter.sortOrder} onSortChange={rq.filter.setSort}
onResetFilter={rq.filter.resetFilter} quickSearchTerm={rq.filter.quickSearchTerm}
rowSelection={rq.selection.rowSelection} onRowSelectionChange={rq.selection.setRowSelection}
onColumnWidthChange={rq.columns.setColumnWidth} density={rq.density}
onPageChange={rq.pagination.setPage} onPageSizeChange={rq.pagination.setPageSize}
/>
}
// Hold each real HTTP response independently, including the last automatic attempt.
function controlledEndpoint(endpoint: 'query' | 'count') {
const responses: Array<(response: Response) => void> = []
server.use(http.post(`*/qqq/v1/table/${table.name}/${endpoint}`, () =>
new Promise<Response>((resolve) => { responses.push(resolve) })))
return responses
}
// Flush HTTP/React work without advancing the retry clock. Real IO can require
// another event-loop turn; only zero-duration fake timers (query notifications) run.
async function settle(assertion: () => void) {
for (let turn = 0; turn < 100; turn++) {
await act(async () => {
await new Promise<void>((resolve) => { realSetTimeout(resolve, 1) })
await vi.advanceTimersByTimeAsync(0)
})
try { assertion(); return } catch (error) { if (turn === 99) throw error }
}
}
async function advance(milliseconds: number) {
await act(async () => { await vi.advanceTimersByTimeAsync(milliseconds) })
}
function failureCount(endpoint: 'query' | 'count') {
return queryClient.getQueryCache().findAll({ queryKey: queryKeys.tableRecords(table.name) })
.find((query) => query.queryKey[3] === endpoint)?.state.fetchFailureCount
}
async function exhaustServerErrors(responses: Array<(response: Response) => void>, endpoint: 'query' | 'count', message: string) {
for (let attempt = 0; attempt < 4; attempt++) {
await settle(() => { expect(responses).toHaveLength(attempt + 1) })
responses[attempt](HttpResponse.json({ error: message }, { status: 500 }))
await settle(() => { expect(failureCount(endpoint)).toBe(attempt + 1) })
if (attempt < 3) await advance([1000, 2000, 4000][attempt])
}
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
queryClient.clear()
localStorage.clear()
server.use(
http.post(`*/qqq/v1/table/${table.name}/query`, () => HttpResponse.json({ records })),
http.post(`*/qqq/v1/table/${table.name}/count`, () => HttpResponse.json({ count: 1 })),
)
})
afterEach(() => {
cleanup()
queryClient.clear()
vi.useRealTimers()
})
describe('record query production retry lifecycle', () => {
it('waits for the first attempt and three retries before an accessible terminal error, then recovers through Retry', async () => {
const responses = controlledEndpoint('query')
render(<QueryContent />, { wrapper: Wrapper })
for (const [attempt, delay] of [1000, 2000, 4000].entries()) {
await settle(() => { expect(responses).toHaveLength(attempt + 1) })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
responses[attempt](HttpResponse.json({ error: 'Database query unavailable' }, { status: 500 }))
await settle(() => { expect(failureCount('query')).toBe(attempt + 1) })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
await advance(delay - 1)
expect(responses).toHaveLength(attempt + 1)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
await advance(1)
}
await settle(() => { expect(responses).toHaveLength(4) })
// A slow final response is still pending; reaching attempt four is not terminal.
await advance(5000)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByText('No records found')).not.toBeInTheDocument()
responses[3](HttpResponse.json({ error: 'Database query unavailable' }, { status: 500 }))
await settle(() => { expect(screen.getByRole('alert')).toHaveTextContent('Database query unavailable') })
const alert = screen.getByRole('alert')
expect(within(alert).getByRole('button', { name: 'Dismiss' })).toBeEnabled()
await advance(8000)
expect(responses).toHaveLength(4)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await settle(() => { expect(responses).toHaveLength(5) })
responses[4](HttpResponse.json({ records }))
await settle(() => { expect(screen.getByRole('heading', { name: 'Recovered row' })).toBeInTheDocument() })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
})
it('dismisses only the current terminal error without retrying or hiding a later distinct failure', async () => {
const responses = controlledEndpoint('query')
const view = render(<QueryContent />, { wrapper: Wrapper })
await exhaustServerErrors(responses, 'query', 'First backend failure')
await settle(() => { expect(screen.getByRole('alert')).toHaveTextContent('First backend failure') })
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
view.rerender(<QueryContent />)
await advance(8000)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(responses).toHaveLength(4)
act(() => { void queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(table.name) }) })
await settle(() => { expect(responses).toHaveLength(5) })
// Keep the same controlled handler and collect subsequent retry responses.
for (let attempt = 0; attempt < 4; attempt++) {
await settle(() => { expect(responses).toHaveLength(5 + attempt) })
responses[4 + attempt](HttpResponse.json({ error: 'Later backend failure' }, { status: 500 }))
await settle(() => { expect(failureCount('query')).toBe(attempt + 1) })
if (attempt < 3) await advance([1000, 2000, 4000][attempt])
}
await settle(() => { expect(screen.getByRole('alert')).toHaveTextContent('Later backend failure') })
expect(screen.queryByText('First backend failure')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled()
})
it('keeps successful records when the separate count exhausts its server-error retries', async () => {
const responses = controlledEndpoint('count')
const { result } = renderHook(() => useRecordQuery(options), { wrapper: Wrapper })
await settle(() => { expect(result.current.data.records).toEqual(records) })
await exhaustServerErrors(responses, 'count', 'Count temporarily unavailable')
await settle(() => { expect(result.current.data.countError?.message).toBe('Count temporarily unavailable') })
expect(result.current.data.records).toEqual(records)
expect(result.current.data.isError).toBe(false)
expect(result.current.data.error).toBeNull()
expect(result.current.data.isLoading).toBe(false)
expect(getErrorStatusCode(result.current.data.countError)).toBe(500)
await advance(8000)
expect(responses).toHaveLength(4)
})
it.each([403, 404])('shows HTTP %i immediately without an automatic retry', async (status) => {
const responses = controlledEndpoint('query')
render(<QueryContent />, { wrapper: Wrapper })
await settle(() => { expect(responses).toHaveLength(1) })
responses[0](HttpResponse.json({ error: 'Backend access detail' }, { status }))
await settle(() => { expect(screen.getByRole('alert')).toHaveTextContent('Backend access detail') })
expect(screen.getByRole('alert')).toHaveTextContent(status === 403
? 'You do not have permission to view these records.' : 'This table could not be found.')
await advance(7000)
expect(responses).toHaveLength(1)
})
})
+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]
}
@@ -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.
*/
/** @file Query-view storage keys and cleanup, independent of view parsing and API utilities. */
/** Root of the per-table key for the last view. */
export const VIEW_STORAGE_KEY_ROOT = 'qqq.recordQueryView'
/** Root of the per-table key for the last saved view's id. */
export const CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT = 'qqq.currentSavedViewId'
/** Forgets every table's remembered view and saved-view selection, preserving machine preferences. */
export function clearAllStoredQueryState(): void {
try {
for (let index = localStorage.length - 1; index >= 0; index--) {
const key = localStorage.key(index)
if (key?.startsWith(`${VIEW_STORAGE_KEY_ROOT}.`) || key?.startsWith(`${CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT}.`)) {
localStorage.removeItem(key)
}
}
} catch {
// persistence is best-effort
}
}
+2 -4
View File
@@ -23,11 +23,9 @@
*/
import { parseViewJson, type RecordQueryView } from './saved-view-utils'
import { CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT, VIEW_STORAGE_KEY_ROOT } from './query-view-storage-cleanup'
/** Root of the per-table key for the last view. */
export const VIEW_STORAGE_KEY_ROOT = 'qqq.recordQueryView'
/** Root of the per-table key for the last saved view's id. */
export const CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT = 'qqq.currentSavedViewId'
export { CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT, VIEW_STORAGE_KEY_ROOT } from './query-view-storage-cleanup'
/** The (global) row density key. */
export const DENSITY_STORAGE_KEY = 'qqq.density'
/** The `viewIdentity` of an ad-hoc (unsaved) view. */
+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`
}
+28
View File
@@ -133,6 +133,34 @@ describe('zodFieldFromMetadata', () => {
expect(schema.safeParse('1.5').success).toBe(false)
expect(schema.safeParse('12abc').success).toBe(false)
})
it.each(['1.5', '12abc', '1e3', '+', '--1'])('bounded LONG rejects %s as a field error without throwing', (value) => {
const schema = zodFieldFromMetadata(makeField({ type: 'LONG', minValue: 0, maxValue: 100 }))
const result = schema.safeParse(value)
expect(result.success).toBe(false)
if (!result.success) expect(result.error.issues.map(issue => issue.message)).toContain('Test Field must be a whole number')
})
it('LONG applies exact string bounds beyond the safe-number range', () => {
const schema = zodFieldFromMetadata(makeField({ type: 'LONG', minValue: '9007199254740993', maxValue: '9007199254740995' }))
expect(schema.safeParse('9007199254740992').success).toBe(false)
expect(schema.parse('9007199254740993')).toBe('9007199254740993')
expect(schema.parse('9007199254740995')).toBe('9007199254740995')
expect(schema.safeParse('9007199254740996').success).toBe(false)
})
it('LONG preserves negative exact bounds and fractional-bound rounding', () => {
const exact = zodFieldFromMetadata(makeField({ type: 'LONG', minValue: '-9007199254740995', maxValue: '-9007199254740993' }))
expect(exact.safeParse('-9007199254740996').success).toBe(false)
expect(exact.parse('-9007199254740995')).toBe('-9007199254740995')
expect(exact.parse('-9007199254740993')).toBe('-9007199254740993')
expect(exact.safeParse('-9007199254740992').success).toBe(false)
const fractional = zodFieldFromMetadata(makeField({ type: 'LONG', minValue: -1.5, maxValue: '1.5' }))
expect(fractional.safeParse('-2').success).toBe(false)
expect(fractional.parse('-1')).toBe('-1')
expect(fractional.parse('1')).toBe('1')
expect(fractional.safeParse('2').success).toBe(false)
})
})
describe('DECIMAL type', () => {
+15 -3
View File
@@ -79,14 +79,26 @@ function buildDateTimeSchema(isRequired: boolean, label: string): z.ZodTypeAny {
*/
function buildLongSchema(isRequired: boolean, label: string, minValue?: number | string | null, maxValue?: number | string | null): z.ZodTypeAny {
const text = z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value).trim())
const checked = text
const integer = text
.refine((value) => !isRequired || value !== '', `${label} is required`)
.refine((value) => value === '' || /^[+-]?\d+$/.test(value), `${label} must be a whole number`)
.refine((value) => value === '' || minValue === undefined || minValue === null || BigInt(value) >= BigInt(Math.ceil(Number(minValue))), `${label} must be at least ${minValue}`)
.refine((value) => value === '' || maxValue === undefined || maxValue === null || BigInt(value) <= BigInt(Math.floor(Number(maxValue))), `${label} must be at most ${maxValue}`)
// Refinements on one schema all run, even after a previous refinement fails.
// Pipe only valid integer text into BigInt comparisons so bad input stays a field error.
const checked = integer.pipe(z.string()
.refine((value) => value === '' || minValue === undefined || minValue === null || BigInt(value) >= longRangeBound(minValue), `${label} must be at least ${minValue}`)
.refine((value) => value === '' || maxValue === undefined || maxValue === null || BigInt(value) <= longRangeBound(maxValue), `${label} must be at most ${maxValue}`))
return isRequired ? checked : checked.optional()
}
/**
* Keeps integer bounds exact; numeric fractional bounds compare directly with BigInt.
* @param value - Numeric bound from field metadata.
* @returns An exact integer when supplied as digits, otherwise the numeric bound.
*/
function longRangeBound(value: number | string): bigint | number {
return typeof value === 'string' && /^[+-]?\d+$/.test(value.trim()) ? BigInt(value) : Number(value)
}
/**
* Internal helper used by {@link zodFieldFromMetadata} to build a `coerce.number()` schema
* for INTEGER, LONG, and DECIMAL field types.
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more