Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 77eef1c816 chore(perf): give the table developer route /app/_/dev a 360 KB cap
CI's bundle budget on the combined tree (SQL Workbench #47, FILE_PREVIEW
#49 and this PR) measured /app/_/dev at 350.8 KB first-load JS gzip, 0.8 KB
over the 350 KB default (each change fit alone). The record developer
route /app/_/_/dev measured 354.9 KB under its 360 KB cap. Give /app/_/dev
the same 360 KB route cap (about 2.6% headroom); every other route keeps
the 350 KB default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:42:45 -05:00
james.maesandClaude Opus 5.5 61be012fc2 Merge main (FILE_PREVIEW #49) into feature/responsive-readable-layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:28:51 -05:00
james.maes bc01d10a27 Merge pull request #49 from QRun-IO/feature/image-preview-lightbox
feat(preview): FILE_PREVIEW thumbnails and preview lightbox (QRun-IO/qqq#1056)
2026-10-11 18:28:38 -05:00
james.maesandClaude Opus 5.5 d3daa4d067 Merge SQL Workbench (#47) and FILE_PREVIEW (#49) into feature/responsive-readable-layout
Combines #49's preview handling with #50's compact columns:
- ChildRecordListWidget keeps #50's measured compact columns, long-text
  clamp and top-aligned cells, and gains #49's FILE_PREVIEW gallery
  (shared cellRecords, joined-field preview references, child table
  variant). The gallery covers only the columns on screen, so lightbox
  navigation never steps into a hidden column. A preview column in the
  first position is not turned into the row link (as in #49).
- ChildFieldMetaData.adornments is typed as FieldAdornment[] so it serves
  both CHIP detection (#50) and FILE_PREVIEW (#49).
- DataCell / FieldValue keep both DateTimeText and the preview imports.
- perf-budget.json: one shared 360 KB /app/_/_/dev cap; the note records
  the SQL Workbench, FILE_PREVIEW and #50/#53 measurements (expected
  about 355 KB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:35:17 -05:00
james.maesandClaude Opus 5.5 49788dd4e8 Merge main (SQL Workbench, #47) into feature/image-preview-lightbox
Resolves perf-budget.json: keep one /app/_/_/dev route cap of 360 KB and
combine the FILE_PREVIEW (+0.6 KB) and SQL Workbench (+3.1 KB) measurements
in the note (expected about 353.7 KB). Also corrects the unreleased note:
inline PDF frames are limited to backend /qqq/ paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:31:11 -05:00
james.maes 1412af5f69 Merge pull request #47 from QRun-IO/feature/sql-workbench
feat(sql-workbench): SQL Workbench notebook UI (SQL_WORKBENCH widget)
2026-10-11 16:30:07 -05:00
james.maesandClaude Opus 5.5 9150fb732f chore(perf): share the developer-route budget note with PR #53
Same 360 KB /app/_/_/dev cap; the note now records both measurements (this
PR 351.1 KB, home app PR #53 350.1 KB, main 350.0 KB) so both PRs carry an
identical perf-budget.json and merge cleanly in either order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:11:14 -05:00
james.maesandClaude Opus 5.5 7f03ba1f4c fix(preview): table variant in gallery URLs; frame only backend PDFs
Thumbnails already carry the stored table variant on backend download
URLs, but the preview galleries did not, so the lightbox Open and
Download links for variant tables omitted it. Pass tableVariant to the
gallery in RecordView, AssociatedRecords (join table), ChildRecordListWidget
(child table from the instance metadata), DataGrid and RecordCardView.

AssociatedRecords now memoizes the shown page of records and its visible
fields so the gallery items are not rebuilt on every render.

The lightbox frames a PDF only when it is a same-origin /qqq/ backend path;
any other same-origin URL (e.g. a dashboard page stored in a field) opens
in a new tab instead of running in a frame with the dashboard's origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:36:20 -05:00
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 984c26b62b Merge origin/main (Next UI 1.0.0-RC.12) into feature/responsive-readable-layout
Resolves docs/releases/unreleased.md: RC.12 moved the earlier product fixes
into its release notes; this branch's three entries are the only unreleased
product fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:10:04 -05:00
james.maesandClaude Opus 5.5 310c23f9cd fix(layout): apply the 24rem default record field threshold
The previous commit added the test but missed the constant itself: default
record fields now pair from a 24rem grid (tablet REC-004).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:55:24 -05:00
james.maesandClaude Opus 5.5 de387a61f7 fix(layout): pair default record fields from a 24rem grid
On a tablet the selected-section field grid beside the sidebar is ~416px, below
the 30rem form threshold, so two default-width read-only fields stacked where
they used to share a row (acceptance REC-004 on the tablet profile). Read-only
label/value pairs fit at ~180px each, so the default record field layout pairs
them from 24rem; phones still stack and explicit gridColumns keep the form
thresholds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:55:01 -05:00
james.maes e333223470 Merge pull request #48 from QRun-IO/feature/GH-712-next-rc12
chore(#712): prepare Next UI 1.0.0-RC.12
2026-10-11 12:03:09 -05:00
james.maesandClaude Opus 5.5 c064048c6f chore(perf): give the record developer route its own 360 KB first-load cap
Measured with `QQQ_NEXT_OUTPUT=export next build` + scripts/perf-budget.mjs:

- main b0b0d46: /app/_/_/dev 350.0 KB gzip (exactly at the 350 KB default cap)
- this branch:  /app/_/_/dev 351.1 KB (+1.1 KB: compact date formatter,
  child-list column ranking, container-query span tables)
- other routes stay under the unchanged 350 KB default (largest /app/_/dev
  347.1 KB); total JS 966.6 KB of 1050 KB; CSS 20.0 KB of 21 KB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:49:23 -05:00
james.maesandClaude Opus 5.5 2b4d82309c test(acceptance): assert compact dates and the key-behind-toggle child list
- Record-view DATE and DATE_TIME values show compactly; assertions now check
  the text and the full zoned timestamp in the tooltip (or the <time dateTime>
  calendar day for DATE).
- A value in a declared zone other than the viewer's keeps its time and zone
  even in an older year (`Mar 10, 2024, 3:30 AM CDT`), so fixed- and
  per-record-zone fields still read in their zone (REC-019).
- Audit entries and script log/line timestamps: compact text, full title.
- WID-024: the key column is behind the "more columns" toggle while the name
  identifies the row; the toggle restores Id + Name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:48:52 -05:00
james.maesandClaude Opus 5.5 4fc3dfd7aa docs: note responsive layout, readable child lists and compact dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:57 -05:00
james.maesandClaude Opus 5.5 8c9619e7f7 test(mocks): CMS app with wide child lists for layout and date checks
Adds a mock CMS app (sites with domain and blog child lists, long descriptions
and meta tags, zoned timestamps including "today") so record views, embedded
child lists, the query grid and an app home can be checked at phone, laptop,
desktop and ultra-wide widths with `NEXT_PUBLIC_MOCK_API=true pnpm dev`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:00 -05:00
james.maesandClaude Opus 5.5 d3298cca2b feat(child-records): readable embedded child lists that fit their panel
A child-record list in a record-view panel showed every field of the child
table, so long text wrapped into tall, narrow columns. It now measures its
width and shows the columns that matter most and fit, ranked from metadata and
the rows on screen (compact-columns):

1. the record's name (first short text column, preferring T1 sections)
2. status columns (CHIP / possible values) and booleans
3. one date: the date column with the latest value (modified beats created)
4. other short text, numbers, other dates
5. long text last (TEXT / HTML / heavy, or values that run long in the rows)

The primary key is left out while a name column identifies the row (the row
still links to its record); it leads only when nothing else names the row. An
application that sets the widget's existing `onlyIncludeFieldNames` has chosen
its columns, and that order is their priority, so no new QQQ metadata is needed.

Hidden columns are one click away (`+N more columns` / `Fewer columns`, plus
View All). Cells align to the top, long text is clamped to three lines with the
full text on hover, and headers stay on one line. The CSV export keeps every
column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:42:59 -05:00
james.maesandClaude Opus 5.5 edaa1ffb33 feat(layout): size record, dashboard and form grids by their own width
QQQ sizes cards, widgets and fields in twelfths (gridColumns). Applied at the
viewport's `lg` breakpoint, a half-width card is half of what is left after the
sidebar, so two table panels sat side by side at ~350px on a laptop and stayed
side by side however narrow they got.

Grids are now CSS containers (`@container`) and their items take their declared
span only when the grid itself is wide enough for items of that kind:

- record-view cards (tables, field lists): stacked below 66rem, half from 66rem,
  at least a third from 100rem, the declared span from 120rem; overview cards
  keep their own height instead of stretching to the tallest in the row
- dashboard widgets: 40rem / 60rem / 76rem; table widgets use the card widths
- form and record fields: 30rem / 44rem / 56rem, so a create dialog or a narrow
  panel no longer crams three fields into a row; the default two-column record
  field layout widens to thirds and quarters on ultra-wide screens
- the record header's identity fields flow in a row, each as wide as its value

Material per-breakpoint widget sizes (gridCols:sizeClass:*) keep their
viewport behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:42:47 -05:00
james.maesandClaude Opus 5.5 c564a29ce8 feat(dates): one compact date/time presentation for every table, record view and widget
A shared formatter (compactDateTime / compactDate / relativeDateTime) and a
DateTimeText component replace the long `yyyy-MM-dd hh:mm:ss AM TZ` text that
wrapped over three lines in narrow cells:

- today: `6:33 PM`; this year: `Jul 5, 6:33 PM`; before: `Jul 5, 2025`
- DATE values never show a time and are never shifted by the viewer zone
  (DataCell read `2024-06-15` as UTC midnight and showed the 14th in the US)
- one line (nowrap, tabular figures), the full zoned timestamp as the tooltip,
  a machine-readable <time dateTime>
- the viewer's zone and locale; a backend display value zoned by a field
  behavior in another zone keeps that zone (`Jul 5, 4:33 PM PDT`)
- relative time (`3h ago`) for activity lists (ESB messages and counters)
- grid date columns default to widths that fit the compact form

Exports are unchanged: server exports and the child-list CSV still write the
raw or backend display values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:42:35 -05:00
james.maesandClaude Opus 5.5 3ab0ed88fa fix(preview): stack the record-view file name under its thumbnail (QRun-IO/qqq#1056)
In narrow record-view columns the name beside a 96/160 px thumbnail wrapped
character by character; it now sits under the thumbnail, truncated to its
width (full name in the title). Found in a local smoke against a real backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:39:20 -05:00
james.maesandClaude Opus 5.5 12ef8571f1 feat(preview): FILE_PREVIEW thumbnails and preview lightbox (QRun-IO/qqq#1056)
Fields with the backend FILE_PREVIEW adornment render as fixed-size, lazily
loaded thumbnails (file-type icons for non-images) wherever values are shown:
query grid and card view, record view (larger, with file name), associated
records, child-record list and record-grid widgets. Clicking or pressing Enter
opens an accessible preview dialog (Radix: focus trap, Escape, focus restore)
with zoom/fit, open-original and download, file details, arrow-key
navigation across the screen's previews, same-origin PDF, video and audio
players, and a file card fallback. Only explicitly adorned fields are
previewed; URLs are limited to http(s), same-origin paths and inline images.
Join columns resolve their prefixed values.

The lightbox is a lazy chunk (~3.5 KB gzip). Main measured /app/_/_/dev at
350.0 KB (the default cap); the thumbnail resolver, thumbnail and gallery
context add 0.6 KB to record routes (350.6 KB), so that route gets an explicit
360 KB cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:28:13 -05:00
james.maesandClaude Opus 5.5 a4c8eaf8c9 chore(#712): prepare Next UI 1.0.0-RC.12
Version 1.0.0-RC.12 (package.json, pom revision), RC12 preparation notes
collecting everything merged since RC11 (PR25-PR46, incl. the #45
always-available record Actions menu), reset unreleased notes, and updated
version references in README, guides and the testing policy. Backend
requirement moves to published QQQ 4.1 candidates (RC.1+, RC.2+ for
standalone branding images, QQQ #1009).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:52:03 -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 b0b0d469fe Merge pull request #46 from QRun-IO/fix/45-record-actions-menu
fix(record-view): always show the Actions menu when it has entries (#45)
2026-10-11 10:04:06 -05:00
james.maesandClaude Opus 5.5 3ceed91214 test(acceptance): PRC-050 record menu also offers Audit (#45)
The default record Actions menu ends with Audit for users who can read
audits, so assert Developer Mode and Audit are present rather than
Developer Mode being last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:58:42 -05:00
james.maesandClaude Opus 5.5 4e12a5d524 fix(record-view): always show the Actions menu when it has entries (#45)
RecordViewHeader passed an empty entry list to the desktop RecordActions
unless the table had processes or a custom VIEW_SCREEN_ACTIONS menu, so
plain tables lost Developer Mode and Audit. The Actions controls (desktop
and phone) were also gated on write permissions or processes, hiding them
from read-only tables that still offer Developer Mode and Audit.

Pass the resolved default/custom menu entries through unchanged and show
the Actions controls whenever that menu has items. RecordActions keeps
de-duplicating Copy/Delete via menuOffersAction, so those now live in the
menu instead of also appearing as buttons, matching Material.

Update the component tests and the PRC-050 / REC-046 acceptance specs
that asserted the old missing-menu behavior.

Closes #45

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

No files matched your search

+3 -3
View File
@@ -2,7 +2,7 @@
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.
**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.
**This checkout targets Next UI 1.0.0-RC.12**, with the always-available record Actions menu, private query-state clearing between users, standalone branding and CSP, grid focus and other fixes. Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing; see the [RC12 notes](docs/releases/1.0.0-RC.12.md) for the preparation checkpoint and verification scope. [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) 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).
@@ -14,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.11</version>
<version>1.0.0-RC.12</version>
</dependency>
```
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 explicit version overrides an older BOM-managed frontend. RC12 requires a published QQQ 4.1 candidate: 4.1.0-RC.1 or later, and 4.1.0-RC.2 or later for standalone branding images (QQQ #1009); see the release notes for the verified backend. 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
View File
@@ -26,6 +26,7 @@ The ID may be on a layout-neutral wrapper around a native button or menu item. S
- App home and chrome: `app-card-{appName}-icon`, `.banner.info`/`.banner.warning`/`.banner.error`, and `.qqq-branded-header-bar`.
- Query: `button-filter-builder`, `button-views`, `quick-views-container`, `button-query-mode-basic`, `button-query-mode-advanced`, `table-header-{field}`, and `menu-item-{action}`.
- Record view: `.recordView`, `record-sidebar`, `record-view-header-{table}`, `record-view-title-{table}`, `record-view-button-bar-{table}`, `record-view-actions-menu`, and `delete-confirmation-*` with `button-delete-yes`/`button-delete-no`.
- File previews: a `FILE_PREVIEW` value keeps its cell or value ID (`grid-cell-{field}`, `field-value-{field}`) on the thumbnail button with `data-preview-kind`; the box is `{id}-thumbnail`. The dialog uses `file-preview-dialog`, `file-preview-image`/`-pdf`/`-video`/`-audio`/`-unavailable`, `file-preview-details`, and `file-preview-{previous,next,zoom-in,zoom-out,zoom-fit,open,download,close}`.
- Forms: `.entityForm`, `record-create-{table}`/`record-edit-{table}`, `form-section-{section}`, `.field-wrapper.is-visible`, and `.stickyBottomButtonBar`. Default action buttons expose `data-button-variant` values such as `gradient`, `contained`, and `outlined`.
The record view has a phone accordion and action sheet, a tablet tab list without the large-screen sidebar, and a large-screen tab list with the sidebar. A selector can be present in the DOM while its control is hidden at a narrower width. Test custom CSS at all three widths, including focus and contrast, before shipping it.
+2 -2
View File
@@ -1,10 +1,10 @@
# Next UI agent guide
> **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.
> **This checkout targets RC12.** Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. [RC12 notes](../releases/1.0.0-RC.12.md) record the preparation checkpoint and verification scope; RC11 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.
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 RC12 preparation checkpoint was [1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) at `2853b9b`, 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
+4 -4
View File
@@ -1,12 +1,12 @@
# Next UI developer guide
> **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.
> **This checkout targets RC12.** Confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. [RC12 notes](../releases/1.0.0-RC.12.md) record the preparation checkpoint and verification scope; RC11 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.
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 RC12 preparation checkpoint was [1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) (commit `2853b9b`); 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.
@@ -35,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.11</version>
<version>1.0.0-RC.12</version>
</dependency>
```
@@ -48,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). 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.
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.12`, overriding an older BOM-managed frontend; confirm its availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. It requires a published QQQ 4.1 candidate (4.1.0-RC.1 or later; 4.1.0-RC.2 or later for standalone branding images, QQQ #1009); see the release notes for the verified backend. Use a matching local backend/frontend build to test later branch changes.
| Setting | Behavior in this checkout |
|---|---|
+2 -2
View File
@@ -10,11 +10,11 @@ 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.
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.
Previously verified public candidate at the RC12 preparation checkpoint: **[1.0.0-RC.11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC10 remain available and unchanged.
## Fixes during testing
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.
This checkout targets [RC12](1.0.0-RC.12.md); confirm availability in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) before installing. The candidate adds the always-available record Actions menu ([#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)), private query-state clearing between users (QQQ #1006), standalone branding and CSP derivation (QQQ #1009, #1017), grid and report focus fixes, and the other changes listed in the RC12 notes. These changes are absent from immutable RC11. 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.
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.
+60
View File
@@ -0,0 +1,60 @@
# Next UI 1.0.0-RC.12 — preparation
At this preparation checkpoint, RC12 is being prepared for application testing and **is not yet published**. [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.12` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.12`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
## Source
Main at `b0b0d469feb0b1bd473fbf189540e712ffc09807` (merge of [PR46](https://github.com/QRun-IO/qqq-frontend-next/pull/46)). Merged since RC11: PR25, PR27, PR28, PR29, PR30, PR32, PR35, PR36, PR38, PR40, PR42, PR43, PR44 and PR46. The SQL Workbench work in draft PR47 is not included.
## Backend requirements
RC12 runs on a published QQQ 4.1 candidate: **4.1.0-RC.1** or later. Standalone branding images (below) need **4.1.0-RC.2** or later, which contains QQQ #1009's `/qqq/branding/logo` and `/qqq/branding/icon` endpoints. RC12 does not publish QQQ 4.1.
## Product fixes
- Always show the record view's Actions menu when it has entries. Tables without processes or a custom `VIEW_SCREEN_ACTIONS` menu, and read-only tables, again reach Developer Mode and Audit (Material parity); Copy and Delete move into that menu instead of also appearing as separate buttons. [#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)
- 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)
## Standalone branding and CSP (QQQ #1009, #1017)
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.
## 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 checkpoint
PR checks on the RC12 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the full real-backend acceptance workflow) are recorded on the preparation PR. Tag acceptance and public-artifact verification are recorded in the [RC12 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) after delivery. No RC11 test count or artifact digest is attributed to RC12.
## Remaining RC limits
The RC11 limits carry forward unchanged. The RC11 tag acceptance recorded 2,990 passes and 2 owner-accepted failures (Firefox NAV-014 font diagnostic, tablet NAV-056 access-control diagnostics) under the documented secondary-browser/tablet exception ([#985](https://github.com/QRun-IO/qqq/issues/985), [#798](https://github.com/QRun-IO/qqq/issues/798)); it does not waive new product defects or authorize GA. Historical REC-054/PRC-061 observations remain under [#954](https://github.com/QRun-IO/qqq/issues/954) and [#970](https://github.com/QRun-IO/qqq/issues/970). 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), [#712](https://github.com/QRun-IO/qqq/issues/712) and [#904](https://github.com/QRun-IO/qqq/issues/904). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin.
+18 -43
View File
@@ -1,53 +1,28 @@
# 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).
[RC12 preparation](1.0.0-RC.12.md) collects every change merged since [RC11](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11): the always-available record Actions menu ([#45](https://github.com/QRun-IO/qqq-frontend-next/issues/45)), private query-state clearing between users, standalone branding and CSP derivation, grid and report focus fixes, and browser-testing hardening. RC11 is published and immutable. Changes merged after the RC12 preparation commit are listed below. 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).
## Features
- Show `FILE_PREVIEW`-adorned fields as thumbnails, and open a preview on click or Enter. Thumbnails appear in the query grid and card view, the record view (a larger thumbnail with the file name), associated-record tables, child-record list widgets and record-grid widgets. They load lazily at a fixed size, so loading does not shift the layout, with a loading pulse and a file-type icon fallback. The preview is an accessible dialog: focus is trapped and restored, Escape closes it, and Left/Right/Home/End step through the other previews on the same screen. It offers zoom and fit, open-original and download links, and shows type, size, dimensions and caption. PDFs served by the backend (same-origin `/qqq/` paths) and video/audio play inline; other types, and other or cross-origin PDFs (frames are limited to `'self'`), get a file card or open in a new tab. Thumbnails are shown only for explicitly adorned fields; plain URL values are never auto-detected. URLs are limited to http(s), same-origin paths and inline images. The lightbox loads on demand (about 3.5 KB gzip). Requires the QQQ backend adornment from [qqq#1056](https://github.com/QRun-IO/qqq/issues/1056).
## 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)
- Size record-view cards, dashboard widgets and form fields by the width of their own grid (container queries) rather than the viewport: table panels sit side by side only when each is readable and otherwise stack, cards keep their own height, fields share a row only when they fit, and very wide screens use the room. No horizontal page scroll from 360px up.
- Make child-record lists in record-view panels readable: they show the columns that matter most and fit (the record name, status and boolean columns, the most recently changed date; the key and long text last), with `+N more columns` and View All for the rest. Cells align to the top and long text is clamped with the full text on hover. A widget's `onlyIncludeFieldNames` order is respected.
- Show dates compactly everywhere: `6:33 PM` today, `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before, on one line with the full zoned timestamp on hover, in the viewer's zone and locale. DATE values are no longer shifted a day west of UTC. Exports are unchanged.
## 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.
None yet.
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.
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "qqq-frontend-next",
"version": "1.0.0-RC.11",
"version": "1.0.0-RC.12",
"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,6 +61,7 @@
"react-hook-form": "^7.71.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"sql-formatter": "15.6.6",
"tailwind-merge": "^3.5.0",
"zod": "^3.25.76"
},
+13 -1
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. 2026-10-11 (QRun-IO/qqq#1056): main measured /app/_/_/dev at 350.0 KB, exactly the default cap; FILE_PREVIEW thumbnails add 0.6 KB to the record routes (resolver, thumbnail, gallery context; the lightbox is a lazy chunk), giving 350.6 KB, so that route gets an explicit 360 KB cap (about 2.7% headroom). 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) (that route's cap was 355 KB on the SQL Workbench branch). Combined with FILE_PREVIEW (+0.6 KB) the expected /app/_/_/dev first load is about 353.7 KB, inside the 360 KB route cap. Responsive layouts (PR #50): the compact date, child-list column and container-grid work measured 351.1 KB on /app/_/_/dev against main's 350.0 KB (+1.1 KB), and the instance home app (PR #53) 350.1 KB (+0.1 KB). With SQL Workbench, FILE_PREVIEW and PR #50 together the route is expected at about 354.8 KB (355 KB with PR #53), inside the shared 360 KB route cap; every other route keeps the 350 KB default. CI on the combined SQL Workbench + FILE_PREVIEW + PR #50 tree (2026-10-11) measured /app/_/_/dev at 354.9 KB and the table developer route /app/_/dev at 350.8 KB (over the 350 KB default by 0.8 KB), so /app/_/dev gets the same 360 KB route cap (about 2.6% headroom). Raise a cap only with a measured reason in the commit.",
"firstLoadJsGzipKB": 350,
"routes": {
"/": 255,
@@ -12,7 +12,9 @@
"/app/_": 350,
"/app/_/_": 350,
"/app/_/_/copy": 390,
"/app/_/_/dev": 360,
"/app/_/_/edit": 350,
"/app/_/dev": 360,
"/app/_/create": 350,
"/app/_/savedView/_": 350
},
@@ -24,6 +26,16 @@
"$comment": "RapiDoc (the table developer view's API docs, QRun-IO/qqq#714) loads only when that view opens: import('rapidoc') in TableApiDocs. Measured 204.5 KB gzip at 1.0; budget about 5% above.",
"marker": "customElements.define(\"rapi-doc\"",
"gzipKB": 215
},
"sqlWorkbench": {
"$comment": "CodeMirror 6 (state, view, language, autocomplete, lang-sql) plus the SQL Workbench widget, loaded when the SQL_WORKBENCH widget renders: import() in WidgetRenderer. Measured 127.2 KB gzip at introduction; budget about 6% above.",
"marker": "qqq-sql-workbench-editor",
"gzipKB": 135
},
"sqlFormatter": {
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
"marker": "logicalOperatorNewline",
"gzipKB": 65
}
}
}
+175 -2
View File
@@ -13,9 +13,30 @@ importers:
.:
dependencies:
'@codemirror/autocomplete':
specifier: 6.18.6
version: 6.18.6
'@codemirror/commands':
specifier: 6.8.1
version: 6.8.1
'@codemirror/lang-sql':
specifier: 6.9.0
version: 6.9.0
'@codemirror/language':
specifier: 6.11.2
version: 6.11.2
'@codemirror/state':
specifier: 6.5.2
version: 6.5.2
'@codemirror/view':
specifier: 6.38.1
version: 6.38.1
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))
'@lezer/highlight':
specifier: 1.2.1
version: 1.2.1
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -91,6 +112,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
sql-formatter:
specifier: 15.6.6
version: 15.6.6
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -335,6 +359,24 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@codemirror/autocomplete@6.18.6':
resolution: {integrity: sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==}
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-sql@6.9.0':
resolution: {integrity: sha512-xmtpWqKSgum1B1J3Ro6rf7nuPqf2+kJQg5SjrofCAcyCThOe0ihSktSoXfXuhQBnwx1QbmreBbLJM5Jru6zitg==}
'@codemirror/language@6.11.2':
resolution: {integrity: sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==}
'@codemirror/state@6.5.2':
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
'@codemirror/view@6.38.1':
resolution: {integrity: sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
@@ -875,12 +917,24 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.3':
resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
'@lezer/lr@1.4.11':
resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==}
'@lit-labs/ssr-dom-shim@1.6.0':
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@marijn/find-cluster-break@1.0.4':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -2748,6 +2802,9 @@ packages:
core-js-pure@3.50.0:
resolution: {integrity: sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==}
crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2909,6 +2966,9 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
discontinuous-range@1.0.0:
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -3881,6 +3941,9 @@ packages:
module-alias@2.3.4:
resolution: {integrity: sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==}
moo@0.5.3:
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -3918,6 +3981,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
needle@2.9.1:
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
engines: {node: '>= 4.4.x'}
@@ -4163,6 +4230,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
ramda-adjunct@5.1.0:
resolution: {integrity: sha512-8qCpl2vZBXEJyNbi4zqcgdfHtcdsWjOGbiNSEnEBrM6Y0OKOT8UxJbIVGm1TIcjaSu2MxaWcgtsNlKlCk7o7qg==}
engines: {node: '>=0.10.3'}
@@ -4172,6 +4242,10 @@ packages:
ramda@0.30.1:
resolution: {integrity: sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==}
randexp@0.4.6:
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
engines: {node: '>=0.12'}
randexp@0.5.3:
resolution: {integrity: sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==}
engines: {node: '>=4'}
@@ -4314,6 +4388,10 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
ret@0.1.15:
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
engines: {node: '>=0.12'}
ret@0.2.2:
resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==}
engines: {node: '>=4'}
@@ -4470,6 +4548,10 @@ packages:
spdx-license-ids@3.0.23:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
sql-formatter@15.6.6:
resolution: {integrity: sha512-bZydXEXhaNDQBr8xYHC3a8thwcaMuTBp0CkKGjwGYDsIB26tnlWeWPwJtSQ0TEwiJcz9iJJON5mFPkx7XroHcg==}
hasBin: true
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -4559,6 +4641,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
style-mod@4.1.4:
resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -4913,6 +4998,9 @@ packages:
jsdom:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -5207,6 +5295,49 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@codemirror/autocomplete@6.18.6':
dependencies:
'@codemirror/language': 6.11.2
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.1
'@lezer/common': 1.5.3
'@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.11.2
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.1
'@lezer/common': 1.5.3
'@codemirror/lang-sql@6.9.0':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.11.2
'@codemirror/state': 6.5.2
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.11
'@codemirror/language@6.11.2':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.38.1
'@lezer/common': 1.5.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.11
style-mod: 4.1.4
'@codemirror/state@6.5.2':
dependencies:
'@marijn/find-cluster-break': 1.0.4
'@codemirror/view@6.38.1':
dependencies:
'@codemirror/state': 6.5.2
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
'@csstools/color-helpers@5.1.0': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
@@ -5651,12 +5782,24 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@lezer/common@1.5.3': {}
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.5.3
'@lezer/lr@1.4.11':
dependencies:
'@lezer/common': 1.5.3
'@lit-labs/ssr-dom-shim@1.6.0': {}
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@marijn/find-cluster-break@1.0.4': {}
'@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)':
dependencies:
'@types/mdx': 2.0.14
@@ -7702,8 +7845,7 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3:
optional: true
commander@2.20.3: {}
comment-parser@1.4.5: {}
@@ -7715,6 +7857,8 @@ snapshots:
core-js-pure@3.50.0: {}
crelt@1.0.7: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -7848,6 +7992,8 @@ snapshots:
diff-sequences@29.6.3: {}
discontinuous-range@1.0.0: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -8996,6 +9142,8 @@ snapshots:
module-alias@2.3.4: {}
moo@0.5.3: {}
mrmime@2.0.1: {}
ms@2.0.0: {}
@@ -9035,6 +9183,13 @@ snapshots:
natural-compare@1.4.0: {}
nearley@2.20.1:
dependencies:
commander: 2.20.3
moo: 0.5.3
railroad-diagrams: 1.0.0
randexp: 0.4.6
needle@2.9.1:
dependencies:
debug: 3.2.7
@@ -9327,12 +9482,19 @@ snapshots:
queue-microtask@1.2.3: {}
railroad-diagrams@1.0.0: {}
ramda-adjunct@5.1.0(ramda@0.30.1):
dependencies:
ramda: 0.30.1
ramda@0.30.1: {}
randexp@0.4.6:
dependencies:
discontinuous-range: 1.0.0
ret: 0.1.15
randexp@0.5.3:
dependencies:
drange: 1.1.1
@@ -9510,6 +9672,8 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
ret@0.1.15: {}
ret@0.2.2: {}
rettime@0.10.1: {}
@@ -9719,6 +9883,11 @@ snapshots:
spdx-license-ids@3.0.23: {}
sql-formatter@15.6.6:
dependencies:
argparse: 2.0.1
nearley: 2.20.1
stable-hash@0.0.5: {}
stack-utils@2.0.6:
@@ -9842,6 +10011,8 @@ snapshots:
strip-json-comments@3.1.1: {}
style-mod@4.1.4: {}
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4):
dependencies:
client-only: 0.0.1
@@ -10245,6 +10416,8 @@ snapshots:
transitivePeerDependencies:
- msw
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
+1 -1
View File
@@ -47,7 +47,7 @@
</developers>
<properties>
<revision>1.0.0-RC.11</revision>
<revision>1.0.0-RC.12</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
@@ -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 (
<>
+2 -2
View File
@@ -21,7 +21,7 @@
import React from 'react'
import type { EsbCounter } from '@/types'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { compactDateTime, relativeDateTime } from '@/lib/utils/datetime-utils'
/** Which side of a destination the counters describe. */
export type EsbCountersKind = 'publish' | 'consume'
@@ -59,7 +59,7 @@ export function EsbCounters({ counters, kind }: { counters: EsbCounter; kind: Es
['Max time', `${Math.round(counters.maxMs)} ms`]
)
}
items.push(['Last activity', formatDateTime(counters.lastActivity) ?? '—'])
items.push(['Last activity', relativeDateTime(counters.lastActivity) ?? compactDateTime(counters.lastActivity)?.text ?? '—'])
return (
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
+2 -2
View File
@@ -30,7 +30,7 @@ import { X } from 'lucide-react'
import type { EsbMessage, EsbPermissions } from '@/types'
import { ESB_PAGE_SIZE, getEsbDeadLetters, getEsbMessages } from '@/lib/api/esb'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import {
EsbQueueActions,
EsbSelectionActions,
@@ -223,7 +223,7 @@ function MessageRow({
)}
<td className="px-2 py-2 font-mono text-xs text-foreground">{message.messageId}</td>
<td className="whitespace-nowrap px-2 py-2 text-foreground">
{formatDateTime(message.timestamp) ?? message.timestamp}
<DateTimeText type="DATE_TIME" value={message.timestamp} relative className="text-xs" />
</td>
<td className="px-2 py-2 tabular-nums text-foreground">{message.deliveryCount}</td>
<td className="space-y-1 px-2 py-2">
+2 -2
View File
@@ -417,7 +417,7 @@ export function DynamicForm({
/>
)}
{/* a 12-column grid: full width on phones, half from sm, the field's gridColumns from lg (Material) */}
<div className="grid grid-cols-12 gap-4">
<div className="@container grid grid-cols-12 gap-4">
{gridFields.map((f) => (
<div
key={f.name}
@@ -456,7 +456,7 @@ export function DynamicForm({
{formLabel && (
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
)}
<div className="grid grid-cols-12 gap-4">
<div className="@container grid grid-cols-12 gap-4">
{resolvedFields.map((f) => (
<div
key={f.name}
+43
View File
@@ -0,0 +1,43 @@
/*
* 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 FileKindIcon — the file-type icon shown for a FILE_PREVIEW value that has no image thumbnail.
*/
import React from 'react'
import { File, FileAudio, FileImage, FileText, FileVideo } from 'lucide-react'
import type { FilePreviewKind } from '@/lib/utils/file-preview'
const ICONS: Record<FilePreviewKind, typeof File> = {
image: FileImage,
pdf: FileText,
video: FileVideo,
audio: FileAudio,
other: File,
}
/**
* A decorative icon for a kind of file.
*
* @param props - The kind and classes.
* @returns The icon (hidden from assistive technology).
*/
export function FileKindIcon({ kind, className }: { kind: FilePreviewKind; className?: string }) {
const Icon = ICONS[kind]
return <Icon className={className} aria-hidden="true" data-file-kind={kind} />
}
@@ -0,0 +1,94 @@
/*
* 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_PREVIEW stories: thumbnails as the query grid and the record view show them, and the
* preview lightbox they open (arrow keys step through the gallery).
*/
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import type { QFieldMetaData, QRecord } from '@/types'
import { DataCell } from '@/components/query/DataCell'
import { FieldValue } from '@/components/records/FieldValue'
import { FilePreviewGallery } from './FilePreviewGallery'
/** An inline SVG "photo", so the stories need no network. */
function swatch(from: string, to: string, label: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="1200" height="800" fill="url(#g)"/><text x="600" y="430" font-family="sans-serif" font-size="96" fill="white" text-anchor="middle">${label}</text></svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
const field: QFieldMetaData = {
name: 'originalUrl', label: 'Image', type: 'STRING', isEditable: false, isRequired: false, isHeavy: false, isHidden: false,
adornments: [{
type: 'FILE_PREVIEW',
values: { fileNameField: 'fileName', mimeTypeField: 'mimeType', widthField: 'width', heightField: 'height', sizeBytesField: 'fileSize', captionField: 'caption' },
}],
}
const records: QRecord[] = [
{ tableName: 'media', values: { originalUrl: swatch('#0ea5e9', '#6366f1', 'Harbor'), fileName: 'harbor.svg', mimeType: 'image/svg+xml', width: 1200, height: 800, fileSize: 48213, caption: 'Morning at the harbor' } },
{ tableName: 'media', values: { originalUrl: swatch('#f97316', '#db2777', 'Sunset'), fileName: 'sunset.svg', mimeType: 'image/svg+xml', width: 1200, height: 800, fileSize: 51022 } },
{ tableName: 'media', values: { originalUrl: '/files/annual-report.pdf', fileName: 'annual-report.pdf', mimeType: 'application/pdf', fileSize: 1832000 } },
]
/** Thumbnails in a grid-like list; click one (or focus it and press Enter) to preview. */
function GridThumbnails() {
return (
<FilePreviewGallery records={records} fields={[field]}>
<table className="text-sm">
<tbody>
{records.map((record) => (
<tr key={String(record.values.fileName)} className="border-b border-border">
<td className="p-2"><DataCell field={field} value={record.values.originalUrl} displayValue={undefined} record={record} /></td>
<td className="p-2">{String(record.values.fileName)}</td>
</tr>
))}
</tbody>
</table>
</FilePreviewGallery>
)
}
const meta = {
title: 'Records/FilePreview',
component: GridThumbnails,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'FILE_PREVIEW adornment: fixed-size lazy thumbnails (file-type icons for non-images) that open an accessible preview lightbox with zoom, details, open/download and arrow-key navigation.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof GridThumbnails>
export default meta
type Story = StoryObj<typeof meta>
/** Query-grid thumbnails (40 px, the default `thumbnailMaxSize`). */
export const QueryGrid: Story = {}
/** A record view value: a larger thumbnail with the file name. */
export const RecordView: Story = {
render: () => (
<FilePreviewGallery records={[records[0]]} fields={[field]}>
<FieldValue field={field} record={records[0]} thumbnailSize={160} />
</FilePreviewGallery>
),
}
+310
View File
@@ -0,0 +1,310 @@
/*
* 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 from 'react'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import type { FieldAdornment, QFieldMetaData, QRecord } from '@/types'
import { filePreview } from '@/lib/utils/file-preview'
import { DataCell } from '@/components/query/DataCell'
import { FieldValue } from '@/components/records/FieldValue'
import { FilePreviewGallery } from './FilePreviewGallery'
import { FileThumbnail } from './FileThumbnail'
import { FilePreviewLightbox } from './FilePreviewLightbox'
import type { FilePreviewItem } from '@/lib/utils/file-preview'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
function field(name: string, values: Record<string, unknown> = {}, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
const adornments: FieldAdornment[] = [{ type: 'FILE_PREVIEW', values }]
return { name, label: name, type: 'STRING', isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments, ...extra }
}
function record(values: Record<string, unknown>, displayValues: Record<string, string> = {}): QRecord {
return { tableName: 'media', values, displayValues }
}
function item(overrides: Partial<FilePreviewItem> = {}): FilePreviewItem {
return { url: '/files/a.png', thumbnailUrl: '/files/a-t.png', fileName: 'a.png', mimeType: 'image/png', kind: 'image', alt: 'a.png', previewable: true, thumbnailSize: 40, ...overrides }
}
const media = field('url', { thumbnailUrlField: 'thumb', fileNameField: 'name', mimeTypeField: 'type', widthField: 'w', heightField: 'h', sizeBytesField: 'size', captionField: 'caption' })
const rows = [
record({ url: '/files/one.jpg', thumb: '/files/one-t.jpg', name: 'one.jpg', type: 'image/jpeg', w: 1200, h: 800, size: 204800, caption: 'First' }),
record({ url: '/qqq/v1/table/media/2/blob/two.pdf', name: 'two.pdf', type: 'application/pdf' }),
record({ url: '/files/three.png', name: 'three.png', type: 'image/png' }),
]
/**
* Renders the thumbnails of rows inside a gallery, as a grid would.
*/
function Gallery({ onRowClick }: { onRowClick?: () => void }) {
return (
<FilePreviewGallery records={rows} fields={[media]}>
{rows.map((row, index) => (
<div key={index} onClick={onRowClick} data-testid={`row-${index}`}>
<DataCell field={media} value={row.values.url} displayValue={undefined} record={row} />
</div>
))}
</FilePreviewGallery>
)
}
describe('FileThumbnail (QRun-IO/qqq#1056)', () => {
it('renders a fixed-size lazy thumbnail with a loading pulse until the image loads', () => {
render(<FileThumbnail field={media} record={rows[0]} item={filePreview(media, rows[0])!} dataQqqId="grid-cell-url" />)
const button = screen.getByRole('button', { name: 'Preview one.jpg' })
expect(button).toHaveAttribute('aria-haspopup', 'dialog')
const box = document.querySelector('[data-qqq-id="grid-cell-url-thumbnail"]') as HTMLElement
expect(box.style.width).toBe('40px')
expect(box.style.height).toBe('40px')
const image = box.querySelector('img') as HTMLImageElement
expect(image).toHaveAttribute('src', '/files/one-t.jpg')
expect(image).toHaveAttribute('loading', 'lazy')
expect(image).toHaveAttribute('alt', '')
expect(document.querySelector('[data-qqq-id="grid-cell-url-thumbnail-loading"]')).not.toBeNull()
fireEvent.load(image)
expect(document.querySelector('[data-qqq-id="grid-cell-url-thumbnail-loading"]')).toBeNull()
})
it('shows the file-type icon when the image fails or the file is not an image', () => {
render(<FileThumbnail field={media} record={rows[0]} item={filePreview(media, rows[0])!} dataQqqId="t1" />)
fireEvent.error(document.querySelector('[data-qqq-id="t1-thumbnail"] img')!)
expect(document.querySelector('[data-qqq-id="t1-thumbnail"] img')).toBeNull()
expect(document.querySelector('[data-qqq-id="t1-thumbnail"] [data-file-kind="image"]')).not.toBeNull()
render(<FileThumbnail field={media} record={rows[1]} item={filePreview(media, rows[1])!} dataQqqId="t2" />)
expect(document.querySelector('[data-qqq-id="t2-thumbnail"] [data-file-kind="pdf"]')).not.toBeNull()
})
it('is not a button when previewable is false, and keeps the alt text', () => {
const still = field('url', { previewable: false })
const row = record({ url: '/files/x.png' })
render(<FileThumbnail field={still} record={row} item={filePreview(still, row)!} size={160} showName dataQqqId="t3" />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByRole('img', { name: 'x.png' })).toBeInTheDocument()
expect(screen.getByText('x.png')).toBeInTheDocument()
expect((document.querySelector('[data-qqq-id="t3-thumbnail"]') as HTMLElement).style.width).toBe('160px')
})
it('opens a preview of just its file outside a gallery', async () => {
const user = userEvent.setup()
render(<FileThumbnail field={media} record={rows[2]} item={filePreview(media, rows[2])!} dataQqqId="solo" />)
await user.click(screen.getByRole('button', { name: 'Preview three.png' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText('three.png')).toBeInTheDocument()
expect(within(dialog).getByText('Preview')).toBeInTheDocument()
expect(within(dialog).queryByRole('button', { name: 'Next file' })).toBeNull()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).toBeNull()
})
})
describe('FilePreviewGallery', () => {
it('opens at the clicked value without triggering the row, steps with the arrow keys and returns focus on Escape', async () => {
const user = userEvent.setup()
const onRowClick = vi.fn()
render(<Gallery onRowClick={onRowClick} />)
const first = screen.getByRole('button', { name: 'Preview one.jpg' })
await user.click(first)
expect(onRowClick).not.toHaveBeenCalled()
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByRole('heading', { name: 'one.jpg' })).toBeInTheDocument()
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
// the caption is the alt text when there is no alt text field
expect(within(dialog).getByRole('img', { name: 'First' })).toHaveAttribute('src', '/files/one.jpg')
// details: type, size, dimensions and caption
const details = document.querySelector('[data-qqq-id="file-preview-details"]') as HTMLElement
expect(details).toHaveTextContent('First')
expect(details).toHaveTextContent('image/jpeg')
expect(details).toHaveTextContent('200 KB')
expect(details).toHaveTextContent('1200 × 800 px')
await user.keyboard('{ArrowRight}')
expect(within(dialog).getByRole('heading', { name: 'two.pdf' })).toBeInTheDocument()
expect(dialog).toHaveAttribute('data-preview-kind', 'pdf')
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toHaveAttribute('src', '/qqq/v1/table/media/2/blob/two.pdf')
await user.keyboard('{End}')
expect(within(dialog).getByText('3 of 3')).toBeInTheDocument()
await user.keyboard('{ArrowRight}')
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
await user.keyboard('{ArrowLeft}')
expect(within(dialog).getByText('3 of 3')).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'Previous file' }))
expect(within(dialog).getByText('2 of 3')).toBeInTheDocument()
await user.keyboard('{Home}')
expect(within(dialog).getByText('1 of 3')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).toBeNull()
expect(first).toHaveFocus()
expect(onRowClick).not.toHaveBeenCalled()
})
it('opens from the keyboard without the key reaching the row', async () => {
const user = userEvent.setup()
const onKeyDown = vi.fn()
render(
<div onKeyDown={onKeyDown}><Gallery /></div>,
)
screen.getByRole('button', { name: 'Preview three.png' }).focus()
await user.keyboard('{Enter}')
expect(await screen.findByRole('dialog')).toBeInTheDocument()
expect(within(screen.getByRole('dialog')).getByText('3 of 3')).toBeInTheDocument()
expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: 'Enter' }))
})
it('has no accessibility violations with the preview open', async () => {
const user = userEvent.setup()
const { baseElement } = render(<Gallery />)
expect(await axe(baseElement)).toHaveNoViolations()
await user.click(screen.getByRole('button', { name: 'Preview one.jpg' }))
await screen.findByRole('dialog')
expect(await axe(baseElement)).toHaveNoViolations()
})
})
describe('FilePreviewLightbox', () => {
function show(items: FilePreviewItem[], index = 0) {
const onIndexChange = vi.fn()
const onClose = vi.fn()
render(<FilePreviewLightbox items={items} index={index} onIndexChange={onIndexChange} onClose={onClose} />)
return { onIndexChange, onClose, dialog: screen.getByRole('dialog') }
}
it('zooms images with the buttons, keys and a double click, and resets to fit', async () => {
const user = userEvent.setup()
show([item()])
const image = screen.getByRole('img', { name: 'a.png' })
expect(image).toHaveAttribute('data-zoom', '1')
expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Zoom in' }))
expect(image).toHaveAttribute('data-zoom', '1.5')
await user.keyboard('+')
expect(image).toHaveAttribute('data-zoom', '2')
await user.keyboard('-')
expect(image).toHaveAttribute('data-zoom', '1.5')
await user.keyboard('0')
expect(image).toHaveAttribute('data-zoom', '1')
fireEvent.doubleClick(image)
expect(image).toHaveAttribute('data-zoom', '2')
await user.click(screen.getByRole('button', { name: 'Zoom 200%, fit to window' }))
expect(image).toHaveAttribute('data-zoom', '1')
for (let i = 0; i < 6; i++) await user.click(screen.getByRole('button', { name: 'Zoom in' }).closest('button')!)
expect(image).toHaveAttribute('data-zoom', '4')
expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled()
})
it('applies the zoom as a width once the image has its fitted size', () => {
show([item()])
const image = screen.getByRole('img', { name: 'a.png' }) as HTMLImageElement
Object.defineProperty(image, 'naturalWidth', { value: 2000 })
Object.defineProperty(image, 'naturalHeight', { value: 1000 })
image.getBoundingClientRect = () => ({ width: 500, height: 250 } as DOMRect)
fireEvent.load(image)
expect(document.querySelector('[data-qqq-id="file-preview-details"]')).toHaveTextContent('2000 × 1000 px')
fireEvent.doubleClick(image)
expect(image.style.width).toBe('1000px')
})
it('offers open-original and download links (attachment for backend files)', () => {
show([item({ url: '/qqq/v1/table/media/1/blob/a.png', fileName: 'a.png' })])
expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toHaveAttribute('href', '/qqq/v1/table/media/1/blob/a.png')
expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toHaveAttribute('rel', 'noopener noreferrer')
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', '/qqq/v1/table/media/1/blob/a.png?download=1')
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('download', 'a.png')
})
it('falls back to the file card for an image that fails to load', () => {
show([item()])
fireEvent.error(screen.getByRole('img', { name: 'a.png' }))
expect(screen.getByText('This file could not be loaded.')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Zoom in' })).toBeNull()
})
it('explains types it cannot show', () => {
show([item({ kind: 'other', mimeType: 'application/zip', fileName: 'b.zip', url: '/files/b.zip' })])
expect(screen.getByText('No preview is available for this type of file.')).toBeInTheDocument()
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', '/files/b.zip')
})
it('does not frame a PDF from another origin', () => {
show([item({ kind: 'pdf', mimeType: 'application/pdf', fileName: 'c.pdf', url: 'https://elsewhere.example/c.pdf' })])
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toBeNull()
expect(screen.getByText(/opens in a new tab instead/)).toBeInTheDocument()
})
it('frames only backend file paths, not other pages of this origin', () => {
show([item({ kind: 'pdf', mimeType: 'application/pdf', fileName: 'd.pdf', url: '/app/media/1' })])
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toBeNull()
expect(screen.getByText(/not a file served by the application/)).toBeInTheDocument()
})
it('plays video and audio, falling back when the player cannot load them', () => {
show([item({ kind: 'video', mimeType: 'video/mp4', fileName: 'v.mp4', url: '/files/v.mp4' }), item({ kind: 'audio', mimeType: 'audio/mpeg', fileName: 's.mp3', url: '/files/s.mp3' })])
const video = document.querySelector('[data-qqq-id="file-preview-video"]') as HTMLVideoElement
expect(video).toHaveAttribute('src', '/files/v.mp4')
expect(video).toHaveAttribute('controls')
// arrow keys used inside the player are its own
fireEvent.keyDown(video, { key: 'ArrowRight' })
act(() => { fireEvent.error(video) })
expect(screen.getByText('This file could not be loaded.')).toBeInTheDocument()
})
it('renders an audio player', () => {
show([item({ kind: 'audio', mimeType: 'audio/mpeg', fileName: 's.mp3', url: '/files/s.mp3' })])
expect(document.querySelector('[data-qqq-id="file-preview-audio"]')).toHaveAttribute('src', '/files/s.mp3')
})
it('reports navigation and closing to its owner, ignoring modified keys', async () => {
const user = userEvent.setup()
const { onIndexChange, onClose } = show([item(), item({ fileName: 'b.png', url: '/b.png' })], 1)
await user.keyboard('{Control>}{ArrowRight}{/Control}')
expect(onIndexChange).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Next file' }))
expect(onIndexChange).toHaveBeenLastCalledWith(0)
await user.click(screen.getByRole('button', { name: 'Close preview' }))
expect(onClose).toHaveBeenCalled()
})
})
describe('FILE_PREVIEW in the shared value renderers', () => {
function renderValue(element: React.ReactElement) {
return render(<QueryClientProvider client={new QueryClient()}>{element}</QueryClientProvider>)
}
it('DataCell shows a thumbnail in place of other adornments, and the usual value without a URL', () => {
const both = { ...media, adornments: [{ type: 'LINK' as const }, ...media.adornments] }
renderValue(<DataCell field={both} value={rows[0].values.url} displayValue={undefined} record={rows[0]} />)
expect(document.querySelector('[data-qqq-id="grid-cell-url"]')).toHaveAttribute('data-preview-kind', 'image')
const empty = record({ url: 'javascript:alert(1)' })
renderValue(<DataCell field={field('bad')} value={empty.values.bad} displayValue={undefined} record={empty} />)
expect(document.querySelector('[data-qqq-id="grid-cell-bad"]')).toHaveTextContent('—')
})
it('FieldValue shows a record-view thumbnail with the file name, and an em dash without a file', () => {
renderValue(<FieldValue field={media} record={rows[0]} thumbnailSize={160} />)
const value = document.querySelector('[data-qqq-id="field-value-url"]') as HTMLElement
expect(value).toHaveTextContent('one.jpg')
expect((document.querySelector('[data-qqq-id="field-value-url-thumbnail"]') as HTMLElement).style.width).toBe('160px')
renderValue(<FieldValue field={field('none')} record={record({ none: null })} />)
expect(document.querySelector('[data-qqq-id="field-value-none"]')).toHaveTextContent('—')
})
})
@@ -0,0 +1,108 @@
/*
* 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 FilePreviewGallery — groups the FILE_PREVIEW values of a set of records (a grid page, a
* child list, a record) so the preview lightbox steps through them with the arrow keys. The
* lightbox code loads only when a preview first opens.
*/
'use client'
import React, { createContext, lazy, Suspense, useCallback, useContext, useMemo, useState } from 'react'
import type { QFieldMetaData, QRecord } from '@/types'
import type { FilePreviewItem } from '@/lib/utils/file-preview'
import { filePreviewItems } from '@/lib/utils/file-preview'
const FilePreviewLightbox = lazy(() => import('./FilePreviewLightbox'))
/** What a thumbnail asks of the gallery around it. */
interface FilePreviewGalleryContextValue {
/**
* Opens the preview at a record's field.
*
* @returns `false` when the value is not part of this gallery.
*/
open: (record: QRecord, field: QFieldMetaData) => boolean
}
const FilePreviewGalleryContext = createContext<FilePreviewGalleryContextValue | null>(null)
/** Props for {@link FilePreviewGallery}. */
interface FilePreviewGalleryProps {
/** Records on screen, in display order. */
records: QRecord[]
/** Fields shown for them, in display order (fields without FILE_PREVIEW are ignored). */
fields: QFieldMetaData[]
/** Selected table variant as v1 JSON, for backend file URLs. */
tableVariant?: string
/** The content holding the thumbnails. */
children: React.ReactNode
}
/**
* Provides the gallery to the thumbnails rendered inside it.
*
* @param props - See {@link FilePreviewGalleryProps}.
* @returns The children, plus the lightbox while one is open.
*/
export function FilePreviewGallery({ records, fields, tableVariant, children }: FilePreviewGalleryProps) {
const entries = useMemo(() => filePreviewItems(records, fields, tableVariant), [records, fields, tableVariant])
const [index, setIndex] = useState<number | null>(null)
const open = useCallback((record: QRecord, field: QFieldMetaData) => {
const position = entries.findIndex((entry) => entry.record === record && entry.field.name === field.name)
if (position < 0) return false
setIndex(position)
return true
}, [entries])
const value = useMemo(() => ({ open }), [open])
const items = useMemo(() => entries.map((entry) => entry.item), [entries])
return (
<FilePreviewGalleryContext.Provider value={value}>
{children}
{index !== null && items.length > 0 && (
<LightboxHost items={items} index={Math.min(index, items.length - 1)} onIndexChange={setIndex} onClose={() => setIndex(null)} />
)}
</FilePreviewGalleryContext.Provider>
)
}
/**
* The gallery around a thumbnail, if any.
*
* @returns The gallery, or `null` outside one.
*/
export function useFilePreviewGallery(): FilePreviewGalleryContextValue | null {
return useContext(FilePreviewGalleryContext)
}
/**
* Loads and shows the lightbox.
*
* @param props - The lightbox props.
* @returns The lazily loaded lightbox.
*/
export function LightboxHost(props: { items: FilePreviewItem[]; index: number; onIndexChange: (index: number) => void; onClose: () => void }) {
return (
<Suspense fallback={null}>
<FilePreviewLightbox {...props} />
</Suspense>
)
}
@@ -0,0 +1,351 @@
/*
* 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 FilePreviewLightbox — the accessible preview dialog for FILE_PREVIEW values: the full-size
* image with zoom, a same-origin PDF, a video or audio player, or a file card with open and
* download links; with the file's details and arrow-key navigation across the gallery. Loaded
* on demand by {@link FilePreviewGallery}.
*/
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { ChevronLeft, ChevronRight, Download, ExternalLink, Maximize2, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { FilePreviewItem } from '@/lib/utils/file-preview'
import { apiUrl } from '@/lib/api/client'
import { attachmentUrl } from '@/lib/utils/adornment-utils'
import { cn } from '@/lib/utils/cn'
import { formatBytes, isBackendFileUrl, isSameOrigin } from '@/lib/utils/file-preview'
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
import { FileKindIcon } from './FileKindIcon'
/** Zoom steps of an image preview (1 = fit to the window). */
export const ZOOM_STEPS = [1, 1.5, 2, 3, 4]
/** Props for {@link FilePreviewLightbox}. */
export interface FilePreviewLightboxProps {
/** The files the preview steps through. */
items: FilePreviewItem[]
/** Index of the file shown. */
index: number
/** Called with the index to show next. */
onIndexChange: (index: number) => void
/** Called when the preview closes. */
onClose: () => void
}
/**
* The preview dialog. Escape closes it, focus is trapped while it is open and returns to the
* thumbnail that opened it; Left/Right (and Home/End) step through the items; `+`, `-` and `0`
* zoom an image, as do the toolbar buttons and a double click.
*
* @param props - See {@link FilePreviewLightboxProps}.
* @returns The dialog.
*/
export function FilePreviewLightbox({ items, index, onIndexChange, onClose }: FilePreviewLightboxProps) {
const restoreFocus = useRestoreFocus(true)
const item = items[Math.min(Math.max(index, 0), items.length - 1)]
const count = items.length
const [zoom, setZoom] = useState(1)
const [failed, setFailed] = useState(false)
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null)
useEffect(() => {
setZoom(1)
setFailed(false)
setNatural(null)
}, [index])
// warm the browser cache with the neighbouring images, so stepping through is instant
useEffect(() => {
if (count < 2 || typeof Image === 'undefined') return
for (const neighbour of [items[(index + 1) % count], items[(index - 1 + count) % count]]) {
if (neighbour?.kind === 'image' && neighbour.url) new Image().src = neighbour.url
}
}, [items, index, count])
const go = useCallback((delta: number) => {
if (count > 1) onIndexChange((index + delta + count) % count)
}, [count, index, onIndexChange])
const zoomBy = useCallback((direction: 1 | -1) => {
setZoom((current) => {
const position = ZOOM_STEPS.indexOf(current)
const next = ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, Math.max(0, position + direction))]
return next ?? 1
})
}, [])
if (!item) return null
const isImage = item.kind === 'image' && !failed
const width = item.width ?? natural?.width
const height = item.height ?? natural?.height
/**
* Keyboard navigation and zoom; keys typed into a media player's controls are left to it.
*
* @param event - The key event.
*/
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement
if (target.closest('video, audio, iframe, input, textarea, select')) return
if (event.altKey || event.ctrlKey || event.metaKey) return
let handled = true
if (event.key === 'ArrowRight') go(1)
else if (event.key === 'ArrowLeft') go(-1)
else if (event.key === 'Home' && count > 1) onIndexChange(0)
else if (event.key === 'End' && count > 1) onIndexChange(count - 1)
else if (isImage && (event.key === '+' || event.key === '=')) zoomBy(1)
else if (isImage && event.key === '-') zoomBy(-1)
else if (isImage && event.key === '0') setZoom(1)
else handled = false
if (handled) event.preventDefault()
}
const details = [
item.mimeType && { label: 'Type', value: item.mimeType },
item.sizeBytes !== undefined && { label: 'Size', value: formatBytes(item.sizeBytes) },
width && height && { label: 'Dimensions', value: `${width} × ${height} px` },
].filter(Boolean) as Array<{ label: string; value: string }>
return (
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose() }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-[2000] bg-black/80" />
<Dialog.Content
onCloseAutoFocus={restoreFocus}
onKeyDown={handleKeyDown}
className="fixed inset-2 z-[2001] flex flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-xl focus:outline-none sm:inset-6"
data-qqq-id="file-preview-dialog"
data-preview-kind={item.kind}
>
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
<div className="min-w-0 flex-1">
<Dialog.Title className="truncate text-sm font-semibold" title={item.fileName} data-qqq-id="file-preview-title">
{item.fileName}
</Dialog.Title>
<Dialog.Description className="text-xs text-muted-foreground" aria-live="polite" data-qqq-id="file-preview-position">
{count > 1 ? `${index + 1} of ${count}` : 'Preview'}
{count > 1 && <span className="sr-only">. Use the left and right arrow keys to see the others.</span>}
</Dialog.Description>
</div>
<div className="flex items-center gap-1" role="toolbar" aria-label="Preview actions">
{isImage && (
<>
<ToolbarButton label="Zoom out" onClick={() => zoomBy(-1)} disabled={zoom <= ZOOM_STEPS[0]} id="zoom-out"><ZoomOut className="h-4 w-4" aria-hidden="true" /></ToolbarButton>
<ToolbarButton label={zoom === 1 ? 'Fit to window' : `Zoom ${Math.round(zoom * 100)}%, fit to window`} onClick={() => setZoom(1)} id="zoom-fit">
<Maximize2 className="h-4 w-4" aria-hidden="true" />
<span className="ml-1 min-w-9 text-xs tabular-nums" aria-hidden="true">{Math.round(zoom * 100)}%</span>
</ToolbarButton>
<ToolbarButton label="Zoom in" onClick={() => zoomBy(1)} disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]} id="zoom-in"><ZoomIn className="h-4 w-4" aria-hidden="true" /></ToolbarButton>
</>
)}
<FileActions item={item} />
<Dialog.Close asChild>
<button type="button" aria-label="Close preview" data-qqq-id="file-preview-close"
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X className="h-4 w-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
</div>
<div className="relative min-h-0 flex-1 bg-muted/40">
<PreviewStage item={item} zoom={zoom} failed={failed} onFailed={() => setFailed(true)}
onNaturalSize={setNatural} onToggleZoom={() => setZoom((current) => (current === 1 ? 2 : 1))} />
{count > 1 && (
<>
<NavButton side="left" label="Previous file" onClick={() => go(-1)}><ChevronLeft className="h-5 w-5" aria-hidden="true" /></NavButton>
<NavButton side="right" label="Next file" onClick={() => go(1)}><ChevronRight className="h-5 w-5" aria-hidden="true" /></NavButton>
</>
)}
</div>
{(details.length > 0 || item.caption) && (
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 border-t border-border px-3 py-2 text-xs sm:px-4" data-qqq-id="file-preview-details">
{item.caption && <p className="w-full text-sm text-foreground">{item.caption}</p>}
{details.length > 0 && (
<dl className="flex flex-wrap gap-x-6 gap-y-1">
{details.map((detail) => (
<div key={detail.label} className="flex gap-1.5">
<dt className="text-muted-foreground">{detail.label}</dt>
<dd className="font-medium tabular-nums">{detail.value}</dd>
</div>
))}
</dl>
)}
</div>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
/**
* The previewed file itself, by kind.
*
* @param props - The item, zoom and callbacks.
* @returns The stage content.
*/
function PreviewStage({ item, zoom, failed, onFailed, onNaturalSize, onToggleZoom }: {
item: FilePreviewItem
zoom: number
failed: boolean
onFailed: () => void
onNaturalSize: (size: { width: number; height: number }) => void
onToggleZoom: () => void
}) {
const [fitWidth, setFitWidth] = useState<number | null>(null)
if (failed || item.kind === 'other' || !item.url) {
return <Unavailable item={item} reason={failed ? 'This file could not be loaded.' : 'No preview is available for this type of file.'} />
}
if (item.kind === 'image') {
const zoomed = zoom > 1 && fitWidth
return (
<div className={cn('absolute inset-0 overflow-auto p-4', !zoomed && 'flex items-center justify-center')} data-qqq-id="file-preview-stage">
{/* eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer */}
<img
key={item.url}
src={item.url}
alt={item.alt}
decoding="async"
draggable={false}
onDoubleClick={onToggleZoom}
onLoad={(event) => {
const image = event.currentTarget
onNaturalSize({ width: image.naturalWidth, height: image.naturalHeight })
setFitWidth(image.getBoundingClientRect().width || null)
}}
onError={onFailed}
className={cn('select-none', zoomed ? 'mx-auto max-w-none cursor-zoom-out' : 'max-h-full max-w-full cursor-zoom-in object-contain')}
style={zoomed ? { width: `${Math.round(fitWidth * zoom)}px` } : undefined}
data-qqq-id="file-preview-image"
data-zoom={zoom}
/>
</div>
)
}
if (item.kind === 'pdf') {
// frames are limited to the dashboard's own origin by its Content-Security-Policy, and within
// it to backend file paths, so a stored URL cannot frame another page of the dashboard
if (!isSameOrigin(item.url)) {
return <Unavailable item={item} reason="This PDF is on another site, so it opens in a new tab instead of here." />
}
if (!isBackendFileUrl(item.url)) {
return <Unavailable item={item} reason="This PDF is not a file served by the application, so it opens in a new tab instead of here." />
}
return <iframe src={item.url} title={item.fileName} className="absolute inset-0 h-full w-full border-0 bg-white" data-qqq-id="file-preview-pdf" />
}
if (item.kind === 'video') {
return (
<div className="absolute inset-0 flex items-center justify-center p-4">
<video key={item.url} src={item.url} controls preload="metadata" className="max-h-full max-w-full" onError={onFailed} data-qqq-id="file-preview-video">
<track kind="captions" />
</video>
</div>
)
}
return (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 p-4">
<FileKindIcon kind="audio" className="h-16 w-16 text-muted-foreground" />
<audio key={item.url} src={item.url} controls preload="metadata" className="w-full max-w-md" onError={onFailed} data-qqq-id="file-preview-audio" />
</div>
)
}
/**
* The fallback for a file that cannot be shown here: its icon, name and open/download links.
*
* @param props - The item and why it is not shown.
* @returns The fallback panel.
*/
function Unavailable({ item, reason }: { item: FilePreviewItem; reason: string }) {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center" data-qqq-id="file-preview-unavailable">
<FileKindIcon kind={item.kind} className="h-16 w-16 text-muted-foreground" />
<p className="max-w-md break-all text-sm font-medium">{item.fileName}</p>
<p className="max-w-md text-sm text-muted-foreground">{reason}</p>
</div>
)
}
/**
* Open-in-new-tab and download links for the full-size file.
*
* @param props - The item.
* @returns The links.
*/
function FileActions({ item }: { item: FilePreviewItem }) {
if (!item.url) return null
// backend field downloads honour ?download=1; other URLs rely on the download attribute (same origin)
const backendFile = item.url.startsWith(apiUrl('/table/'))
const downloadHref = backendFile ? attachmentUrl(item.url) : item.url
const linkClass = 'inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
return (
<>
<a href={item.url} target="_blank" rel="noopener noreferrer" className={linkClass} title="Open original" data-qqq-id="file-preview-open">
<ExternalLink className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Open original (opens in a new tab)</span>
</a>
<a href={downloadHref} download={item.fileName} className={linkClass} title="Download" data-qqq-id="file-preview-download">
<Download className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Download {item.fileName}</span>
</a>
</>
)
}
/**
* An icon button of the toolbar.
*
* @param props - Label, handler, state and icon.
* @returns The button.
*/
function ToolbarButton({ label, onClick, disabled, id, children }: { label: string; onClick: () => void; disabled?: boolean; id: string; children: React.ReactNode }) {
return (
<button type="button" onClick={onClick} disabled={disabled} aria-label={label} title={label} data-qqq-id={`file-preview-${id}`}
className="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-2 text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40">
{children}
</button>
)
}
/**
* A previous/next button over the stage.
*
* @param props - Side, label, handler and icon.
* @returns The button.
*/
function NavButton({ side, label, onClick, children }: { side: 'left' | 'right'; label: string; onClick: () => void; children: React.ReactNode }) {
return (
<button type="button" onClick={onClick} aria-label={label} data-qqq-id={`file-preview-${side === 'left' ? 'previous' : 'next'}`}
className={cn('absolute top-1/2 -translate-y-1/2 inline-flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card/90 text-foreground shadow hover:bg-card focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
side === 'left' ? 'left-2' : 'right-2')}>
{children}
</button>
)
}
export default FilePreviewLightbox
+142
View File
@@ -0,0 +1,142 @@
/*
* 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 FileThumbnail — a FILE_PREVIEW value as a fixed-size, lazily loaded thumbnail (or a
* file-type icon) that opens the preview lightbox: through the surrounding
* {@link FilePreviewGallery} when there is one, else for this file alone.
*/
'use client'
import React, { useState } from 'react'
import type { QFieldMetaData, QRecord } from '@/types'
import type { FilePreviewItem } from '@/lib/utils/file-preview'
import { cn } from '@/lib/utils/cn'
import { FileKindIcon } from './FileKindIcon'
import { LightboxHost, useFilePreviewGallery } from './FilePreviewGallery'
/** Props for {@link FileThumbnail}. */
interface FileThumbnailProps {
/** The adorned field. */
field: QFieldMetaData
/** The record holding the value. */
record: QRecord
/** The resolved preview (see `filePreview`). */
item: FilePreviewItem
/** Edge of the thumbnail box in CSS pixels; defaults to the adornment's `thumbnailMaxSize`. */
size?: number
/** Show the file name beside the thumbnail (record views). */
showName?: boolean
/** The `data-qqq-id` of the value. */
dataQqqId: string
/** Additional classes for the root. */
className?: string
}
/**
* Renders the thumbnail. The box has a fixed size, so loading never shifts the layout; a pulse
* shows until the image loads, and the file-type icon replaces an image that fails to load.
* Clicks and keys do not reach an enclosing row, so a grid row does not also navigate.
*
* @param props - See {@link FileThumbnailProps}.
* @returns The thumbnail, as a button when the value is previewable.
*/
export function FileThumbnail({ field, record, item, size, showName = false, dataQqqId, className }: FileThumbnailProps) {
const gallery = useFilePreviewGallery()
const [loaded, setLoaded] = useState(false)
const [failed, setFailed] = useState(false)
const [standalone, setStandalone] = useState(false)
const edge = size ?? item.thumbnailSize
const image = item.kind === 'image' && item.thumbnailUrl && !failed
const box = (
<span
className="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-muted"
style={{ width: edge, height: edge }}
data-qqq-id={`${dataQqqId}-thumbnail`}
>
{image ? (
<>
{!loaded && <span className="absolute inset-0 animate-pulse bg-muted-foreground/15" aria-hidden="true" data-qqq-id={`${dataQqqId}-thumbnail-loading`} />}
{/* eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer */}
<img
src={item.thumbnailUrl}
alt={item.previewable ? '' : item.alt}
width={edge}
height={edge}
loading="lazy"
decoding="async"
draggable={false}
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
className={cn('h-full w-full', edge > 64 ? 'object-contain' : 'object-cover')}
/>
</>
) : (
<FileKindIcon kind={item.kind} className="h-1/2 w-1/2 text-muted-foreground" />
)}
</span>
)
// the name sits under the thumbnail, no wider than it, so narrow value columns do not break it up
const name = showName ? <span className="block truncate text-sm text-foreground" style={{ maxWidth: edge }}>{item.fileName}</span> : null
const layout = showName ? 'inline-flex flex-col items-start gap-1' : 'inline-flex items-center gap-2'
if (!item.previewable) {
return (
<span className={cn(layout, className)} data-qqq-id={dataQqqId} title={item.fileName}>
{box}
{name}
{!image && !showName && <span className="sr-only">{item.fileName}</span>}
</span>
)
}
/**
* Opens the preview within the gallery, or alone when the value is not part of one.
*
* @param event - The click.
*/
const open = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
event.preventDefault()
// Safari does not focus a clicked button; focus it so closing the preview returns here
event.currentTarget.focus()
if (!gallery?.open(record, field)) setStandalone(true)
}
return (
<>
<button
type="button"
onClick={open}
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() }}
aria-haspopup="dialog"
aria-label={`Preview ${item.fileName}`}
title={item.fileName}
className={cn('group max-w-full cursor-zoom-in rounded-md text-left', layout, 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1', className)}
data-qqq-id={dataQqqId}
data-preview-kind={item.kind}
>
<span className="contents [&>span:first-child]:transition-shadow group-hover:[&>span:first-child]:shadow-md">{box}</span>
{name}
</button>
{standalone && <LightboxHost items={[item]} index={0} onIndexChange={() => undefined} onClose={() => setStandalone(false)} />}
</>
)
}
@@ -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)
+10 -63
View File
@@ -15,7 +15,7 @@
*/
/**
* @file DataCell — dispatches to the correct cell renderer based on QQQ field type and adornments. Handles LINK, CHIP, ERROR, RENDER_HTML, REVEAL, FILE_DOWNLOAD, TOOLTIP adornments (backend value keys) and type-based fallbacks.
* @file DataCell — dispatches to the correct cell renderer based on QQQ field type and adornments. Handles FILE_PREVIEW, LINK, CHIP, ERROR, RENDER_HTML, REVEAL, FILE_DOWNLOAD, TOOLTIP adornments (backend value keys) and type-based fallbacks.
*/
'use client'
@@ -27,6 +27,9 @@ import Link from 'next/link'
import { sanitizeHtml } from '@/lib/utils/sanitize-html'
import type { QFieldMetaData, QRecord } from '@/types'
import { chipStyle, CHIP_COLOR_CLASSES, fileDownload, linkTarget, tooltipText } from '@/lib/utils/adornment-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { filePreview } from '@/lib/utils/file-preview'
import { FileThumbnail } from '@/components/preview/FileThumbnail'
/**
* Props for the DataCell component.
@@ -56,6 +59,10 @@ interface DataCellProps {
export function DataCell({ field, value, displayValue, record }: DataCellProps) {
const display = displayValue ?? (value != null ? String(value) : '')
// FILE_PREVIEW wins over the other adornments: a thumbnail that opens the preview
const preview = filePreview(field, record)
if (preview) return <FileThumbnail field={field} record={record} item={preview} dataQqqId={`grid-cell-${field.name}`} />
// Check adornments for special rendering
for (const adornment of field.adornments ?? []) {
switch (adornment.type) {
@@ -186,22 +193,10 @@ export function DataCell({ field, value, displayValue, record }: DataCellProps)
)
}
case 'DATE': {
if (!value) return <EmptyCell fieldName={field.name} />
return (
<span className="text-sm text-foreground" data-qqq-id={`grid-cell-${field.name}`}>
{display || formatDate(String(value))}
</span>
)
}
case 'DATE':
case 'DATE_TIME': {
if (!value) return <EmptyCell fieldName={field.name} />
return (
<span className="text-sm text-foreground" data-qqq-id={`grid-cell-${field.name}`}>
{displayValue && displayValue !== String(value) ? displayValue : formatDateTime(String(value))}
</span>
)
return <DateTimeText type={field.type} value={value} displayValue={displayValue} dataQqqId={`grid-cell-${field.name}`} />
}
case 'PASSWORD': {
@@ -304,51 +299,3 @@ function RevealCell({ value, fieldName }: { value: string; fieldName: string })
</button>
)
}
// ------------------------------------------------------------------
// Utilities
// ------------------------------------------------------------------
// Intl formatters are costly to construct and a 250-row page can hold thousands of date
// cells, so each is built once, on first use (QRun-IO/qqq#710).
let dateFormat: Intl.DateTimeFormat | undefined
let zoneFormat: Intl.DateTimeFormat | undefined
/**
* Formats an ISO date string using the browser's locale date formatting.
*
* Returns the original string unchanged if parsing fails.
*
* @param value - An ISO 8601 date string (e.g., `"2024-06-15"`).
* @returns A locale-formatted date string (e.g., `"6/15/2024"`).
*/
function formatDate(value: string): string {
try {
const d = new Date(value)
if (isNaN(d.getTime())) return value
// the same output as d.toLocaleDateString(), without a new formatter per call
dateFormat ??= new Intl.DateTimeFormat()
return dateFormat.format(d)
} catch {
return value
}
}
/**
* Formats an ISO datetime string using the browser's locale date-time formatting.
*
* Returns the original string unchanged if parsing fails.
*
* @param value - An ISO 8601 datetime string (e.g., `"2024-06-15T14:30:00Z"`).
* @returns A locale-formatted datetime string (e.g., `"6/15/2024, 2:30:00 PM"`).
*/
function formatDateTime(value: string): string {
const d = new Date(value)
if (isNaN(d.getTime())) return value
const pad = (n: number) => String(n).padStart(2, '0')
const hours = d.getHours() % 12 === 0 ? 12 : d.getHours() % 12
zoneFormat ??= new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' })
const zone = zoneFormat.formatToParts(d).find((p) => p.type === 'timeZoneName')?.value ?? ''
// Material's "yyyy-MM-dd hh:mm:ss AM TZ", in the browser's time zone
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(hours)}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ${d.getHours() < 12 ? 'AM' : 'PM'} ${zone}`.trim()
}
+127 -117
View File
@@ -43,10 +43,13 @@ import { selectHelpContent, QUERY_SCREEN_HELP_ROLES } from '@/lib/utils/help-uti
import { cn } from '@/lib/utils/cn'
import { arrangePinnedColumns, effectivePins, getQueryColumns, orderColumns, type ColumnPins, type QueryColumn } from '@/lib/utils/query-columns'
import { isColumnVisible } from '@/lib/utils/saved-view-utils'
import { joinedPreviewField } from '@/lib/utils/file-preview'
import { HelpContent } from '@/components/records/HelpContent'
import { ColumnHeaderMenu, type ColumnMenuActions } from './ColumnHeaderMenu'
import { tableHeaderQqqId } from '@/lib/utils/qqq-id'
import { DataCell } from './DataCell'
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
/**
* Props for the DataGrid component.
@@ -232,6 +235,9 @@ export function DataGrid({
}
}, [tableMetaData, columnVisibility, columnOrder, columnPins])
// The fields the preview lightbox steps through (FILE_PREVIEW columns, as their cells read them)
const previewFields = useMemo(() => visibleFields.map((column) => column.isJoin ? joinedPreviewField(column.field, column.tableName) : column.field), [visibleFields])
// A user-resized width wins, then explicit metadata, then the readable Next UI default.
const widthOf = useCallback((column: QueryColumn) => Math.max(MIN_COLUMN_WIDTH, columnWidths[column.name] ?? column.defaultWidth), [columnWidths])
@@ -351,6 +357,8 @@ export function DataGrid({
const fieldColumns: ColumnDef<QRecord>[] = visibleFields.map((column) => {
const field = column.field
// join values are keyed `joinTable.field`; a FILE_PREVIEW field reads them so
const cellField = column.isJoin ? joinedPreviewField(field, column.tableName) : field
const sortInfo = sortMap.get(column.name)
const help = selectHelpContent(field.helpContents, QUERY_SCREEN_HELP_ROLES)
const filtered = column.isQueryCriteria && filteredColumns?.has(column.name)
@@ -424,7 +432,7 @@ export function DataGrid({
),
cell: ({ row }) => (
<DataCell
field={field}
field={cellField}
value={trimValue(row.original.values[column.name])}
displayValue={trimValue(row.original.displayValues?.[column.name])}
record={row.original}
@@ -752,129 +760,131 @@ export function DataGrid({
// Main grid
// ------------------------------------------------------------------
return (
<div
ref={scrollRef}
className={`relative w-full ${height ? 'overflow-auto' : 'overflow-x-auto'}`}
style={height ? { height } : undefined}
data-qqq-id={`grid-${tableName}`}
data-grid-scroll={height ? 'viewport' : 'page'}
>
{/* D-Q-6: SR announcement for background refetches */}
<FilePreviewGallery records={records} fields={previewFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
ref={scrollRef}
className={`relative w-full ${height ? 'overflow-auto' : 'overflow-x-auto'}`}
style={height ? { height } : undefined}
data-qqq-id={`grid-${tableName}`}
data-grid-scroll={height ? 'viewport' : 'page'}
>
{isFetching ? 'Loading results' : ''}
</div>
{isFetching && (
<div className="sticky left-0 top-0 z-10 h-0.5 w-full overflow-hidden bg-primary/20">
<div className="h-full bg-primary animate-[slideRight_1s_ease-in-out_infinite]" />
{/* D-Q-6: SR announcement for background refetches */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{isFetching ? 'Loading results' : ''}
</div>
)}
<table
ref={tableRef}
className="w-full border-collapse table-fixed min-w-[600px]"
style={{ minWidth: `${table.getTotalSize()}px` }}
role="grid"
aria-label={`${tableMetaData.label} records`}
>
<thead className={height ? 'sticky top-0 z-[3]' : undefined}>
{table.getHeaderGroups().map((headerGroup) => (
<tr
key={headerGroup.id}
className="border-b border-border bg-muted"
>
{headerGroup.headers.map((header) => {
// Compute aria-sort for sortable columns
const isSelectCol = header.id === '_select'
const sortInfo = !isSelectCol
? sortOrder.find((s) => s.fieldName === header.id)
: undefined
const isSortable = !isSelectCol && header.column.columnDef.enableSorting !== false
const ariaSortValue: 'ascending' | 'descending' | 'none' | undefined = !isSortable
? undefined
: sortInfo
? sortInfo.isAscending
? 'ascending'
: 'descending'
: 'none'
const pinned = pinStyles[header.id]
// the selection column always stays in view, as the pinned columns do
const stickyClass = isSelectCol ? 'sticky left-0 z-[2] bg-muted' : pinned ? `${pinned.className} z-[2] bg-muted` : ''
{isFetching && (
<div className="sticky left-0 top-0 z-10 h-0.5 w-full overflow-hidden bg-primary/20">
<div className="h-full bg-primary animate-[slideRight_1s_ease-in-out_infinite]" />
</div>
)}
return (
<th
key={header.id}
data-qqq-id={isSelectCol ? undefined : tableHeaderQqqId(undefined, header.id)}
scope="col"
data-col={header.id}
data-pinned={isSelectCol ? undefined : pins[header.id]}
className={cn('group relative text-left font-semibold text-foreground select-none', cellClass, stickyClass, isSelectCol && 'pointer-coarse:px-0', !isSelectCol && columnMenu && 'pr-1')}
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
: typeof header.column.columnDef.header === 'function'
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
<table
ref={tableRef}
className="w-full border-collapse table-fixed min-w-[600px]"
style={{ minWidth: `${table.getTotalSize()}px` }}
role="grid"
aria-label={`${tableMetaData.label} records`}
>
<thead className={height ? 'sticky top-0 z-[3]' : undefined}>
{table.getHeaderGroups().map((headerGroup) => (
<tr
key={headerGroup.id}
className="border-b border-border bg-muted"
>
{headerGroup.headers.map((header) => {
// Compute aria-sort for sortable columns
const isSelectCol = header.id === '_select'
const sortInfo = !isSelectCol
? sortOrder.find((s) => s.fieldName === header.id)
: undefined
const isSortable = !isSelectCol && header.column.columnDef.enableSorting !== false
const ariaSortValue: 'ascending' | 'descending' | 'none' | undefined = !isSortable
? undefined
: sortInfo
? sortInfo.isAscending
? 'ascending'
: 'descending'
: 'none'
const pinned = pinStyles[header.id]
// the selection column always stays in view, as the pinned columns do
const stickyClass = isSelectCol ? 'sticky left-0 z-[2] bg-muted' : pinned ? `${pinned.className} z-[2] bg-muted` : ''
{/* Column resize handle */}
{!isSelectCol && (() => {
const fieldLabel = visibleFields.find(f => f.name === header.id)?.label ?? header.id
return (
<div
className="absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/40 active:bg-primary focus:outline-none focus-visible:bg-primary/60"
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${fieldLabel} column`}
aria-valuenow={Math.round(header.getSize())}
aria-valuemin={MIN_COLUMN_WIDTH}
aria-valuemax={2000}
tabIndex={0}
onMouseDown={(e) =>
handleResizeMouseDown(e, header.id, header.getSize())
}
onKeyDown={(e) =>
handleResizeKeyDown(e, header.id, header.getSize())
}
/>
)
})()}
</th>
)
})}
</tr>
))}
</thead>
return (
<th
key={header.id}
data-qqq-id={isSelectCol ? undefined : tableHeaderQqqId(undefined, header.id)}
scope="col"
data-col={header.id}
data-pinned={isSelectCol ? undefined : pins[header.id]}
className={cn('group relative text-left font-semibold text-foreground select-none', cellClass, stickyClass, isSelectCol && 'pointer-coarse:px-0', !isSelectCol && columnMenu && 'pr-1')}
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
: typeof header.column.columnDef.header === 'function'
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
<tbody>
{table.getRowModel().rows.map((row) => (
<GridRow
key={row.id}
row={row}
columns={columns}
isSelected={row.getIsSelected()}
isChecked={isRowSelectedByQuery ? isRowSelectedByQuery(row.index) : row.getIsSelected()}
rowClass={rowClass}
cellClass={cellClass}
pinStyles={pinStyles}
onRowMouseDown={disableRowClick ? undefined : handleRowMouseDown}
onRowClick={disableRowClick ? undefined : handleRowClick}
onRowDoubleClick={disableRowClick ? undefined : handleRowDoubleClick}
onOpenRecord={disableRowClick ? undefined : openRecord}
onCellKeyDown={handleCellKeyDown}
/>
))}
</tbody>
</table>
</div>
{/* Column resize handle */}
{!isSelectCol && (() => {
const fieldLabel = visibleFields.find(f => f.name === header.id)?.label ?? header.id
return (
<div
className="absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-primary/40 active:bg-primary focus:outline-none focus-visible:bg-primary/60"
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${fieldLabel} column`}
aria-valuenow={Math.round(header.getSize())}
aria-valuemin={MIN_COLUMN_WIDTH}
aria-valuemax={2000}
tabIndex={0}
onMouseDown={(e) =>
handleResizeMouseDown(e, header.id, header.getSize())
}
onKeyDown={(e) =>
handleResizeKeyDown(e, header.id, header.getSize())
}
/>
)
})()}
</th>
)
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<GridRow
key={row.id}
row={row}
columns={columns}
isSelected={row.getIsSelected()}
isChecked={isRowSelectedByQuery ? isRowSelectedByQuery(row.index) : row.getIsSelected()}
rowClass={rowClass}
cellClass={cellClass}
pinStyles={pinStyles}
onRowMouseDown={disableRowClick ? undefined : handleRowMouseDown}
onRowClick={disableRowClick ? undefined : handleRowClick}
onRowDoubleClick={disableRowClick ? undefined : handleRowDoubleClick}
onOpenRecord={disableRowClick ? undefined : openRecord}
onCellKeyDown={handleCellKeyDown}
/>
))}
</tbody>
</table>
</div>
</FilePreviewGallery>
)
}
+89 -84
View File
@@ -33,6 +33,9 @@ import type { QTableMetaData, QRecord, QFieldMetaData } from '@/types'
import { getQueryColumns, orderColumns } from '@/lib/utils/query-columns'
import { isColumnVisible } from '@/lib/utils/saved-view-utils'
import { joinedPreviewField } from '@/lib/utils/file-preview'
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import { DataCell } from './DataCell'
interface RecordCardViewProps {
@@ -83,7 +86,7 @@ export function RecordCardView({
() => orderColumns(getQueryColumns(tableMetaData), columnOrder)
.filter((column) => isColumnVisible(column.name, columnVisibility))
.slice(0, maxFieldsPerCard)
.map((column) => ({ ...column.field, name: column.name, label: column.label })),
.map((column) => ({ ...(column.isJoin ? joinedPreviewField(column.field, column.tableName) : column.field), name: column.name, label: column.label })),
[tableMetaData, columnVisibility, columnOrder, maxFieldsPerCard]
)
@@ -164,91 +167,93 @@ export function RecordCardView({
}
return (
<div
className="flex flex-col gap-3"
role="list"
aria-label={`${tableMetaData.label} records`}
data-qqq-id={`record-card-view-${tableName}`}
>
{records.map((record, index) => {
const recordId = getRecordId(record, index)
const isSelected = isRowSelectedByQuery ? isRowSelectedByQuery(index) : Boolean(rowSelection[recordId])
const recordLabel = record.recordLabel || recordId
<FilePreviewGallery records={records} fields={visibleFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
<div
className="flex flex-col gap-3"
role="list"
aria-label={`${tableMetaData.label} records`}
data-qqq-id={`record-card-view-${tableName}`}
>
{records.map((record, index) => {
const recordId = getRecordId(record, index)
const isSelected = isRowSelectedByQuery ? isRowSelectedByQuery(index) : Boolean(rowSelection[recordId])
const recordLabel = record.recordLabel || recordId
return (
<div
key={recordId}
role="listitem"
tabIndex={0}
onClick={() => handleCardClick(record)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleCardClick(record)
}
}}
className={`cursor-pointer rounded-xl border p-4 transition-colors hover:border-primary/30 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border bg-card'
}`}
data-qqq-id={`record-card-${recordId}`}
>
{/* Card header: checkbox + record label */}
<div className="flex items-start gap-3 pointer-coarse:items-center">
{/* The label is the checkbox's touch target (44 px on coarse pointers, globals.css) */}
<label
className="inline-flex shrink-0 cursor-pointer items-center justify-center pointer-coarse:-my-2.5 pointer-coarse:-ml-3"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
e.stopPropagation()
handleSelectionToggle(recordId, index)
}}
onClick={(e) => e.stopPropagation()}
aria-label={`Select ${recordLabel}`}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input text-primary focus:ring-ring cursor-pointer pointer-coarse:mt-0 pointer-coarse:h-5 pointer-coarse:w-5"
data-qqq-id={`card-select-${recordId}`}
/>
</label>
<div className="min-w-0 flex-1">
<h3
className="truncate text-sm font-semibold text-card-foreground"
data-qqq-id={`card-title-${recordId}`}
>
{recordLabel}
</h3>
</div>
</div>
{/* Card body: field key-value pairs */}
<dl className="mt-3 grid grid-cols-1 gap-1.5">
{visibleFields.map((field) => {
// Skip the primary key if it is the same as recordLabel
if (field.name === tableMetaData.primaryKeyField && recordLabel === String(record.values[field.name])) {
return null
return (
<div
key={recordId}
role="listitem"
tabIndex={0}
onClick={() => handleCardClick(record)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleCardClick(record)
}
}}
className={`cursor-pointer rounded-xl border p-4 transition-colors hover:border-primary/30 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border bg-card'
}`}
data-qqq-id={`record-card-${recordId}`}
>
{/* Card header: checkbox + record label */}
<div className="flex items-start gap-3 pointer-coarse:items-center">
{/* The label is the checkbox's touch target (44 px on coarse pointers, globals.css) */}
<label
className="inline-flex shrink-0 cursor-pointer items-center justify-center pointer-coarse:-my-2.5 pointer-coarse:-ml-3"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
e.stopPropagation()
handleSelectionToggle(recordId, index)
}}
onClick={(e) => e.stopPropagation()}
aria-label={`Select ${recordLabel}`}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input text-primary focus:ring-ring cursor-pointer pointer-coarse:mt-0 pointer-coarse:h-5 pointer-coarse:w-5"
data-qqq-id={`card-select-${recordId}`}
/>
</label>
<div className="min-w-0 flex-1">
<h3
className="truncate text-sm font-semibold text-card-foreground"
data-qqq-id={`card-title-${recordId}`}
>
{recordLabel}
</h3>
</div>
</div>
return (
<div key={field.name} className="flex items-baseline gap-2 text-sm" data-qqq-id={`card-field-${field.name}`}>
<dt className="shrink-0 text-muted-foreground">
{field.label}:
</dt>
<dd className="min-w-0 break-words text-card-foreground">
{/* Formatted exactly like the grid cell (dates, money, possible-value links) */}
<DataCell field={field} value={record.values[field.name]} displayValue={record.displayValues?.[field.name]} record={record} />
</dd>
</div>
)
})}
</dl>
</div>
)
})}
</div>
{/* Card body: field key-value pairs */}
<dl className="mt-3 grid grid-cols-1 gap-1.5">
{visibleFields.map((field) => {
// Skip the primary key if it is the same as recordLabel
if (field.name === tableMetaData.primaryKeyField && recordLabel === String(record.values[field.name])) {
return null
}
return (
<div key={field.name} className="flex items-baseline gap-2 text-sm" data-qqq-id={`card-field-${field.name}`}>
<dt className="shrink-0 text-muted-foreground">
{field.label}:
</dt>
<dd className="min-w-0 break-words text-card-foreground">
{/* Formatted exactly like the grid cell (dates, money, possible-value links) */}
<DataCell field={field} value={record.values[field.name]} displayValue={record.displayValues?.[field.name]} record={record} />
</dd>
</div>
)
})}
</dl>
</div>
)
})}
</div>
</FilePreviewGallery>
)
}
+70 -62
View File
@@ -20,7 +20,7 @@
'use client'
import React, { useId, useState } from 'react'
import React, { useId, useMemo, useState } from 'react'
import Link from 'next/link'
import { ArrowUpRight, ExternalLink, X } from 'lucide-react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
@@ -29,6 +29,9 @@ import type { QTableMetaData, QRecord, QAssociation } from '@/types'
import { resolveAssociationValues, type AssociationTableState } from '@/lib/utils/association-utils'
import { serializeFilter } from '@/lib/utils/filter-utils'
import { cn } from '@/lib/utils/cn'
import { hasFilePreview } from '@/lib/utils/file-preview'
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import { FieldValue } from './FieldValue'
import { RecordHoverCard } from './RecordHoverCard'
import { EntityForm } from '@/components/forms/EntityForm'
@@ -78,6 +81,14 @@ export function AssociatedRecords({
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
const headingId = useId()
const associationId = encodeURIComponent(association.name)
const reverse = joinTableMetaData ? association.join.leftTable !== parentTableMetaData.name : false
const availableRecords = joinTableMetaData && !reverse && joinTableMetaData.readPermission ? loadedRecords : undefined
// stable arrays per page, so the preview gallery's items are not rebuilt on every render
const shownRecords = useMemo(() => (availableRecords ?? []).slice(0, visibleCount), [availableRecords, visibleCount])
const joinFields = joinTableMetaData?.fields
const visibleFields = useMemo(() => Object.values(joinFields ?? {})
.filter((f) => !f.isHidden && !f.isHeavy)
.slice(0, 6), [joinFields])
if (metadata?.isDenied) return null
@@ -89,13 +100,8 @@ export function AssociatedRecords({
}
const relationship = resolveAssociationValues(association, parentTableMetaData, parentRecord, joinTableMetaData)
const reverse = association.join.leftTable !== parentTableMetaData.name
const availableRecords = !reverse && joinTableMetaData.readPermission ? loadedRecords : undefined
const records = availableRecords ?? []
const fixedValues = 'values' in relationship ? relationship.values : undefined
const visibleFields = Object.values(joinTableMetaData.fields)
.filter((f) => !f.isHidden && !f.isHeavy)
.slice(0, 6)
// Build from params for outgoing links
const fromParams = navigateFrom
@@ -200,63 +206,65 @@ export function AssociatedRecords({
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{records.slice(0, visibleCount).map((childRecord, rowIdx) => {
const childPk =
childRecord.values[joinTableMetaData.primaryKeyField] as string | number
const recordHref = joinTableMetaData.readPermission && childPk !== undefined
? `/app/${encodeURIComponent(joinTableMetaData.name)}/${encodeURIComponent(String(childPk))}${fromParamsFirst}`
: undefined
return (
<tr
key={childPk ?? rowIdx}
className="hover:bg-accent transition-colors"
data-qqq-id={`assoc-row-${joinTableMetaData.name}-${childPk}`}
>
{visibleFields.map((field, fieldIdx) => (
<td
key={field.name}
className="whitespace-nowrap px-3 py-2 text-sm"
data-qqq-id={`grid-cell-${field.name}`}
>
{fieldIdx <= 1 && recordHref ? (
<RecordHoverCard
tableName={joinTableMetaData.name}
primaryKey={childPk}
tableMetaData={joinTableMetaData}
navigateFrom={navigateFrom}
>
<Link
href={recordHref}
className="text-primary hover:text-primary/80 hover:underline"
aria-label={`View ${joinTableMetaData.label} record ${childPk}`}
>
{childRecord.displayValues?.[field.name] ?? String(childRecord.values[field.name] ?? '')}
</Link>
</RecordHoverCard>
) : (
<FieldValue field={field} record={childRecord} allTables={allTables} navigateFrom={navigateFrom} />
)}
</td>
))}
{recordHref && (
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
<Link
href={recordHref}
className={cn(
'inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80',
'pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center',
'focus:outline-none focus:underline'
)}
data-qqq-id={`link-view-${joinTableMetaData.name}-${childPk}`}
<FilePreviewGallery records={shownRecords} fields={visibleFields} tableVariant={storedRecordVariantJson(joinTableMetaData)}>
{shownRecords.map((childRecord, rowIdx) => {
const childPk =
childRecord.values[joinTableMetaData.primaryKeyField] as string | number
const recordHref = joinTableMetaData.readPermission && childPk !== undefined
? `/app/${encodeURIComponent(joinTableMetaData.name)}/${encodeURIComponent(String(childPk))}${fromParamsFirst}`
: undefined
return (
<tr
key={childPk ?? rowIdx}
className="hover:bg-accent transition-colors"
data-qqq-id={`assoc-row-${joinTableMetaData.name}-${childPk}`}
>
{visibleFields.map((field, fieldIdx) => (
<td
key={field.name}
className="whitespace-nowrap px-3 py-2 text-sm"
data-qqq-id={`grid-cell-${field.name}`}
>
View
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</Link>
</td>
)}
</tr>
)
})}
{fieldIdx <= 1 && recordHref && !hasFilePreview(field) ? (
<RecordHoverCard
tableName={joinTableMetaData.name}
primaryKey={childPk}
tableMetaData={joinTableMetaData}
navigateFrom={navigateFrom}
>
<Link
href={recordHref}
className="text-primary hover:text-primary/80 hover:underline"
aria-label={`View ${joinTableMetaData.label} record ${childPk}`}
>
{childRecord.displayValues?.[field.name] ?? String(childRecord.values[field.name] ?? '')}
</Link>
</RecordHoverCard>
) : (
<FieldValue field={field} record={childRecord} allTables={allTables} navigateFrom={navigateFrom} />
)}
</td>
))}
{recordHref && (
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
<Link
href={recordHref}
className={cn(
'inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80',
'pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center',
'focus:outline-none focus:underline'
)}
data-qqq-id={`link-view-${joinTableMetaData.name}-${childPk}`}
>
View
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</Link>
</td>
)}
</tr>
)
})}
</FilePreviewGallery>
</tbody>
</table>
</div>
@@ -30,6 +30,7 @@ import { cn } from '@/lib/utils/cn'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import { useAuditRecords } from '@/lib/hooks/use-audit-records'
import { DateTimeText } from '@/components/ui/DateTimeText'
/**
* Props for {@link AuditHistoryDialog}.
@@ -215,7 +216,7 @@ function AuditEntry({ entry, tableMetaData }: { entry: QAuditRecord; tableMetaDa
return (
<li className="rounded-lg border border-border p-3" data-qqq-id={`audit-entry-${entry.id}`}>
<div className="flex flex-wrap items-baseline gap-x-3 text-xs text-muted-foreground">
<time dateTime={entry.timestamp} data-qqq-id="audit-entry-timestamp">{formatDateTime(entry.timestamp) ?? entry.timestamp}</time>
<DateTimeText type="DATE_TIME" value={entry.timestamp} className="text-xs text-muted-foreground" dataQqqId="audit-entry-timestamp" />
{entry.user && <span data-qqq-id="audit-entry-user">{entry.user}</span>}
</div>
{entry.message && <p className="mt-1 text-sm font-medium text-foreground" data-qqq-id="audit-entry-message">{entry.message}</p>}
+15 -5
View File
@@ -109,12 +109,22 @@ describe('FieldValue adornments use the backend value keys', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Error: Could not load widget [nope]')
})
it('shows a zoned DATE_TIME display value as-is and formats a raw instant in the viewer zone', () => {
expect(show(field('fixed', [], { type: 'DATE_TIME' }), { fixed: '2024-03-10T08:30:00Z' }, { fixed: '2024-03-10 03:30:00 AM CDT' }))
.toHaveTextContent('2024-03-10 03:30:00 AM CDT')
it('shows DATE_TIME compactly on one line with the full zoned timestamp as its tooltip', () => {
const fixed = show(field('fixed', [], { type: 'DATE_TIME' }), { fixed: '2024-03-10T08:30:00Z' }, { fixed: '2024-03-10 03:30:00 AM CDT' })
// in a CDT viewer the zone matches (date only); elsewhere the declared zone keeps its time
expect(fixed.textContent).toMatch(/^Mar 10, 2024(, 3:30 AM CDT)?$/)
expect(fixed.getAttribute('title')).toContain('2024-03-10 03:30:00 AM CDT')
const raw = show(field('plain', [], { type: 'DATE_TIME' }), { plain: '2024-03-10T08:30:00Z' }, { plain: '2024-03-10T08:30:00Z' })
expect(raw.textContent).toMatch(/^2024-03-10 \d{2}:30:00 [AP]M \S+$/)
expect(raw).toHaveAttribute('dateTime', '2024-03-10T08:30:00Z')
expect(raw.textContent).toMatch(/^Mar (9|10), 2024$/)
expect(raw.getAttribute('title')).toMatch(/^2024-03-(09|10) \d{2}:30:00 [AP]M \S+$/)
expect(raw).toHaveAttribute('dateTime', '2024-03-10T08:30:00.000Z')
expect(raw).toHaveClass('whitespace-nowrap')
})
it('shows a DATE as its calendar day, never shifted by the viewer zone and never with a time', () => {
const date = show(field('day', [], { type: 'DATE' }), { day: '2024-06-15' })
expect(date).toHaveTextContent('Jun 15, 2024')
expect(date).toHaveAttribute('dateTime', '2024-06-15')
})
it('renders RENDER_HTML sanitized and an empty value as a dash', () => {
+24 -13
View File
@@ -16,7 +16,7 @@
/**
* @file FieldValue — renders a single field value from metadata: adornments first
* (LINK, REVEAL, RENDER_HTML, CHIP, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET,
* (FILE_PREVIEW, LINK, REVEAL, RENDER_HTML, CHIP, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET,
* TOOLTIP), then type-specific formatting.
*/
@@ -33,15 +33,17 @@ import { useFocusSafeTooltip } from '@/lib/hooks/use-focus-safe-tooltip'
import { cn } from '@/lib/utils/cn'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import { isHttpUrl, isEmail } from '@/lib/utils/string-utils'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { formatJson, formatSql, languageFor } from '@/lib/utils/code-highlight'
import {
attachmentUrl, chipStyle, CHIP_COLOR_CLASSES, fileDownload, findAdornment, linkTarget, tooltipText,
} from '@/lib/utils/adornment-utils'
import { filePreview } from '@/lib/utils/file-preview'
import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { HighlightedCode } from '@/components/scripts/HighlightedCode'
import { SeededWidget } from '@/components/widgets/ConnectedWidget'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { FileThumbnail } from '@/components/preview/FileThumbnail'
import { RecordHoverCard } from './RecordHoverCard'
/**
@@ -68,6 +70,11 @@ interface FieldValueProps {
tableMetaData?: QTableMetaData
/** Additional CSS classes applied to the root element. */
className?: string
/**
* Edge, in CSS pixels, of a FILE_PREVIEW thumbnail, which then also shows the file name
* (record views). Lists leave it unset and use the adornment's `thumbnailMaxSize`.
*/
thumbnailSize?: number
}
/**
@@ -75,17 +82,17 @@ interface FieldValueProps {
*
* Adornments are applied in the order the QQQ dashboards use (one per value), and
* a TOOLTIP adornment wraps whatever is rendered. Without an adornment, the value
* is formatted by type: DATE_TIME in the viewer's time zone (unless the backend
* supplied a zoned display value), BOOLEAN as Yes/No, TEXT with line breaks, HTML
* is formatted by type: DATE and DATE_TIME compactly in the viewer's zone and locale
* (see DateTimeText), BOOLEAN as Yes/No, TEXT with line breaks, HTML
* sanitized, and everything else as the backend display value. Empty values render
* an em dash.
*
* @param props - See {@link FieldValueProps}.
* @returns The rendered value.
*/
export function FieldValue({ field, record, allTables, navigateFrom, widgetMetaDataMap, tableMetaData, className }: FieldValueProps) {
export function FieldValue({ field, record, allTables, navigateFrom, widgetMetaDataMap, tableMetaData, className, thumbnailSize }: FieldValueProps) {
const content = <FieldValueContent field={field} record={record} allTables={allTables}
navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} className={className} />
navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} className={className} thumbnailSize={thumbnailSize} />
const tooltip = tooltipText(field, record)
const tooltipState = useFocusSafeTooltip()
if (!tooltip) return content
@@ -117,7 +124,7 @@ export function FieldValue({ field, record, allTables, navigateFrom, widgetMetaD
* @param props - See {@link FieldValueProps}.
* @returns The rendered value.
*/
function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaDataMap, tableMetaData, className }: FieldValueProps) {
function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaDataMap, tableMetaData, className, thumbnailSize }: FieldValueProps) {
const rawValue = record.values[field.name]
const displayValue = record.displayValues?.[field.name]
const value = displayValue ?? rawValue
@@ -148,6 +155,12 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
)
}
const preview = filePreview(field, record, storedRecordVariantJson(tableMetaData ?? allTables?.[record.tableName]))
if (preview) {
return <FileThumbnail field={field} record={record} item={preview} size={thumbnailSize} showName={thumbnailSize !== undefined}
dataQqqId={dataQqqId} className={className} />
}
if (value === null || value === undefined || value === '') {
return <EmptyValue fieldName={field.name} className={className} />
}
@@ -251,12 +264,10 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
)
}
case 'DATE_TIME': {
// A zoned display value from the backend (e.g. a fixed or per-record zone) wins;
// otherwise the instant is shown in the viewer's time zone.
const text = displayValue && displayValue !== rawValue ? displayValue : (formatDateTime(rawValue) ?? String(value))
return <time dateTime={typeof rawValue === 'string' ? rawValue : undefined} className={cn('text-sm text-foreground', className)}
data-qqq-id={dataQqqId}>{text}</time>
case 'DATE_TIME':
case 'DATE': {
// compact and on one line, full value on hover; a backend zone other than the viewer's is kept
return <DateTimeText type={field.type} value={rawValue} displayValue={displayValue} className={className} dataQqqId={dataQqqId} />
}
case 'BLOB': {
+166 -158
View File
@@ -37,6 +37,9 @@ import { recordHashAction } from '@/lib/utils/material-links'
import { recordSectionField, recordViewActionsPlacement, showRecordSidebar, visibleRecordViewSections } from '@/lib/utils/record-layout-utils'
import { RecordViewSection } from './RecordViewSection'
import { RECORD_VIEW_THUMBNAIL_SIZE } from '@/lib/utils/file-preview'
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import { FieldValue } from './FieldValue'
import { FieldLabel } from './FieldLabel'
import { RecordViewHeader } from './RecordViewHeader'
@@ -556,182 +559,187 @@ function RecordViewContent({
[tableMetaData, allTables, navigateFrom]
)
const viewId = `record-view-${tableMetaData.name}`
// the record's FILE_PREVIEW values, in section order, form the preview gallery of the page
const previewFields = [...new Set((tableMetaData.sections ?? []).flatMap((section) => section.fieldNames ?? []))]
.map((name) => tableMetaData.fields[name]).filter((field): field is QFieldMetaData => Boolean(field))
const materialViewId = `record-view-${sanitizeQqqId(tableMetaData.name)}`
return (
<RecordViewContext.Provider value={contextValue}>
<div className="recordView" data-qqq-id={materialViewId !== viewId ? materialViewId : undefined}>
<div
className={cn('space-y-5', className)}
data-qqq-id={viewId}
>
{/* Back link — returns to source page if navigated from another record, otherwise table list */}
<Link
href={safeFromPath || `/app/${tableMetaData.name}`}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors pointer-coarse:min-h-11"
data-qqq-id="link-back-to-table"
<FilePreviewGallery records={[record]} fields={previewFields} tableVariant={storedRecordVariantJson(tableMetaData)}>
<div className="recordView" data-qqq-id={materialViewId !== viewId ? materialViewId : undefined}>
<div
className={cn('space-y-5', className)}
data-qqq-id={viewId}
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Back to {fromLabel || tableMetaData.label}
</Link>
{/* Back link — returns to source page if navigated from another record, otherwise table list */}
<Link
href={safeFromPath || `/app/${tableMetaData.name}`}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors pointer-coarse:min-h-11"
data-qqq-id="link-back-to-table"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Back to {fromLabel || tableMetaData.label}
</Link>
<div className={cn(hasSidebar && 'lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-6')}>
{hasSidebar && <RecordViewSidebar sections={orderedSections} onNavigate={navigateToSection} />}
<div className="min-w-0 space-y-5">
<div className={cn(hasSidebar && 'lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-6')}>
{hasSidebar && <RecordViewSidebar sections={orderedSections} onNavigate={navigateToSection} />}
<div className="min-w-0 space-y-5">
{/* Record header — avatar + name + T1 fields + view-mode toggle + actions */}
<RecordViewHeader
actionsPlacement={recordViewActionsPlacement(instance, tableMetaData)}
tableMetaData={tableMetaData}
record={record}
t1Fields={t1Fields}
viewMode={viewMode}
setViewMode={setViewMode}
hideActions={hideActions}
processes={processes}
allProcesses={allProcesses}
allTables={allTables}
navigateFrom={navigateFrom}
auditSource={auditSource}
widgetMetaDataMap={widgetMetaDataMap}
onRecordChanged={onRefetch}
/>
{viewMode === 'tabs' && t1Sections.filter((section) => section.widgetName).map((section) => (
<RecordViewSection key={section.name} section={section} tableMetaData={tableMetaData} record={record}
widgetMetaDataMap={widgetMetaDataMap} allTables={allTables} navigateFrom={navigateFrom}
renderAssociation={renderAssociation} />
))}
{/* Record errors/warnings */}
{(record.errors?.length ?? 0) > 0 && (
<div role="alert" className="rounded-md border border-red-200 bg-red-50 px-4 py-3">
<ul className="list-inside list-disc space-y-1">
{record.errors!.map((err, i) => (
<li key={i} className="text-sm text-red-700">{err}</li>
))}
</ul>
</div>
)}
{/* The warning of the save that led here (Material: create/update navigation state) */}
<SaveWarningBanner tableName={tableMetaData.name} primaryKey={parentPk as string | number} />
{(record.warnings?.length ?? 0) > 0 && (
<div role="status" className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3">
<ul className="list-inside list-disc space-y-1">
{record.warnings!.map((warn, i) => (
<li key={i} className="text-sm text-yellow-700">{warn}</li>
))}
</ul>
</div>
)}
{/* === View mode: Tabs (default) === */}
{viewMode === 'tabs' && tabs.length > 0 && (
<RecordViewTabs
{/* Record header — avatar + name + T1 fields + view-mode toggle + actions */}
<RecordViewHeader
actionsPlacement={recordViewActionsPlacement(instance, tableMetaData)}
tableMetaData={tableMetaData}
record={record}
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
secondarySections={secondarySections}
tertiarySections={tertiarySections}
associations={associations}
renderAssociation={renderAssociation}
widgetMetaDataMap={widgetMetaDataMap}
t1Fields={t1Fields}
viewMode={viewMode}
setViewMode={setViewMode}
hideActions={hideActions}
processes={processes}
allProcesses={allProcesses}
allTables={allTables}
navigateFrom={navigateFrom}
auditSource={auditSource}
widgetMetaDataMap={widgetMetaDataMap}
onRecordChanged={onRefetch}
/>
)}
{/* === View mode: List (compact top-to-bottom data view) === */}
{viewMode === 'list' && (
<div className="space-y-4" data-qqq-id="record-view-list-mode">
{/* T1 sections as compact cards */}
{t1Sections.length > 0 && t1Sections.map((section) => (
<div
key={section.name}
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
>
<RecordViewSection
section={section}
renderAssociation={renderAssociation}
tableMetaData={tableMetaData}
record={record}
widgetMetaDataMap={widgetMetaDataMap}
allTables={allTables}
navigateFrom={navigateFrom}
compact
/>
</div>
))}
{viewMode === 'tabs' && t1Sections.filter((section) => section.widgetName).map((section) => (
<RecordViewSection key={section.name} section={section} tableMetaData={tableMetaData} record={record}
widgetMetaDataMap={widgetMetaDataMap} allTables={allTables} navigateFrom={navigateFrom}
renderAssociation={renderAssociation} />
))}
{/* T2 sections as compact cards */}
{secondarySections.map((section) => (
<div
key={section.name}
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
>
<RecordViewSection
section={section}
renderAssociation={renderAssociation}
tableMetaData={tableMetaData}
record={record}
widgetMetaDataMap={widgetMetaDataMap}
allTables={allTables}
navigateFrom={navigateFrom}
compact
/>
</div>
))}
{/* T3 sections as compact cards */}
{tertiarySections.map((section) => (
<div
key={section.name}
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
>
<RecordViewSection
section={section}
renderAssociation={renderAssociation}
tableMetaData={tableMetaData}
record={record}
widgetMetaDataMap={widgetMetaDataMap}
allTables={allTables}
navigateFrom={navigateFrom}
compact
/>
</div>
))}
{/* Related records */}
<RecordViewAssociated associations={associations} renderAssociation={renderAssociation} />
</div>
)}
{/* No tabs/sections — just show all fields if nothing to tab */}
{tabs.length === 0 && secondarySections.length === 0 && t1Fields.length === 0 && (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<dl className="grid gap-x-8 gap-y-4 grid-cols-1 sm:grid-cols-2">
{Object.values(tableMetaData.fields)
.filter((f) => !f.isHidden)
.map((field) => (
<div key={field.name} className="flex flex-col gap-0.5" data-qqq-id={`record-field-${field.name}`}>
<dt className="text-sm font-semibold text-foreground">
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
</dt>
<dd>
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
</dd>
</div>
{/* Record errors/warnings */}
{(record.errors?.length ?? 0) > 0 && (
<div role="alert" className="rounded-md border border-red-200 bg-red-50 px-4 py-3">
<ul className="list-inside list-disc space-y-1">
{record.errors!.map((err, i) => (
<li key={i} className="text-sm text-red-700">{err}</li>
))}
</dl>
</div>
)}
</ul>
</div>
)}
{/* The warning of the save that led here (Material: create/update navigation state) */}
<SaveWarningBanner tableName={tableMetaData.name} primaryKey={parentPk as string | number} />
{(record.warnings?.length ?? 0) > 0 && (
<div role="status" className="rounded-md border border-yellow-200 bg-yellow-50 px-4 py-3">
<ul className="list-inside list-disc space-y-1">
{record.warnings!.map((warn, i) => (
<li key={i} className="text-sm text-yellow-700">{warn}</li>
))}
</ul>
</div>
)}
{/* === View mode: Tabs (default) === */}
{viewMode === 'tabs' && tabs.length > 0 && (
<RecordViewTabs
tableMetaData={tableMetaData}
record={record}
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
secondarySections={secondarySections}
tertiarySections={tertiarySections}
associations={associations}
renderAssociation={renderAssociation}
widgetMetaDataMap={widgetMetaDataMap}
allTables={allTables}
navigateFrom={navigateFrom}
/>
)}
{/* === View mode: List (compact top-to-bottom data view) === */}
{viewMode === 'list' && (
<div className="space-y-4" data-qqq-id="record-view-list-mode">
{/* T1 sections as compact cards */}
{t1Sections.length > 0 && t1Sections.map((section) => (
<div
key={section.name}
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
>
<RecordViewSection
section={section}
renderAssociation={renderAssociation}
tableMetaData={tableMetaData}
record={record}
widgetMetaDataMap={widgetMetaDataMap}
allTables={allTables}
navigateFrom={navigateFrom}
compact
/>
</div>
))}
{/* T2 sections as compact cards */}
{secondarySections.map((section) => (
<div
key={section.name}
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
>
<RecordViewSection
section={section}
renderAssociation={renderAssociation}
tableMetaData={tableMetaData}
record={record}
widgetMetaDataMap={widgetMetaDataMap}
allTables={allTables}
navigateFrom={navigateFrom}
compact
/>
</div>
))}
{/* T3 sections as compact cards */}
{tertiarySections.map((section) => (
<div
key={section.name}
className="rounded-xl border border-border bg-card px-6 py-4 shadow-sm"
>
<RecordViewSection
section={section}
renderAssociation={renderAssociation}
tableMetaData={tableMetaData}
record={record}
widgetMetaDataMap={widgetMetaDataMap}
allTables={allTables}
navigateFrom={navigateFrom}
compact
/>
</div>
))}
{/* Related records */}
<RecordViewAssociated associations={associations} renderAssociation={renderAssociation} />
</div>
)}
{/* No tabs/sections — just show all fields if nothing to tab */}
{tabs.length === 0 && secondarySections.length === 0 && t1Fields.length === 0 && (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<dl className="grid gap-x-8 gap-y-4 grid-cols-1 sm:grid-cols-2">
{Object.values(tableMetaData.fields)
.filter((f) => !f.isHidden)
.map((field) => (
<div key={field.name} className="flex flex-col gap-0.5" data-qqq-id={`record-field-${field.name}`}>
<dt className="text-sm font-semibold text-foreground">
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
</dt>
<dd>
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
</dd>
</div>
))}
</dl>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</FilePreviewGallery>
</RecordViewContext.Provider>
)
}
@@ -16,12 +16,13 @@
// Phone action sheet: focus management and routes (QRun-IO/qqq#694, #649)
import React from 'react'
import { render, screen, waitFor } from '@testing-library/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 { beforeEach, describe, expect, it, vi } from 'vitest'
import type { QProcessMetaData, QTableMetaData } from '@/types'
import type { AuditSource } from '@/lib/api/audits'
import type { RecordViewActionsPlacement } from '@/lib/utils/record-layout-utils'
import { RecordViewHeader } from './RecordViewHeader'
@@ -144,20 +145,92 @@ describe('RecordViewHeader #/launchProcess= links', () => {
})
})
const readOnly = { ...table, insertPermission: false, editPermission: false, deletePermission: false,
capabilities: ['TABLE_QUERY', 'TABLE_GET'] } as unknown as QTableMetaData
function renderTable(tableMetaData: QTableMetaData, auditSource: AuditSource = null) {
return render(
<QueryClientProvider client={new QueryClient()}>
<RecordViewHeader tableMetaData={tableMetaData} record={{ tableName: 'person', values: { id: 5 }, recordLabel: 'Morgan Sample' }}
t1Fields={[]} viewMode="tabs" setViewMode={vi.fn()} hideActions={false} navigateFrom={{ path: '/app/person', label: 'Person' }}
auditSource={auditSource} />
</QueryClientProvider>
)
}
/** Labels of the desktop Actions menu's items, after opening it. */
async function openDesktopActions(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Record actions menu' }))
return screen.getAllByRole('menuitem').map((item) => item.textContent)
}
describe('RecordViewHeader for a read-only user', () => {
it('offers no phone Actions trigger when there is nothing to do', () => {
const readOnly = { ...table, insertPermission: false, editPermission: false, deletePermission: false,
capabilities: ['TABLE_QUERY', 'TABLE_GET'] } as unknown as QTableMetaData
render(
<QueryClientProvider client={new QueryClient()}>
<RecordViewHeader tableMetaData={readOnly} record={{ tableName: 'person', values: { id: 5 }, recordLabel: 'Morgan Sample' }}
t1Fields={[]} viewMode="tabs" setViewMode={vi.fn()} hideActions={false} navigateFrom={{ path: '/app/person', label: 'Person' }} />
</QueryClientProvider>
)
beforeEach(() => vi.clearAllMocks())
it('still offers Developer Mode from the desktop Actions menu, with no write actions (#45)', async () => {
const user = userEvent.setup()
renderTable(readOnly)
for (const action of ['Edit', 'Copy', 'Delete']) {
expect(screen.queryByRole('button', { name: `${action} Person record` })).not.toBeInTheDocument()
}
const items = await openDesktopActions(user)
expect(items).toEqual(['Developer Mode'])
await user.click(screen.getByRole('menuitem', { name: 'Developer Mode' }))
expect(push).toHaveBeenCalledWith('/app/person/5/dev')
})
it('offers Developer Mode and Audit from the phone sheet', async () => {
const user = userEvent.setup()
renderTable(readOnly, 'table')
await user.click(screen.getByRole('button', { name: 'Record actions' }))
const sheet = screen.getByRole('dialog', { name: 'Record actions' })
expect(within(sheet).getByRole('button', { name: 'Developer Mode' })).toBeInTheDocument()
expect(within(sheet).getByRole('button', { name: 'Audit' })).toBeInTheDocument()
expect(within(sheet).queryByRole('button', { name: 'Edit Person' })).not.toBeInTheDocument()
expect(within(sheet).queryByRole('button', { name: 'Delete Person' })).not.toBeInTheDocument()
})
it('offers no Actions controls when a custom menu resolves to nothing', () => {
const empty = { ...readOnly, menus: [{ slot: 'VIEW_SCREEN_ACTIONS', label: 'Actions', items: [
{ itemType: 'BUILT_IN', values: { option: 'EDIT' } },
] }] } as unknown as QTableMetaData
renderTable(empty)
expect(screen.queryByRole('button', { name: 'Record actions menu' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Record actions' })).not.toBeInTheDocument()
})
})
describe('RecordViewHeader Actions menu for a table without processes or a custom menu (#45)', () => {
beforeEach(() => vi.clearAllMocks())
it('renders the default menu with New, Copy, Delete and Developer Mode', async () => {
const user = userEvent.setup()
renderTable(table)
expect(await openDesktopActions(user)).toEqual(['New', 'Copy', 'Edit', 'Delete', 'Developer Mode'])
await user.click(screen.getByRole('menuitem', { name: 'Developer Mode' }))
expect(push).toHaveBeenCalledWith('/app/person/5/dev')
})
it('adds Audit when the user can read audits', async () => {
const user = userEvent.setup()
renderTable(table, 'table')
const items = await openDesktopActions(user)
expect(items).toContain('Developer Mode')
expect(items).toContain('Audit')
})
it('does not duplicate Copy or Delete as standalone buttons beside the menu', async () => {
const user = userEvent.setup()
renderTable(table)
expect(screen.getByRole('button', { name: 'Edit Person record' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy Person record' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete Person record' })).not.toBeInTheDocument()
const items = await openDesktopActions(user)
expect(items.filter((label) => label === 'Copy')).toHaveLength(1)
expect(items.filter((label) => label === 'Delete')).toHaveLength(1)
})
})
describe('RecordViewHeader layout', () => {
it('keeps secondary actions in the process menu without duplicate desktop buttons', async () => {
const user = userEvent.setup()
@@ -170,10 +243,10 @@ describe('RecordViewHeader layout', () => {
expect(push).toHaveBeenCalledWith('/app/person/5/copy')
})
it('keeps standalone secondary actions when there is no process menu', () => {
renderHeader()
expect(screen.getByRole('button', { name: 'Copy Person record' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete Person record' })).toBeInTheDocument()
it('keeps the table processes in the Actions menu alongside Developer Mode', async () => {
const user = userEvent.setup()
renderHeader([{ name: 'tagRecords', label: 'Tag Records', hasPermission: true } as QProcessMetaData])
expect(await openDesktopActions(user)).toEqual(['New', 'Copy', 'Edit', 'Delete', 'Tag Records', 'Developer Mode'])
})
it('lets the controls wrap onto their own row instead of squeezing the title', () => {
+14 -12
View File
@@ -41,6 +41,7 @@ import { recordFieldFile, deliverRecordFieldFile } from '@/lib/utils/record-down
import { RecordActions } from './RecordActions'
import { RecordMenuIcon } from './RecordMenuIcon'
import { FieldLabel } from './FieldLabel'
import { HEADER_THUMBNAIL_SIZE } from '@/lib/utils/file-preview'
import { FieldValue } from './FieldValue'
import { DeleteConfirmDialog } from './DeleteConfirmDialog'
import { AuditHistoryDialog } from './AuditHistoryDialog'
@@ -242,9 +243,11 @@ export function RecordViewHeader({
const phoneActionEntries = hasCustomActionsMenu ? actionEntries : defaultPhoneMenuExtras(actionEntries)
const hasMetadataActions = (tableMetaData.menus ?? []).some((menu) =>
(menu.slot === 'VIEW_SCREEN_ACTIONS' || menu.slot === 'VIEW_SCREEN_ADDITIONAL') && hasMenuItems(resolveMenu(menu)))
// Keep the existing read-only security gate: a default Developer Mode entry alone does not
// expose an otherwise empty Actions control, while a table's explicit menu still can.
const hasMobileActions = canEdit || canInsert || canDelete || availableProcesses.length > 0 || hasMetadataActions
// Show the Actions controls whenever the resolved menu has something to offer. The default menu
// always resolves Developer Mode (and Audit when the user can read audits), so a table without
// processes or a custom menu, or a read-only table, still reaches them, as Material did (#45).
const hasRecordActions = hasMenuItems(actionEntries) || canEdit || canInsert || canDelete
|| availableProcesses.length > 0 || hasMetadataActions
/**
* Runs a resolved menu action from either the desktop dropdown or the phone sheet.
@@ -296,9 +299,8 @@ export function RecordViewHeader({
const desktopActions = !hideActions && (
<div className="hidden md:flex md:flex-wrap md:items-center md:gap-2" data-qqq-id="record-view-desktop-actions">
{tableMetaData.shareableTableMetaData && <ShareButton tableMetaData={tableMetaData} record={record} />}
{hasMobileActions && <RecordActions className="flex-wrap" tableMetaData={tableMetaData} record={record}
actionEntries={hasCustomActionsMenu || availableProcesses.length > 0 ? actionEntries : []}
resolveMenu={resolveMenu} onAction={onAction} />}
{hasRecordActions && <RecordActions className="flex-wrap" tableMetaData={tableMetaData} record={record}
actionEntries={actionEntries} resolveMenu={resolveMenu} onAction={onAction} />}
</div>
)
@@ -340,19 +342,19 @@ export function RecordViewHeader({
</button>
{actionsPlacement === 'INLINE_WITH_PAGE_TITLE' && desktopActions}
</div>
{/* T1 fields as a compact grid under the name */}
{/* T1 fields in a row under the name, each as wide as its value (up to 32rem), wrapping as the header narrows */}
{t1Fields.length > 0 && (
<dl
className="mt-2 grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3 lg:grid-cols-4"
className="mt-2 flex flex-wrap gap-x-8 gap-y-2"
data-qqq-id="record-primary-sections"
>
{t1Fields.map((field) => (
<div key={field.name} className="flex min-w-0 flex-col" data-qqq-id={`record-field-${field.name}`}>
<div key={field.name} className="flex min-w-[8rem] max-w-[32rem] flex-col" data-qqq-id={`record-field-${field.name}`}>
<dt className="text-xs text-muted-foreground">
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
</dt>
<dd className="min-w-0 text-sm [overflow-wrap:anywhere]">
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={HEADER_THUMBNAIL_SIZE} />
</dd>
</div>
))}
@@ -437,7 +439,7 @@ export function RecordViewHeader({
{/* Mobile: bottom-sheet trigger button — MED-17 */}
<div className="flex items-center gap-2 md:hidden">
{tableMetaData.shareableTableMetaData && <ShareButton tableMetaData={tableMetaData} record={record} />}
{hasMobileActions && <button
{hasRecordActions && <button
type="button"
ref={mobileActionsTrigger}
onClick={() => setMobileActionsOpen(true)}
@@ -461,7 +463,7 @@ export function RecordViewHeader({
</div>
{/* Mobile actions bottom-sheet — MED-17 */}
{mobileActionsOpen && hasMobileActions && (
{mobileActionsOpen && hasRecordActions && (
<div className="md:hidden" data-qqq-id="mobile-actions-sheet">
{/* Backdrop */}
<div
+7 -5
View File
@@ -27,9 +27,11 @@ import type { QTableMetaData, QTableSection, QRecord, QWidgetMetaData } from '@/
import { associationWidgetBinding } from '@/lib/utils/association-utils'
import { cn } from '@/lib/utils/cn'
import { gridSpanClasses, initialSectionOpen, isCollapsibleSection, recordSectionField, storeSectionOpen, twelfths } from '@/lib/utils/record-layout-utils'
import { DEFAULT_FIELD_SPAN_CLASSES } from '@/lib/utils/responsive-grid'
import { selectSlotHelpContent, VIEW_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
import { useHelpHelpActive } from '@/lib/context/q-context'
import { RECORD_VIEW_THUMBNAIL_SIZE } from '@/lib/utils/file-preview'
import { FieldValue } from '@/components/records/FieldValue'
import { FieldLabel } from '@/components/records/FieldLabel'
import { HelpContent } from '@/components/records/HelpContent'
@@ -242,7 +244,7 @@ export function RecordViewSection({
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
</dt>
<dd className="min-w-0 flex-1 text-sm text-foreground [overflow-wrap:anywhere]">
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
</dd>
</div>
)
@@ -263,7 +265,7 @@ export function RecordViewSection({
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
</dt>
<dd className="min-w-0 text-sm text-foreground [overflow-wrap:anywhere]">
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
</dd>
</div>
)
@@ -272,7 +274,7 @@ export function RecordViewSection({
) : (
/* Explicit field widths use twelfths; the surrounding view supplies the default layout. */
<dl
className={cn('grid grid-cols-12 gap-x-8', defaultFieldColumns === 2 ? 'gap-y-6' : 'gap-y-4')}
className={cn('@container grid grid-cols-12 gap-x-8', defaultFieldColumns === 2 ? 'gap-y-6' : 'gap-y-4')}
>
{visibleFields.map((field) => {
if (!field) return null
@@ -283,7 +285,7 @@ export function RecordViewSection({
'flex min-w-0 flex-col gap-0.5',
twelfths(field.gridColumns) !== undefined
? gridSpanClasses(field.gridColumns!)
: defaultFieldColumns === 2 ? 'col-span-12 sm:col-span-6' : 'col-span-12'
: defaultFieldColumns === 2 ? DEFAULT_FIELD_SPAN_CLASSES : 'col-span-12'
)}
data-qqq-id={`record-field-${field.name}`}
>
@@ -291,7 +293,7 @@ export function RecordViewSection({
<FieldLabel field={field} data-qqq-id={`field-label-${field.name}`} helpKey={`table:${tableMetaData.name};field:${field.name}`} />
</dt>
<dd className={cn('min-w-0 [overflow-wrap:anywhere]', defaultFieldColumns === 1 && 'text-sm text-foreground')}>
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} />
<FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={tableMetaData} thumbnailSize={RECORD_VIEW_THUMBNAIL_SIZE} />
</dd>
</div>
)
+6 -4
View File
@@ -232,10 +232,12 @@ export function RecordViewTabs({
</div>
</div>
{/* Tab content: Overview — Material's 12-column card widths */}
{/* Tab content: Overview — Material's 12-column card widths, applied by the panel's own
width (container queries) so cards sit side by side only when each is readable; cards
keep their own height instead of stretching to the tallest in the row */}
{activeTab === 'overview' && (
<div
className={cn('grid grid-cols-12 gap-6', panelFocus)}
className={cn('@container grid grid-cols-12 items-start gap-6', panelFocus)}
{...panelAttributes}
data-qqq-id="record-tab-panel-overview"
>
@@ -270,7 +272,7 @@ export function RecordViewTabs({
className={panelFocus}
data-qqq-id={`record-tab-panel-${section.name}`}
>
<div className="grid grid-cols-12 gap-6">
<div className="@container grid grid-cols-12 gap-6">
<div className={cn('rounded-xl border border-border bg-card p-6 shadow-sm', recordSectionGridSpan(section, widgetMetaDataMap, 12))}>
<RecordViewSection
section={section}
@@ -297,7 +299,7 @@ export function RecordViewTabs({
className={panelFocus}
data-qqq-id={`record-tab-panel-${section.name}`}
>
<div className="grid grid-cols-12 gap-6">
<div className="@container grid grid-cols-12 gap-6">
<div className={cn('rounded-xl border border-border bg-card p-6 shadow-sm', recordSectionGridSpan(section, widgetMetaDataMap, 12))}>
<RecordViewSection
section={section}
+2 -1
View File
@@ -32,6 +32,7 @@ import { cn } from '@/lib/utils/cn'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { CodeBlock } from './HighlightedCode'
import { dateTimeText, domId, text } from './script-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
export const BUTTON_CLASS = cn(
'inline-flex items-center justify-center gap-2 rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-foreground',
@@ -202,7 +203,7 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
<tbody>
{result.logLines.map((line, index) => (
<tr key={index} className="border-t border-border align-top">
<td className="whitespace-nowrap px-3 py-2 text-foreground">{dateTimeText(line.timestamp)}</td>
<td className="whitespace-nowrap px-3 py-2 text-foreground"><DateTimeText type="DATE_TIME" value={line.timestamp} /></td>
<td className="whitespace-pre-wrap px-3 py-2 text-foreground">{line.text}</td>
</tr>
))}
+2 -1
View File
@@ -41,6 +41,7 @@ import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { languageFor } from '@/lib/utils/code-highlight'
import { cn } from '@/lib/utils/cn'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { CodeBlock } from './HighlightedCode'
import { ScriptEditorDialog } from './ScriptEditorDialog'
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, ScriptDocs, ScriptTest, type ScriptTestFields } from './ScriptPanels'
@@ -509,7 +510,7 @@ function ScriptLogs({ idKey, source, scriptRevisionId }: {
const hadError = log.values.hadError
return (
<tr key={text(log.values.id)} className="border-t border-border align-top" data-qqq-id={`script-log-${text(log.values.id)}`}>
<td className="whitespace-nowrap px-3 py-2 text-foreground">{dateTimeText(log.values.startTimestamp)}</td>
<td className="whitespace-nowrap px-3 py-2 text-foreground"><DateTimeText type="DATE_TIME" value={log.values.startTimestamp} /></td>
<td className="px-3 py-2 text-right text-foreground">{text(log.values.runTimeMillis)}</td>
<td className={cn('px-3 py-2', hadError === true ? 'text-destructive' : 'text-foreground')}>
{hadError === true ? 'Yes' : hadError === false ? 'No' : ''}
@@ -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>
)
}
+51
View File
@@ -0,0 +1,51 @@
/*
* 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 from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { DateTimeText } from './DateTimeText'
afterEach(() => {
vi.useRealTimers()
})
describe('DateTimeText', () => {
it('renders one compact, unwrapped line with the full timestamp as its tooltip', () => {
render(<DateTimeText type="DATE_TIME" value="2024-03-10T08:30:00Z" dataQqqId="when" />)
const time = screen.getByText(/2024/)
expect(time.tagName).toBe('TIME')
expect(time.textContent).toMatch(/^Mar (9|10), 2024$/)
expect(time).toHaveAttribute('dateTime', '2024-03-10T08:30:00.000Z')
expect(time.getAttribute('title')).toMatch(/^2024-03-(09|10) \d{2}:30:00 [AP]M \S+$/)
expect(time).toHaveClass('whitespace-nowrap')
expect(time).toHaveAttribute('data-qqq-id', 'when')
})
it('shows recent activity as relative time when asked', () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-11T16:20:00Z'))
render(<DateTimeText type="DATE_TIME" value="2026-10-11T13:20:00Z" relative />)
expect(screen.getByText('3h ago')).toBeInTheDocument()
})
it('never shows a time for a DATE and shows a dash for an empty value', () => {
const { container } = render(<><DateTimeText type="DATE" value="2024-06-15" /><DateTimeText type="DATE" value={null} /></>)
expect(screen.getByText('Jun 15, 2024')).toHaveAttribute('dateTime', '2024-06-15')
expect(container).toHaveTextContent('—')
})
})
+71
View File
@@ -0,0 +1,71 @@
/*
* 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 DateTimeText — the one way a DATE or DATE_TIME value is shown on screen: compact,
* on one line, with the full value in a tooltip (see `compactDateTime` / `compactDate`).
*/
'use client'
import React from 'react'
import { compactDate, compactDateTime, relativeDateTime } from '@/lib/utils/datetime-utils'
import { cn } from '@/lib/utils/cn'
/** Props for {@link DateTimeText}. */
export interface DateTimeTextProps {
/** Raw value: an ISO-8601 instant (DATE_TIME) or `yyyy-MM-dd` (DATE). */
value: unknown
/** Backend display value, used for zoned display values and unreadable raw values. */
displayValue?: unknown
/** The field type; DATE never shows a time. */
type: 'DATE' | 'DATE_TIME'
/** Show recent DATE_TIME values as relative time (`3h ago`), e.g. in activity lists. */
relative?: boolean
/** `data-qqq-id` of the element. */
dataQqqId?: string
/** Extra classes. */
className?: string
/** Shown when the value is empty (default: an em dash). */
empty?: React.ReactNode
}
/**
* Renders a date or timestamp compactly and without wrapping: `6:33 PM` today,
* `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before (DATE: `Jul 5` / `Jul 5, 2025`), in the
* viewer's zone and locale, with the full value as the tooltip and a machine-readable
* `<time dateTime>`.
*
* @param props - See {@link DateTimeTextProps}.
* @returns The `<time>` element, or the empty placeholder.
*/
export function DateTimeText({ value, displayValue, type, relative = false, dataQqqId, className, empty }: DateTimeTextProps) {
const display = type === 'DATE' ? compactDate(value, displayValue) : compactDateTime(value, displayValue)
if (!display) {
return empty !== undefined ? <>{empty}</> : <span className={cn('text-sm text-muted-foreground', className)} data-qqq-id={dataQqqId}>—</span>
}
const text = (relative && type === 'DATE_TIME' && relativeDateTime(value)) || display.text
return (
<time
dateTime={display.dateTime}
title={display.title}
className={cn('whitespace-nowrap text-sm tabular-nums text-foreground', className)}
data-qqq-id={dataQqqId}
>
{text}
</time>
)
}
+2 -2
View File
@@ -246,9 +246,9 @@ export function AppHome({ appMetaData, instance, widgetRegistry }: AppHomeProps)
{/* Widget grid: QQQ sizes widgets in twelfths (gridColumns), full width on small screens */}
{widgetItems.length > 0 && (
<section aria-label="Dashboard widgets" className="grid grid-cols-12 gap-5" data-qqq-id="widget-grid">
<section aria-label="Dashboard widgets" className="@container grid grid-cols-12 gap-5" data-qqq-id="widget-grid">
{widgetItems.map((meta) => (
<div key={meta.name} id={meta.name} className={`${widgetColumnClasses(meta.gridColumns, meta.defaultValues)} ${WIDGET_ANCHOR}`} data-qqq-id={`widget-grid-item-${meta.name}`}>
<div key={meta.name} id={meta.name} className={`${widgetColumnClasses(meta.gridColumns, meta.defaultValues, meta.type)} ${WIDGET_ANCHOR}`} data-qqq-id={`widget-grid-item-${meta.name}`}>
<ConnectedWidget widgetMetaData={meta} widgetRegistry={widgetRegistry} />
</div>
))}
@@ -29,7 +29,7 @@ vi.mock('./widget-utils', async (importOriginal) => ({
downloadText: (fileName: string, text: string) => { downloads.calls.push([fileName, text]) },
}))
import type { QRecord, QWidgetMetaData } from '@/types'
import type { FieldAdornment, QRecord, QWidgetMetaData } from '@/types'
import {
ChildRecordListWidget, addChildHref, childColumns, childExportTitle, childRecordsCsv, listColumns, nextViewAllHref, shownColumns,
} from './ChildRecordListWidget'
@@ -114,6 +114,44 @@ describe('ChildRecordListWidget', () => {
expect(screen.queryByText(/Showing/)).not.toBeInTheDocument()
})
it('passes the child table variant to the preview gallery, as the thumbnails get it', async () => {
const variant = { type: 'store', id: '2' }
localStorage.setItem('qqq.tableVariant.media', JSON.stringify(variant))
try {
instance.value = { tables: { media: { name: 'media', label: 'Media', usesVariants: true, readPermission: true, fields: {} } }, widgets: {} }
const adornments: FieldAdornment[] = [
{ type: 'FILE_PREVIEW', values: { fileNameField: 'name', mimeTypeField: 'type' } },
{ type: 'FILE_DOWNLOAD', values: {} },
]
const mediaTable: ChildTableMetaData = {
name: 'media',
label: 'Media',
primaryKeyField: 'id',
fields: {
id: { name: 'id', label: 'Id' },
// adornments ride along on the payload's field metadata
file: { name: 'file', label: 'File', type: 'BLOB', ...{ adornments } },
},
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'file'], isHidden: false }],
}
const data: ChildRecordListPayload = {
type: 'childRecordList',
childFrontendTableMetaData: mediaTable,
queryOutput: { records: [{ tableName: 'media', values: { id: 1, file: '/data/media/1/file/a.png', name: 'a.png', type: 'image/png' } }] },
}
render(<ChildRecordListWidget widgetMetaData={meta} data={data} />)
const json = encodeURIComponent(JSON.stringify(variant))
const url = `/qqq/v1/table/media/1/file/a.png?tableVariant=${json}`
// the cell thumbnail resolves the variant from the instance metadata once it has loaded
await waitFor(() => expect(screen.getByRole('button', { name: 'Preview a.png' }).querySelector('img')).toHaveAttribute('src', url))
fireEvent.click(screen.getByRole('button', { name: 'Preview a.png' }))
await waitFor(() => expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toHaveAttribute('href', url))
expect(document.querySelector('[data-qqq-id="file-preview-download"]')).toHaveAttribute('href', expect.stringContaining(`tableVariant=${json}`))
} finally {
localStorage.removeItem('qqq.tableVariant.media')
}
})
it('shows the empty message when the backend returned no records', () => {
render(<ChildRecordListWidget widgetMetaData={meta} data={{ ...payload, queryOutput: {}, totalRows: 0, viewAllLink: undefined }} />)
expect(screen.getByText('No Widget Host Child records found')).toBeInTheDocument()
@@ -274,3 +312,91 @@ describe('ChildRecordListWidget Material parity (WID-072)', () => {
expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled()
})
})
describe('ChildRecordListWidget in a narrow panel', () => {
const longText = 'Notes from the work of building systems, making decisions and understanding what changes along the way.'
const wideTable: ChildTableMetaData = {
name: 'blog',
label: 'Blogs',
primaryKeyField: 'id',
fields: {
id: { name: 'id', label: 'Id', type: 'INTEGER' },
siteId: { name: 'siteId', label: 'Site', type: 'INTEGER' },
name: { name: 'name', label: 'Name', type: 'STRING' },
isLive: { name: 'isLive', label: 'Is Live', type: 'BOOLEAN' },
description: { name: 'description', label: 'Description', type: 'STRING', maxLength: 1000 },
metaDescription: { name: 'metaDescription', label: 'Meta Description', type: 'STRING', maxLength: 1000 },
createDate: { name: 'createDate', label: 'Create Date', type: 'DATE_TIME' },
modifyDate: { name: 'modifyDate', label: 'Modify Date', type: 'DATE_TIME' },
},
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'siteId', 'name', 'isLive', 'description', 'metaDescription', 'createDate', 'modifyDate'] }],
}
const widePayload: ChildRecordListPayload = {
type: 'childRecordList',
childFrontendTableMetaData: wideTable,
defaultValuesForNewChildRecords: { siteId: 1 },
queryOutput: {
records: [{
tableName: 'blog',
values: { id: 7, siteId: 1, name: 'Field Notes', isLive: true, description: longText, metaDescription: longText, createDate: '2025-07-05T23:33:29Z', modifyDate: '2026-10-10T11:01:39Z' },
displayValues: { id: '7', name: 'Field Notes' },
}],
},
}
/**
* Gives every observed element the given content width, as a browser would report it.
*
* @param width - Width in CSS pixels.
*/
function stubResizeObserver(width: number) {
vi.stubGlobal('ResizeObserver', class {
private readonly callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) { this.callback = callback }
observe(target: Element) { this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver) }
unobserve() {}
disconnect() {}
})
}
/** @returns The headers of the rendered list. */
function headers(): string[] {
return screen.getAllByRole('columnheader').map((header) => header.textContent ?? '')
}
it('shows every column when its width is unknown', () => {
vi.stubGlobal('ResizeObserver', undefined)
render(<ChildRecordListWidget widgetMetaData={meta} data={widePayload} />)
expect(headers()).toEqual(['Id', 'Name', 'Is Live', 'Description', 'Meta Description', 'Create Date', 'Modify Date'])
vi.unstubAllGlobals()
})
it('keeps the name, flags and latest date that fit, and reveals the rest on request', async () => {
stubResizeObserver(400)
render(<ChildRecordListWidget widgetMetaData={meta} data={widePayload} />)
await waitFor(() => expect(headers()).toEqual(['Name', 'Is Live', 'Modify Date']))
// the first shown column links the row; cells align to the top; dates stay on one line
expect(screen.getByRole('link', { name: 'Field Notes' })).toHaveAttribute('href', '/app/blog/7')
expect(screen.getAllByRole('cell')[0]).toHaveClass('align-top')
expect(screen.getAllByRole('cell')[2].querySelector('time')).toHaveClass('whitespace-nowrap')
fireEvent.click(screen.getByRole('button', { name: '+4 more columns' }))
expect(headers()).toEqual(['Id', 'Name', 'Is Live', 'Description', 'Meta Description', 'Create Date', 'Modify Date'])
// long text is clamped, with the full text on hover
const description = screen.getAllByRole('cell')[3].firstElementChild
expect(description).toHaveClass('line-clamp-3')
expect(description).toHaveAttribute('title', longText)
fireEvent.click(screen.getByRole('button', { name: 'Fewer columns' }))
expect(headers()).toEqual(['Name', 'Is Live', 'Modify Date'])
vi.unstubAllGlobals()
})
it('follows the application column order when onlyIncludeFieldNames is set', async () => {
stubResizeObserver(300)
render(<ChildRecordListWidget widgetMetaData={meta} data={{ ...widePayload, onlyIncludeFieldNames: ['description', 'name'] }} />)
await waitFor(() => expect(headers()).toEqual(['Description']))
expect(screen.getByRole('button', { name: '+1 more column' })).toBeInTheDocument()
vi.unstubAllGlobals()
})
})
+135 -46
View File
@@ -24,16 +24,26 @@
* click anywhere on a row to open the child record, a CSV export of the rows shown,
* and a View All link when the backend limited the rows (Material RecordGridWidget).
* Association editing is handled by the record view, not here.
*
* The list adapts to the width it is given (a half-width panel, a phone): it shows the
* columns that matter most and fit (see `compact-columns`), with a toggle to show every
* column; cells align to the top, long text is clamped with its full text on hover, and
* dates are compact.
*/
'use client'
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import Link from 'next/link'
import { Download } from 'lucide-react'
import type { QRecord, QTableMetaData } from '@/types'
import type { FieldAdornment, QRecord, QTableMetaData } from '@/types'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { useContainerWidth } from '@/lib/hooks/use-container-width'
import { cn } from '@/lib/utils/cn'
import { columnKind, pickCompactColumns } from '@/lib/utils/compact-columns'
import { hasFilePreview, joinedPreviewField } from '@/lib/utils/file-preview'
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import { FieldValue } from '@/components/records/FieldValue'
import { displayText } from './record-lookup-utils'
import { asList, isPlainObject, payloadProblem } from './widget-types'
@@ -51,6 +61,10 @@ interface ChildFieldMetaData {
isHidden?: boolean
isHeavy?: boolean
possibleValueSourceName?: string
/** Declared maximum length (long text when there are no rows to measure). */
maxLength?: number
/** Field adornments (a CHIP marks a status column). */
adornments?: FieldAdornment[]
}
/** An exposed join of the child table (`QFrontendExposedJoin`), with its join table's metadata. */
@@ -309,6 +323,50 @@ export function nextViewAllHref(viewAllLink: string, tableName: string): string
return `/app/${encodeURIComponent(tableName)}${queryIndex >= 0 ? viewAllLink.slice(queryIndex) : ''}`
}
/** The columns an embedded list shows in its width, and which of them hold long text. */
export interface CompactChildColumns {
/** Columns to show, in metadata order. */
compact: ChildColumn[]
/** How many columns did not fit. */
hiddenCount: number
/** Names of the columns holding long text (clamped in their cells). */
longText: Set<string>
}
/**
* Chooses the child columns that matter most and fit the list's width (see `compact-columns`):
* the T1 identity, status and boolean columns and the most recent date before long text, the
* key only when nothing else names the row, and the widget's `onlyIncludeFieldNames` order
* when the application set one.
*
* @param columns - Candidate columns (parent key already removed).
* @param table - Child table metadata.
* @param data - Widget payload.
* @param records - Rows shown.
* @param width - The list's width in CSS pixels; 0 shows every column.
* @returns The choice.
*/
export function compactChildColumns(columns: ChildColumn[], table: ChildTableMetaData | undefined, data: ChildRecordListPayload | undefined, records: QRecord[], width: number): CompactChildColumns {
const valuesOf = (record: QRecord) => (isPlainObject(record?.values) ? record.values : {})
const shownText = (name: string) => records.map((record) => {
const display = isPlainObject(record?.displayValues) ? record.displayValues[name] : undefined
return displayText(display !== undefined && display !== null ? display : valuesOf(record)[name])
})
const rawText = (name: string) => records.map((record) => displayText(valuesOf(record)[name]))
const t1FieldNames = (asList<ChildSectionMetaData>(table?.sections) ?? [])
.filter((section) => isPlainObject(section) && section.tier === 'T1')
.flatMap((section) => asList<string>(section.fieldNames) ?? [])
const context = {
primaryKeyField: table?.primaryKeyField,
t1FieldNames,
explicitOrder: asList<string>(data?.onlyIncludeFieldNames) ?? [],
samples: shownText,
}
const longText = new Set(columns.filter((column) => columnKind(column, context) === 'longText').map((column) => column.name))
const { shown, hidden } = pickCompactColumns(columns, width, context, rawText)
return { compact: shown, hiddenCount: hidden.length, longText }
}
/**
* Renders the child records returned for the hosting record: each value formatted by its
* field's type and adornments (possible values link to their record), the exposed join
@@ -325,7 +383,7 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
const { data: instance } = useMetaData()
const table = data?.childFrontendTableMetaData ?? data?.childTableMetaData
const queryOutput = data?.queryOutput
const records = queryOutput === undefined || queryOutput === null ? [] : isPlainObject(queryOutput) ? asList<QRecord>(queryOutput.records) : undefined
const records = useMemo(() => queryOutput === undefined || queryOutput === null ? [] : isPlainObject(queryOutput) ? asList<QRecord>(queryOutput.records) : undefined, [queryOutput])
const valid = Boolean(table) && isPlainObject(table) && typeof table?.name === 'string' && records !== undefined
// Join columns need the user's table permissions, which the instance metadata carries.
@@ -334,8 +392,24 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
const tables = instance?.tables
return listColumns(table, data, (name) => Boolean(tables?.[name]) && tables?.[name]?.readPermission !== false)
}, [valid, table, data, instance?.tables])
const columns = useMemo(() => shownColumns(allColumns, data), [allColumns, data])
const fields = useMemo(() => Object.fromEntries(columns.map((column) => [column.name, widgetField(column.field, column.name, column.label)])), [columns])
const candidateColumns = useMemo(() => shownColumns(allColumns, data), [allColumns, data])
const [measureRef, availableWidth] = useContainerWidth()
const [showAllColumns, setShowAllColumns] = useState(false)
const { compact: columns, hiddenCount, longText } = useMemo(() => {
const rows = isPlainObject(data?.queryOutput) ? asList<QRecord>(data.queryOutput.records) ?? [] : []
return compactChildColumns(candidateColumns, table, data, rows, showAllColumns ? 0 : availableWidth)
}, [candidateColumns, table, data, showAllColumns, availableWidth])
// a join column's values are keyed `joinTable.field`, and so are its FILE_PREVIEW field references
const fields = useMemo(() => Object.fromEntries(candidateColumns.map((column) => [column.name, widgetField(
column.name.includes('.') ? joinedPreviewField(column.field, column.name.slice(0, column.name.indexOf('.'))) : column.field, column.name, column.label)])), [candidateColumns])
// one record per row, shared by its cells and the preview gallery (which matches thumbnails by record)
const cellRecords = useMemo<QRecord[]>(() => (records ?? []).map((record) => ({
tableName: typeof record?.tableName === 'string' ? record.tableName : table?.name ?? '',
values: isPlainObject(record?.values) ? record.values : {},
displayValues: (isPlainObject(record?.displayValues) ? record.displayValues : {}) as Record<string, string>,
})), [records, table?.name])
// only the columns on screen contribute previews, so lightbox navigation never steps into a hidden column
const galleryFields = useMemo(() => columns.map((column) => fields[column.name]).filter(Boolean), [columns, fields])
if (!valid || !table || records === undefined) {
return <WidgetPayloadNotice widgetName={widgetName} message={payloadProblem('child record list', 'queryOutput.records and child table metadata')} />
@@ -355,8 +429,20 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
const allTables = instance?.tables as Record<string, QTableMetaData> | undefined
const showToolbar = data.canAddChildRecord === true || widgetMetaData.showExportButton === true
const columnToggle = hiddenCount > 0 || showAllColumns ? (
<button
type="button"
onClick={() => setShowAllColumns((current) => !current)}
aria-expanded={showAllColumns}
className="rounded text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring"
data-qqq-id={`child-record-columns-toggle-${widgetName}`}
>
{showAllColumns ? 'Fewer columns' : `+${hiddenCount} more column${hiddenCount === 1 ? '' : 's'}`}
</button>
) : null
return (
<div className="space-y-2" data-qqq-id={`widget-childRecordList-${widgetName}`}>
<div ref={measureRef} className="min-w-0 space-y-2" data-qqq-id={`widget-childRecordList-${widgetName}`} data-hidden-columns={hiddenCount}>
{showToolbar && (
<div className="flex flex-wrap items-center justify-end gap-3">
{widgetMetaData.showExportButton === true && (
@@ -382,56 +468,59 @@ export function ChildRecordListWidget({ widgetMetaData, data, recordContext, onW
<thead>
<tr className="border-b border-border text-left">
{columns.map((column) => (
<th key={column.name} scope="col" className="px-2 py-1.5 font-medium text-muted-foreground" data-qqq-id={`child-record-column-${widgetName}-${column.name}`}>
<th key={column.name} scope="col" className="whitespace-nowrap px-2 py-1.5 align-bottom font-medium text-muted-foreground" data-qqq-id={`child-record-column-${widgetName}-${column.name}`}>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{records.map((record, rowIndex) => {
const values = isPlainObject(record?.values) ? record.values : {}
const displayValues = isPlainObject(record?.displayValues) ? record.displayValues : {}
const pk = primaryKeyField ? values[primaryKeyField] : undefined
const pkText = displayText(pk)
const href = !data.disableRowClick && pkText !== '' ? `/app/${encodeURIComponent(table.name)}/${encodeURIComponent(pkText)}` : undefined
const cellRecord: QRecord = {
tableName: typeof record?.tableName === 'string' ? record.tableName : table.name,
values,
displayValues: displayValues as Record<string, string>,
}
return (
<tr
key={pkText || rowIndex}
className={cn('border-b border-border/50 last:border-0', href && 'cursor-pointer hover:bg-accent/50')}
onClick={href ? openRowRecord : undefined}
data-qqq-id={`child-record-row-${widgetName}-${pkText || rowIndex}`}
>
{columns.map((column, columnIndex) => {
const display = displayValues[column.name]
const text = displayText(display !== undefined && display !== null ? display : values[column.name])
return (
<td key={column.name} className="px-2 py-1.5 text-foreground" data-qqq-id={`child-record-cell-${widgetName}-${column.name}`}>
{columnIndex === 0 && href ? (
<Link href={href} data-row-link="" className="text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring">
{text || pkText}
</Link>
) : (
<FieldValue field={fields[column.name]} record={cellRecord} allTables={allTables} widgetMetaDataMap={instance?.widgets} />
)}
</td>
)
})}
</tr>
)
})}
<FilePreviewGallery records={cellRecords} fields={galleryFields} tableVariant={storedRecordVariantJson(allTables?.[table.name])}>
{cellRecords.map((cellRecord, rowIndex) => {
const { values } = cellRecord
const displayValues = cellRecord.displayValues ?? {}
const pk = primaryKeyField ? values[primaryKeyField] : undefined
const pkText = displayText(pk)
const href = !data.disableRowClick && pkText !== '' ? `/app/${encodeURIComponent(table.name)}/${encodeURIComponent(pkText)}` : undefined
return (
<tr
key={pkText || rowIndex}
className={cn('border-b border-border/50 last:border-0', href && 'cursor-pointer hover:bg-accent/50')}
onClick={href ? openRowRecord : undefined}
data-qqq-id={`child-record-row-${widgetName}-${pkText || rowIndex}`}
>
{columns.map((column, columnIndex) => {
const display = displayValues[column.name]
const text = displayText(display !== undefined && display !== null ? display : values[column.name])
return (
<td key={column.name} className="px-2 py-1.5 align-top text-foreground" data-qqq-id={`child-record-cell-${widgetName}-${column.name}`}>
{columnIndex === 0 && href && !hasFilePreview(fields[column.name]) ? (
<Link href={href} data-row-link="" className="text-primary underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-ring">
{text || pkText}
</Link>
) : (
// long text is clamped to three lines; the full value is its tooltip
<div className={cn('break-words', longText.has(column.name) ? 'line-clamp-3 min-w-[12rem] max-w-[28rem]' : 'max-w-[20rem]')} title={longText.has(column.name) ? text : undefined}>
<FieldValue field={fields[column.name]} record={cellRecord} allTables={allTables} widgetMetaDataMap={instance?.widgets} />
</div>
)}
</td>
)
})}
</tr>
)
})}
</FilePreviewGallery>
</tbody>
</table>
</div>
)}
{(totalRows !== undefined && totalRows > records.length) || viewAllHref ? (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{totalRows !== undefined && totalRows > records.length ? `Showing ${records.length} of ${totalRows}` : ''}</span>
{(totalRows !== undefined && totalRows > records.length) || viewAllHref || columnToggle ? (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex flex-wrap items-center gap-x-3">
{totalRows !== undefined && totalRows > records.length ? <span>{`Showing ${records.length} of ${totalRows}`}</span> : null}
{columnToggle}
</span>
{viewAllHref && (
<Link
href={viewAllHref}
@@ -152,9 +152,9 @@ export function QqqParentWidget({ widgetMetaData, data, widgetRegistry, childPar
}
return (
<div className="grid grid-cols-12 gap-4" data-qqq-id={`parent-widget-${widgetMetaData.name}`} data-layout="GRID">
<div className="@container grid grid-cols-12 gap-4" data-qqq-id={`parent-widget-${widgetMetaData.name}`} data-layout="GRID">
{children.map((child) => (
<div key={child.name} id={child.name} className={`${widgetColumnClasses(child.gridColumns, child.defaultValues)} ${WIDGET_ANCHOR}`}>
<div key={child.name} id={child.name} className={`${widgetColumnClasses(child.gridColumns, child.defaultValues, child.type)} ${WIDGET_ANCHOR}`}>
<ConnectedWidget
widgetMetaData={child}
params={childParams}
+66 -63
View File
@@ -28,6 +28,7 @@ import { Inbox } from 'lucide-react'
import type { QFieldMetaData, QRecord } from '@/types'
import { DataCell } from '@/components/query/DataCell'
import { FilePreviewGallery } from '@/components/preview/FilePreviewGallery'
/** Wire-format payload for a record-grid widget returned by the backend API. */
export interface RecordGridWidgetPayload {
@@ -126,74 +127,76 @@ export function RecordGridWidget({ data, widgetName }: RecordGridWidgetProps) {
}))
return (
<div
className="w-full overflow-x-auto"
data-qqq-id={`record-grid-${widgetName}`}
>
<table
className="w-full border-collapse text-sm"
aria-label={`${widgetName} record grid`}
<FilePreviewGallery records={qRecords} fields={resolvedFields}>
<div
className="w-full overflow-x-auto"
data-qqq-id={`record-grid-${widgetName}`}
>
<thead>
<tr className="border-b border-border bg-muted">
{resolvedFields.map((f) => (
<th
key={f.name}
className="px-3 py-2 text-left text-xs font-semibold text-muted-foreground"
data-qqq-id={`widget-grid-header-${f.name}`}
>
{f.label}
</th>
))}
</tr>
</thead>
<tbody>
{qRecords.map((record, rowIdx) => (
<tr
key={rowIdx}
className="border-b border-border transition-colors hover:bg-accent"
data-qqq-id={`widget-grid-row-${widgetName}-${rowIdx}`}
>
{resolvedFields.map((field) => (
<td
key={field.name}
className="px-3 py-2"
data-qqq-id={`widget-grid-cell-${widgetName}-${field.name}`}
<table
className="w-full border-collapse text-sm"
aria-label={`${widgetName} record grid`}
>
<thead>
<tr className="border-b border-border bg-muted">
{resolvedFields.map((f) => (
<th
key={f.name}
className="px-3 py-2 text-left text-xs font-semibold text-muted-foreground"
data-qqq-id={`widget-grid-header-${f.name}`}
>
<DataCell
field={field}
value={record.values[field.name]}
displayValue={record.displayValues?.[field.name]}
record={record}
/>
</td>
{f.label}
</th>
))}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{qRecords.map((record, rowIdx) => (
<tr
key={rowIdx}
className="border-b border-border transition-colors hover:bg-accent"
data-qqq-id={`widget-grid-row-${widgetName}-${rowIdx}`}
>
{resolvedFields.map((field) => (
<td
key={field.name}
className="px-3 py-2"
data-qqq-id={`widget-grid-cell-${widgetName}-${field.name}`}
>
<DataCell
field={field}
value={record.values[field.name]}
displayValue={record.displayValues?.[field.name]}
record={record}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
{/* "View all" link when rows are capped at MAX_DISPLAY_ROWS */}
{hasMore && tableName && (
<div className="mt-2 text-center" data-qqq-id={`widget-grid-view-all-${widgetName}`}>
<a
href={`/app/${tableName}`}
className="text-sm text-primary underline hover:text-primary/80"
{/* "View all" link when rows are capped at MAX_DISPLAY_ROWS */}
{hasMore && tableName && (
<div className="mt-2 text-center" data-qqq-id={`widget-grid-view-all-${widgetName}`}>
<a
href={`/app/${tableName}`}
className="text-sm text-primary underline hover:text-primary/80"
>
View all {records.length} records &rarr;
</a>
</div>
)}
{/* Server-side count footer when backend reports more records than were sent */}
{data.totalCount !== undefined && data.totalCount > records.length && (
<p
className="mt-2 text-center text-xs text-muted-foreground"
data-qqq-id={`widget-grid-more-${widgetName}`}
>
View all {records.length} records &rarr;
</a>
</div>
)}
{/* Server-side count footer when backend reports more records than were sent */}
{data.totalCount !== undefined && data.totalCount > records.length && (
<p
className="mt-2 text-center text-xs text-muted-foreground"
data-qqq-id={`widget-grid-more-${widgetName}`}
>
Showing {Math.min(displayedRecords.length, records.length)} of {data.totalCount.toLocaleString()} records
</p>
)}
</div>
Showing {Math.min(displayedRecords.length, records.length)} of {data.totalCount.toLocaleString()} records
</p>
)}
</div>
</FilePreviewGallery>
)
}
+2 -2
View File
@@ -25,10 +25,10 @@ import { useQuery } from '@tanstack/react-query'
import type { QFieldMetaData } from '@/types'
import { fetchPossibleValues } from '@/lib/api/possible-values'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { formatPlainValue } from './record-widget-utils'
import type { BuilderRow } from './row-builder-model'
import { DateTimeText } from '@/components/ui/DateTimeText'
/** Props for {@link RowBuilderTable}. */
export interface RowBuilderTableProps {
@@ -76,7 +76,7 @@ function cellContent(field: QFieldMetaData, row: BuilderRow): React.ReactNode {
const inline = field.inlinePossibleValueSource?.enumValues?.find((option) => String(option.id) === String(value))
if (inline) return inline.label
if (field.possibleValueSourceName) return <PossibleValueLabel sourceName={field.possibleValueSourceName} value={value} />
if (field.type === 'DATE_TIME') return formatDateTime(value) ?? formatPlainValue(value, field.type)
if (field.type === 'DATE_TIME' || field.type === 'DATE') return <DateTimeText type={field.type} value={value} />
return formatPlainValue(value, field.type)
}
+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':
@@ -26,7 +26,7 @@ import {
describe('widgetColumnClasses with Material per-breakpoint sizes', () => {
it('keeps the gridColumns sizing when no sizeClass is declared', () => {
expect(widgetColumnClasses(6, { other: 1 })).toBe('col-span-12 min-w-0 lg:col-span-6')
expect(widgetColumnClasses(6, { other: 1 })).toBe('col-span-12 @min-[40rem]:col-span-6 min-w-0')
})
it('sizes from the declared breakpoints at Material widths, xs defaulting to 12 and xxl to gridColumns', () => {
@@ -38,7 +38,7 @@ describe('widgetColumnClasses with Material per-breakpoint sizes', () => {
})
it('ignores unusable sizes', () => {
expect(widgetColumnClasses(5, { 'gridCols:sizeClass:md': 40, 'gridCols:sizeClass:lg': 'wide' })).toBe('col-span-12 min-w-0 lg:col-span-5')
expect(widgetColumnClasses(5, { 'gridCols:sizeClass:md': 40, 'gridCols:sizeClass:lg': 'wide' })).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-5 min-w-0')
})
})
+12 -5
View File
@@ -73,11 +73,18 @@ describe('dropdown selections', () => {
})
describe('widgetColumnClasses', () => {
it('maps gridColumns (twelfths) to spans, defaulting to full width', () => {
expect(widgetColumnClasses(4)).toBe('col-span-12 min-w-0 lg:col-span-4')
expect(widgetColumnClasses(12)).toBe('col-span-12 min-w-0 lg:col-span-12')
expect(widgetColumnClasses(undefined)).toBe('col-span-12 min-w-0 lg:col-span-12')
expect(widgetColumnClasses(40)).toBe('col-span-12 min-w-0 lg:col-span-12')
it('maps gridColumns (twelfths) to spans by the grid width, defaulting to full width', () => {
expect(widgetColumnClasses(4)).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 min-w-0')
expect(widgetColumnClasses(3)).toBe('col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-3 min-w-0')
expect(widgetColumnClasses(12)).toBe('col-span-12 min-w-0')
expect(widgetColumnClasses(undefined)).toBe('col-span-12 min-w-0')
expect(widgetColumnClasses(40)).toBe('col-span-12 min-w-0')
})
it('gives table widgets card-sized room before they sit side by side', () => {
expect(widgetColumnClasses(6, undefined, 'childRecordList')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
expect(widgetColumnClasses(6, undefined, 'table')).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
expect(widgetColumnClasses(6, undefined, 'statistics')).toBe('col-span-12 @min-[40rem]:col-span-6 min-w-0')
})
})
+10 -10
View File
@@ -20,6 +20,7 @@
import type { QHelpContent, QWidgetDropdown, QWidgetHelpContent, QWidgetMetaData } from '@/types'
import { WIDGET_HELP_ROLES, selectSlotHelpContent } from '@/lib/utils/help-utils'
import { containerSpanClasses } from '@/lib/utils/responsive-grid'
/** Root of the local-storage keys that persist widget dropdown selections (shared with Material). */
export const WIDGET_DROPDOWN_STORAGE_ROOT = 'qqq.widgets.dropdownData'
@@ -289,11 +290,8 @@ export function downloadText(fileName: string, text: string, mimeType = 'text/cs
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
}
/** Tailwind classes for QQQ's 12-column widget sizing (static strings for the JIT). */
const COLUMN_SPANS: Record<number, string> = {
1: 'lg:col-span-1', 2: 'lg:col-span-2', 3: 'lg:col-span-3', 4: 'lg:col-span-4', 5: 'lg:col-span-5', 6: 'lg:col-span-6',
7: 'lg:col-span-7', 8: 'lg:col-span-8', 9: 'lg:col-span-9', 10: 'lg:col-span-10', 11: 'lg:col-span-11', 12: 'lg:col-span-12',
}
/** Widget types that render tables, which need card-sized room before they sit side by side. */
const TABLE_WIDGET_TYPES = new Set(['childRecordList', 'table', 'multiTable', 'recordGrid', 'rowBuilder', 'dataBagViewer'])
/** Material Dashboard's grid breakpoints (xs 0, sm 576, md 768, lg 992, xl 1200, xxl 1400 px). */
const SIZE_CLASSES = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const
@@ -320,8 +318,10 @@ function columnSpan(value: unknown): number | undefined {
}
/**
* Column-span classes for a widget in a 12-column grid: full width on small
* screens, `gridColumns` of 12 (default 12, as in Material) on large screens.
* Column-span classes for a widget in a 12-column `@container` grid: full width in a narrow
* grid, `gridColumns` of 12 (default 12, as in Material) once the grid is wide enough for
* widgets of that width to sit in a row; table widgets need more room than charts and
* statistics before they do (see `responsive-grid`).
*
* A widget that declares per-breakpoint sizes (`gridCols:sizeClass:{xs,sm,md,lg,xl,xxl}` in its
* `defaultValues`) is sized as Material sizes it: each declared breakpoint (Material's widths)
@@ -329,9 +329,10 @@ function columnSpan(value: unknown): number | undefined {
*
* @param gridColumns - Widget metadata grid columns.
* @param defaultValues - Widget metadata default values (per-breakpoint overrides).
* @param widgetType - Widget type, so table widgets get card-sized room.
* @returns Tailwind classes.
*/
export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record<string, unknown>): string {
export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record<string, unknown>, widgetType?: string): string {
const sizes = Object.fromEntries(SIZE_CLASSES.map((size) => [size, columnSpan(defaultValues?.[`gridCols:sizeClass:${size}`])]))
if (Object.values(sizes).some((span) => span !== undefined)) {
sizes.xs ??= 12
@@ -339,8 +340,7 @@ export function widgetColumnClasses(gridColumns?: number, defaultValues?: Record
const classes = SIZE_CLASSES.flatMap((size) => (sizes[size] ? [SIZE_CLASS_SPANS[size][sizes[size]]] : []))
return `${classes.join(' ')} min-w-0`
}
const span = columnSpan(gridColumns) ?? 12
return `col-span-12 min-w-0 ${COLUMN_SPANS[span]}`
return containerSpanClasses(columnSpan(gridColumns) ?? 12, widgetType && TABLE_WIDGET_TYPES.has(widgetType) ? 'cards' : 'widgets')
}
/** Classes for a dashboard grid item: an in-page anchor target (`#widgetName`) clear of the header. */
+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)
}
+48
View File
@@ -0,0 +1,48 @@
/*
* 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 use-container-width — the live content width of an element, for layouts that adapt to
* the space they are given (a panel, a card) rather than to the viewport.
*/
'use client'
import { useCallback, useEffect, useState } from 'react'
/**
* Tracks an element's content-box width with a `ResizeObserver`.
*
* @returns A callback ref for the element and its width in CSS pixels; 0 until measured, and
* always 0 where `ResizeObserver` is unavailable (callers then keep their full layout).
*/
export function useContainerWidth<T extends HTMLElement = HTMLDivElement>(): [(element: T | null) => void, number] {
const [element, setElement] = useState<T | null>(null)
const [width, setWidth] = useState(0)
const ref = useCallback((node: T | null) => setElement(node), [])
useEffect(() => {
if (!element || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver((entries) => {
const next = Math.round(entries[0]?.contentRect.width ?? 0)
// ignore sub-pixel jitter so a re-layout cannot loop on its own size change
setWidth((previous) => (Math.abs(previous - next) < 2 ? previous : next))
})
observer.observe(element)
return () => observer.disconnect()
}, [element])
return [ref, width]
}
+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.
+151
View File
@@ -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.
*/
import { describe, expect, it } from 'vitest'
import { columnKind, estimateColumnWidth, pickCompactColumns, rankColumns } from './compact-columns'
import type { CompactColumn, CompactColumnContext } from './compact-columns'
/**
* A column candidate.
*
* @param name - Field name.
* @param type - Field type.
* @param extra - Extra field metadata.
* @returns The column.
*/
function column(name: string, type = 'STRING', extra: Partial<CompactColumn['field']> = {}): CompactColumn {
return { name, label: name.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()), field: { name, type, ...extra } }
}
const fieldNotes = 'Notes from the work of building systems, making decisions and understanding what changes along the way.'
// a blog list shaped like the CMS screenshot that prompted this: every column crammed into a panel
const blogColumns = [
column('id', 'INTEGER'),
column('name', 'STRING', { maxLength: 255 }),
column('shortName', 'STRING', { maxLength: 100 }),
column('slug', 'STRING', { maxLength: 100 }),
column('blogTypeId', 'INTEGER', { possibleValueSourceName: 'blogType' }),
column('description', 'STRING', { maxLength: 1000 }),
column('metaTitle', 'STRING', { maxLength: 255 }),
column('metaDescription', 'STRING', { maxLength: 1000 }),
column('metaKeywords', 'STRING', { maxLength: 1000 }),
column('createDate', 'DATE_TIME'),
column('modifyDate', 'DATE_TIME'),
]
const blogValues: Record<string, string[]> = {
id: ['1', '2'],
name: ['Field Notes', 'Ideas Into Systems'],
shortName: ['Field Notes', 'Ideas Into Systems'],
slug: ['quick-bits', 'ideas-into-systems'],
blogTypeId: ['Site Blog', 'Site Blog'],
description: [fieldNotes, fieldNotes],
metaTitle: ['Field Notes - Engineering & Leadership', 'Ideas Into Systems - A CTO\'s Perspective'],
metaDescription: [fieldNotes, fieldNotes],
metaKeywords: ['engineering, leadership, observations, systems', 'technology, architecture, leadership, engineering, systems'],
createDate: ['2026-07-05T23:33:29Z', '2026-07-05T23:33:29Z'],
modifyDate: ['2026-10-10T11:01:39Z', '2026-10-11T07:36:21Z'],
}
const blogContext: CompactColumnContext = {
primaryKeyField: 'id',
t1FieldNames: ['id', 'name', 'shortName', 'slug', 'blogTypeId'],
samples: (name) => blogValues[name] ?? [],
}
describe('columnKind', () => {
it('classifies keys, statuses, booleans, dates, numbers and long text from metadata', () => {
expect(columnKind(column('id', 'INTEGER'), { primaryKeyField: 'id' })).toBe('key')
expect(columnKind(column('status', 'STRING', { adornments: [{ type: 'CHIP' }] }))).toBe('status')
expect(columnKind(column('typeId', 'INTEGER', { possibleValueSourceName: 'type' }))).toBe('status')
expect(columnKind(column('isPrimary', 'BOOLEAN'))).toBe('boolean')
expect(columnKind(column('day', 'DATE'))).toBe('date')
expect(columnKind(column('at', 'DATE_TIME'))).toBe('dateTime')
expect(columnKind(column('total', 'DECIMAL'))).toBe('number')
expect(columnKind(column('body', 'TEXT'))).toBe('longText')
expect(columnKind(column('page', 'HTML'))).toBe('longText')
expect(columnKind(column('blob', 'STRING', { isHeavy: true }))).toBe('longText')
})
it('decides long text from the rows shown, and from the declared length without rows', () => {
expect(columnKind(column('description', 'STRING', { maxLength: 500 }), { samples: () => ['Primary base domain', 'Naked domain, redirect to www'] })).toBe('text')
expect(columnKind(column('description', 'STRING', { maxLength: 100 }), { samples: () => [fieldNotes] })).toBe('longText')
expect(columnKind(column('description', 'STRING', { maxLength: 500 }))).toBe('longText')
expect(columnKind(column('name', 'STRING', { maxLength: 100 }))).toBe('text')
})
})
describe('estimateColumnWidth', () => {
it('sizes dates to their compact form and text to its content within bounds', () => {
expect(estimateColumnWidth(column('createDate', 'DATE_TIME'), 'dateTime')).toBe(150)
expect(estimateColumnWidth(column('day', 'DATE'), 'date')).toBe(130)
expect(estimateColumnWidth(column('name'), 'text', ['ab'])).toBe(90)
expect(estimateColumnWidth(column('name'), 'text', ['x'.repeat(200)])).toBe(260)
expect(estimateColumnWidth(column('body'), 'longText')).toBe(280)
})
})
describe('rankColumns', () => {
it('puts the name first, then status, then the most recently changed date, and long text last', () => {
const ranked = rankColumns(blogColumns, blogContext)
const order = [...ranked].sort((a, b) => b.priority - a.priority).map(({ column: { name } }) => name)
expect(order[0]).toBe('name')
expect(order[1]).toBe('blogTypeId')
// modifyDate holds the latest values, so it is the date that stays
expect(order.indexOf('modifyDate')).toBeLessThan(order.indexOf('createDate'))
// the key ranks below even long text while a name column identifies the row
expect(order.slice(-4).sort()).toEqual(['description', 'id', 'metaDescription', 'metaKeywords'])
expect(ranked.find(({ column: { name } }) => name === 'id')?.kind).toBe('key')
})
it('keeps the key as the row name when no text column names the row', () => {
const ranked = rankColumns([column('id', 'INTEGER'), column('qty', 'INTEGER'), column('at', 'DATE_TIME')], { primaryKeyField: 'id' })
expect([...ranked].sort((a, b) => b.priority - a.priority)[0].column.name).toBe('id')
})
it('follows the application order of onlyIncludeFieldNames', () => {
const ranked = rankColumns(blogColumns, { ...blogContext, explicitOrder: ['metaDescription', 'slug'] })
const order = [...ranked].sort((a, b) => b.priority - a.priority).map(({ column: { name } }) => name)
expect(order.slice(0, 2)).toEqual(['metaDescription', 'slug'])
})
})
describe('pickCompactColumns', () => {
it('shows every column when the width is unknown', () => {
expect(pickCompactColumns(blogColumns, 0, blogContext)).toEqual({ shown: blogColumns, hidden: [] })
})
it('keeps the identity, status and one date in a half-width panel, in metadata order', () => {
const { shown, hidden } = pickCompactColumns(blogColumns, 560, blogContext)
expect(shown.map(({ name }) => name)).toEqual(['name', 'shortName', 'blogTypeId', 'modifyDate'])
expect(hidden.map(({ name }) => name)).toContain('metaDescription')
expect(hidden.map(({ name }) => name)).toContain('id')
})
it('adds columns as the panel widens and shows long text only with room to spare', () => {
const medium = pickCompactColumns(blogColumns, 1000, blogContext).shown.map(({ name }) => name)
expect(medium).toEqual(expect.arrayContaining(['name', 'shortName', 'slug', 'blogTypeId', 'modifyDate']))
expect(medium).not.toContain('metaDescription')
// the key is left out while a name column identifies the row
expect(pickCompactColumns(blogColumns, 4000, blogContext).hidden.map(({ name }) => name)).toEqual(['id'])
})
it('always shows at least the most important column', () => {
expect(pickCompactColumns(blogColumns, 40, blogContext).shown.map(({ name }) => name)).toEqual(['name'])
})
})
+227
View File
@@ -0,0 +1,227 @@
/*
* 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 compact-columns — which columns an embedded table (a child-record list in a record
* view panel) shows in the width it has.
*
* Every column gets a priority from its field metadata and the rows on screen, and an
* estimated width; the highest-priority columns that fit are shown, in their metadata order:
*
* 1. the record's name: the first short text column, preferring the table's T1 section;
* 2. status-like columns (a CHIP adornment or a possible-value source) and booleans;
* 3. one date: the date column holding the latest value (so "modified" beats "created");
* 4. other short text, then numbers, then other dates;
* 5. the primary key only when nothing else names the row (otherwise it is left out, like the
* parent's key: the row already links to its record);
* 6. long text last (TEXT / HTML / heavy fields, or values that run long in the rows shown).
*
* An application that lists `onlyIncludeFieldNames` on the widget has chosen its columns:
* their order is their priority. Hidden columns stay one click away (the widget's
* "more columns" toggle and View All).
*/
import { DATE_COLUMN_WIDTH, DATE_TIME_COLUMN_WIDTH } from './datetime-utils'
/** The field metadata the column choice reads. */
export interface CompactColumnField {
name: string
type?: string
maxLength?: number
isHeavy?: boolean
possibleValueSourceName?: string
adornments?: Array<{ type?: string }>
}
/** A column candidate: its value key, heading and field. */
export interface CompactColumn {
name: string
label: string
field: CompactColumnField
}
/** What kind of value a column holds, for priority and width. */
export type CompactColumnKind = 'key' | 'label' | 'status' | 'boolean' | 'date' | 'dateTime' | 'number' | 'text' | 'longText'
/** Context for the choice. */
export interface CompactColumnContext {
/** The table's primary key field. */
primaryKeyField?: string
/** Field names in the table's T1 sections (the record's identity). */
t1FieldNames?: string[]
/** Columns the application chose (`onlyIncludeFieldNames`), most important first. */
explicitOrder?: string[]
/** The text of each column's values in the rows shown. */
samples?: (columnName: string) => string[]
}
/** The outcome: the columns to show (metadata order) and the ones left out. */
export interface CompactColumnChoice<T extends CompactColumn> {
shown: T[]
hidden: T[]
}
/** Average and longest sample length above which a text column counts as long text. */
const LONG_TEXT_AVERAGE = 40
const LONG_TEXT_MAX = 80
/** A declared maximum length that marks long text when there are no rows to look at. */
const LONG_TEXT_MAX_LENGTH = 255
/** Approximate width of one character of 14px table text, and a cell's horizontal padding. */
const CHAR_WIDTH = 7
const CELL_PADDING = 16
/**
* Classifies a column.
*
* @param column - The column.
* @param context - Primary key and sample rows.
* @returns The kind (`label` is assigned later, by position).
*/
export function columnKind(column: CompactColumn, context: CompactColumnContext = {}): Exclude<CompactColumnKind, 'label'> {
const { field } = column
if (column.name === context.primaryKeyField) return 'key'
if (field.adornments?.some((adornment) => adornment?.type === 'CHIP') || field.possibleValueSourceName) return 'status'
switch (field.type) {
case 'BOOLEAN': return 'boolean'
case 'DATE': return 'date'
case 'DATE_TIME': return 'dateTime'
case 'INTEGER': case 'LONG': case 'DECIMAL': return 'number'
case 'TEXT': case 'HTML': case 'BLOB': return 'longText'
default: break
}
if (field.isHeavy) return 'longText'
const samples = (context.samples?.(column.name) ?? []).filter((text) => text !== '')
if (samples.length > 0) {
const lengths = samples.map((text) => text.length)
const average = lengths.reduce((sum, length) => sum + length, 0) / lengths.length
return average > LONG_TEXT_AVERAGE || Math.max(...lengths) > LONG_TEXT_MAX ? 'longText' : 'text'
}
return (field.maxLength ?? 0) > LONG_TEXT_MAX_LENGTH ? 'longText' : 'text'
}
/**
* Estimated width a column needs to show its values without crowding.
*
* @param column - The column.
* @param kind - Its kind.
* @param samples - Its values' text in the rows shown.
* @returns Width in CSS pixels.
*/
export function estimateColumnWidth(column: CompactColumn, kind: CompactColumnKind, samples: string[] = []): number {
const heading = Math.min(column.label.length * CHAR_WIDTH + CELL_PADDING, 140)
switch (kind) {
case 'key': case 'number': return Math.max(72, Math.min(heading, 110))
case 'boolean': return Math.max(80, heading)
case 'date': return DATE_COLUMN_WIDTH
case 'dateTime': return DATE_TIME_COLUMN_WIDTH
case 'longText': return 280
default: {
const longest = samples.reduce((max, text) => Math.max(max, text.length), 0)
const content = longest * CHAR_WIDTH + CELL_PADDING
return Math.round(Math.min(260, Math.max(kind === 'status' ? 100 : 90, heading, content)))
}
}
}
/** Base priority of each kind (higher is kept first). */
const KIND_PRIORITY: Record<CompactColumnKind, number> = {
label: 100, status: 70, boolean: 65, dateTime: 40, date: 40, text: 45, number: 35, key: 20, longText: 5,
}
/** Extra priority of the one date column chosen to stay, and of a field in a T1 section. */
const CHOSEN_DATE_BONUS = 25
const T1_BONUS = 5
/**
* The date column whose newest value is the latest (ties: the first), or undefined.
*
* @param candidates - Columns with their kinds.
* @param samples - Sample values per column.
* @returns The chosen date column's name.
*/
function chosenDate(candidates: Array<{ column: CompactColumn; kind: CompactColumnKind }>, samples: (name: string) => string[]): string | undefined {
let best: { name: string; latest: string } | undefined
for (const { column, kind } of candidates) {
if (kind !== 'date' && kind !== 'dateTime') continue
// ISO-8601 values sort as text; display text that is not ISO just keeps metadata order
const latest = samples(column.name).reduce((max, text) => (text > max ? text : max), '')
if (!best || latest > best.latest) best = { name: column.name, latest }
}
return best?.name
}
/**
* Scores every column.
*
* @param columns - Candidates in metadata order.
* @param context - Choice context.
* @param rawSamples - Raw (ISO) values per column, for choosing the date.
* @returns Each column with its kind, priority and estimated width.
*/
export function rankColumns<T extends CompactColumn>(columns: T[], context: CompactColumnContext = {}, rawSamples?: (name: string) => string[]):
Array<{ column: T; kind: CompactColumnKind; priority: number; width: number }> {
const samples = context.samples ?? (() => [])
const kinds = columns.map((column) => ({ column, kind: columnKind(column, context) as CompactColumnKind }))
const t1 = new Set(context.t1FieldNames ?? [])
// the record's name: the first short text column, in a T1 section if there is one
const texts = kinds.filter(({ kind }) => kind === 'text')
const label = (texts.find(({ column }) => t1.has(column.name)) ?? texts[0])?.column.name
for (const entry of kinds) if (entry.column.name === label) entry.kind = 'label'
const date = chosenDate(kinds, rawSamples ?? samples)
const explicit = context.explicitOrder ?? []
return kinds.map(({ column, kind }) => {
let priority = KIND_PRIORITY[kind]
if (explicit.length > 0) {
const index = explicit.indexOf(column.name)
priority = index >= 0 ? 1000 - index : priority
} else {
if (column.name === date) priority += CHOSEN_DATE_BONUS
if (t1.has(column.name) && kind !== 'key') priority += T1_BONUS
// a key is the row's name when nothing else names it, and otherwise not worth the room
if (kind === 'key') priority = label ? -1 : KIND_PRIORITY.label
}
return { column, kind, priority, width: estimateColumnWidth(column, kind, samples(column.name)) }
})
}
/**
* Chooses the columns that fit: the highest priorities first, while their estimated widths fit
* the available width (at least one column is always shown; a negative priority is never used
* to fill space), returned in metadata order.
*
* @param columns - Candidates in metadata order.
* @param availableWidth - The table's width in CSS pixels; 0 or less shows every column.
* @param context - Choice context.
* @param rawSamples - Raw (ISO) values per column, for choosing the date.
* @returns The shown and hidden columns.
*/
export function pickCompactColumns<T extends CompactColumn>(columns: T[], availableWidth: number, context: CompactColumnContext = {}, rawSamples?: (name: string) => string[]): CompactColumnChoice<T> {
if (!(availableWidth > 0)) return { shown: columns, hidden: [] }
const ranked = rankColumns(columns, context, rawSamples)
.map((entry, index) => ({ ...entry, index }))
.sort((a, b) => b.priority - a.priority || a.index - b.index)
const keep = new Set<string>()
let used = 0
for (const { column, width, priority } of ranked) {
if (keep.size > 0 && (priority < 0 || used + width > availableWidth)) continue
keep.add(column.name)
used += width
}
return {
shown: columns.filter((column) => keep.has(column.name)),
hidden: columns.filter((column) => !keep.has(column.name)),
}
}
+82 -1
View File
@@ -16,7 +16,7 @@
import { describe, expect, it } from 'vitest'
import { formatDateTime, fromLocalDateTimeInput, toLocalDateTimeInput } from './datetime-utils'
import { compactDate, compactDateTime, formatDateTime, fromLocalDateTimeInput, relativeDateTime, toLocalDateTimeInput } from './datetime-utils'
describe('formatDateTime', () => {
it('formats an instant in the requested zone with a 12-hour clock and zone abbreviation', () => {
@@ -60,3 +60,84 @@ describe('datetime-local conversion', () => {
expect(toLocalDateTimeInput('garbage')).toBe('')
})
})
describe('compactDateTime', () => {
const now = new Date('2026-10-11T16:20:00Z')
const options = { now, timeZone: 'America/Chicago', locale: 'en-US' }
it('shows only the time for today, month-day-time this year, and the date before', () => {
expect(compactDateTime('2026-10-11T13:05:00Z', undefined, options)?.text).toBe('8:05 AM')
expect(compactDateTime('2026-07-05T23:33:29Z', undefined, options)?.text).toBe('Jul 5, 6:33 PM')
expect(compactDateTime('2025-07-05T23:33:29Z', undefined, options)?.text).toBe('Jul 5, 2025')
})
it('decides today in the viewer zone, not in UTC', () => {
// 02:00 UTC on the 11th is still the evening of the 10th in Chicago
expect(compactDateTime('2026-10-11T02:00:00Z', undefined, options)?.text).toBe('Oct 10, 9:00 PM')
})
it('keeps the full zoned timestamp and the instant for the tooltip and time element', () => {
expect(compactDateTime('2026-07-05T23:33:29Z', undefined, options)).toEqual({
text: 'Jul 5, 6:33 PM', title: '2026-07-05 06:33:29 PM CDT', dateTime: '2026-07-05T23:33:29.000Z',
})
})
it('ignores a backend display value that is the raw value or in the viewer zone', () => {
expect(compactDateTime('2026-07-05T23:33:29Z', '2026-07-05T23:33:29Z', options)?.text).toBe('Jul 5, 6:33 PM')
expect(compactDateTime('2026-07-05T23:33:29Z', '2026-07-05 06:33:29 PM CDT', options)?.text).toBe('Jul 5, 6:33 PM')
})
it('keeps a backend zone that differs from the viewer zone', () => {
const display = compactDateTime('2026-07-05T23:33:29Z', '2026-07-05 04:33:29 PM PDT', options)
expect(display?.text).toBe('Jul 5, 4:33 PM PDT')
expect(display?.title).toBe('2026-07-05 04:33:29 PM PDT\nYour time: 2026-07-05 06:33:29 PM CDT')
// a declared zone is about the time of day, so older values keep their time and zone too
expect(compactDateTime('2025-07-05T23:33:29Z', '2025-07-05 04:33:29 PM PDT', options)?.text).toBe('Jul 5, 2025, 4:33 PM PDT')
})
it('reads zone-less timestamps as local wall-clock time and shows other text as sent', () => {
expect(compactDateTime('2025-01-10T08:00:00', undefined, { now, locale: 'en-US' })?.text).toBe('Jan 10, 2025')
expect(compactDateTime('soon', undefined, options)).toEqual({ text: 'soon', title: 'soon' })
expect(compactDateTime(null, undefined, options)).toBeNull()
expect(compactDateTime('', '', options)).toBeNull()
})
it('follows the locale', () => {
expect(compactDateTime('2026-07-05T23:33:29Z', undefined, { ...options, locale: 'en-GB' })?.text).toBe('5 Jul, 18:33')
})
})
describe('compactDate', () => {
const now = new Date('2026-10-11T16:20:00Z')
it('never shows a time and drops the year only for this year', () => {
expect(compactDate('2026-07-05', undefined, { now, locale: 'en-US' })?.text).toBe('Jul 5')
expect(compactDate('2025-07-05', undefined, { now, locale: 'en-US' })?.text).toBe('Jul 5, 2025')
expect(compactDate('2025-07-05T00:00:00Z', undefined, { now, locale: 'en-US' })?.text).toBe('Jul 5, 2025')
})
it('does not shift the calendar day by the viewer zone', () => {
expect(compactDate('2024-06-15', undefined, { now, timeZone: 'America/Los_Angeles', locale: 'en-US' })).toEqual({
text: 'Jun 15, 2024', title: 'Saturday, June 15, 2024 (2024-06-15)', dateTime: '2024-06-15',
})
})
it('falls back to the display value for anything that is not a date', () => {
expect(compactDate('n/a', 'Not Applicable', { now })).toEqual({ text: 'Not Applicable', title: 'Not Applicable' })
expect(compactDate(undefined, undefined, { now })).toBeNull()
})
})
describe('relativeDateTime', () => {
const now = new Date('2026-10-11T16:20:00Z')
const options = { now, locale: 'en-US' }
it('describes recent instants and leaves older ones to the date formatter', () => {
expect(relativeDateTime('2026-10-11T16:19:40Z', options)).toBe('just now')
expect(relativeDateTime('2026-10-11T16:15:00Z', options)).toBe('5m ago')
expect(relativeDateTime('2026-10-11T13:20:00Z', options)).toBe('3h ago')
expect(relativeDateTime('2026-10-10T12:00:00Z', options)).toBe('yesterday')
expect(relativeDateTime('2026-10-01T12:00:00Z', options)).toBeNull()
expect(relativeDateTime('garbage', options)).toBeNull()
})
})
+242 -4
View File
@@ -16,7 +16,9 @@
/**
* @file datetime-utils — conversions between QQQ DATE_TIME instants (UTC ISO-8601)
* and what a person reads or types in their own time zone.
* and what a person reads or types in their own time zone, and the one compact
* DATE / DATE_TIME presentation every table, record view, widget and card shares
* (see {@link compactDateTime}, {@link compactDate} and {@link relativeDateTime}).
*/
/** A QQQ DATE_TIME wire value: ISO-8601 with an explicit `Z` or offset. */
@@ -58,9 +60,19 @@ function pad(value: number): string {
*/
export function formatDateTime(value: unknown, timeZone?: string): string | null {
const date = parseInstant(value)
if (!date) return null
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
return date ? formatInstant(date, timeZone) : null
}
/**
* Formats an instant as `yyyy-MM-dd hh:mm:ss AM TZ` in the given (or the browser's) zone.
*
* @param date - The instant.
* @param timeZone - IANA zone; defaults to the browser's zone.
* @returns The formatted text.
*/
function formatInstant(date: Date, timeZone?: string): string {
const parts = Object.fromEntries(cachedFormat('en-US', timeZone, 'full', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short',
}).formatToParts(date).map((part) => [part.type, part.value]))
const hour = parts.hour === '24' ? '12' : parts.hour
@@ -103,3 +115,229 @@ export function fromLocalDateTimeInput(value: string, originalValue?: unknown):
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second ?? 0))
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
}
// ---------------------------------------------------------------------------
// Compact presentation
// ---------------------------------------------------------------------------
/** Options shared by the compact formatters (all optional; tests pin them). */
export interface DateDisplayOptions {
/** The reference "now" for today / this-year decisions; defaults to the current time. */
now?: Date
/** IANA zone to show instants in; defaults to the browser's zone. */
timeZone?: string
/** BCP 47 locale for month names and clock style; defaults to the browser's locale. */
locale?: string
}
/** A date or timestamp ready to show: short text, its full form, and the machine value. */
export interface DateDisplay {
/** Compact text, e.g. `6:33 PM`, `Jul 5, 6:33 PM` or `Jul 5, 2025`. */
text: string
/** The full value for a tooltip, e.g. `2026-07-05 06:33:29 PM CDT`. */
title: string
/** A `<time dateTime>` value, when the value could be read. */
dateTime?: string
}
/**
* Width, in CSS pixels, that fits the compact DATE_TIME form (`Sep 30, 12:59 PM`) in a
* 14px table cell with padding, so grids size date-time columns to their content.
*/
export const DATE_TIME_COLUMN_WIDTH = 150
/**
* Width, in CSS pixels, that fits the compact DATE form (`Sep 30, 2025`) in a table cell, with
* room left for a two-word header's sort and menu controls.
*/
export const DATE_COLUMN_WIDTH = 130
/** Formatters are costly to build and a page can show thousands of dates (QRun-IO/qqq#710). */
const formatCache = new Map<string, Intl.DateTimeFormat>()
/**
* A cached `Intl.DateTimeFormat`.
*
* @param locale - Locale, or undefined for the browser's.
* @param timeZone - Zone, or undefined for the browser's.
* @param kind - Cache discriminator for the option set.
* @param options - Format options.
* @returns The formatter.
*/
function cachedFormat(locale: string | undefined, timeZone: string | undefined, kind: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${locale ?? ''}|${timeZone ?? ''}|${kind}`
let format = formatCache.get(key)
if (!format) {
format = new Intl.DateTimeFormat(locale, { ...options, timeZone })
formatCache.set(key, format)
}
return format
}
/**
* An instant's calendar day in a zone, as a sortable `yyyy-MM-dd` key.
*
* @param date - The instant.
* @param timeZone - The zone.
* @returns The day key.
*/
function dayKey(date: Date, timeZone: string | undefined): string {
return cachedFormat('en-CA', timeZone, 'day', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date)
}
/**
* The short zone name (`CDT`, `GMT+2`) of an instant in a zone.
*
* @param date - The instant.
* @param timeZone - The zone.
* @returns The name.
*/
function zoneName(date: Date, timeZone: string | undefined): string {
return cachedFormat('en-US', timeZone, 'zone', { timeZoneName: 'short' }).formatToParts(date).find((part) => part.type === 'timeZoneName')?.value ?? ''
}
/**
* Compact text for an instant: the time today, month and day with the time this year,
* otherwise month, day and year.
*
* @param date - The instant.
* @param now - Reference time.
* @param timeZone - Zone to read both in.
* @param locale - Locale.
* @returns The text.
*/
function compactInstantText(date: Date, now: Date, timeZone: string | undefined, locale: string | undefined): string {
const day = dayKey(date, timeZone)
const today = dayKey(now, timeZone)
if (day === today) return cachedFormat(locale, timeZone, 'time', { hour: 'numeric', minute: '2-digit' }).format(date)
if (day.slice(0, 4) === today.slice(0, 4)) {
return cachedFormat(locale, timeZone, 'monthDayTime', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(date)
}
return cachedFormat(locale, timeZone, 'monthDayYear', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
}
/** A zone-less ISO date-time (`yyyy-MM-ddTHH:mm[:ss]`), read as wall-clock time in the browser's zone. */
const UNZONED_ISO = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/
/**
* A backend display value zoned by a field behavior (QQQ `DateTimeDisplayValueBehavior`,
* `yyyy-MM-dd hh:mm:ss a z`): the wall-clock parts and the zone name.
*/
const BACKEND_ZONED = /^(\d{4})-(\d{2})-(\d{2}) (\d{1,2}):(\d{2})(?::(\d{2}))? ([AP]M) (\S+)$/i
/**
* Reads a DATE_TIME value: a zoned instant, a `Date`, or a zone-less ISO timestamp (local).
*
* @param value - The raw value.
* @returns The instant, or null.
*/
function readDateTime(value: unknown): Date | null {
const instant = parseInstant(value)
if (instant) return instant
if (typeof value !== 'string') return null
const match = UNZONED_ISO.exec(value.trim())
if (!match) return null
const [, year, month, day, hour, minute, second] = match
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second ?? 0))
return Number.isNaN(date.getTime()) ? null : date
}
/**
* The compact presentation of a DATE_TIME value, in the viewer's zone and locale:
* `6:33 PM` today, `Jul 5, 6:33 PM` this year, `Jul 5, 2025` before; the full
* `yyyy-MM-dd hh:mm:ss AM TZ` timestamp is the title.
*
* A backend display value zoned by a field behavior in a zone other than the viewer's keeps
* that zone and always its time: `Jul 5, 4:33 PM PDT` this year, `Jul 5, 2025, 4:33 PM PDT`
* before. A display value that is not a timestamp at all is shown as sent.
*
* @param value - Raw value (ISO-8601 instant).
* @param displayValue - Backend display value, if any.
* @param options - Reference time, zone and locale.
* @returns The presentation, or null for an empty value.
*/
export function compactDateTime(value: unknown, displayValue?: unknown, options: DateDisplayOptions = {}): DateDisplay | null {
const now = options.now ?? new Date()
const { timeZone, locale } = options
const date = readDateTime(value)
const display = typeof displayValue === 'string' && displayValue.trim() !== '' && displayValue !== value ? displayValue.trim() : undefined
const zoned = display ? BACKEND_ZONED.exec(display) : null
if (zoned && (!date || zoned[8] !== zoneName(date, timeZone))) {
const [, year, month, day, hour12, minute, second, period, zone] = zoned
const hour = (Number(hour12) % 12) + (period.toUpperCase() === 'PM' ? 12 : 0)
// the zone's wall clock, formatted as if it were UTC so no further shift applies
const wall = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), hour, Number(minute), Number(second ?? 0)))
// the viewer's today, at noon on the same wall-clock footing, for the today / this-year choice
const [nowYear, nowMonth, nowDay] = dayKey(now, timeZone).split('-').map(Number)
const wallNow = new Date(Date.UTC(nowYear, nowMonth - 1, nowDay, 12))
const viewer = date ? `\nYour time: ${formatInstant(date, timeZone)}` : ''
// a declared zone is about the time of day, so the time (and its zone) is always kept
const text = year === String(wallNow.getUTCFullYear())
? compactInstantText(wall, wallNow, 'UTC', locale)
: cachedFormat(locale, 'UTC', 'monthDayYearTime', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }).format(wall)
return { text: `${text} ${zone}`, title: `${display}${viewer}`, dateTime: date?.toISOString() }
}
if (date) return { text: compactInstantText(date, now, timeZone, locale), title: formatInstant(date, timeZone), dateTime: date.toISOString() }
const fallback = display ?? (value === null || value === undefined ? '' : String(value).trim())
return fallback ? { text: fallback, title: fallback } : null
}
/** A calendar date (`yyyy-MM-dd`, optionally followed by a time that is ignored). */
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})(?:$|[T ])/
/**
* The compact presentation of a DATE value: `Jul 5` this year, `Jul 5, 2025` otherwise, never
* a time. A DATE is a calendar day, so no zone shifts it (`new Date('2024-06-15')` would read
* UTC midnight and show the 14th west of Greenwich). The title is the long date.
*
* @param value - Raw value (`yyyy-MM-dd`).
* @param displayValue - Backend display value, shown as sent when the raw value is not a date.
* @param options - Reference time, zone (for "this year") and locale.
* @returns The presentation, or null for an empty value.
*/
export function compactDate(value: unknown, displayValue?: unknown, options: DateDisplayOptions = {}): DateDisplay | null {
const now = options.now ?? new Date()
const match = typeof value === 'string' ? DATE_ONLY.exec(value.trim()) : null
if (match) {
const [, year, month, day] = match
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
if (!Number.isNaN(date.getTime())) {
const thisYear = dayKey(now, options.timeZone).slice(0, 4) === year
const text = thisYear
? cachedFormat(options.locale, 'UTC', 'monthDay', { month: 'short', day: 'numeric' }).format(date)
: cachedFormat(options.locale, 'UTC', 'monthDayYear', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
const long = cachedFormat(options.locale, 'UTC', 'longDate', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }).format(date)
return { text, title: `${long} (${year}-${month}-${day})`, dateTime: `${year}-${month}-${day}` }
}
}
const fallback = typeof displayValue === 'string' && displayValue.trim() !== '' ? displayValue.trim() : value === null || value === undefined ? '' : String(value).trim()
return fallback ? { text: fallback, title: fallback } : null
}
/** Relative-time steps: below the limit (seconds), count in the unit of that many seconds. */
const RELATIVE_STEPS: Array<[limit: number, unit: Intl.RelativeTimeFormatUnit, seconds: number]> = [
[3_600, 'minute', 60],
[86_400, 'hour', 3_600],
[7 * 86_400, 'day', 86_400],
]
/**
* A recent instant as relative time (`just now`, `5m ago`, `3h ago`, `yesterday`, `4d ago`),
* for activity lists; null for instants a week or more away, which read better as dates.
*
* @param value - Raw value (ISO-8601 instant).
* @param options - Reference time and locale.
* @returns The text, or null.
*/
export function relativeDateTime(value: unknown, options: DateDisplayOptions = {}): string | null {
const date = readDateTime(value)
if (!date) return null
const seconds = ((options.now ?? new Date()).getTime() - date.getTime()) / 1000
if (Math.abs(seconds) < 45) return 'just now'
const step = RELATIVE_STEPS.find(([limit]) => Math.abs(seconds) < limit)
if (!step) return null
const [, unit, size] = step
return new Intl.RelativeTimeFormat(options.locale, { style: 'narrow', numeric: 'auto' }).format(-Math.round(seconds / size), unit)
}
+215
View File
@@ -0,0 +1,215 @@
/*
* 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 { describe, expect, it } from 'vitest'
import type { FieldAdornment, QFieldMetaData, QRecord } from '@/types'
import {
DEFAULT_THUMBNAIL_SIZE, filePreview, filePreviewItems, formatBytes, hasFilePreview, isBackendFileUrl, isSameOrigin,
joinedPreviewField, kindOfMimeType, mimeTypeOfName, safePreviewUrl,
} from './file-preview'
function field(name: string, adornments: FieldAdornment[] = [], extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
return { name, label: name, type: 'STRING', isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments, ...extra }
}
function record(values: Record<string, unknown>, displayValues: Record<string, string> = {}): QRecord {
return { tableName: 'media', values, displayValues }
}
const preview = (values: Record<string, unknown> = {}): FieldAdornment => ({ type: 'FILE_PREVIEW', values })
describe('safePreviewUrl (QRun-IO/qqq#1056)', () => {
it('accepts https URLs and same-origin paths', () => {
expect(safePreviewUrl('https://cdn.example.com/a.jpg')).toBe('https://cdn.example.com/a.jpg')
expect(safePreviewUrl(' /files/a b.jpg ')).toBe('/files/a b.jpg')
})
it('serves backend field downloads from the v1 route', () => {
expect(safePreviewUrl('/data/media/7/blob/a.png')).toBe('/qqq/v1/table/media/7/blob/a.png')
})
it('rejects other schemes, protocol-relative and malformed values', () => {
for (const value of ['javascript:alert(1)', '//evil.example/a.png', 'ftp://x/a.png', 'images/a.png', '', ' ', '/a"onerror="x', '/a\u0000b', 42, null, undefined]) {
expect(safePreviewUrl(value)).toBeUndefined()
}
})
it('accepts inline images only where asked, and never other data URLs', () => {
const png = 'data:image/png;base64,iVBORw0KGgo='
expect(safePreviewUrl(png)).toBeUndefined()
expect(safePreviewUrl(png, true)).toBe(png)
expect(safePreviewUrl('data:text/html,<b>x</b>', true)).toBeUndefined()
})
})
describe('file type helpers', () => {
it('maps MIME types to preview kinds', () => {
expect(kindOfMimeType('image/webp')).toBe('image')
expect(kindOfMimeType('Application/PDF; charset=binary')).toBe('pdf')
expect(kindOfMimeType('video/mp4')).toBe('video')
expect(kindOfMimeType('audio/mpeg')).toBe('audio')
expect(kindOfMimeType('application/zip')).toBe('other')
expect(kindOfMimeType(undefined)).toBe('other')
})
it('reads MIME types from extensions, ignoring query strings', () => {
expect(mimeTypeOfName('photo.JPG')).toBe('image/jpeg')
expect(mimeTypeOfName('https://x.example/a/clip.webm?sig=1#t')).toBe('video/webm')
expect(mimeTypeOfName('report.pdf')).toBe('application/pdf')
expect(mimeTypeOfName('song.mp3')).toBe('audio/mpeg')
expect(mimeTypeOfName('archive.tar.zz')).toBeUndefined()
expect(mimeTypeOfName('https://x.example/a.b/file')).toBeUndefined()
expect(mimeTypeOfName(undefined)).toBeUndefined()
})
it('formats byte sizes', () => {
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(1536)).toBe('1.5 KB')
expect(formatBytes(15 * 1024 * 1024)).toBe('15 MB')
expect(formatBytes(3 * 1024 ** 4)).toBe('3 TB')
expect(formatBytes(-1)).toBe('')
expect(formatBytes(Number.NaN)).toBe('')
})
it('tells same-origin URLs from others', () => {
expect(isSameOrigin('/files/a.pdf')).toBe(true)
expect(isSameOrigin(`${window.location.origin}/a.pdf`)).toBe(true)
expect(isSameOrigin('https://elsewhere.example/a.pdf')).toBe(false)
expect(isSameOrigin('//elsewhere.example/a.pdf')).toBe(false)
expect(isSameOrigin('not a url')).toBe(false)
expect(isBackendFileUrl('/qqq/v1/table/media/1/blob/a.pdf')).toBe(true)
expect(isBackendFileUrl(`${window.location.origin}/qqq/v1/table/media/1/blob/a.pdf`)).toBe(true)
expect(isBackendFileUrl('/app/media/1')).toBe(false)
expect(isBackendFileUrl('/qqq-not/a.pdf')).toBe(false)
expect(isBackendFileUrl('https://elsewhere.example/qqq/a.pdf')).toBe(false)
})
})
describe('filePreview', () => {
it('ignores fields without the adornment', () => {
expect(hasFilePreview(field('url'))).toBe(false)
expect(hasFilePreview(undefined)).toBe(false)
expect(filePreview(field('url'), record({ url: 'https://x.example/a.jpg' }))).toBeNull()
})
it('uses the field itself as thumbnail and full-size source by default', () => {
const item = filePreview(field('url', [preview()]), record({ url: 'https://x.example/a/cat%20photo.png' }))
expect(item).toEqual({
thumbnailUrl: 'https://x.example/a/cat%20photo.png', url: 'https://x.example/a/cat%20photo.png', fileName: 'cat photo.png',
mimeType: 'image/png', kind: 'image', alt: 'cat photo.png', previewable: true, thumbnailSize: DEFAULT_THUMBNAIL_SIZE,
})
})
it('reads the thumbnail, full-size URL and details from the named fields', () => {
const f = field('originalUrl', [preview({
thumbnailUrlField: 'thumbnailUrl', fullSizeUrlField: 'galleryUrl', fileNameField: 'fileName', mimeTypeField: 'mimeType',
altTextField: 'alt', captionField: 'caption', widthField: 'width', heightField: 'height', sizeBytesField: 'fileSize', thumbnailMaxSize: 64,
})])
const item = filePreview(f, record({
originalUrl: 'https://x.example/o.jpg', thumbnailUrl: 'https://x.example/t.jpg', galleryUrl: 'https://x.example/g.jpg',
fileName: 'sunset.jpg', mimeType: 'image/jpeg', alt: 'A sunset', caption: 'Evening', width: 4000, height: '3000', fileSize: 2048,
}))
expect(item).toMatchObject({
thumbnailUrl: 'https://x.example/t.jpg', url: 'https://x.example/g.jpg', fileName: 'sunset.jpg', mimeType: 'image/jpeg',
kind: 'image', alt: 'A sunset', caption: 'Evening', width: 4000, height: 3000, sizeBytes: 2048, thumbnailSize: 64,
})
})
it('falls back to the field value when a named URL field is empty or unsafe, and to the thumbnail when only it is set', () => {
const f = field('url', [preview({ fullSizeUrlField: 'big', thumbnailUrlField: 'small' })])
expect(filePreview(f, record({ url: '/a.png', big: 'javascript:x', small: '' }))?.url).toBe('/a.png')
expect(filePreview(f, record({ url: null, small: '/t.png' }))).toMatchObject({ url: '/t.png', thumbnailUrl: '/t.png' })
expect(filePreview(f, record({ url: null, small: null }))).toBeNull()
})
it('uses the caption as alt text and the display value as file name', () => {
const f = field('url', [preview({ captionField: 'caption' })])
const item = filePreview(f, record({ url: '/x/123', caption: 'Team photo' }, { url: 'team.webp' }))
expect(item).toMatchObject({ alt: 'Team photo', fileName: 'team.webp', kind: 'image', mimeType: 'image/webp' })
// a display value that is the URL itself is not a file name
expect(filePreview(f, record({ url: '/x/b.gif' }, { url: '/x/b.gif' }))?.fileName).toBe('b.gif')
})
it('takes the type from the MIME field, then the default, then the extension', () => {
const f = field('url', [preview({ mimeTypeField: 'type', defaultMimeType: 'application/pdf' })])
expect(filePreview(f, record({ url: '/f/a', type: 'video/mp4' }))?.kind).toBe('video')
expect(filePreview(f, record({ url: '/f/a', type: '' }))?.kind).toBe('pdf')
const wildcard = field('url', [preview({ defaultMimeType: 'image/*' })])
expect(filePreview(wildcard, record({ url: '/f/a.mp3' }))).toMatchObject({ kind: 'audio', mimeType: 'audio/mpeg' })
expect(filePreview(wildcard, record({ url: '/f/blob' }))).toMatchObject({ kind: 'image', mimeType: 'image/*' })
const none = filePreview(field('url', [preview()]), record({ url: '/f/blob' }))
expect(none?.kind).toBe('other')
expect(none).not.toHaveProperty('thumbnailUrl')
expect(none).not.toHaveProperty('mimeType')
})
it('previews a BLOB through its FILE_DOWNLOAD URL and file name', () => {
const f = field('photo', [{ type: 'FILE_DOWNLOAD', values: { fileNameField: 'photoFileName' } }, preview()], { type: 'BLOB' })
const item = filePreview(f, record({ photo: '/data/media/3/photo/me.png' }, { photo: 'me.png' }), '{"type":"site","id":"1"}')
expect(item).toMatchObject({ url: '/qqq/v1/table/media/3/photo/me.png?tableVariant=%7B%22type%22%3A%22site%22%2C%22id%22%3A%221%22%7D', fileName: 'me.png', kind: 'image' })
})
it('honours previewable=false (boolean or text) and clamps the thumbnail size', () => {
expect(filePreview(field('u', [preview({ previewable: false })]), record({ u: '/a.png' }))?.previewable).toBe(false)
expect(filePreview(field('u', [preview({ previewable: 'FALSE' })]), record({ u: '/a.png' }))?.previewable).toBe(false)
expect(filePreview(field('u', [preview({ thumbnailMaxSize: 4 })]), record({ u: '/a.png' }))?.thumbnailSize).toBe(16)
expect(filePreview(field('u', [preview({ thumbnailMaxSize: '9000' })]), record({ u: '/a.png' }))?.thumbnailSize).toBe(512)
expect(filePreview(field('u', [preview({ thumbnailMaxSize: 'big' })]), record({ u: '/a.png' }))?.thumbnailSize).toBe(DEFAULT_THUMBNAIL_SIZE)
})
it('ignores non-positive dimensions and blank details', () => {
const f = field('u', [preview({ widthField: 'w', heightField: 'h', sizeBytesField: 's', altTextField: 'a', fileNameField: 'n' })])
const item = filePreview(f, record({ u: '/a.png', w: 0, h: 'x', s: '', a: ' ', n: null }))
expect(item).not.toHaveProperty('width')
expect(item).not.toHaveProperty('height')
expect(item).not.toHaveProperty('sizeBytes')
expect(item?.alt).toBe('a.png')
})
it('names a data URL file after the field', () => {
expect(filePreview(field('logo', [preview()]), record({ logo: 'data:image/png;base64,AAAA' }))?.fileName).toBe('logo')
})
})
describe('filePreviewItems', () => {
it('lists the previewable values of adorned fields, record by record', () => {
const a = field('a', [preview()])
const b = field('b', [preview({ previewable: false })])
const c = field('c')
const records = [record({ a: '/1.png', b: '/x.png', c: '/c.png' }), record({ a: null }), record({ a: '/2.png' })]
const items = filePreviewItems(records, [a, b, c])
expect(items.map((entry) => entry.item.url)).toEqual(['/1.png', '/2.png'])
expect(items[1].record).toBe(records[2])
expect(filePreviewItems(records, [c])).toEqual([])
})
})
describe('joinedPreviewField', () => {
it('prefixes the field name and FILE_PREVIEW field references with the join table', () => {
const f = field('photoUrl', [{ type: 'CHIP' }, preview({ thumbnailUrlField: 'thumbUrl', thumbnailMaxSize: 48, captionField: '' })])
const joined = joinedPreviewField(f, 'author')
expect(joined.name).toBe('author.photoUrl')
expect(joined.adornments[0]).toEqual({ type: 'CHIP' })
expect(joined.adornments[1].values).toEqual({ thumbnailUrlField: 'author.thumbUrl', thumbnailMaxSize: 48, captionField: '' })
expect(filePreview(joined, record({ 'author.photoUrl': '/p.png', 'author.thumbUrl': '/t.png' }))?.thumbnailUrl).toBe('/t.png')
})
it('returns other fields unchanged', () => {
const f = field('name')
expect(joinedPreviewField(f, 'author')).toBe(f)
expect(joinedPreviewField({ name: 'x', adornments: [{ type: 'FILE_PREVIEW' }] }, 'j')).toEqual({ name: 'j.x', adornments: [{ type: 'FILE_PREVIEW', values: {} }] })
})
})
+342
View File
@@ -0,0 +1,342 @@
/*
* 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 file-preview — resolves a FILE_PREVIEW-adorned field of a record into the thumbnail,
* full-size URL and file details a thumbnail and the preview lightbox show, using the backend's
* value keys (`AdornmentType.FilePreviewValues`). Only explicitly adorned fields are previewed.
*/
import type { FieldAdornment, QFieldMetaData, QRecord } from '@/types'
import { isSafeImageSource } from '@/lib/theme/apply-branding'
import { fieldDownloadUrl, fileDownload, findAdornment } from './adornment-utils'
/** What kind of preview a file gets. */
export type FilePreviewKind = 'image' | 'pdf' | 'video' | 'audio' | 'other'
/** A previewable file value, resolved from a record. */
export interface FilePreviewItem {
/** URL of the small rendition shown as a thumbnail (images only). */
thumbnailUrl?: string
/** URL of the full-size file, shown in the preview and used to open or download it. */
url: string
/** File name for the title, alt text fallback and download. */
fileName: string
/** MIME type when known (from a field, the default, or the extension). */
mimeType?: string
/** Preview kind. */
kind: FilePreviewKind
/** Alternative text for the image. */
alt: string
/** Caption, when the backend names a caption field. */
caption?: string
/** Pixel width, when known. */
width?: number
/** Pixel height, when known. */
height?: number
/** File size in bytes, when known. */
sizeBytes?: number
/** Whether clicking the thumbnail opens the preview. */
previewable: boolean
/** Largest edge of the list thumbnail, in CSS pixels. */
thumbnailSize: number
}
/** Backend value keys of a FILE_PREVIEW adornment (`AdornmentType.FilePreviewValues`). */
export const FILE_PREVIEW_KEYS = {
thumbnailUrlField: 'thumbnailUrlField',
fullSizeUrlField: 'fullSizeUrlField',
fileNameField: 'fileNameField',
mimeTypeField: 'mimeTypeField',
altTextField: 'altTextField',
captionField: 'captionField',
widthField: 'widthField',
heightField: 'heightField',
sizeBytesField: 'sizeBytesField',
defaultMimeType: 'defaultMimeType',
thumbnailMaxSize: 'thumbnailMaxSize',
previewable: 'previewable',
} as const
/** Thumbnail size the backend defaults to, and its bounds. */
export const DEFAULT_THUMBNAIL_SIZE = 40
/** Thumbnail edge in record view sections, which also show the file name. */
export const RECORD_VIEW_THUMBNAIL_SIZE = 160
/** Thumbnail edge in the record view header. */
export const HEADER_THUMBNAIL_SIZE = 96
const MIN_THUMBNAIL_SIZE = 16
const MAX_THUMBNAIL_SIZE = 512
/** Characters never valid in a URL from a record value: control characters, quotes, angle brackets, backslashes. */
const UNSAFE_URL_CHARACTERS = /[\u0000-\u001f\u007f<>"\\]/
const EXTENSION_TYPES: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif',
svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
pdf: 'application/pdf',
mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime',
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', wav: 'audio/wav', oga: 'audio/ogg', ogg: 'audio/ogg', flac: 'audio/flac',
}
/**
* Whether a field has a FILE_PREVIEW adornment.
*
* @param field - Field metadata.
* @returns `true` when the field is adorned for previews.
*/
export function hasFilePreview(field: { adornments?: FieldAdornment[] } | undefined): boolean {
return Boolean(field?.adornments?.some((adornment) => adornment.type === 'FILE_PREVIEW'))
}
/**
* A URL that may be loaded into an image, media element or frame and opened in a new tab:
* an http(s) URL or a same-origin path (never `//host`, `javascript:` or other schemes). Backend
* field-download paths (`/data/...`) are served from the v1 route. Inline `data:image/*` sources
* are accepted for images only.
*
* @param value - A URL from a record value.
* @param allowDataImage - Whether `data:image/*` is acceptable (thumbnails and image previews).
* @returns The URL to use, or `undefined` when it is unsafe or empty.
*/
export function safePreviewUrl(value: unknown, allowDataImage = false): string | undefined {
if (typeof value !== 'string') return undefined
const url = value.trim()
if (!url || url.startsWith('//') || UNSAFE_URL_CHARACTERS.test(url)) return undefined
if (/^data:/i.test(url)) return allowDataImage && /^data:image\//i.test(url) && isSafeImageSource(url) ? url : undefined
if (!url.startsWith('/') && !/^https?:\/\//i.test(url)) return undefined
if (!isSafeImageSource(url)) return undefined
return fieldDownloadUrl(url)
}
/**
* Whether a URL is served by the page's own origin (frames and media are limited to `'self'`
* by the dashboard's Content-Security-Policy).
*
* @param url - An absolute URL or a path.
* @returns `true` for paths and URLs on the current origin.
*/
export function isSameOrigin(url: string): boolean {
if (url.startsWith('/') && !url.startsWith('//')) return true
if (typeof window === 'undefined') return false
try {
return new URL(url).origin === window.location.origin
} catch {
return false
}
}
/**
* Whether a URL is a file served by the QQQ backend on this origin (a path under `/qqq/`). Only
* these are framed in the preview: any other same-origin page in an iframe would run with the
* dashboard's own origin.
*
* @param url - A URL or path.
* @returns True for a same-origin `/qqq/...` path.
*/
export function isBackendFileUrl(url: string): boolean {
if (!isSameOrigin(url)) return false
try {
return new URL(url, typeof window === 'undefined' ? 'http://localhost' : window.location.origin).pathname.startsWith('/qqq/')
} catch {
return false
}
}
/**
* The preview kind of a MIME type.
*
* @param mimeType - A MIME type such as `image/png`.
* @returns The kind, or `other`.
*/
export function kindOfMimeType(mimeType: string | undefined): FilePreviewKind {
const type = (mimeType ?? '').toLowerCase().split(';')[0].trim()
if (type.startsWith('image/')) return 'image'
if (type === 'application/pdf') return 'pdf'
if (type.startsWith('video/')) return 'video'
if (type.startsWith('audio/')) return 'audio'
return 'other'
}
/**
* The MIME type of a file name or URL, from its extension.
*
* @param name - A file name or URL.
* @returns The MIME type, or `undefined` for an unknown extension.
*/
export function mimeTypeOfName(name: string | undefined): string | undefined {
const path = (name ?? '').split(/[?#]/)[0]
const dot = path.lastIndexOf('.')
if (dot < 0 || dot < path.lastIndexOf('/')) return undefined
return EXTENSION_TYPES[path.slice(dot + 1).toLowerCase()]
}
/**
* Formats a byte count for people (`1.4 MB`).
*
* @param bytes - Number of bytes.
* @returns The formatted size.
*/
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return ''
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`
}
/**
* Resolves the FILE_PREVIEW adornment of a field against a record.
*
* The adorned field's own value is the default thumbnail and full-size source; for a field that
* also has FILE_DOWNLOAD (a BLOB), the download URL the backend wrote into the value is used.
* Unsafe or missing URLs yield `null`, so the caller renders the value as usual.
*
* @param field - Field metadata with a FILE_PREVIEW adornment.
* @param record - The record holding the value and the fields the adornment names.
* @param tableVariant - Selected table variant as v1 JSON, for backend field-download URLs.
* @returns The preview item, or `null` when the field is not adorned or has no usable URL.
*/
export function filePreview(field: QFieldMetaData, record: QRecord, tableVariant?: string): FilePreviewItem | null {
const adornment = findAdornment(field, 'FILE_PREVIEW')
if (!adornment) return null
const settings = adornment.values ?? {}
const fieldName = (key: keyof typeof FILE_PREVIEW_KEYS): string | undefined => {
const name = settings[FILE_PREVIEW_KEYS[key]]
return typeof name === 'string' && name ? name : undefined
}
const valueOf = (key: keyof typeof FILE_PREVIEW_KEYS): unknown => {
const name = fieldName(key)
return name ? record.values[name] : undefined
}
const text = (value: unknown): string | undefined =>
((typeof value === 'string' && value.trim()) || typeof value === 'number') ? String(value).trim() : undefined
const positive = (value: unknown): number | undefined => {
const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN
return Number.isFinite(number) && number > 0 ? number : undefined
}
const download = findAdornment(field, 'FILE_DOWNLOAD') ? fileDownload(field, record, tableVariant) : null
const ownUrl = download?.url ?? safePreviewUrl(record.values[field.name], true)
const fullSizeField = fieldName('fullSizeUrlField')
const url = fullSizeField ? safePreviewUrl(record.values[fullSizeField], true) ?? ownUrl : ownUrl
const thumbnailField = fieldName('thumbnailUrlField')
const thumbnailSource = thumbnailField ? safePreviewUrl(record.values[thumbnailField], true) : undefined
if (!url && !thumbnailSource) return null
const display = record.displayValues?.[field.name]
const fileName = text(valueOf('fileNameField')) ?? download?.fileName
?? (typeof display === 'string' && display && !/^(https?:)?\//i.test(display) ? display : undefined)
?? lastSegment(url ?? thumbnailSource ?? '') ?? field.label
const defaultMimeType = typeof settings.defaultMimeType === 'string' ? settings.defaultMimeType : undefined
const declaredType = text(valueOf('mimeTypeField')) ?? (defaultMimeType && !defaultMimeType.endsWith('/*') ? defaultMimeType : undefined)
const mimeType = declaredType ?? mimeTypeOfName(fileName) ?? mimeTypeOfName(url) ?? defaultMimeType
const kind = kindOfMimeType(mimeType)
const size = positive(settings.thumbnailMaxSize)
const previewable = settings.previewable !== false && String(settings.previewable).toLowerCase() !== 'false'
return {
...(kind === 'image' && (thumbnailSource ?? url) ? { thumbnailUrl: thumbnailSource ?? url } : {}),
url: url ?? thumbnailSource ?? '',
fileName,
...(mimeType ? { mimeType } : {}),
kind,
alt: text(valueOf('altTextField')) ?? text(valueOf('captionField')) ?? fileName,
...optional('caption', text(valueOf('captionField'))),
...optional('width', positive(valueOf('widthField'))),
...optional('height', positive(valueOf('heightField'))),
...optional('sizeBytes', positive(valueOf('sizeBytesField'))),
previewable,
thumbnailSize: size ? Math.min(MAX_THUMBNAIL_SIZE, Math.max(MIN_THUMBNAIL_SIZE, Math.round(size))) : DEFAULT_THUMBNAIL_SIZE,
}
}
/**
* Previewable items of the given fields across records, in record then field order: the set the
* lightbox steps through with the arrow keys.
*
* @param records - Records on screen.
* @param fields - Fields shown for them.
* @param tableVariant - Selected table variant as v1 JSON.
* @returns The items with the record and field each came from.
*/
export function filePreviewItems(records: QRecord[], fields: QFieldMetaData[], tableVariant?: string): Array<{ record: QRecord; field: QFieldMetaData; item: FilePreviewItem }> {
const adorned = fields.filter(hasFilePreview)
if (adorned.length === 0) return []
const items: Array<{ record: QRecord; field: QFieldMetaData; item: FilePreviewItem }> = []
for (const record of records) {
for (const field of adorned) {
const item = filePreview(field, record, tableVariant)
if (item?.previewable) items.push({ record, field, item })
}
}
return items
}
/**
* An object holding one optional property, or none when the value is missing.
*
* @param key - Property name.
* @param value - Property value.
* @returns `{ [key]: value }` or `{}`.
*/
function optional<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
return value === undefined ? {} : { [key]: value } as Record<K, V>
}
/**
* The decoded last path segment of a URL.
*
* @param url - A URL or path.
* @returns The segment, or `undefined` when there is none.
*/
function lastSegment(url: string): string | undefined {
if (/^data:/i.test(url)) return undefined
const segment = url.split(/[?#]/)[0].split('/').pop()
if (!segment) return undefined
try {
return decodeURIComponent(segment)
} catch {
return segment
}
}
/**
* A join column's field as its record values are keyed (`joinTable.field`): the field name and
* the FILE_PREVIEW field references get the join table prefix. Fields without FILE_PREVIEW are
* returned as they are.
*
* @param field - The joined table's field.
* @param joinTableName - The join table's name.
* @returns The field to resolve against a main-table record holding join values.
*/
export function joinedPreviewField<F extends { name: string; adornments?: FieldAdornment[] }>(field: F, joinTableName: string): F {
if (!hasFilePreview(field)) return field
const fieldKeys = new Set<string>(Object.values(FILE_PREVIEW_KEYS).filter((key) => key.endsWith('Field')))
return {
...field,
name: `${joinTableName}.${field.name}`,
adornments: (field.adornments ?? []).map((adornment) => adornment.type !== 'FILE_PREVIEW' ? adornment : {
...adornment,
values: Object.fromEntries(Object.entries(adornment.values ?? {}).map(([key, value]) =>
[key, fieldKeys.has(key) && typeof value === 'string' && value ? `${joinTableName}.${value}` : value])),
}),
}
}
+3 -2
View File
@@ -20,14 +20,15 @@ describe('metadata driven form layout', () => {
it('uses a half-width default, valid 12-column spans, and full-width file uploads', () => {
expect(formFieldColumns(field())).toBe(6)
expect(formFieldColumns(field({ gridColumns: 4 }))).toBe(4)
expect(formFieldColumnClasses(field({ gridColumns: 4 }))).toContain('lg:col-span-4')
expect(formFieldColumnClasses(field({ gridColumns: 4 }))).toBe('col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 min-w-0')
expect(formFieldColumnClasses(field())).toBe('col-span-12 @min-[30rem]:col-span-6 min-w-0')
expect(formFieldColumns(field({ gridColumns: 0 }))).toBe(6)
expect(formFieldColumns(field({ gridColumns: 13 }))).toBe(6)
expect(formFieldColumns(field({ gridColumns: Number.NaN }))).toBe(6)
const upload = field({ gridColumns: 3, adornments: [{ type: 'FILE_UPLOAD', values: { width: 'full' } }] })
expect(fileUploadWidth(upload)).toBe('full')
expect(formFieldColumns(upload)).toBe(12)
expect(formFieldColumnClasses(upload)).toContain('sm:col-span-12 lg:col-span-12')
expect(formFieldColumnClasses(upload)).toBe('col-span-12 min-w-0')
expect(fileUploadWidth(field())).toBe('half')
})
+6 -10
View File
@@ -18,20 +18,16 @@
* @file form-layout — how record and process forms size, decorate and transform fields, from
* field metadata, as the Material dashboard does (DynamicForm and DynamicFormField):
*
* - a 12-column grid: full width on phones, half from `sm`, and the field's `gridColumns`
* (default 6, half) from `lg`; a `FILE_UPLOAD` field with `width: full` spans the row from `sm`;
* - a 12-column `@container` grid sized by the form's own width (a dialog or a full page):
* full width when narrow, half from 30rem, and the field's `gridColumns` (default 6, half)
* from 56rem; a `FILE_UPLOAD` field with `width: full` always spans the row;
* - a `$` prefix for a `displayFormat` starting with `$`, and a `%` suffix for one ending in `%%`;
* - live case change for the `TO_UPPER_CASE` and `TO_LOWER_CASE` behaviors.
*/
import type { QFieldMetaData } from '@/types'
import { findAdornment } from './adornment-utils'
/** Large-screen column spans, spelled out so Tailwind generates them. */
const LG_SPANS: Record<number, string> = {
1: 'lg:col-span-1', 2: 'lg:col-span-2', 3: 'lg:col-span-3', 4: 'lg:col-span-4', 5: 'lg:col-span-5', 6: 'lg:col-span-6',
7: 'lg:col-span-7', 8: 'lg:col-span-8', 9: 'lg:col-span-9', 10: 'lg:col-span-10', 11: 'lg:col-span-11', 12: 'lg:col-span-12',
}
import { containerSpanClasses } from './responsive-grid'
/** Default large-screen width of a form field, in twelfths (Material: half). */
export const DEFAULT_FORM_FIELD_COLUMNS = 6
@@ -67,8 +63,8 @@ export function formFieldColumns(field: QFieldMetaData): number {
* @returns Tailwind classes.
*/
export function formFieldColumnClasses(field: QFieldMetaData): string {
const fullFile = Boolean(findAdornment(field, 'FILE_UPLOAD')) && fileUploadWidth(field) === 'full'
return `col-span-12 min-w-0 ${fullFile ? 'sm:col-span-12' : 'sm:col-span-6'} ${LG_SPANS[formFieldColumns(field)]}`
// sized by the form's own width (a dialog, a side panel, a full page), not the viewport
return containerSpanClasses(formFieldColumns(field), 'fields')
}
/** Text shown before and after a numeric input, from its display format. */
+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]
}
+7 -1
View File
@@ -23,6 +23,7 @@
import type { QFieldMetaData, QTableMetaData, QVirtualFieldMetaData } from '@/types'
import { sizeWidth } from './adornment-utils'
import { DATE_COLUMN_WIDTH, DATE_TIME_COLUMN_WIDTH } from './datetime-utils'
/** One column the grid, column chooser and export can use. */
export interface QueryColumn {
@@ -72,12 +73,17 @@ export type ColumnPins = Record<string, ColumnPin>
/**
* Preserve the original Next UI column width unless metadata explicitly sets a SIZE
* adornment. Narrow Material defaults leave too little room for this grid's header controls.
* Date and date-time columns are sized to their compact form (`Jul 5, 2025`, `Jul 5, 6:33 PM`).
*
* @param field - The field.
* @returns The width in pixels.
*/
export function defaultColumnWidth(field: QFieldMetaData): number {
return sizeWidth(field) ?? 150
const declared = sizeWidth(field)
if (declared !== undefined) return declared
if (field.type === 'DATE') return DATE_COLUMN_WIDTH
if (field.type === 'DATE_TIME') return DATE_TIME_COLUMN_WIDTH
return 150
}
/**
+7 -5
View File
@@ -120,12 +120,14 @@ describe('record view layout from metadata', () => {
expect(twelfths(0)).toBeUndefined()
expect(twelfths(13)).toBeUndefined()
expect(twelfths(undefined)).toBeUndefined()
expect(gridSpanClasses(4)).toBe('col-span-12 min-w-0 lg:col-span-4')
expect(gridSpanClasses(99)).toBe('col-span-12 min-w-0 lg:col-span-12')
expect(recordSectionGridSpan(section('fields', { gridColumns: 6 }))).toBe('col-span-12 min-w-0 lg:col-span-6')
expect(recordSectionGridSpan(section('fields'))).toBe('col-span-12 min-w-0 lg:col-span-6')
expect(gridSpanClasses(4)).toBe('col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 min-w-0')
expect(gridSpanClasses(99)).toBe('col-span-12 min-w-0')
// cards sit side by side only when the panel is wide enough for each to hold a table
expect(recordSectionGridSpan(section('fields', { gridColumns: 6 }))).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
expect(recordSectionGridSpan(section('fields'))).toBe('col-span-12 @min-[66rem]:col-span-6 min-w-0')
expect(recordSectionGridSpan(section('fields'), undefined, 12)).toBe('col-span-12 min-w-0')
expect(recordSectionGridSpan(section('widget', { gridColumns: 4, widgetName: 'summary' }), {
summary: { name: 'summary', label: 'Summary', hasPermission: true, gridColumns: 3 },
})).toBe('col-span-12 min-w-0 lg:col-span-3')
})).toBe('col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4 @min-[120rem]:col-span-3 min-w-0')
})
})
+8 -10
View File
@@ -24,6 +24,7 @@
import type { QFieldMetaData, QInstance, QTableMetaData, QTableSection, QueryJoin, QWidgetMetaData } from '@/types'
import { MATERIAL_DASHBOARD_TYPE, materialDashboardTableMetaData } from './goto-utils'
import { withReadableExposedJoins } from './query-columns'
import { containerSpanClasses } from './responsive-grid'
/** Section alternative type for the record view screen. */
export const RECORD_VIEW_ALTERNATIVE = 'RECORD_VIEW'
@@ -197,12 +198,6 @@ export function recordViewActionsPlacement(instance: QInstance | undefined, tabl
return fromInstance ?? asPlacement(materialDashboardTableMetaData(table)?.recordViewActionsPlacement) ?? 'IN_IDENTITY_SECTION'
}
/** `lg:col-span-N` for each width of a 12-column grid (static, so Tailwind emits them). */
const LG_SPANS: Record<number, string> = {
1: 'lg:col-span-1', 2: 'lg:col-span-2', 3: 'lg:col-span-3', 4: 'lg:col-span-4', 5: 'lg:col-span-5', 6: 'lg:col-span-6',
7: 'lg:col-span-7', 8: 'lg:col-span-8', 9: 'lg:col-span-9', 10: 'lg:col-span-10', 11: 'lg:col-span-11', 12: 'lg:col-span-12',
}
/**
* A metadata `gridColumns` value as a width in twelfths, or undefined when unset or invalid.
*
@@ -214,24 +209,27 @@ export function twelfths(gridColumns: number | undefined | null): number | undef
}
/**
* Classes that size an item of a 12-column grid: full width below `lg`, `span` twelfths from `lg`.
* Classes that size a field in a section's 12-column `@container` grid: full width in a narrow
* card, `span` twelfths once the card is wide enough (see `responsive-grid`).
*
* @param span - Width in twelfths.
* @returns The classes.
*/
export function gridSpanClasses(span: number): string {
return `col-span-12 min-w-0 ${LG_SPANS[twelfths(span) ?? 12]}`
return containerSpanClasses(twelfths(span), 'fields')
}
/**
* A widget section's card takes the widget width, otherwise the section width.
* Cards hold tables and field lists, so they only sit side by side when each one is wide enough
* to read (the grid must be an `@container`; see `responsive-grid`).
*
* @param section - Record-view section metadata.
* @param widgets - Widgets available to the viewer.
* @param defaultSpan - Card width when metadata omits it: half-width in an overview, full-width in a selected tab.
* @returns Classes for a full mobile row and the declared desktop span.
* @returns Classes for a full row in a narrow grid and the declared span in a wide one.
*/
export function recordSectionGridSpan(section: QTableSection, widgets?: Record<string, QWidgetMetaData>, defaultSpan = 6): string {
const width = section.widgetName ? widgets?.[section.widgetName]?.gridColumns : section.gridColumns
return gridSpanClasses(twelfths(width) ?? defaultSpan)
return containerSpanClasses(twelfths(width) ?? defaultSpan, 'cards')
}
+63
View File
@@ -0,0 +1,63 @@
/*
* 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 { describe, expect, it } from 'vitest'
import { containerSpanClasses, DEFAULT_FIELD_SPAN_CLASSES } from './responsive-grid'
/**
* The span an item takes at each container level (base, L1, L2, L3), read back from its classes.
*
* @param classes - The item's classes.
* @param breakpoints - The profile's L1-L3 widths.
* @returns Spans at each level.
*/
function spansAt(classes: string, breakpoints: string[]): number[] {
const spans = [12]
for (const breakpoint of breakpoints) {
const match = new RegExp(`@min-\\[${breakpoint}\\]:col-span-(\\d+)`).exec(classes)
spans.push(match ? Number(match[1]) : spans[spans.length - 1])
}
return spans
}
describe('containerSpanClasses', () => {
it('stacks table cards until the grid can hold two readable cards, then follows the metadata', () => {
const cards = ['66rem', '100rem', '120rem']
expect(spansAt(containerSpanClasses(6, 'cards'), cards)).toEqual([12, 6, 6, 6])
expect(spansAt(containerSpanClasses(3, 'cards'), cards)).toEqual([12, 6, 4, 3])
expect(spansAt(containerSpanClasses(8, 'cards'), cards)).toEqual([12, 12, 8, 8])
expect(spansAt(containerSpanClasses(12, 'cards'), cards)).toEqual([12, 12, 12, 12])
})
it('lets small widgets and fields share a row in narrower grids', () => {
expect(spansAt(containerSpanClasses(3, 'widgets'), ['40rem', '60rem', '76rem'])).toEqual([12, 6, 4, 3])
expect(spansAt(containerSpanClasses(4, 'fields'), ['30rem', '44rem', '56rem'])).toEqual([12, 6, 4, 4])
})
it('treats a missing or invalid span as full width and always allows the item to shrink', () => {
for (const span of [undefined, null, 0, 13, Number.NaN]) {
expect(containerSpanClasses(span, 'cards')).toBe('col-span-12 min-w-0')
}
expect(containerSpanClasses(5.6, 'widgets')).toBe(containerSpanClasses(6, 'widgets'))
})
})
describe('DEFAULT_FIELD_SPAN_CLASSES', () => {
it('pairs read-only fields from a 24rem grid (a tablet panel beside the sidebar) and widens on very wide grids', () => {
expect(spansAt(DEFAULT_FIELD_SPAN_CLASSES, ['24rem', '64rem', '96rem'])).toEqual([12, 6, 4, 3])
})
})
+115
View File
@@ -0,0 +1,115 @@
/*
* 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 responsive-grid — 12-column grid spans that follow the width of the grid itself
* (CSS container queries), not the viewport.
*
* QQQ metadata sizes cards, widgets and fields in twelfths (`gridColumns`). Applied at a
* viewport breakpoint, a half-width card is half of whatever is left after the sidebar, so two
* table panels sit side by side at 350px each on a laptop. Here an item only takes its declared
* width when the grid is wide enough for that many items of its kind to sit in a row:
*
* | grid width ≥ | cards (tables) | widgets | fields | span |
* |--------------|-----------------|----------|----------|--------------------------|
* | 0 | | | | full width |
* | L1 | 66rem (1056px) | 40rem | 30rem | ≤ 6 → half, else full |
* | L2 | 100rem (1600px) | 60rem | 44rem | at least a third |
* | L3 | 120rem (1920px) | 76rem | 56rem | the declared span |
*
* The grid element must carry {@link GRID_CONTAINER} (`@container`) so its items can query it.
* Every class is spelled out below so Tailwind generates it.
*/
/** Which kind of item a grid holds, which sets how narrow an item may get. */
export type GridItemProfile = 'cards' | 'widgets' | 'fields'
/** Class for a grid element whose items use {@link containerSpanClasses}. */
export const GRID_CONTAINER = '@container'
/** Span classes per profile and declared span (static strings for the Tailwind scanner). */
const SPANS: Record<GridItemProfile, Record<number, string>> = {
cards: {
1: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4 @min-[120rem]:col-span-1',
2: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4 @min-[120rem]:col-span-2',
3: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4 @min-[120rem]:col-span-3',
4: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-4',
5: 'col-span-12 @min-[66rem]:col-span-6 @min-[100rem]:col-span-5',
6: 'col-span-12 @min-[66rem]:col-span-6',
7: 'col-span-12 @min-[100rem]:col-span-7',
8: 'col-span-12 @min-[100rem]:col-span-8',
9: 'col-span-12 @min-[100rem]:col-span-9',
10: 'col-span-12 @min-[100rem]:col-span-10',
11: 'col-span-12 @min-[100rem]:col-span-11',
12: 'col-span-12',
},
widgets: {
1: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-1',
2: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-2',
3: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4 @min-[76rem]:col-span-3',
4: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-4',
5: 'col-span-12 @min-[40rem]:col-span-6 @min-[60rem]:col-span-5',
6: 'col-span-12 @min-[40rem]:col-span-6',
7: 'col-span-12 @min-[60rem]:col-span-7',
8: 'col-span-12 @min-[60rem]:col-span-8',
9: 'col-span-12 @min-[60rem]:col-span-9',
10: 'col-span-12 @min-[60rem]:col-span-10',
11: 'col-span-12 @min-[60rem]:col-span-11',
12: 'col-span-12',
},
fields: {
1: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 @min-[56rem]:col-span-1',
2: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 @min-[56rem]:col-span-2',
3: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4 @min-[56rem]:col-span-3',
4: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-4',
5: 'col-span-12 @min-[30rem]:col-span-6 @min-[44rem]:col-span-5',
6: 'col-span-12 @min-[30rem]:col-span-6',
7: 'col-span-12 @min-[44rem]:col-span-7',
8: 'col-span-12 @min-[44rem]:col-span-8',
9: 'col-span-12 @min-[44rem]:col-span-9',
10: 'col-span-12 @min-[44rem]:col-span-10',
11: 'col-span-12 @min-[44rem]:col-span-11',
12: 'col-span-12',
},
}
/**
* Default field width when metadata sets none, in a two-column layout of read-only label/value
* pairs: half from 24rem (two ~180px values beside the sidebar on a tablet), a third and then a
* quarter on very wide grids (ultra-wide record tabs); phones stack.
*/
export const DEFAULT_FIELD_SPAN_CLASSES = 'col-span-12 min-w-0 @min-[24rem]:col-span-6 @min-[64rem]:col-span-4 @min-[96rem]:col-span-3'
/**
* A declared `gridColumns` value as a span of 1 to 12.
*
* @param span - Declared twelfths.
* @returns The rounded span, or 12 when unset or invalid.
*/
function normalizedSpan(span: number | undefined | null): number {
return typeof span === 'number' && Number.isFinite(span) && span >= 1 && span <= 12 ? Math.round(span) : 12
}
/**
* Classes that size an item of a 12-column `@container` grid by the grid's own width.
*
* @param span - Declared width in twelfths (`gridColumns`); full width when unset.
* @param profile - What the grid holds.
* @returns The classes (always including `min-w-0`, so wide content cannot stretch the track).
*/
export function containerSpanClasses(span: number | undefined | null, profile: GridItemProfile): string {
return `${SPANS[profile][normalizedSpan(span)]} min-w-0`
}
+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`
}
+268
View File
@@ -0,0 +1,268 @@
/*
* 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 CMS fixture — a site with child domain and blog lists, shaped like a real content
* app: wide child tables with long text (descriptions, meta tags) and zoned timestamps, so
* record views, embedded child lists, the query grid and the app home can be checked for
* readability at every width.
*/
import type { QAppMetaData, QAppTreeNode, QFieldMetaData, QRecord, QTableMetaData, QWidgetMetaData } from '@/types'
/**
* Field metadata with the defaults the backend sends.
*
* @param name - Field name.
* @param label - Field label.
* @param type - Field type.
* @param overrides - Extra metadata.
* @returns The field.
*/
function field(name: string, label: string, type: QFieldMetaData['type'], overrides: Partial<QFieldMetaData> = {}): QFieldMetaData {
return { name, label, type, isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments: [], ...overrides }
}
/**
* A wire timestamp some hours before now, so "today" values exist whenever the mock runs.
*
* @param hours - Hours ago.
* @returns ISO-8601 instant in UTC.
*/
function hoursAgo(hours: number): string {
return new Date(Date.now() - hours * 3_600_000).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
/** Capabilities and permissions every CMS table grants in the mock. */
const tableDefaults = {
exposedJoins: [],
capabilities: ['TABLE_QUERY', 'TABLE_GET', 'TABLE_COUNT', 'TABLE_INSERT', 'TABLE_UPDATE', 'TABLE_DELETE'],
readPermission: true,
insertPermission: true,
editPermission: true,
deletePermission: true,
usesVariants: false,
variantTableLabel: '',
} satisfies Partial<QTableMetaData>
const auditFields = {
createDate: field('createDate', 'Create Date', 'DATE_TIME', { isEditable: false }),
modifyDate: field('modifyDate', 'Modify Date', 'DATE_TIME', { isEditable: false }),
}
export const siteTable: QTableMetaData = {
name: 'site',
label: 'Sites',
isHidden: false,
primaryKeyField: 'id',
...tableDefaults,
fields: {
id: field('id', 'Id', 'INTEGER', { isEditable: false }),
name: field('name', 'Name', 'STRING', { maxLength: 100 }),
shortName: field('shortName', 'Short Name', 'STRING', { maxLength: 50 }),
description: field('description', 'Description', 'STRING', { maxLength: 500 }),
launchDate: field('launchDate', 'Launch Date', 'DATE'),
...auditFields,
},
sections: [
{ name: 'identity', label: 'Site', tier: 'T1', iconName: 'language', fieldNames: ['id', 'name', 'shortName', 'description'], isHidden: false },
{ name: 'domains', label: 'Domains', tier: 'T2', iconName: 'dns', fieldNames: [], widgetName: 'siteDomains', isHidden: false },
{ name: 'blogs', label: 'Blogs', tier: 'T2', iconName: 'folder', fieldNames: [], widgetName: 'siteBlogs', isHidden: false },
{ name: 'dates', label: 'Dates', tier: 'T3', iconName: 'calendar_month', fieldNames: ['launchDate', 'createDate', 'modifyDate'], isHidden: false },
],
}
export const siteDomainTable: QTableMetaData = {
name: 'siteDomain',
label: 'Site Domains',
isHidden: false,
primaryKeyField: 'id',
...tableDefaults,
fields: {
id: field('id', 'Id', 'INTEGER', { isEditable: false }),
siteId: field('siteId', 'Site', 'INTEGER', { possibleValueSourceName: 'site' }),
name: field('name', 'Name', 'STRING', { maxLength: 255 }),
shortName: field('shortName', 'Short Name', 'STRING', { maxLength: 100 }),
fqdn: field('fqdn', 'Fqdn', 'STRING', { maxLength: 255 }),
isPrimary: field('isPrimary', 'Is Primary', 'BOOLEAN'),
description: field('description', 'Description', 'STRING', { maxLength: 500 }),
...auditFields,
},
sections: [
{ name: 'identity', label: 'Domain', tier: 'T1', iconName: 'dns', fieldNames: ['id', 'siteId', 'name', 'shortName', 'fqdn', 'isPrimary'], isHidden: false },
{ name: 'details', label: 'Details', tier: 'T2', iconName: 'info', fieldNames: ['description'], isHidden: false },
{ name: 'dates', label: 'Dates', tier: 'T3', iconName: 'calendar_month', fieldNames: ['createDate', 'modifyDate'], isHidden: false },
],
}
export const siteBlogTable: QTableMetaData = {
name: 'siteBlog',
label: 'Site Blogs',
isHidden: false,
primaryKeyField: 'id',
...tableDefaults,
fields: {
id: field('id', 'Id', 'INTEGER', { isEditable: false }),
siteId: field('siteId', 'Site', 'INTEGER', { possibleValueSourceName: 'site' }),
name: field('name', 'Name', 'STRING', { maxLength: 255 }),
shortName: field('shortName', 'Short Name', 'STRING', { maxLength: 100 }),
slug: field('slug', 'Slug', 'STRING', { maxLength: 100 }),
blogTypeId: field('blogTypeId', 'Blog Type', 'INTEGER', { possibleValueSourceName: 'blogType' }),
description: field('description', 'Description', 'STRING', { maxLength: 1000 }),
metaTitle: field('metaTitle', 'Meta Title', 'STRING', { maxLength: 255 }),
metaDescription: field('metaDescription', 'Meta Description', 'STRING', { maxLength: 1000 }),
metaKeywords: field('metaKeywords', 'Meta Keywords', 'STRING', { maxLength: 1000 }),
publishDate: field('publishDate', 'Publish Date', 'DATE'),
...auditFields,
},
sections: [
{ name: 'identity', label: 'Blog', tier: 'T1', iconName: 'article', fieldNames: ['id', 'siteId', 'name', 'shortName', 'slug', 'blogTypeId'], isHidden: false },
{ name: 'details', label: 'Details', tier: 'T2', iconName: 'info', fieldNames: ['description', 'publishDate'], isHidden: false },
{ name: 'seo', label: 'SEO', tier: 'T2', iconName: 'search', fieldNames: ['metaTitle', 'metaDescription', 'metaKeywords'], isHidden: false },
{ name: 'dates', label: 'Dates', tier: 'T3', iconName: 'calendar_month', fieldNames: ['createDate', 'modifyDate'], isHidden: false },
],
}
export const siteRecords: QRecord[] = [
{
tableName: 'site',
recordLabel: 'Kof22',
values: {
id: 1, name: 'Kof22', shortName: 'KofTwentyTwo Personal Site', description: 'Kof22 Personal Website for James Maes',
launchDate: '2026-07-05', createDate: '2026-07-05T23:33:29Z', modifyDate: hoursAgo(3),
},
displayValues: { id: '1' },
},
]
/**
* A site domain record.
*
* @param id - Key.
* @param name - Host name.
* @param isPrimary - Primary domain flag.
* @param description - Description.
* @param createDate - Created instant.
* @param modifyDate - Modified instant.
* @returns The record.
*/
function domain(id: number, name: string, isPrimary: boolean, description: string, createDate: string, modifyDate: string): QRecord {
return {
tableName: 'siteDomain',
recordLabel: name,
values: { id, siteId: 1, name, shortName: name, fqdn: name, isPrimary, description, createDate, modifyDate },
displayValues: { id: String(id), siteId: 'Kof22', isPrimary: isPrimary ? 'Yes' : 'No' },
}
}
export const siteDomainRecords: QRecord[] = [
domain(1, 'www.kof22.com', true, 'Primary base domain', '2026-07-05T23:33:29Z', '2026-07-05T23:33:29Z'),
domain(2, 'kof22.com', false, 'Naked domain, redirect to www', '2026-07-05T23:33:29Z', '2025-11-02T15:04:00Z'),
domain(3, 'koftwentytwo.com', false, 'Naked domain, redirect to www', hoursAgo(5), hoursAgo(5)),
domain(4, 'www.koftwentytwo.com', false, 'Naked domain, redirect to www', hoursAgo(4.8), hoursAgo(1)),
]
const fieldNotes = 'Notes from the work of building systems, making decisions and understanding what changes along the way.'
const ideas = 'A CTO\'s perspective on how technical ideas become systems, and what those decisions mean for the people building and depending on them.'
export const siteBlogRecords: QRecord[] = [
{
tableName: 'siteBlog',
recordLabel: 'Field Notes',
values: {
id: 1, siteId: 1, name: 'Field Notes', shortName: 'Field Notes', slug: 'quick-bits', blogTypeId: 1, description: fieldNotes,
metaTitle: 'Field Notes - Engineering & Leadership', metaDescription: fieldNotes, metaKeywords: 'engineering, leadership, observations, systems',
publishDate: '2026-07-06', createDate: '2026-07-05T23:33:29Z', modifyDate: '2026-10-10T11:01:39Z',
},
displayValues: { id: '1', siteId: 'Kof22', blogTypeId: 'Site Blog' },
},
{
tableName: 'siteBlog',
recordLabel: 'Ideas Into Systems',
values: {
id: 2, siteId: 1, name: 'Ideas Into Systems', shortName: 'Ideas Into Systems', slug: 'ideas-into-systems', blogTypeId: 1, description: ideas,
metaTitle: 'Ideas Into Systems - A CTO\'s Perspective', metaDescription: ideas, metaKeywords: 'technology, architecture, leadership, engineering, systems',
publishDate: '2025-03-14', createDate: '2026-07-05T23:33:29Z', modifyDate: hoursAgo(2),
},
displayValues: { id: '2', siteId: 'Kof22', blogTypeId: 'Site Blog' },
},
]
/**
* The `ChildRecordListRenderer` payload for a child table of site 1, as the backend sends it
* (the join field is omitted and becomes the new child's default).
*
* @param table - Child table.
* @param records - Child rows.
* @param label - Widget label.
* @returns The payload.
*/
function childList(table: QTableMetaData, records: QRecord[], label: string): Record<string, unknown> {
return {
type: 'childRecordList',
title: label,
label,
childFrontendTableMetaData: table,
queryOutput: { records },
totalRows: records.length,
viewAllLink: `/cms/sites/${table.name}?filter=${encodeURIComponent(JSON.stringify({ criteria: [{ fieldName: 'siteId', operator: 'EQUALS', values: [1] }] }))}`,
defaultValuesForNewChildRecords: { siteId: 1 },
omitFieldNames: ['siteId'],
canAddChildRecord: false,
}
}
/** Widget payloads served for the CMS widgets. */
export const cmsWidgetData: Record<string, () => unknown> = {
siteDomains: () => childList(siteDomainTable, siteDomainRecords, 'Site Domains'),
siteBlogs: () => childList(siteBlogTable, siteBlogRecords, 'Site Blogs'),
cmsRecentBlogs: () => childList(siteBlogTable, siteBlogRecords, 'Recently Changed Blogs'),
cmsSiteCount: () => ({ type: 'statistics', title: 'Sites', value: siteRecords.length, unit: 'sites' }),
cmsDomainCount: () => ({ type: 'statistics', title: 'Domains', value: siteDomainRecords.length, unit: 'domains' }),
cmsBlogCount: () => ({ type: 'statistics', title: 'Blogs', value: siteBlogRecords.length, unit: 'blogs' }),
}
export const cmsWidgets: Record<string, QWidgetMetaData> = {
siteDomains: { name: 'siteDomains', label: 'Domains', type: 'childRecordList', hasPermission: true, showExportButton: true, showReloadButton: true },
siteBlogs: { name: 'siteBlogs', label: 'Blogs', type: 'childRecordList', hasPermission: true, showExportButton: true, showReloadButton: true },
cmsSiteCount: { name: 'cmsSiteCount', label: 'Sites', type: 'statistics', hasPermission: true, gridColumns: 4 },
cmsDomainCount: { name: 'cmsDomainCount', label: 'Domains', type: 'statistics', hasPermission: true, gridColumns: 4 },
cmsBlogCount: { name: 'cmsBlogCount', label: 'Blogs', type: 'statistics', hasPermission: true, gridColumns: 4 },
cmsRecentBlogs: { name: 'cmsRecentBlogs', label: 'Recently Changed Blogs', type: 'childRecordList', hasPermission: true, gridColumns: 12 },
}
export const cmsTreeNode: QAppTreeNode = {
name: 'cms',
label: 'CMS',
type: 'APP',
iconName: 'web',
children: [
{ name: 'site', label: 'Sites', type: 'TABLE', iconName: 'language' },
{ name: 'siteDomain', label: 'Domains', type: 'TABLE', iconName: 'dns' },
{ name: 'siteBlog', label: 'Blogs', type: 'TABLE', iconName: 'article' },
],
}
export const cmsApp: QAppMetaData = {
name: 'cms',
label: 'CMS',
iconName: 'web',
widgets: ['cmsSiteCount', 'cmsDomainCount', 'cmsBlogCount', 'cmsRecentBlogs'],
children: cmsTreeNode.children ?? [],
sections: [
{ name: 'sites', label: 'Sites', icon: { name: 'language' }, tables: ['site', 'siteDomain', 'siteBlog'], processes: [], reports: [] },
],
}
+7 -2
View File
@@ -26,6 +26,7 @@ import type {
QAppMetaData,
QAppTreeNode,
} from '@/types'
import { cmsApp, cmsTreeNode, cmsWidgets, siteBlogTable, siteDomainTable, siteTable } from './cms'
// ─── Field helpers ────────────────────────────────────────────────────────────
@@ -900,8 +901,9 @@ export const qInstance: QInstance = {
apps: {
crm: crmApp,
inventory: inventoryApp,
cms: cmsApp,
},
appTree: [crmTreeNode, inventoryTreeNode],
appTree: [crmTreeNode, inventoryTreeNode, cmsTreeNode],
tables: {
person: personTable,
company: companyTable,
@@ -909,6 +911,9 @@ export const qInstance: QInstance = {
orderLine: orderLineTable,
product: productTable,
supplier: supplierTable,
site: siteTable,
siteDomain: siteDomainTable,
siteBlog: siteBlogTable,
},
processes: {
importPeople: importPeopleProcess,
@@ -918,7 +923,7 @@ export const qInstance: QInstance = {
bulkUpdateOrderStatus: bulkUpdateStatusProcess,
},
reports: {},
widgets: { ...crmWidgets, ...inventoryWidgets },
widgets: { ...crmWidgets, ...inventoryWidgets, ...cmsWidgets },
branding: {
companyName: 'QQQ Demo',
companyUrl: 'https://qqq.example.com',
+279
View File
@@ -0,0 +1,279 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Fixtures for the SQL Workbench endpoints, shaped as qbit-sql-workbench answers.
*/
import type {
SqlExecuteResponse,
SqlExplainResponse,
SqlHistoryEntry,
SqlNotebook,
SqlNotebookSummary,
SqlRowsPage,
SqlSchema,
SqlWorkbenchCapabilities,
} from '@/types'
export const sqlCapabilities: SqlWorkbenchCapabilities = {
version: '1.0.0-RC.1',
canWrite: true,
canShare: true,
writeModeEnabled: true,
limits: {
maxRows: 10000,
pageSize: 500,
maxPageSize: 2000,
maxResultBytes: 20971520,
statementTimeoutSeconds: 30,
writeStatementTimeoutSeconds: 60,
maxAffectedRowsWithoutAcknowledgement: 1000,
maxAffectedRows: 100000,
maxSqlLength: 100000,
maxStatementsPerRun: 50,
},
backends: [
{
name: 'postgres',
label: 'postgres',
vendor: 'postgres',
dialect: 'postgresql',
readOnlyCredentials: true,
readOnlyEnforced: true,
transactionalDdl: true,
writeAllowed: true,
},
],
}
export const sqlReadOnlyCapabilities: SqlWorkbenchCapabilities = {
...sqlCapabilities,
canWrite: false,
canShare: false,
backends: sqlCapabilities.backends.map((b) => ({ ...b, writeAllowed: false })),
}
export const sqlSchema: SqlSchema = {
backendName: 'postgres',
dialect: 'postgresql',
schemas: [
{
name: 'public',
tables: [
{
name: 'person',
type: 'TABLE',
columns: [
{ name: 'id', typeName: 'int4', nullable: false },
{ name: 'first_name', typeName: 'text', nullable: true },
],
},
],
},
],
truncated: false,
keywords: ['SELECT', 'FROM'],
functions: ['count'],
qqqTables: [
{
name: 'person',
label: 'Person',
sqlName: 'person',
fields: [{ name: 'firstName', label: 'First Name', sqlName: 'first_name', type: 'STRING' }],
},
],
cachedAt: '2026-10-11T00:00:00Z',
}
export const sqlSelectResult: SqlExecuteResponse = {
executionUuid: 'exec-1',
status: 'SUCCEEDED',
mode: 'READ_ONLY',
backendName: 'postgres',
durationMs: 12,
statements: [
{ index: 0, kind: 'READ', leadingKeyword: 'SELECT', text: 'SELECT ...', affectedRows: null },
],
columns: [
{ name: 'id', label: 'id', typeName: 'int4', jdbcType: 4, kind: 'number' },
{ name: 'first_name', label: 'first_name', typeName: 'text', jdbcType: 12, kind: 'string' },
{ name: 'doc', label: 'doc', typeName: 'jsonb', jdbcType: 1111, kind: 'json' },
{ name: 'photo', label: 'photo', typeName: 'bytea', jdbcType: -2, kind: 'binary' },
],
rows: [
[1, 'Darin', '{"k":1}', { $binary: true, length: 4, hexPreview: 'cafebabe' }],
[2, null, null, null],
],
rowCount: 3,
hasMore: true,
truncated: false,
truncatedReason: null,
}
export const sqlRowsPage: SqlRowsPage = {
executionUuid: 'exec-1',
columns: sqlSelectResult.columns!,
rows: [[3, 'Tim', null, null]],
offset: 2,
rowCount: 3,
truncated: false,
truncatedReason: null,
}
export const sqlWritePreview: SqlExecuteResponse = {
executionUuid: 'exec-2',
status: 'PREVIEW',
mode: 'WRITE_PREVIEW',
backendName: 'postgres',
durationMs: 41,
statements: [
{
index: 0,
kind: 'WRITE',
leadingKeyword: 'UPDATE',
text: 'UPDATE person SET active = false',
affectedRows: 1204,
},
],
dryRunSupported: true,
dryRunNote: null,
totalAffectedRows: 1204,
rowGuardExceeded: true,
rowGuardLimit: 1000,
confirmToken: 'token-1',
expiresAt: '2026-10-11T00:05:00Z',
}
export const sqlWriteCommit: SqlExecuteResponse = {
executionUuid: 'exec-3',
status: 'SUCCEEDED',
mode: 'WRITE_COMMIT',
backendName: 'postgres',
durationMs: 50,
statements: [
{
index: 0,
kind: 'WRITE',
leadingKeyword: 'UPDATE',
text: 'UPDATE person SET active = false',
affectedRows: 1204,
},
],
columns: [],
rows: [],
rowCount: 0,
hasMore: false,
truncated: false,
rowsAffected: 1204,
}
export const sqlExplain: SqlExplainResponse = {
executionUuid: 'exec-4',
status: 'SUCCEEDED',
dialect: 'postgresql',
analyze: false,
raw: '[{"Plan": {}}]',
plan: {
label: 'Sort',
detail: null,
startupCost: 10,
totalCost: 12.5,
planRows: 100,
actualTimeMs: null,
actualRows: null,
loops: null,
extra: { 'Sort Key': '["id"]' },
children: [
{
label: 'Seq Scan',
detail: 'on person',
startupCost: 0,
totalCost: 7.1,
planRows: 100,
actualTimeMs: null,
actualRows: null,
loops: null,
extra: {},
children: [],
},
],
},
}
export const sqlHistory: SqlHistoryEntry[] = [
{
executionUuid: 'exec-1',
backendName: 'postgres',
mode: 'READ_ONLY',
status: 'SUCCEEDED',
sqlText: 'SELECT * FROM person',
notebookId: 1,
cellKey: 'c1',
startTime: '2026-10-11T14:02:00Z',
durationMs: 12,
rowsReturned: 3,
rowsAffected: null,
errorMessage: null,
},
]
export const sqlNotebookSummary: SqlNotebookSummary = {
id: 1,
title: 'Monthly signups',
description: null,
visibility: 'PERSONAL',
ownerUserId: 'user-1',
ownerName: 'User One',
defaultBackendName: 'postgres',
currentVersion: 2,
canEdit: true,
createDate: '2026-10-01T00:00:00Z',
modifyDate: '2026-10-11T00:00:00Z',
}
export const sqlSharedNotebookSummary: SqlNotebookSummary = {
...sqlNotebookSummary,
id: 2,
title: 'Orphan media',
visibility: 'SHARED',
ownerUserId: 'user-2',
ownerName: 'User Two',
canEdit: false,
}
export const sqlNotebook: SqlNotebook = {
...sqlNotebookSummary,
parameters: [{ name: 'since', type: 'DATE', defaultValue: '2026-01-01' }],
cells: [
{
cellKey: 'c0',
cellType: 'MARKDOWN',
title: null,
content: '## Signups by **month**',
backendName: null,
sortOrder: 0,
},
{
cellKey: 'c1',
cellType: 'SQL',
title: 'Signups',
content: 'SELECT * FROM person WHERE create_date >= :since',
backendName: null,
sortOrder: 1,
},
],
}
+2
View File
@@ -25,6 +25,7 @@ import { possibleValuesHandlers } from './possible-values'
import { processHandlers } from './processes'
import { widgetHandlers } from './widgets'
import { esbHandlers } from './esb'
import { sqlWorkbenchHandlers } from './sql-workbench'
export const handlers = [
...authHandlers,
@@ -35,4 +36,5 @@ export const handlers = [
...processHandlers,
...widgetHandlers,
...esbHandlers,
...sqlWorkbenchHandlers,
]
+145
View File
@@ -0,0 +1,145 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file MSW handlers for the SQL Workbench endpoints (`/qqq/v1/sql-workbench/*`).
*/
import { http, HttpResponse } from 'msw'
import type { SqlExecuteRequest } from '@/types'
import {
sqlCapabilities,
sqlExplain,
sqlHistory,
sqlNotebook,
sqlNotebookSummary,
sqlRowsPage,
sqlSchema,
sqlSelectResult,
sqlSharedNotebookSummary,
sqlWriteCommit,
sqlWritePreview,
} from '../fixtures/sql-workbench'
const BASE = '/qqq/v1/sql-workbench'
export const sqlWorkbenchHandlers = [
http.get(`${BASE}/capabilities`, () => HttpResponse.json(sqlCapabilities)),
http.get(`${BASE}/schema/:backend`, ({ params }) =>
params.backend === 'postgres'
? HttpResponse.json(sqlSchema)
: HttpResponse.json(
{ error: `Unknown SQL Workbench backend: ${String(params.backend)}` },
{ status: 404 }
)
),
http.post(`${BASE}/execute`, async ({ request }) => {
const body = (await request.json()) as SqlExecuteRequest
if (body.mode === 'WRITE') {
return HttpResponse.json(body.confirmToken ? sqlWriteCommit : sqlWritePreview)
}
if (/^\s*select\s+broken/i.test(body.sql)) {
return HttpResponse.json({
...sqlSelectResult,
status: 'FAILED',
error: 'column "broken" does not exist',
columns: undefined,
rows: undefined,
})
}
return HttpResponse.json(sqlSelectResult)
}),
http.post(`${BASE}/explain`, () => HttpResponse.json(sqlExplain)),
http.get(`${BASE}/executions/:uuid/rows`, () => HttpResponse.json(sqlRowsPage)),
http.post(`${BASE}/executions/:uuid/cancel`, () => HttpResponse.json({ cancelled: true })),
http.get(`${BASE}/history`, () => HttpResponse.json({ executions: sqlHistory })),
http.get(`${BASE}/notebooks`, ({ request }) => {
const scope = new URL(request.url).searchParams.get('scope')
return HttpResponse.json({
notebooks: scope === 'shared' ? [sqlSharedNotebookSummary] : [sqlNotebookSummary],
})
}),
http.post(`${BASE}/notebooks`, async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
...sqlNotebook,
id: 9,
title: body.title,
cells: body.cells ?? [],
currentVersion: 1,
})
}),
http.get(`${BASE}/notebooks/:id`, ({ params }) =>
params.id === '1'
? HttpResponse.json(sqlNotebook)
: HttpResponse.json({ error: 'Notebook not found.' }, { status: 404 })
),
http.put(`${BASE}/notebooks/:id`, async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return HttpResponse.json({
...sqlNotebook,
...body,
currentVersion: sqlNotebook.currentVersion + 1,
})
}),
http.delete(`${BASE}/notebooks/:id`, () => HttpResponse.json({ deleted: true })),
http.post(`${BASE}/notebooks/:id/publish`, async ({ request }) => {
const body = (await request.json()) as { shared: boolean }
return HttpResponse.json({ ...sqlNotebook, visibility: body.shared ? 'SHARED' : 'PERSONAL' })
}),
http.post(`${BASE}/notebooks/:id/duplicate`, () =>
HttpResponse.json({ ...sqlNotebook, id: 10, title: 'Copy of Monthly signups' })
),
http.get(`${BASE}/notebooks/:id/versions`, () =>
HttpResponse.json({
versions: [
{
versionNumber: 2,
savedByName: 'User One',
comment: null,
createDate: '2026-10-11T00:00:00Z',
},
{
versionNumber: 1,
savedByName: 'User One',
comment: null,
createDate: '2026-10-01T00:00:00Z',
},
],
})
),
http.post(`${BASE}/notebooks/:id/versions/:n/restore`, () =>
HttpResponse.json({ ...sqlNotebook, currentVersion: 3 })
),
http.get(`${BASE}/notebooks/:id/snapshots`, () => HttpResponse.json({ snapshots: [] })),
http.post(`${BASE}/notebooks/:id/snapshots`, async ({ request }) => {
const body = (await request.json()) as { cellKey: string; executionUuid: string }
return HttpResponse.json({
id: 5,
cellKey: body.cellKey,
executionUuid: body.executionUuid,
label: null,
backendName: 'postgres',
rowCount: 2,
truncated: false,
createDate: null,
result: { columns: sqlSelectResult.columns, rows: sqlSelectResult.rows },
})
}),
http.delete(`${BASE}/notebooks/:id/snapshots/:snapshotId`, () =>
HttpResponse.json({ deleted: true })
),
]
+13
View File
@@ -30,6 +30,7 @@ import { orderLineRecords } from '../fixtures/records/order-line'
import { productRecords } from '../fixtures/records/product'
import { supplierRecords } from '../fixtures/records/supplier'
import { auditRecords } from '../fixtures/records/audits'
import { siteBlogRecords, siteDomainRecords, siteRecords } from '../fixtures/cms'
const BASE = '/qqq/v1'
@@ -52,6 +53,9 @@ const store: Record<string, QRecord[]> = {
orderLine: deepClone(orderLineRecords),
product: deepClone(productRecords),
supplier: deepClone(supplierRecords),
site: deepClone(siteRecords),
siteDomain: deepClone(siteDomainRecords),
siteBlog: deepClone(siteBlogRecords),
}
// Auto-increment counters per table
@@ -62,6 +66,9 @@ const nextId: Record<string, number> = {
orderLine: 21,
product: 16,
supplier: 9,
site: 2,
siteDomain: 5,
siteBlog: 3,
}
// ─── Filter helpers ───────────────────────────────────────────────────────────
@@ -177,6 +184,9 @@ const primaryKeyFields: Record<string, string> = {
orderLine: 'id',
product: 'id',
supplier: 'id',
site: 'id',
siteDomain: 'id',
siteBlog: 'id',
}
/**
@@ -288,6 +298,9 @@ const tableLabels: Record<string, string> = {
orderLine: 'Order Lines',
product: 'Products',
supplier: 'Suppliers',
site: 'Sites',
siteDomain: 'Site Domains',
siteBlog: 'Site Blogs',
}
// ─── Handlers ────────────────────────────────────────────────────────────────
+2 -1
View File
@@ -37,6 +37,7 @@ import {
invValueTrend,
invLowStockItems,
} from '../fixtures/widgets/inventory'
import { cmsWidgetData } from '../fixtures/cms'
const widgetData: Record<string, unknown> = {
// CRM widgets
@@ -59,7 +60,7 @@ export const widgetHandlers = [
// GET /widget/:widgetName
http.post('/qqq/v1/widget/:widgetName', ({ params }) => {
const { widgetName } = params as { widgetName: string }
const data = widgetData[widgetName]
const data = widgetData[widgetName] ?? cmsWidgetData[widgetName]?.()
if (!data) {
return HttpResponse.json(
+2
View File
@@ -192,6 +192,7 @@ export type QComponentType =
* - `FILE_UPLOAD` — renders a file picker for uploading content to the field
* - `TOOLTIP` — adds a hover tooltip with additional context
* - `WIDGET` — renders the field value as the data of a named widget
* - `FILE_PREVIEW` — renders an image thumbnail (or file icon) that opens a preview
*/
export type AdornmentType =
| 'LINK'
@@ -205,3 +206,4 @@ export type AdornmentType =
| 'FILE_UPLOAD'
| 'TOOLTIP'
| 'WIDGET'
| 'FILE_PREVIEW'
+31
View File
@@ -128,3 +128,34 @@ export type {
EsbMessage,
EsbMessagePage,
} from './esb'
export type {
SqlWorkbenchDialect,
SqlWorkbenchBackend,
SqlWorkbenchLimits,
SqlWorkbenchCapabilities,
SqlColumnKind,
SqlColumn,
SqlBinaryValue,
SqlValue,
SqlStatementView,
SqlExecutionStatus,
SqlExecuteRequest,
SqlExecuteResponse,
SqlRowsPage,
SqlPlanNode,
SqlExplainResponse,
SqlSchemaColumn,
SqlSchemaTable,
SqlQqqTable,
SqlSchema,
SqlHistoryEntry,
SqlNotebookParameter,
SqlNotebookCell,
SqlNotebookSummary,
SqlNotebook,
SqlNotebookInput,
SqlNotebookVersionSummary,
SqlResultSnapshot,
SqlExportFormat,
} from './sql-workbench'
+2 -1
View File
@@ -861,7 +861,8 @@ export interface Banner {
* uses the backend's keys, for example `toRecordFromTable` / `target` (LINK),
* `color.<value>` / `icon.<value>` (CHIP), `width` (SIZE), `languageMode`
* (CODE_EDITOR), `fileNameField` / `defaultMimeType` (FILE_DOWNLOAD), `format`
* (FILE_UPLOAD), `staticText` / `tooltipDynamic` (TOOLTIP) and `widgetName` (WIDGET).
* (FILE_UPLOAD), `staticText` / `tooltipDynamic` (TOOLTIP), `widgetName` (WIDGET) and
* `thumbnailUrlField` / `fullSizeUrlField` / `mimeTypeField` / `thumbnailMaxSize` (FILE_PREVIEW).
* Read them with the helpers in `@/lib/utils/adornment-utils`.
*/
export interface FieldAdornment {
+316
View File
@@ -0,0 +1,316 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file SQL Workbench types — the `/qqq/v1/sql-workbench` contract of the qbit-sql-workbench
* QBit (see that repository's docs/DESIGN.md, section 5).
*/
/** Dialect names the backend reports; drive the editor and formatter. */
export type SqlWorkbenchDialect = 'postgresql' | 'mysql' | 'sqlite' | 'h2' | 'sql'
/** One target backend. */
export interface SqlWorkbenchBackend {
name: string
label: string
vendor: string | null
dialect: SqlWorkbenchDialect
/** Read-only sessions use dedicated (least-privilege) credentials. */
readOnlyCredentials: boolean
/** The database enforces read-only sessions (false on H2 without dedicated credentials). */
readOnlyEnforced: boolean
/** DDL can be dry-run (rolled back) on this backend. */
transactionalDdl: boolean
writeAllowed: boolean
}
/** Server limits. */
export interface SqlWorkbenchLimits {
maxRows: number
pageSize: number
maxPageSize: number
maxResultBytes: number
statementTimeoutSeconds: number
writeStatementTimeoutSeconds: number
maxAffectedRowsWithoutAcknowledgement: number
maxAffectedRows: number
maxSqlLength: number
maxStatementsPerRun: number
}
/** `GET /capabilities`. */
export interface SqlWorkbenchCapabilities {
version: string
canWrite: boolean
canShare: boolean
writeModeEnabled: boolean
limits: SqlWorkbenchLimits
backends: SqlWorkbenchBackend[]
}
/** How the UI renders a result column. */
export type SqlColumnKind =
| 'number'
| 'string'
| 'boolean'
| 'date'
| 'datetime'
| 'time'
| 'json'
| 'binary'
| 'array'
| 'other'
/** One result column. */
export interface SqlColumn {
name: string
label: string
typeName: string
jdbcType: number
kind: SqlColumnKind
}
/** A binary value as the backend sends it. */
export interface SqlBinaryValue {
$binary: true
length: number
hexPreview: string
}
/** One result value (already JSON-safe). */
export type SqlValue = string | number | boolean | null | SqlBinaryValue | SqlValue[]
/** One statement of a run, as the backend classified it. */
export interface SqlStatementView {
index: number
kind: 'READ' | 'EXPLAIN_ANALYZE' | 'WRITE' | 'DDL' | 'FORBIDDEN'
leadingKeyword: string | null
text: string
affectedRows: number | null
}
/** Outcome of a run. */
export type SqlExecutionStatus =
| 'SUCCEEDED'
| 'FAILED'
| 'TIMED_OUT'
| 'CANCELLED'
| 'REJECTED'
| 'PREVIEW'
| 'DRIFTED'
/** `POST /execute` request. */
export interface SqlExecuteRequest {
backendName: string
sql: string
params?: Record<string, string | number | boolean | null>
mode?: 'READ_ONLY' | 'WRITE'
notebookId?: number
cellKey?: string
pageSize?: number
confirmToken?: string
acknowledgeRowGuard?: boolean
/** Client-chosen id (a UUID), so a running statement can be cancelled before it answers. */
executionUuid?: string
}
/** `POST /execute` response (fields depend on the status). */
export interface SqlExecuteResponse {
executionUuid: string
status: SqlExecutionStatus
mode: string
backendName: string
durationMs?: number | null
statements?: SqlStatementView[]
columns?: SqlColumn[]
rows?: SqlValue[][]
rowCount?: number
hasMore?: boolean
truncated?: boolean
truncatedReason?: 'ROWS' | 'BYTES' | null
rowsAffected?: number
error?: string
sqlState?: string | null
dryRunSupported?: boolean
dryRunNote?: string | null
totalAffectedRows?: number | null
rowGuardExceeded?: boolean
rowGuardLimit?: number
confirmToken?: string
expiresAt?: string
previewAffectedRows?: number
actualAffectedRows?: number
}
/** `GET /executions/{uuid}/rows`. */
export interface SqlRowsPage {
executionUuid: string
columns: SqlColumn[]
rows: SqlValue[][]
offset: number
rowCount: number
truncated: boolean
truncatedReason: 'ROWS' | 'BYTES' | null
}
/** One node of a normalized query plan. */
export interface SqlPlanNode {
label: string
detail: string | null
startupCost: number | null
totalCost: number | null
planRows: number | null
actualTimeMs: number | null
actualRows: number | null
loops: number | null
extra: Record<string, unknown>
children: SqlPlanNode[]
}
/** `POST /explain` response. */
export interface SqlExplainResponse {
executionUuid: string
status: SqlExecutionStatus
dialect?: SqlWorkbenchDialect
analyze?: boolean
plan?: SqlPlanNode
raw?: string
error?: string
durationMs?: number | null
}
/** Schema column. */
export interface SqlSchemaColumn {
name: string
typeName: string
nullable: boolean
}
/** Schema table. */
export interface SqlSchemaTable {
name: string
type: string
columns: SqlSchemaColumn[]
}
/** A QQQ table on a backend, with its SQL names. */
export interface SqlQqqTable {
name: string
label: string | null
sqlName: string
fields: { name: string; label: string | null; sqlName: string; type: string | null }[]
}
/** `GET /schema/{backend}`. */
export interface SqlSchema {
backendName: string
dialect: SqlWorkbenchDialect
schemas: { name: string; tables: SqlSchemaTable[] }[]
truncated: boolean
keywords: string[]
functions: string[]
qqqTables: SqlQqqTable[]
cachedAt: string
}
/** One audit row from `GET /history`. */
export interface SqlHistoryEntry {
executionUuid: string
backendName: string
mode: string
status: string
sqlText: string
notebookId: number | null
cellKey: string | null
startTime: string | null
durationMs: number | null
rowsReturned: number | null
rowsAffected: number | null
errorMessage: string | null
}
/** A notebook parameter definition. */
export interface SqlNotebookParameter {
name: string
type?: 'STRING' | 'INTEGER' | 'DECIMAL' | 'BOOLEAN' | 'DATE' | 'DATE_TIME'
defaultValue?: string | null
label?: string | null
}
/** One notebook cell. */
export interface SqlNotebookCell {
cellKey: string
cellType: 'SQL' | 'MARKDOWN'
title?: string | null
content: string
backendName?: string | null
sortOrder?: number
}
/** Notebook list entry. */
export interface SqlNotebookSummary {
id: number
title: string
description: string | null
visibility: 'PERSONAL' | 'SHARED'
ownerUserId: string
ownerName: string | null
defaultBackendName: string | null
currentVersion: number
canEdit: boolean
createDate: string | null
modifyDate: string | null
}
/** A full notebook. */
export interface SqlNotebook extends SqlNotebookSummary {
parameters: SqlNotebookParameter[]
cells: SqlNotebookCell[]
}
/** Notebook save / create body. */
export interface SqlNotebookInput {
title: string
description?: string | null
defaultBackendName?: string | null
parameters?: SqlNotebookParameter[]
cells?: SqlNotebookCell[]
comment?: string
}
/** One notebook version (list entry). */
export interface SqlNotebookVersionSummary {
versionNumber: number
savedByName: string | null
comment: string | null
createDate: string
}
/** A pinned result. */
export interface SqlResultSnapshot {
id: number
cellKey: string | null
executionUuid: string | null
label: string | null
backendName: string | null
rowCount: number
truncated: boolean
createDate: string | null
result: { columns?: SqlColumn[]; rows?: SqlValue[][] }
}
/** Export formats. */
export type SqlExportFormat = 'csv' | 'xlsx' | 'json'
+3 -2
View File
@@ -361,7 +361,7 @@
"fixture": "fieldLab; browser zone America/New_York",
"scenarios": [
"typed local time stored as the UTC instant",
"view shows yyyy-MM-dd hh:mm:ss AM EDT",
"view shows the compact date (Jul 4, 2024) with the full yyyy-MM-dd hh:mm:ss AM EDT timestamp on hover",
"edit form shows the local time"
],
"negative": [
@@ -912,6 +912,7 @@
"fixture": "persona viewer",
"scenarios": [
"no Edit, Copy or Delete",
"the Actions menu (or phone sheet) still offers Developer Mode, with no New, Edit, Copy or Delete (QRun-IO/qqq-frontend-next#45)",
"create and edit URLs show the permission message"
],
"negative": [
@@ -1195,7 +1196,7 @@
"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"
],
"negative": [
"A read-only user gets no phone Actions trigger (asserted in REC-046)"
"A read-only user's phone Actions sheet offers no write action (asserted in REC-046)"
],
"issues": [
"QRun-IO/qqq#708"
+2 -1
View File
@@ -437,7 +437,8 @@
],
"fixture": "tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accWidgetHostJoinChild (maxRows 2)",
"scenarios": [
"rows link to child records, Showing 2 of 3, View All filter link"
"rows link to child records, Showing 2 of 3, View All filter link",
"the key column is behind the more-columns toggle while the name identifies the row"
],
"negative": [
"host with no children shows empty state"
@@ -8,6 +8,7 @@
import type { Locator, Page } from '@playwright/test'
import { expect, open, test } from '../../support/fixtures'
import { expectNoHorizontalScroll, expectTouchReady, expectTouchTargets, undersizedTargets } from '../../support/touch'
import { waitForSampleDashboard } from './sample-dashboard-navigation'
import { tabKey } from '../security/support/ui'
import { appNavigation, expectBreadcrumbs, recordCollection, waitForShell } from './nav-helpers'
@@ -39,6 +40,7 @@ test.describe('mobile navigation', () => {
if (!navBox || !titleBox) return 0
return Math.min(navBox.x + navBox.width, titleBox.x + titleBox.width) - Math.max(navBox.x, titleBox.x)
}).toBeGreaterThan(100)
await waitForSampleDashboard(page)
await open(page, '/app')
await waitForShell(page)
const drawer = page.locator('[data-qqq-id="sidebar-mobile-drawer"]')
@@ -0,0 +1,15 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import type { Page } from '@playwright/test'
import { expect } from '../../support/fixtures'
import { sampleDashboardReady } from './sample-dashboard-readiness'
/** Wait for this fixed fixture's actual request and renderer outputs before leaving it. */
export async function waitForSampleDashboard(page: Page) {
await expect.poll(() => page.evaluate(sampleDashboardReady), { message: 'Sample dashboard widgets and renderers complete before departure' }).toBe(true)
}
@@ -0,0 +1,26 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/** Fixed sample dashboard dependency boundary for NAV026. Runs in the document. */
export function sampleDashboardReady(): boolean {
const home = document.querySelector('[data-qqq-id="app-home-SampleWidgetsDashboard"]')
if (!home || home.querySelectorAll('[data-qqq-id^="widget-grid-item-"]').length !== 11) return false
if (home.querySelector('[role="alert"], [aria-busy="true"], [aria-label="Loading widget"], [data-qqq-id^="widget-error-"], [data-qqq-id^="widget-no-permission-"], [data-qqq-id^="widget-permission-denied-"], [data-qqq-id^="widget-needs-selection-"]')) return false
const outputs: Array<[string, string]> = [
['BigNumberBlocks', '[data-block-type="BIG_NUMBER"]'],
['MultiStatistics', '[data-qqq-id="multi-statistics-group-SampleMultiStatisticsWidget-0"]'],
['Statistics', '[data-qqq-id="statistics-count-SampleStatisticsWidget"]'],
['Table', 'tbody tr td'],
['Stepper', '[data-qqq-id="stepper-step-SampleStepperWidget-0"]'],
['HTML', '[data-qqq-id="html-widget-SampleHTMLWidget"]'],
...['PieChart', 'StackedBarChart', 'SmallLineChart', 'LineChart', 'BarChart'].map(name => [name, `[data-qqq-id="chart-data-Sample${name}Widget"] tbody tr td`] as [string, string]),
]
return outputs.every(([name, selector]) => {
const card = home.querySelector(`[data-qqq-id="widget-Sample${name}Widget"][aria-busy="false"]`)
return Boolean(card?.querySelector(selector)?.textContent?.trim())
})
}
@@ -120,8 +120,14 @@ test.describe('Processes added to every screen, for a user whose metadata omits
await open(page, '/app/qryBin/2')
await expect(page.getByRole('heading', { level: 1, name: 'Nuts' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Delete Storage Bin record' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Record actions menu' })).toHaveCount(0)
// the default Actions menu still renders (Delete, Developer Mode, Audit), without the omitted process (#45)
await page.getByRole('button', { name: 'Record actions menu' }).click()
const recordItems = page.locator('[data-qqq-id="record-view-actions-menu"]').getByRole('menuitem')
await expect(recordItems.filter({ hasText: 'Delete' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Developer Mode' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Audit' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Tag Records' })).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(page.getByRole('button', { name: 'Tag Records', exact: true })).toHaveCount(0)
await open(page, `/app/${TAG}?recordsParam=recordIds&recordIds=1&tableName=person`)
await expect(page.locator('[data-qqq-id="not-found-state"]', { hasText: TAG })).toBeVisible()
@@ -153,7 +159,12 @@ test.describe('Processes added to every screen, without process permission', ()
await open(page, '/app/qryBin/2')
await expect(page.getByRole('heading', { level: 1, name: 'Nuts' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Edit Storage Bin record' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Record actions menu' })).toHaveCount(0)
await page.getByRole('button', { name: 'Record actions menu' }).click()
const recordItems = page.locator('[data-qqq-id="record-view-actions-menu"]').getByRole('menuitem')
await expect(recordItems.filter({ hasText: 'Developer Mode' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Audit' })).toHaveCount(1)
await expect(recordItems.filter({ hasText: 'Tag Records' })).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(page.getByRole('button', { name: 'Tag Records', exact: true })).toHaveCount(0)
// the v1 route the UI uses refuses it, as does the unversioned one
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more