Compare commits

...
Author SHA1 Message Date
james.maes c7fe584911 Merge pull request #90 from QRun-IO/feature/next-rc17-prep
chore(#712): prepare Next UI 1.0.0-RC.17
2026-10-13 12:19:16 -05:00
james.maesandClaude Opus 5.5 05479bfcdf chore(#712): prepare Next UI 1.0.0-RC.17
Source is main at 6ed2575 (merge of #89, notification center). Total JS
1098.0/1100 KB and /app first load 349.2/350 KB are within 2 KB of their
caps; the next feature needs a measured raise or a code-split pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:13:36 -05:00
james.maes 6ed257565a Merge pull request #89 from QRun-IO/feature/notification-center
feat(alerts): notification center - badge, popover, page and banners
2026-10-13 12:11:23 -05:00
james.maesandClaude Opus 5.5 9169112f18 fix(alerts): refuse open-redirect notification links
linkKind treated `/\evil.com` as an in-app path, and browsers normalize
`\` to `/`. Refuse any href with a backslash, whitespace or a control
character, or whose second character is `/` or `\`; parse it against the
page origin and treat it as in-app only when the origin matches and it
starts with a single `/`; external only for https:. Everything else is
plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:15:06 -05:00
james.maesandClaude Opus 5.5 03d7bae9e4 feat(alerts): notification center - badge, popover, page and banners
Turn the Alerts bell into the notification center (qbit-alerts
NOTIFICATIONS.md section 8, phase b):

- Badge: unread count (99+), red for an unread CRITICAL, labelled
  "Notifications, N unread". Polls the summary every pollSeconds, backs
  off exponentially to 5 minutes after errors, pauses while the tab is
  hidden and refetches when it shows. Still no bell and no request unless
  the metadata lists the alerts app.
- Popover (lazy): severity icon, title, source, relative time, link
  (marks read), per-item mark read/unread and dismiss, Mark all read and
  View all. Focus-trapped; Escape closes.
- /app/notifications (lazy, fixed route like /app/search): All/Unread,
  category and severity filters and paging in the URL, markdown bodies
  through MarkdownView (no HTML; relative app links now allowed), same
  actions.
- Banners: the summary's sticky items above the page until dismissed.
- Actions are optimistic on the summary and every loaded page, rolled
  back with a toast on failure, then refetched.
- Older backends (0.1.0-RC.1: alertId, group, no id) still render, with
  per-item actions hidden and read-by-alertId on open.

Bundle: /app routes +0.7 KB first load (/app/_/_ 349.2 of 350 KB); the
three lazy chunks (15.2 KB) get a notificationCenter lazyChunks budget;
total JS 1098.0 KB of 1100; CSS unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:13:12 -05:00
james.maes ef7f1c5714 Merge pull request #88 from QRun-IO/feature/next-rc16-prep
chore(#712): prepare Next UI 1.0.0-RC.16
2026-10-13 06:20:09 -05:00
james.maesandClaude Opus 5.5 42ddc06864 chore(#712): prepare Next UI 1.0.0-RC.16
Source is main at 6dea1c3 (merge of #80). Merged since RC15: #79, #84,
#86, #87, #85, #82, #81 and #80. The record picker, validation limits
and date presets need QQQ 4.1.0-RC.5; the Alerts bell needs qbit-alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:09:38 -05:00
james.maes 6dea1c3a86 Merge pull request #80 from QRun-IO/feature/record-picker
feat(forms): record picker for possible-value fields over large tables (thumbnails, paged dialog)
2026-10-13 06:01:41 -05:00
james.maesandClaude Sonnet 5.5 ebc734545e Merge main (#85, #82, #81) into feature/record-picker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 01:06:13 -05:00
james.maes 67e817adfa Merge pull request #81 from QRun-IO/feature/form-validation-ux
feat(forms): quiet, metadata-driven validation on record create and edit
2026-10-13 01:06:10 -05:00
james.maesandClaude Sonnet 5.5 47dcd6ff50 Merge main (#85, #82) into feature/form-validation-ux
Keeps this branch's zod-from-metadata.ts: it carries the same backend-worded range
messages as #87, plus the label-aware required, number and too-long messages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 00:04:02 -05:00
james.maes 7720ce2fbe Merge pull request #82 from QRun-IO/feature/date-filters
feat(query): date-first filters for DATE and DATE_TIME fields (dates, presets, Include time)
2026-10-13 00:03:08 -05:00
james.maes e7ab2f8f9b Merge pull request #85 from QRun-IO/feature/alerts-bell-panel
feat(alerts): header bell and record "Open alerts" panel for the Alerts qbit
2026-10-12 23:04:44 -05:00
james.maesandClaude Sonnet 5.5 bc79580464 Merge main (#85) into feature/date-filters
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 23:04:44 -05:00
james.maesandClaude Sonnet 5.5 008aa8daf8 perf(forms): one lazy import site for the process runner and the file preview lightbox
The record picker dialog lazy-imports ProcessRun (upload) and FilePreviewLightbox, which
other screens already lazy-import. Each import site made the bundler emit its own variants of
those chunk groups: stubbing the two in the dialog saved 12 KB, and sharing one import site
for each saves 25.2 KB of total JS (1099.7 KB to 1074.5 KB on this branch, same tree).
The picker dialog itself stays a dynamic import, budgeted under lazyChunks.recordPicker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:18:00 -05:00
james.maesandClaude Sonnet 5.5 22648a5c79 Merge main (#87) into feature/record-picker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:04:01 -05:00
james.maesandClaude Sonnet 5.5 78edbdf4c3 Merge main (#87) into feature/alerts-bell-panel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:03:45 -05:00
james.maes 4d673edb5a Merge pull request #87 from QRun-IO/fix/range-error-backend-wording
fix(forms): word client-side range errors as the backend does
2026-10-12 22:03:28 -05:00
james.maesandClaude Sonnet 5.5 a3ad88bc6f Merge the range-error wording fix (#87) into feature/date-filters
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:49:21 -05:00
james.maesandClaude Sonnet 5.5 12b2eee0a3 fix(query): keep the quick filter operator name, and walk the specs through the date-first flow
The date quick filter's comparison select lost its "Operator for <Field>" accessible name
(it was "Filter operator"); it keeps the name the quick filters always had.

The advanced rows keep "Filter operator", but date fields now offer whole-day comparisons
("is on", "is not on") first. QRY-012, QRY-013, QRY-080 and QRY-086 follow the new flow:
"is on"/"is not on" for the equality cases, "is relative to today" (dates) or "Include time"
(date-times) to reach exact values and relative expressions, and a new date quick filter starting as
"is between". Their assertions on the rows returned are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:49:08 -05:00
james.maesandClaude Sonnet 5.5 6bc5ea875f fix(forms): backend wording for too-long values, and focus the schedule editor on a failed save
An over-long value on a field with the ERROR policy is now stopped at the form with the
backend's own sentence ("The value for <Label> is too long (max allowed length=N)").
REC-024 asserts the typing cap and sends the over-long value straight to the API to
keep covering the backend refusal.

A failed save moved focus only to fields the form renders itself, so the schedule editor
(WID-064) lost the focus it had on main; fall back to the field's own ref.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:35:18 -05:00
james.maesandClaude Sonnet 5.5 9a5acd9e9e fix(forms): word client-side range errors as the backend does
QQQ #1100 now publishes inclusive ERROR value ranges in field metadata, so the
form refuses an out-of-range number before any request and the backend
message never appeared (REC-026, REC-048, INT-007 fail on main). The client
message now reads exactly as the backend's ("The value for <Label> is too large
(maximum allowed value is N)"), so users see one text either way.

Those specs sent Bounded Value 150, which the form now blocks first. They assert
the client message for that, and exercise the real backend rejection through
Exclusive Bounded Value 0, a bound the metadata does not publish.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:32:36 -05:00
james.maesandClaude Sonnet 5.5 363f68eb40 fix(forms): keep the standard "<Field> is required" and backend range wording
Quiet validation had shortened the messages to "Required", "Enter a whole number" and
"Must be between a and b", which broke the acceptance expectations for the standard
sentences. Validation stays quiet (checked on blur or save, one line under the field),
but the text is the standard one again: "<Label> is required", "<Label> must be a
whole number", and range errors worded exactly as the backend words them ("The value
for <Label> is too large (maximum allowed value is N)"). Server messages mapped to a
field keep the backend's range sentence.

Read-only fields now render as plain values (a quieter screen), so REC-010 asserts the
value and the absence of an input instead of a disabled input. REC-026, REC-048 and
INT-007 sent Bounded Value 150, which QQQ #1100's published range makes the form refuse
first; they assert the client message for that and exercise the backend rejection
through Exclusive Bounded Value 0, a bound the metadata does not publish.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:49:50 -05:00
james.maesandClaude Sonnet 5.5 1187660904 fix(forms): word client-side range errors as the backend does
QQQ #1100 now publishes inclusive ERROR value ranges in field metadata, so the
form refuses an out-of-range number before any request and the backend
message never appeared. The client message now reads exactly as the
backend's ("The value for <Label> is too large (maximum allowed value is N)"),
so users see one text either way.

The REC-026, REC-048 and INT-007 acceptance tests asserted the backend
rejection by sending Bounded Value 150, which the form now blocks first. They
assert the client message for that, and exercise the real backend rejection
through Exclusive Bounded Value 0, a bound the metadata does not publish.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:49:30 -05:00
james.maesandClaude Opus 5.5 92bcefddb8 fix(alerts): gate the bell and record panel on the Alerts app in metadata
Merge origin/main into feature/alerts-bell-panel, then stop the bell and
the record "Open alerts" panel from probing /alerts/inbox/summary on
instances without qbit-alerts. That probe 404'd on every page against the
QQQ sample app and failed 599 real-backend acceptance tests on the
console-error guard.

Like the Assistant, the gate is now instance metadata: isAlertsInstalled
checks for the qbit's "alerts" app, which QQQ publishes only when the
qbit is installed and the user holds alerts.view (the permission its
/alerts/* routes require). qbit-alerts publishes no supplemental metadata
or frontend setting, so its app is the cleanest signal. Without it the
bell and panel render nothing and send no request or poll. The popover
and record panel stay lazy-loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:30:42 -05:00
james.maes be6ea9a723 Merge pull request #84 from QRun-IO/feature/search-preview-thumbnails
feat(search): file thumbnails, chips and dates in the record preview
2026-10-12 19:23:24 -05:00
james.maesandClaude Opus 5.5 6e7c908c06 Merge branch 'main' into feature/form-validation-ux
Takes main's budget caps (CSS 24 KB, total JS 1100 KB, owner decision
2026-10-12) instead of this branch's 1060 KB. Merged tree: CSS 23.2 KB,
total JS 1050.4 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:38:30 -05:00
james.maes ae47adc03b Merge branch 'main' into feature/date-filters 2026-10-12 18:37:24 -05:00
james.maes 15a7e43e6f Merge branch 'main' into feature/record-picker 2026-10-12 18:37:21 -05:00
james.maesandClaude Opus 5.5 8d02617aad Merge origin/main (#86 CSS budget 24 KB) into feature/search-preview-thumbnails
perf-budget.json keeps main's #79/#86 CSS note (cap 24 KB) and this
branch's preview measurement note. After the merge: total JS 1053.0 KB
(cap 1100), CSS 23.1 KB (cap 24, identical to main), preview chunk 7.1 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:17:30 -05:00
james.maes 54a67f69ef Merge branch 'main' into feature/alerts-bell-panel 2026-10-12 18:12:42 -05:00
james.maes 8ea0c89312 Merge pull request #86 from QRun-IO/feature/css-budget-24
chore(perf): CSS budget 24 KB after the resizable navigation
2026-10-12 18:12:11 -05:00
james.maesandClaude Opus 5.5 ed079d632b chore(perf): CSS budget 24 KB after the resizable navigation
Main measures 23.07 KB of shared CSS after #79, over the 23 KB cap, and
the RC.16 candidates each add a little. Raised under the owner's budget
decision of 2026-10-12, with the measurement in the comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:05:45 -05:00
james.maesandClaude Opus 5.5 ebdd4c2289 Merge origin/main (#79 resizable nav, RC.15 prep) into feature/search-preview-thumbnails
perf-budget.json: main's owner-decision note (total cap 1100 KB) and this
branch's measurement note are both kept. Measured on the static export
after the merge: total JS 1053.0 KB against main f11cc85's 1043.7 KB
(+9.3 KB, as before); route first loads and CSS identical to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:00:00 -05:00
james.maesandClaude Opus 5.5 64cd1e6dc9 feat(alerts): header bell and record "Open alerts" panel for the Alerts qbit
- AlertsBell replaces the placeholder bell: polls /alerts/inbox/summary
  every 60 s while the tab is visible, renders nothing when Alerts is not
  available (403/404/non-summary), badge red for unread CRITICAL, polite
  live announcement; the popover (lazy) lists the latest items, marks read
  on open, Mark all read, Open Alerts, focus trap and Escape
- AlertsRecordPanelSlot on the record view: only when the probe succeeds,
  never on alerts_* tables; the lazy AlertsRecordPanel shows open alerts
  (Acknowledge / Snooze links for managers), resolved in 30 days, View all
  and Watch alerts for this record; hidden when there is nothing to show
- inline styles for the severity dots and popover placement so no new CSS
  utilities ship (shared CSS stays under the 23 KB cap)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:15:10 -05:00
james.maes f11cc85685 Merge pull request #79 from QRun-IO/feature/resizable-nav
feat(layout): resizable desktop navigation (drag, keyboard, persisted width)
2026-10-12 17:03:42 -05:00
james.maes 7398f1aa01 Merge pull request #83 from QRun-IO/feature/next-rc15-prep
chore(#712): prepare Next UI 1.0.0-RC.15
2026-10-12 17:01:35 -05:00
james.maesandClaude Opus 5.5 061a7cd09b feat(search): file thumbnails, chips and dates in the record preview
The search record preview shows FILE_PREVIEW thumbnails, CHIP values and
compact dates again, as the record view does. It stays a lazy chunk and
keeps its own queries, so no route's first load grows.

Measured on the static export against main 5de69be (total JS 1042.7 KB):
total JS 1052.0 KB (+9.3 KB, the shared file-preview, gallery, adornment
and date modules re-bundled into more on-demand screen chunks), the
preview chunk 7.1 KB (was 3.7), every route first load and the CSS
(23.0 KB) unchanged. The owner approved a 1100 KB total JS cap; the
first-load and CSS caps stay. perf-budget.json records the numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:54:34 -05:00
james.maesandClaude Opus 5.5 596815c605 chore(#712): prepare Next UI 1.0.0-RC.15
Source is main at 5de69be (merge of #59). Merged since RC14: #58, #55,
#54, #77, #78, #61, #60 and #59. The total JS budget is 1100 KB (owner
decision 2026-10-12); route, CSS and lazy-chunk caps are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:49:32 -05:00
james.maes 5de69be7e3 Merge pull request #59 from QRun-IO/feature/search-ux
feat(search): search-as-you-type quick search, richer results and a URL-addressable search page
2026-10-12 16:48:24 -05:00
james.maesandClaude Opus 5.5 8880ec7974 fix(query): keep exact date values where a filter value may be a variable
A report setup lets a criterion value be a filter variable, which a whole-day range (its end is
the next day's start) cannot take, so those rows keep the exact-value inputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:08:17 -05:00
james.maesandClaude Opus 5.5 df59430694 feat(query): date-first filters for DATE and DATE_TIME fields
A DATE_TIME field is now filtered by picking dates: "is on", "is between" (whole days,
inclusive), "is before/after" and "is within" a relative range (Today, Last 7 days, This month,
Last quarter, Last N days/weeks/months, ...). Dates become exact bounds in the user's time zone:
>= the local midnight that starts the first day and < the local midnight that starts the day after
the last, so there is no 23:59:59 edge and a 23 or 25 hour day around daylight saving is covered.
Relative ranges are stored as StartOfPeriod expressions (QRun-IO/qqq), so a saved view stays
relative. An unobtrusive "Include time" switch gives exact date-time pickers; older saved views
with plain datetime bounds still work and open with it on. DATE fields get the same comparisons
and presets, without the switch.

The filter builder shows a day or range as one condition, quick filter chips say it in plain
language (Created: Oct 1 - Oct 12, 2026; Created: Last 7 days), a tooltip names the time zone and
week start, and the API client sends X-QQQ-UserTimezone so the backend evaluates relative dates in
the zone the dates are shown in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:05:29 -05:00
james.maesandClaude Opus 5.5 4644b4023d chore(budget): raise the total JS cap to 1060 KB for form validation
Measured +6.6 KB gzip across all chunks (1042.7 to 1049.3 KB) with +0.1 KB on
the create and edit routes and +0.1 KB CSS; recorded in perf-budget.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:17:58 -05:00
james.maes 9842bc6554 Merge remote-tracking branch 'origin/main' into feature/form-validation-ux 2026-10-12 15:13:47 -05:00
james.maesandClaude Opus 5.5 f23e03e417 feat(forms): quiet, metadata-driven validation on record create and edit screens
Fields are checked when the user leaves them (or on save), then live while they
show an error. A refused too-long value (ValueTooLongBehavior ERROR) caps typing;
a counter appears only in the last 20% of a limit; a trimming field notes extra
characters. Whole-number and decimal inputs refuse non-numeric keys; unreadable
dates and numbers become field errors. Messages are plain (Required, Enter a
whole number, Must be 200 characters or fewer (currently 214)). Save moves focus
to the first invalid field and shows a compact summary only when others are out
of sight. The backend's record errors are matched to fields by label and shown
beside them, keeping the user's input. Read-only fields show as values. Error
lines open smoothly, are announced politely and link to the control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:13:46 -05:00
james.maesandClaude Opus 5.5 3508c505f5 Merge origin/main (#60 assistant, #61, #78) into feature/search-ux; preview fits the budget
Conflicts: CommandMenu keeps #60's Ask Assistant entry (last, and it
suppresses "No results found") and Cmd+J alongside the search group and
record groups; perf-budget.json keeps main's cssGzipKB 23, its assistant
lazy chunk and $comments, and adds the search lazy chunks.

Budget, measured on the static export against main b32a29d (total JS
1042.7 KB, CSS 22.6 KB): the merged tree measured 1065.5 KB. Bisecting
showed the record preview's imports re-bundled into more screen chunks:
without the preview 1042.5; with the useRecord/useTableMetaData hooks +12.4;
with file thumbnails and chips +9.3. The preview now runs its own two
queries (same query keys, so caches are shared) and renders values as
text, Yes/No and links; thumbnails and chips stay on the record view.

Result: total JS 1042.7 KB (budget 1050, 7.3 KB headroom, equal to main),
CSS 23.0 KB (cap 23, main 22.6); no cap raised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:12:13 -05:00
james.maes b32a29d25b Merge pull request #60 from QRun-IO/feature/assistant-panel
feat(assistant): QQQ Assistant panel, phase 1 (read-only Q&A and navigation)
2026-10-12 14:48:48 -05:00
james.maesandClaude Opus 5.5 fec62ec611 feat(forms): record picker for possible-value fields over large tables
A field whose possible value source declares a picker (field.picker, from
QQQ's QPossibleValuePicker) gets an image-aware selection and a picker
dialog built for 100,000+ records.

Inline field (PossibleValueSelect): the typeahead asks for a limited page
(20) of picker options and shows their thumbnails and subtitles. The
selection shows its thumbnail with Change/Browse, Preview (the full-size
lightbox) and View (the record, in a new tab).

Picker dialog (RecordPickerDialog, loaded by import() on Browse/Change):
- debounced server-side search
- filters generated from the picker table's field types
- the declared sorts
- grid and list views with lazy thumbnails
- infinite scroll with a Load more fallback, keyset paging (afterId) for
  the primary-key sort and offset paging otherwise, never a count
- a preview pane with detail fields
- upload-and-select through the picker's upload process (ProcessRun
  gains an onComplete callback)
- single or multiple selection
- filters, sort and view remembered per field in local storage
- arrow-key navigation by row, Enter or double-click to confirm, focus
  trap and Escape (Radix)
- full screen on phones, light and dark tokens only

Bundle: nothing added to any route's first load or to the shared CSS.
Total JS grows 2.5 KB (main 1005.4 KB, branch 1007.9 KB). The dialog is
budgeted as the recordPicker lazy chunk: Turbopack emits one variant per
parent context, 17.0 KB across three, and a session loads one of about
5.9 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:47:57 -05:00
james.maesandClaude Opus 5.5 75c9c5d64d feat(layout): resizable desktop navigation
The left navigation gets a drag handle on its right edge, like a normal
resizable vertical panel:

- Drag (pointer capture, mouse, pen and touch) sets the width; it is
  written to a CSS custom property once per animation frame, so the
  content reflows without a React render per pixel.
- Double-click resets to the default (the theme's --qqq-sidebar-width,
  256 px).
- The handle is a focusable vertical separator with aria-valuenow/min/
  max: Left/Right step 16 px, Shift 64 px, Home/End jump to min/max,
  with a visible focus ring and a grip on hover/focus.
- Width is held to 200 px .. min(480 px, 40% of the viewport), in CSS
  too, so a window resize needs no script.
- The chosen width persists in localStorage (wrapped in try/catch) and
  is applied in a layout effect, before the first paint.
- The phone drawer is unchanged and has no handle.

Bundle: first-load JS +1.0 KB gzip on the app routes, total JS 1005.4
-> 1006.4 KB (cap 1050), shared CSS 21.5 KB unchanged (cap 22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:41:54 -05:00
james.maes 98409c85af Merge pull request #61 from QRun-IO/feature/record-developer-view
feat(developer): record developer view with record navigation, API access and provider panels
2026-10-12 14:38:32 -05:00
james.maesandClaude Opus 5.5 ddc66c6b63 perf(search): load the results page on demand; lighter preview; lazy chunk budgets
Measured on the static export after merging main (a724fc1), against main:
total JS was 1067.0 KB (main 1005.4), over the 1050 KB cap.

- /app/search loads SearchPage through import(), so the results page is no
  longer in any route's first load (/app/search first load 334.5 KB, main
  335.2).
- The record preview renders values itself (file thumbnails, chips, Yes/No,
  dates, links, text) instead of through FieldValue, whose widget imports
  pulled two re-bundled chunks (+17.1 KB) into the preview. Secrets
  (REVEAL), widgets, code, HTML and binary fields are not previewed.
- The record search response schemas move from the shared schemas module to
  record-search.ts, their only user.
- perf-budget.json budgets the four on-demand search chunks under
  lazyChunks with their measured sizes (search page 13.6, quick search panel
  9.7, palette record groups 7.8, record preview 7.3 KB gzip; caps about 8%
  above), so they leave the total but keep their own caps and must stay out
  of every first load.
- A few one-off utility classes are swapped for ones main already ships.

Result: total JS 1012.7 KB (main +7.3), CSS 21.9 KB (main +0.4), every route
first load within 0.5 KB of main; no cap raised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:55 -05:00
james.maesandClaude Opus 5.5 2ed5239642 Merge origin/main (#54, #55, #58, #77, #78) into feature/search-ux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:54:46 -05:00
james.maesandClaude Opus 5.5 725ef87efd Merge branch 'main' into feature/assistant-panel; CSS budget 23 KB
Main now includes SQL Workbench dark mode (#78, shared CSS 21.5 KB).
With the assistant panel the static export measures 22.3 KB of shared
CSS, over the 22 KB cap, so the cap becomes 23 KB (about 3% headroom).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:46:07 -05:00
james.maes a724fc191c Merge pull request #78 from QRun-IO/feature/sql-workbench-theme
feat(sql-workbench): theme-aware workbench built from the standard controls (light and dark)
2026-10-12 13:45:08 -05:00
james.maesandClaude Opus 5.5 5b4ef76b01 fix(search): keep "nothing selected" when records arrive after ArrowUp
ArrowUp above the first option clears the selection so Enter opens the
results page. When record results arrived after that (the options
changed and the stale flag flipped), the first option was preselected
again, so Enter opened a hit instead. An explicit "nothing selected"
choice now holds until the term changes, like any other choice.

Caught by NAV-025 in WebKit acceptance, where the records answer after
the keypress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:23:14 -05:00
james.maesandClaude Opus 5.5 c1be9c61b3 Merge branch 'main' into feature/assistant-panel
Keeps the AssistantProvider wrapper around #54's positioned single
scroll shell (relative, data-qqq-id=app-shell).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:13:37 -05:00
james.maesandClaude Opus 5.5 c8b7e9b16a Merge branch 'main' into feature/record-developer-view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:10:47 -05:00
james.maes 72377ac30b Merge pull request #77 from QRun-IO/chore/dependabot-pr-limit
chore(deps): limit Dependabot to three open PRs per ecosystem
2026-10-12 12:40:58 -05:00
james.maes 4d8b93ed42 Merge pull request #54 from QRun-IO/fix/single-page-scroll-container
fix(layout): one page scroll container; contain absolute descendants
2026-10-12 12:40:54 -05:00
james.maesandClaude Opus 5.5 cf6a326bca Merge main into feature/sql-workbench-theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:23:58 -05:00
james.maesandClaude Opus 5.5 c2aa695044 feat(sql-workbench): theme-aware workbench built from the standard controls
Dark mode and the hand-rolled controls made the SQL Workbench look unlike
the rest of Next UI. Every color now comes from a theme token, so both
modes work by construction:

- Editor: CodeMirror theme from the app tokens and the shared --qqq-code-*
  palette (gutter, cursor, selection, active line, autocomplete popup);
  follows the theme toggle live without reconfiguring.
- Standard controls: buttons, selects, inputs, tabs, menus, badges and
  alerts from a new design-system module (components/ui/controls.ts; the
  script panel classes now live there and are re-exported).
- Result grid in the record grid look; Export menu (CSV, Excel, JSON)
  like the record query; lucide icons replace text glyphs.
- Charts use the theme chart palette, popover tooltips, muted axes.
- Write confirmation and notebook delete use the app dialog pattern
  (notebook delete no longer uses window.confirm).
- Layout: one column until the widget is wide enough (container query);
  cell controls in the cell header, not floating over other controls; no
  inner horizontal scroll outside the result grid; plain labels; dates
  via DateTimeText.
- Primary and danger buttons darken on hover, and the danger fill is
  darkened in dark mode, keeping white labels at 4.5:1.

Tests: e2e checks AA contrast in the workbench in light and dark mode,
the live theme switch of the editor, and no sideways scroll on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:23:44 -05:00
james.maesandClaude Opus 5.5 6c1e3a67b1 feat(search): detail chips on result cards; phones keep only Preview
Result cards show the backend's secondary field values (details) as
label/value chips instead of a run of text, so a record's key fields
read at a glance; screen readers still hear "Label: value".

On phones (below sm) the card keeps only its Preview button: New tab and
Copy link are in the preview sheet, and the snippet gets the width
instead of breaking mid-word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:13:32 -05:00
james.maesandClaude Opus 5.5 9d87b6dfee fix(developer): section bar never scrolls sideways and follows the section in view
- Phones get a "Go to" section picker; wider screens get a wrapping row of
  links. Neither scrolls sideways.
- The bar sticks to the top of the main scroll container (-top-6 offsets its
  p-6), so no page content shows between the app header and the bar.
- The highlighted section comes from the scroll position, using the same
  line a jump lands on (scroll-mt-24). At the bottom of the page the
  section last picked stays highlighted while it is in view.
- The API operation rows label their two copy buttons "Copy URL" and
  "Copy example" instead of two identical "Copy" buttons.
- e2e: the bar does not overflow at any width, phone picker, sticky bar and
  highlight, dark mode at 390 and 1440, and screenshots of the SQL
  Workbench and Assistant panels. The unit test waits up to 5 s for the
  lazy script cards, which timed out under full-suite load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:09:03 -05:00
james.maesandClaude Opus 5.5 21ba08bcd5 Merge branch 'main' into feature/assistant-panel
Resolves the perf-budget comment conflict with the widget width ladder
(#55). Both raise the shared CSS cap to 22 KB; the combined tree measures
21.3 KB locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:56:09 -05:00
james.maes 89add474cf Merge pull request #55 from QRun-IO/feature/widget-width-readability
feat(widgets): readable dashboard widths (per-widget min width, ultra-wide caps, table card layout)
2026-10-12 11:55:08 -05:00
james.maes 3f161f6fd5 Merge pull request #58 from QRun-IO/docs/adopting-in-another-app
docs: add "Adopting in another app" section to the README
2026-10-12 11:55:03 -05:00
james.maesandClaude Opus 5.5 c183249a4b chore(deps): limit Dependabot to three open PRs per ecosystem
Twelve version-update PRs each ran the hour-long real-backend acceptance
workflow and starved release PRs of runners. Minor and patch updates are
already grouped per ecosystem; cap open version-update PRs at three for
npm (was 10), GitHub Actions and Maven (default 5). Security updates are
unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:18:00 -05:00
james.maes 9ce3b81b04 Merge remote-tracking branch 'origin/feature/search-ux' into feature/search-ux 2026-10-12 10:33:37 -05:00
james.maesandClaude Opus 5.5 a62b10ae12 test(e2e): target the previewed record's heading in the sheet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:33:24 -05:00
james.maes 71bcc37c9e Merge remote-tracking branch 'origin/main' into feature/search-ux 2026-10-12 10:30:51 -05:00
james.maesandClaude Opus 5.5 a9975487f6 feat(search): record preview, term completions, relevance and Shift+Enter
Results page: a record preview beside the results on wide screens (>=1280px)
that follows hover, focus and the arrow keys (which now move between
results), and a side sheet elsewhere (Preview button, or a long press on
touch screens). The preview shows the record's first-tier fields as on the
record view (FILE_PREVIEW thumbnails included), the fields that matched,
and open / new tab / copy link / edit (when permitted). It loads on demand.
Cards show a relevance meter when the provider sent scores.

Quick search: completions of the last word from matching labels (never
preselected, so Enter still opens the top hit), and Shift+Enter opens the
results page from anywhere in the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:30:49 -05:00
james.maesandClaude Opus 5.5 95931a7c7e fix(developer): ask a backend without developer panels once; REC-054 follows the new page structure
Real-backend acceptance failed REC-054 on every browser:

- The acceptance backend (QQQ develop checked out before QRun-IO/qqq#1076
  merged) answered GET /table/{t}/{pk}/developer/panels with 404. The view
  already showed no panels, but each 404 is a failed request and a console
  error. A 404/405 now marks the endpoint unsupported for the session, so a
  QQQ before 4.1.0-RC.4 sees one such request, not one per record.
- "a table without associated scripts shows none" assumed the old page's h3
  headings and no tablist at all. Sections are h2s under the record title now
  and API Access has source tabs, so the spec checks the h2 headings, no
  Associated Scripts section, no script-card tabs, and the backend's built-in
  Record Security panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:29:05 -05:00
james.maesandClaude Opus 5.5 38207b3ef5 Merge main into fix/single-page-scroll-container
Resolve docs/releases/unreleased.md against the RC14 reset: keep only
this branch's single-scroll-container entry (the others shipped in RC13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:28:09 -05:00
james.maesandClaude Opus 5.5 1f4841bf12 Merge main into docs/adopting-in-another-app
Resolve the README status paragraph against the RC14 preparation:
RC14 is published, so the line keeps this branch's published wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:27:48 -05:00
james.maes 5629bcb3c7 Merge branch 'main' into feature/search-ux 2026-10-12 09:27:34 -05:00
james.maes 4fe5ee94f2 Merge branch 'main' into feature/widget-width-readability 2026-10-12 09:27:31 -05:00
james.maes ed1dc64330 Merge branch 'main' into feature/assistant-panel 2026-10-12 09:27:27 -05:00
james.maes ef6f0a9219 Merge branch 'main' into feature/record-developer-view 2026-10-12 09:27:24 -05:00
james.maes 3ae29246c9 Merge pull request #62 from QRun-IO/chore/repo-hygiene-trunk
chore: adopt main-only trunk model and add repo hygiene config
2026-10-12 09:27:01 -05:00
james.maes 6eac444a22 Merge pull request #63 from QRun-IO/fix/nav056-webkit-navigation-race
test(acceptance): finish table reads before deliberate document navigations (NAV-056, NAV-014)
2026-10-12 09:26:56 -05:00
james.maesandClaude Opus 5.5 46158c42cc Merge origin/main (#57 record search tolerance, RC.14 prep) into feature/search-ux
Conflicts in src/lib/api/schemas*.ts and tables*.ts resolved in favor of this
branch: record search moved to src/lib/api/record-search.ts, which keeps #57's
behavior (labels may be missing, null or empty; results without a table name or
record id are skipped with a warning) and its tests (record-search.test.ts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:15:33 -05:00
james.maes 03e7415aa2 Merge pull request #64 from QRun-IO/feature/next-rc14-prep
chore(#712): prepare Next UI 1.0.0-RC.14
2026-10-12 08:44:03 -05:00
james.maesandClaude Opus 5.5 b89d406419 chore(#712): prepare Next UI 1.0.0-RC.14
Urgent fix candidate: record search no longer fails when one result has
no label (#57), plus the Next.js 16.3.8 security update (#52). Source is
main at b699204d (merge of #57). The WebKit NAV-056 diagnostic failure,
which also fails on main, is recorded as an owner-accepted known failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:35:59 -05:00
james.maes b699204d87 Merge pull request #57 from QRun-IO/fix/record-search-tolerant-results
fix(search): one result without a label no longer fails record search
2026-10-12 08:35:12 -05:00
james.maes 51c2db74f8 Merge remote-tracking branch 'origin/main' into fix/nav056-home-app-regression 2026-10-12 07:56:51 -05:00
james.maesandClaude Opus 5.5 f34269bd38 test(acceptance): finish table reads before deliberate document navigations (NAV-056, NAV-014)
The recurring webkit/tablet NAV-056 failure ("appearance preferences explain an
application theme override") and the NAV-014/NAV-016/NAV-023 failures of the same shape are
a race in the specs, not a product regression; they predate #53 (first seen 2026-10-02,
run 36981311218).

Each spec replaced the document (reload, goto, goBack) as soon as an early assertion
passed, while the table page was about to send its initial reads (saved-view init, query,
count, or table metadata). WebKit refuses an XHR the old document sends after a navigation
has started and reports it as a page error "<url> due to access control checks." (Chromium
and Firefox report an ignorable abort). The CI trace of run 38238500908 shows those three
URLs never reached the network from the first document; the errors fire 42 ms after the
reload starts. A probe on Linux WebKit reproduces the exact message for an XHR sent at
reload start, and none for a request already in flight.

Add expectRecordsLoaded(): it waits until the table lists records, by which point the page has
sent all of its initial reads. Use it, and the existing greetInteractive response barrier,
before each hard navigation in these specs. Diagnostics stay strict: any API page error
still fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:56:50 -05:00
james.maesandClaude Opus 5.5 930804d36c chore: adopt main-only trunk model and add repo hygiene config
- Add .github/dependabot.yml: weekly grouped npm, GitHub Actions and Maven updates.
- Add .github/CODEOWNERS (* @KofTwentyTwo).
- Remove the dead CircleCI develop, release/* and hotfix/* workflows (no such branches
  exist); test_only runs on every branch except main, tag-based publish is unchanged.
- Drop the develop push trigger from ci.yml.
- Document the trunk model, required checks and tag-based releases in CONTRIBUTING.md
  and CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:55:17 -05:00
james.maesandClaude Opus 5.5 2f745d578b test(acceptance): reach the one-table view through its filter chip
"Show all" appears only when a table has more matches than the overview lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:43:39 -05:00
james.maes b50513c3b4 Merge pull request #52 from QRun-IO/dependabot/npm_and_yarn/npm_and_yarn-446c67f130
chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
2026-10-12 07:37:24 -05:00
james.maesandClaude Opus 5.5 60b110a0b1 test(acceptance): search specs follow the new quick search and results page
Records now sit in Top hits and per-table groups, the first result is
preselected (ArrowUp clears it), the dialog has a Close button in its
Tab order, Escape keeps focus in the header combobox, pages rank exact
and prefix matches first, the quick search asks for 6 per table, and the
results page shows per-table sections (8) with Show all (25).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:35:24 -05:00
james.maesandClaude Opus 5.5 148f9d653b fix(assistant): probe /assistant/config only when metadata announces the qbit
The real-backend acceptance suite fails any page with an unexplained 4xx, and the probe answered
404 on every page load of an application without qbit-assistant (also a console error in every
browser). The controller now probes only when instance metadata carries
supplementalInstanceMetaData.assistant (the spec's recommended metadata signal, published by the
qbit; the v1 allow-list in core needs the matching entry). Without it, no assistant request is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:08:58 -05:00
james.maesandClaude Opus 5.5 ace6579fc0 feat(developer): record developer view with record navigation, API access and provider panels
Rebuild /app/{table}/{id}/dev (owner request 2026-10-12):

- Record screen navigation: back to the record, the shared RecordViewHeader
  (title, copy id, Edit, Actions menu, Audit, share), no view-mode toggle
  (new showViewModeToggle prop), and a sticky section navigation.
- Fields: label, name, type with PVS/adornments/flags, the value as the record
  screen shows it and the raw value; filterable; cards on phones.
- API Access: v1 routes this screen uses and every application API exposing
  the table (operations read from its per-table OpenAPI spec), filtered by
  the user's table permissions and capabilities, with curl, fetch and Java
  examples built from the record's values (placeholder credentials only),
  plus API docs / spec links.
- Developer panels from QQQ's provider contract (QRun-IO/qqq#1076), rendered
  generically from their blocks (key/values, text, code, table, links,
  process actions; safe links only). Older backends (404) show none.
- Relations (references, associations, exposed joins), recent audits with the
  full history dialog, associated scripts, raw JSON with copy, and a
  searchable table metadata explorer (fields with PVS and adornments,
  sections, menus, supplemental metadata, raw JSON).
- The view loads on demand (first load 333.5 KB, budget 360 KB).
- SQL Workbench: ?sql= (and ?backend=) opens the SQL as a new cell, once,
  without running it - the target of developer panel links.

Tests: unit (operations/snippets, panel parsing, page sections, panels
present/absent, workbench URL SQL) and Playwright (navigation parity, panels
present/absent, API sources, 360-3200px without sideways scrolling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:45:09 -05:00
james.maesandClaude Opus 5.5 66edf51719 fix(search): no stale or hidden picks on Enter; keep text typed past a late URL update
Review follow-ups: Enter on a closed header dropdown searches everything
instead of opening a hidden option; while results belong to the previous
term only a current entry is preselected; a new term drops a pointer or
arrow choice made for the old one; the search page ignores the echo of
its own debounced URL update (keeping text typed meanwhile) and builds
that update from the current filters and sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:22:51 -05:00
james.maesandClaude Opus 5.5 3175b47165 feat(assistant): QQQ Assistant panel, phase 1 (read-only Q&A and navigation)
The Next UI side of qbit-assistant (spec docs/specs/2026-10-11-qqq-assistant-qbit-design.md):

- src/lib/api/assistant.ts: typed client for every /qqq/v1/assistant route; turns stream over
  fetch + an incremental SSE parser (sse-parser.ts), deduplicate by seq, resume through
  /turns/{id}/events?after=seq after a dropped connection, and cancel through POST
  /turns/{id}/cancel. The /config probe (assistant-probe.ts) follows the ESB getOrNull pattern:
  403, 404 or a non-config body hides everything.
- src/components/assistant: AssistantProvider (controller created in the dashboard layout),
  header button with unread badge, Ctrl/Cmd+J (also from text fields), a command palette
  "Ask Assistant" item, and the lazily loaded panel: docked and resizable (360-640 px) from
  1024 px with its own scrolling message list, a full-height Radix sheet below that following
  the visual viewport. Conversation rail (listbox), rename/pin/archive/delete, context chip,
  Stop, throttled live announcements, tool steps, record chips with hover previews, records,
  record, count, history and chart widget renders, feedback thumbs.
- Markdown: react-markdown + rehype-sanitize, raw HTML skipped, images never load, links only
  to same-origin /app paths (others plain text with a copy button).
- ?assistant=open&assistantPrompt=... opens a new conversation with the prompt pre-filled
  (not sent) and removes both parameters.

Bundle: the shell adds 1.7 KB first-load JS (/app/_ 335.5 -> 337.2 KB); the panel is a
50.8 KB lazy chunk budgeted under lazyChunks.assistant (55 KB). Its Tailwind utilities add
0.9 KB to the shared CSS (21.3 KB against the 21 KB cap), so the CSS cap is raised to 22 KB.

Tests: vitest for the SSE parser, client (resume, cancel, errors), link safety, reducer and
the panel with MSW streams (gating, streaming, cancel, markdown safety, deep link, axe);
Playwright tests/e2e/assistant.spec.ts at desktop, tablet and phone widths (docked vs sheet,
one scroll container per region, no horizontal page scroll, axe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:15:49 -05:00
james.maesandClaude Opus 5.5 fabbad15a7 feat(search): search-as-you-type quick search and a URL-addressable results page
Header search, the "/" dialog and the Cmd+K palette now search records as
you type: debounced, superseded requests aborted, grouped by table with
counts and icons, exact/prefix matches promoted to "Top hits", matched
terms highlighted (backend ranges when the extended contract sends them,
else local), matched-field snippets, secondary values and last update.
Keyboard: arrows, Enter (first result preselected), Ctrl/Cmd+Enter opens a
new tab. Recent searches and recently viewed records show before typing.

Failures are honest: the failure class (server error with HTTP status,
timeout, unreachable, permission, unsupported, rejected, invalid response)
with the backend message and a retry, keeping page and recent matches; a
failed search provider is reported above its fallback results.

/app/search is rebuilt around URL state (q, tables, view=pages,
sort=recent): filter chips with counts, per-table sections with "Show
all", 25-per-page paging while the backend reports more, result cards
with snippets and quick actions (open, new tab, copy link).

All extended response fields are optional, so older QQQ backends keep
working; malformed results are skipped instead of failing the search.
The results panels load on demand: first load grows ~1.0 KB per route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:09:42 -05:00
james.maesandClaude Opus 5.5 baf7e0a554 docs: link the QQQ adoption checklist from the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:47:01 -05:00
james.maesandClaude Opus 5.5 85da466255 docs: add "Adopting in another app" section to the README
Document how a QQQ backend app switches to or upgrades the Next
dashboard: Maven coordinates and versions, minimum QQQ per feature
(SQL Workbench, FILE_PREVIEW, home app, responsive layouts), the
dashboard selection switches, auth redirect URIs, route changes and
known limitations. Also mark RC13 as published, point at .env.mock
(there is no .env.example) and correct the Next.js major in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:27:18 -05:00
james.maesandClaude Opus 5.5 a7acea381a fix(search): one result without a label no longer fails record search
QQQ serializes with NON_EMPTY, so a record whose label fields are empty
comes back from POST /search without recordLabel. RecordSearchResponseSchema
required recordLabel on every result, so a single such result rejected the
whole response ("Invalid record search response") and global search showed
"Record search failed. Try again." with no results (prod admin.kof22.com,
term "maes": two page_section rows with no name).

- Results are validated one by one. tableLabel/recordLabel may be missing,
  null or empty; recordId may be a number.
- A result without a label is labeled "{table label} {record id}".
- A result without a table name or record id is skipped with a console
  warning; the rest still show. A non-envelope response still throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:25:55 -05:00
dependabot[bot] 4d2b9f685c chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [next](https://github.com/vercel/next.js) and [sharp](https://github.com/lovell/sharp).


Updates `next` from 16.3.6 to 16.3.8
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.6...v16.3.8)

Updates `sharp` from 0.35.4 to 0.35.5
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.4...v0.35.5)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.8
  dependency-type: direct:production
- dependency-name: sharp
  dependency-version: 0.35.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 07:54:25 +00:00
james.maes 36382059bc Merge pull request #56 from QRun-IO/feature/next-rc13-prep
chore(#712): prepare Next UI 1.0.0-RC.13
2026-10-12 02:51:25 -05:00
james.maesandClaude Opus 5.5 51e00b4195 chore(#712): prepare Next UI 1.0.0-RC.13
Bump package.json and the Maven revision to 1.0.0-RC.13, point the README,
guides and 1.0 testing page at RC13, add docs/releases/1.0.0-RC.13.md and
reset unreleased.md.

RC13 source: main 84c95e5 (merge of PR53). Merged since RC12: PR47 (SQL
Workbench), PR49 (FILE_PREVIEW thumbnails and lightbox), PR50 (responsive
layouts, readable child lists, compact dates), PR53 (instance home app).
PR54 and PR55 follow in RC14.

Backend: QQQ 4.1.0-RC.1 or later; 4.1.0-RC.2+ for standalone branding
images; 4.1.0-RC.3+ for FILE_PREVIEW thumbnails and the instance home app;
qbit-sql-workbench 1.0.0-RC.1+ for the SQL Workbench page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:50:47 -05:00
james.maes 84c95e524a Merge pull request #53 from QRun-IO/feature/home-app
feat(navigation): instance home app at /app, sidebar logo and breadcrumb root
2026-10-11 22:55:31 -05:00
james.maes 06d5aacd91 Merge branch 'main' into feature/widget-width-readability 2026-10-11 21:37:58 -05:00
james.maesandClaude Opus 5.5 0449ff315a Merge main (SQL Workbench, FILE_PREVIEW, responsive layouts) into fix/single-page-scroll-container
Resolves docs/releases/unreleased.md by keeping every Product fixes entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:37:39 -05:00
james.maesandClaude Opus 5.5 577bd3dee8 Merge main (responsive layouts #50) into feature/home-app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:37:05 -05:00
james.maes 4586f5b4f2 Merge pull request #50 from QRun-IO/feature/responsive-readable-layout
feat: responsive record/dashboard layouts, readable child lists, compact dates
2026-10-11 21:36:59 -05:00
james.maesandClaude Opus 5.5 effee583b7 test(e2e): card-layout headers are clipped column headers, not hidden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:26:55 -05:00
james.maesandClaude Opus 5.5 1f1c5f546a fix(widgets): keep card-layout table headers for screen readers; layout-aware acceptance
- Card layout hides the table header visually (clip) instead of display:none, so
  column headers stay in the accessibility tree.
- WID-019 / WID-068 acceptance: on phones and tablets the table widgets now use
  the card layout, so assert no sideways scroll and per-value labels there, and
  keep the sticky-header, fr-width and header-help checks for table layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:26:21 -05:00
james.maesandClaude Opus 5.5 a42887f6e5 chore(perf): CSS budget 22 KB for the widget width ladder and table cards
CI measured shared CSS at 21.1 KB gzip (PR #50: 20.4 KB): the gridMinWidth
container-query classes and the table-widget card layout add 0.7 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:08:02 -05:00
james.maesandClaude Opus 5.5 a163dc53b1 feat(widgets): readable dashboard widths from phone to ultra-wide
- Per-widget minimum width: widgetColumnClasses honors defaultValues.gridMinWidth
  (rem or px; snapped up to 16-64rem tiers) with container-query thresholds that
  give the widget, and a neighbour of the same minimum, at least that width; and
  defaultValues.gridProfile ('widgets' | 'cards' | 'table' | 'fields') to pick a
  ladder. gridCols:sizeClass:* still wins.
- Ultra-wide: table and multi-table widget content stops at 96rem
  (defaultValues.tableMaxWidth overrides, 'none' lifts it); an app can cap the
  dashboard page with supplementalAppMetaData.dashboardLayout.maxContentWidth.
- QqqTableWidget card layout: narrower than 30rem, or than its declared column
  widths need, rows render as stacked label: value cards with no forced min
  width, fixed columns or sideways scroll.
- Unit tests (threshold table regenerated from the formula) and a Playwright
  dashboard spec at 390/768/1024/1440/1920/3200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:56:15 -05:00
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 9a905890a8 fix(layout): one page scroll container; contain absolute descendants
On long pages the document grew a second vertical scrollbar beside the
<main> scroller, and scrolling it slid the header off-screen. The shell is
a fixed-height column (h-screen overflow-hidden) where only <main> scrolls,
but nothing in it was positioned: an absolutely positioned descendant
(sr-only text such as the company footer's "opens in a new tab") then has
the viewport as its containing block, overflow-hidden cannot clip it, and
its static position at the end of a long page stretches the document.

Make the shell root and <main> `relative` so they contain such elements.
Adds a mocked e2e spec asserting exactly one vertical scroll container
(main) on a tall app home and none on the document for a table page at
390, 1024, 1440, 1920 and 3200px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:40:56 -05:00
james.maesandClaude Opus 5.5 fe0077e86c Merge responsive layouts (#50, with #47 and #49) into feature/home-app
Resolves docs/releases/unreleased.md (the home-app bullet joins the
merged Features section) and perf-budget.json (the shared 360 KB
/app/_/_/dev cap with the combined measurement note from #50).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:29:28 -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 e540b6fcbd chore(perf): give the record developer route its own 360 KB first-load cap
Measured in CI (static export + scripts/perf-budget.mjs): main is exactly at
the 350 KB default on /app/_/_/dev (350.0 KB, no headroom); this branch's home
app code makes it 350.1 KB. That route gets its own 360 KB cap; every other
route keeps the 350 KB default. The file change is identical to PR #50's, so
the two merge cleanly in either order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:11:11 -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 4e17d720f4 feat(navigation): one landing page - the home app is the single top nav entry
With homeAppName set, the home app is moved to the top of the sidebar in place
of the built-in Dashboard entry, whatever the backend's app order (kof22 labels
its home app "Dashboard"), so there is exactly one landing entry. Without
homeAppName the built-in Dashboard overview entry stays first, as before.

Test: no duplicate landing entry when homeAppName is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:13:05 -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 d1eae03894 feat(navigation): instance home app at /app, the sidebar logo and breadcrumb root
When the backend designates a home app (QInstance.homeAppName, QRun-IO/qqq#1059)
that the user may open, it is the instance's home page:

- /app client-side redirects to /app/{homeAppName} (backends such as
  Website-Backend already redirect full page loads of /app/ server-side; this
  covers client-side navigation), rendering a busy placeholder meanwhile
- the sidebar logo and the breadcrumb Home crumb link there
- the sidebar drops its separate Dashboard entry (the home app is in the tree)

Without homeAppName, or with an app missing from the user's metadata, /app is
Next's overview exactly as before. The route map exposes the result as
`homePath` (lib/utils/home-app.ts).

Tests: home-app helper, route map (home path, Dashboard entry), Breadcrumbs
and Sidebar links, /app page redirect vs overview. Docs: developer guide
routes and metadata, unreleased notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:08:27 -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.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
309 changed files with 31779 additions and 3415 deletions

No files matched your search

+4 -28
View File
@@ -48,12 +48,9 @@ jobs:
- qqq-orb/mvn_jar_deploy
# ─── Workflows ───────────────────────────────────────────────────────────────
# Branch strategy mirrors the QRun-IO orb convention:
# test_only → all feature / non-protected branches
# publish_snapshot → develop
# publish_release_candidate → release/*
# publish_release → main / version tags
# publish_hotfix_release → hotfix/*
# Trunk-based: main is the only long-lived branch (see CONTRIBUTING.md).
# test_only → every branch except main (the PR check ci/circleci: qqq-orb/node_app_test_only)
# publish_release → main builds; v* version tags publish the dashboard jar
workflows:
@@ -62,24 +59,10 @@ workflows:
- qqq-orb/node_app_test_only:
filters:
branches:
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
ignore: main
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ develop ]
publish_release_candidate:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ /release\/.*/ ]
publish_release:
jobs:
- qqq-orb/node_app_build:
@@ -95,10 +78,3 @@ workflows:
ignore: /.*/
tags:
only: /^v[0-9]+\.[0-9]+\.[0-9]+(-RC\.[0-9]+)?$/
publish_hotfix_release:
jobs:
- qqq-orb/node_app_build:
filters:
branches:
only: [ /hotfix\/.*/ ]
+2
View File
@@ -0,0 +1,2 @@
# Default reviewers for every change.
* @KofTwentyTwo
+32
View File
@@ -0,0 +1,32 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once, so their real-backend acceptance runs do not starve release PRs of runners. Security updates are enabled in the
# repository settings and are not limited by this schedule.
version: 2
updates:
- package-ecosystem: npm # pnpm-lock.yaml
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
npm-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
- package-ecosystem: maven # pom.xml (the dashboard jar)
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
+1 -1
View File
@@ -7,7 +7,7 @@ name: CI
on:
pull_request:
push:
branches: [main, develop]
branches: [main]
permissions:
contents: read
+18 -1
View File
@@ -8,7 +8,7 @@ QQQ is a metadata-driven, low-code application framework. The admin UI renders e
## Tech Stack
- **Framework:** Next.js 15 (App Router) with React 19
- **Framework:** Next.js 16 (App Router) with React 19
- **Language:** TypeScript 5.x (`strict: true` — no `any` in component props)
- **Styling:** Tailwind CSS 4.x with CSS custom properties for QQQ theme tokens
- **Components:** shadcn/ui (Radix primitives + Tailwind)
@@ -143,6 +143,13 @@ pnpm lint # ESLint
pnpm format # Prettier
```
## Branching and Releases
Trunk-based: `main` is the only long-lived branch (no `develop`, `release/*` or `hotfix/*`).
Branch from `main`, keep the branch current by merging `main` in (no rebase or force-push under
review), sign every commit, and open the PR into `main`. Releases are signed `v*` tags on `main`.
Required checks and the release flow are in [CONTRIBUTING.md](CONTRIBUTING.md).
## Implementation Plans
Full implementation plans are in `docs/implementation-plans/`. The project is built in 6 work packages:
@@ -182,3 +189,13 @@ the second-brain vault (`R:/Git.Local/KofTwentyTwo/second-brain`):
Consult the dossier before deep-diving the code — it covers the API contract this
frontend targets (including versioned-middleware endpoint gaps), maturity assessment,
known parity gaps, and its relationship to `qqq-frontend-material-dashboard`.
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+37
View File
@@ -0,0 +1,37 @@
# Contributing
## Branching: trunk-based on `main`
`main` is the only long-lived branch. There is no `develop`, `release/*` or `hotfix/*` branch.
1. Branch from the current `main` (`feature/...`, `fix/...`, `docs/...`, `chore/...`).
2. Keep the branch current by merging `main` into it; do not rebase or force-push a branch
that is under review.
3. Open a pull request into `main`. Every commit must be signed.
4. A pull request merges when the required checks pass and the code owner approves.
Head branches are deleted automatically after the merge.
### Required checks
| Check | Source |
|-------|--------|
| `Typecheck, lint, unit tests and coverage` | `.github/workflows/ci.yml` |
| `Mocked e2e (Playwright, chromium)` | `.github/workflows/ci.yml` |
| `Bundle budget (static export)` | `.github/workflows/ci.yml` |
| `Storybook build` | `.github/workflows/ci.yml` |
| `ci/circleci: qqq-orb/node_app_test_only` | `.circleci/config.yml` |
The real-backend acceptance matrix (`.github/workflows/acceptance.yml`) runs on every pull request
and on `main`. Treat a red row as a release blocker and investigate it; never re-run it until it passes.
## Releases
A release is a signed `v<version>` tag on `main` (for example `v1.0.0-RC.14`), where `package.json`
and `pom.xml` carry that version. The tag publishes the dashboard jar to Maven Central (CircleCI
`publish_dashboard_jar`) and the dashboard container image (`.github/workflows/dashboard-image.yml`).
Release notes live in `docs/releases/`.
## Dependencies
Dependabot opens grouped weekly updates for npm, GitHub Actions and Maven (`.github/dependabot.yml`),
plus security updates as they are published.
+52 -4
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.17**, which adds the notification center (bell inbox, notification list and preferences for qbit-alerts user notifications) to RC16. Confirm availability in the [RC17 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.17) before installing; see the [RC17 notes](docs/releases/1.0.0-RC.17.md) for the preparation checkpoint and verification scope. [RC16](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.16) 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,16 +14,64 @@ 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.17</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. RC17 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); and **4.1.0-RC.3 or later** for FILE_PREVIEW thumbnails (QQQ #1056) and the instance home app (QQQ #1059); **4.1.0-RC.4 or later** for record developer panels (QQQ #1076); **4.1.0-RC.5 or later** for the record picker (QQQ #1099), form validation limits (QQQ #1100) and date filter presets (QQQ #1115). The Alerts bell appears only with qbit-alerts (0.1.0-RC.1 or later); the notification center needs qbit-alerts 0.1.0-RC.2 or later. The assistant panel appears only with the qbit-assistant backend (0.1.0-RC.3 or later). The SQL Workbench needs the qbit-sql-workbench QBit (1.0.0-RC.1 or later); 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>`.
Feature coverage is recorded in the real-backend acceptance matrix in [`docs/acceptance/feature-matrix.md`](docs/acceptance/feature-matrix.md) ([QRun-IO/qqq#649](https://github.com/QRun-IO/qqq/issues/649)). Run it with `pnpm test:acceptance` (see [`tests/acceptance/README.md`](tests/acceptance/README.md)).
## Adopting in another app
How an existing QQQ backend application (for example one still on QQQ 0.x with the Material Dashboard) switches to, or upgrades, the Next dashboard. The frontend is distributed only as a Maven jar; there is no npm package (`package.json` is `private`).
### Coordinates and compatible versions
| Piece | Coordinates | Version |
|---|---|---|
| Next dashboard | `com.kingsrook.qqq:qqq-frontend-next` | `1.0.0-RC.13` (latest; [all versions](https://repo1.maven.org/maven2/com/kingsrook/qqq/qqq-frontend-next/)) |
| QQQ BOM | `com.kingsrook.qqq:qqq-bom-pom` (import scope) | `4.1.0-RC.3` (manages `qqq-frontend-next` at `1.0.0-RC.11`, so pin `1.0.0-RC.13` explicitly) |
| SQL Workbench QBit (optional) | `com.kingsrook.qbits:qbit-sql-workbench` | `1.0.0-RC.1` (built against QQQ `4.1.0-RC.2`) |
| Container image (optional, separate Node server) | `ghcr.io/qrun-io/qqq-frontend-next` | `1.0.0-RC.13` |
| Next feature | Minimum QQQ | Backend work |
|---|---|---|
| Dashboard at `/` | 4.1.0-RC.1 | Add the jar; `QApplicationJavalinServer` (`qqq-middleware-javalin`) serves it. |
| Standalone branding images | 4.1.0-RC.2 | Branding metadata ([qqq#1009](https://github.com/QRun-IO/qqq/issues/1009)). |
| FILE_PREVIEW thumbnails and lightbox | 4.1.0-RC.3 | Adorn each field with `AdornmentType.FilePreviewValues.newFieldAdornment()` ([qqq#1056](https://github.com/QRun-IO/qqq/issues/1056)). The field value, or `thumbnailUrlField` / `fullSizeUrlField`, must be an http(s) URL or a same-origin path; a BLOB field needs a `FILE_DOWNLOAD` adornment as well. Plain URL fields are never auto-detected. |
| Instance home app | 4.1.0-RC.3 | `qInstance.setHomeAppName("<app>")` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)). Users who may not open that app get Next's `/app` overview. |
| SQL Workbench | 4.1.0-RC.2 + QBit | Produce the QBit (`new SqlWorkbenchQBitProducer().withQBitConfig(...).produce(qInstance)`); it adds the `sqlWorkbench` app with the `SQL_WORKBENCH` widget, five tables and `/qqq/v1/sql-workbench/*` routes. Grant `sqlWorkbench.hasAccess` (and `sqlWorkbench.write` only for write mode) to super-users; configure `readOnlyCredentials` in production. See the [QBit README](https://github.com/QRun-IO/qbit-sql-workbench#readme). |
| Responsive layouts, readable child lists, compact dates | 4.1.0-RC.1 | None; frontend only. Child-record widgets honour `onlyIncludeFieldNames` order. |
### Switching from the Material Dashboard
1. Move the application to QQQ 4.1 (`qqq-bom-pom` `4.1.0-RC.3`). This is a major upgrade from QQQ 0.x; follow the QQQ 4.1 upgrade notes for the backend itself.
2. Add the `qqq-frontend-next` dependency above. When the jar is on the classpath, QQQ serves Next at `/` unless the Material Dashboard is requested explicitly.
3. Choose the dashboard in code with `withServeFrontendNext(true)` / `withServeFrontendMaterialDashboard(true)`, or, when neither is set in code, with `-Dqqq.javalin.frontend=next|material|none`. An app that sets `withServeFrontendMaterialDashboard(false)` and serves Material through its own route provider gets Next at `/` as soon as the jar is added; remove that custom route, or set `withServeFrontendNext(false)` to keep Material.
4. Authentication: keep the existing identity-provider client. OAUTH2 redirects to `{origin}/token` and AUTH_0 to `{origin}/`, as Material did; both use PKCE.
5. URLs change from `/<app>/<table>/<id>` (Material) to `/app/<table>/<id>` (Next). Map old bookmarks and links with `QInstance.redirects` (exact or wildcard paths).
6. Optional: set `homeAppName`, add `FILE_PREVIEW` adornments, add the SQL Workbench QBit (table above). No database migrations are needed for the dashboard itself; the SQL Workbench QBit needs its five tables (DDL in its README).
For a full per-feature checklist (backend upgrade, QBits, migrations, permissions) see the QQQ adoption checklist: https://github.com/QRun-IO/qqq/blob/develop/docs/migration/adoption-checklist.adoc
To upgrade between RCs, change only the `qqq-frontend-next` version and check the minimum QQQ version in the table above and in the [release notes](docs/releases/).
### Configuration
Served from the jar there is nothing to configure in the frontend: it calls the API on the same origin at `/qqq/v1`, and the Javalin server sends the security headers and Content-Security-Policy. External embeds (for example QuickSight frames) need an allowed CSP source, not a disabled policy. The build-time variables under [Environment Variables](#environment-variables) apply only to frontend development and the standalone container image.
### Known limitations
- Release candidates only; final 1.0 is held for application soak and owner approval ([#712](https://github.com/QRun-IO/qqq/issues/712), tracker [#713](https://github.com/QRun-IO/qqq/issues/713)).
- Served at `/` only: sub-path hosting is not supported ([#732](https://github.com/QRun-IO/qqq/issues/732)).
- Material parity is not complete ([#714](https://github.com/QRun-IO/qqq/issues/714)); check the parity ledger for the screens your app relies on.
- Real Auth0, Google Drive and QuickSight integrations are not yet verified end to end ([#713](https://github.com/QRun-IO/qqq/issues/713)).
- The container image fixes the backend origin at build time (`host.docker.internal:8000`); use the jar, or rebuild the image, for another origin.
- WebKit and tablet acceptance runs show intermittent navigation diagnostics ([#985](https://github.com/QRun-IO/qqq/issues/985), [#904](https://github.com/QRun-IO/qqq/issues/904)).
## Run the local QQQ sample
Use the [QQQ quickstart](https://github.com/QRun-IO/qqq/blob/main/qqq-sample-project/README.md#quickstart-with-next): it clones editable Java sample source, compiles it against published QQQ, and serves this dashboard from the sample on port 8000. It needs JDK 21+, Git, curl and unzip; no Node.js, Maven or Docker installation.
@@ -55,7 +103,7 @@ The dev server starts at [http://localhost:3000](http://localhost:3000).
| `QQQ_NEXT_OUTPUT` | _(unset)_ | `export` produces the static export for QQQ hosting (`pnpm build:export`) |
| `NEXT_PUBLIC_MOCK_API` | _(unset)_ | Set to `true` to activate MSW browser mocks for dev/demo without a live backend |
Copy `.env.example` to `.env.local` and adjust as needed.
Copy `.env.mock` to `.env.local` to run against MSW mocks, or set `NEXT_PUBLIC_MOCK_API=false` and point `NEXT_PUBLIC_API_BASE_URL` at a real backend.
## Available Commands
+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 RC17.** Confirm availability in the [RC17 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.17) before installing. [RC17 notes](../releases/1.0.0-RC.17.md) record the preparation checkpoint and verification scope; RC16 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 RC13 preparation checkpoint was [1.0.0-RC.12](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) at `e333223`, 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
+22 -7
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 RC17.** Confirm availability in the [RC17 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.17) before installing. [RC17 notes](../releases/1.0.0-RC.17.md) record the preparation checkpoint and verification scope; RC16 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 RC17 preparation checkpoint was [1.0.0-RC.16](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.16) (commit `ef7f1c5`); 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.
@@ -25,7 +25,7 @@ The source of truth for the frontend shape is [`src/types/metadata.ts`](../../sr
}
```
`appTree` controls the sidebar; `apps[name].widgets` controls ordered dashboard widgets; `apps[name].sections` controls the app home cards. The server may omit denied objects or send disabled permission flags; the frontend also checks flags before requesting data. The server must still enforce authorization. A widget can also be placed in a table section through `QTableSection.widgetName`, or embedded in a process step of type `WIDGET`. `QInstance.redirects` can map old exact or wildcard Material paths to new targets. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
`appTree` controls the sidebar; `apps[name].widgets` controls ordered dashboard widgets; `apps[name].sections` controls the app home cards. The server may omit denied objects or send disabled permission flags; the frontend also checks flags before requesting data. The server must still enforce authorization. A widget can also be placed in a table section through `QTableSection.widgetName`, or embedded in a process step of type `WIDGET`. `QInstance.redirects` can map old exact or wildcard Material paths to new targets. `QInstance.homeAppName` (QQQ [#1059](https://github.com/QRun-IO/qqq/issues/1059)) designates the app whose dashboard is the instance's home page; it is published only to users who may open that app. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
## 2. Install and run
@@ -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.17</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.13`, overriding an older BOM-managed frontend; confirm its availability in the [RC13 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) 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; 4.1.0-RC.3 or later for FILE_PREVIEW thumbnails and the instance home app, QQQ #1056/#1059); 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 |
|---|---|
@@ -77,7 +77,7 @@ Permissions apply to metadata and data: check table capabilities and the corresp
|---|---|
| `/` | Opens `/app`, or handles an Auth0 authorization callback. |
| `/login`, `/callback`, `/token` | Sign-in and supported authorization callback routes. |
| `/app` | Instance dashboard / app entry point. |
| `/app` | Instance home page: the `homeAppName` app's dashboard when the instance designates one the user may open (client-side redirect to `/app/{homeAppName}`; the sidebar logo and breadcrumb Home crumb link there and the sidebar has no separate Dashboard entry), otherwise Next's overview. |
| `/app/{appName}` | App home and its ordered widgets/sections. |
| `/app/{tableName}` | Query or key lookup, according to table capability. |
| `/app/{tableName}/create` | Create record. |
@@ -138,7 +138,7 @@ For a fixed choice list, put `inlinePossibleValueSource` on the QQQ field with `
The query page supports basic quick-filter chips and an advanced nested filter builder, quick search, sort, column visibility/order/width/pinning, density, pagination, CSV export, row selection, bulk process launch, column statistics, and table variants where declared. On desktop, Filter opens a panel containing the Basic/Advanced switch, sort controls, quick-filter chips, and advanced builder. On phones, Filter opens a sheet containing the same mode, sort, and quick-filter controls; Advanced shows the nested builder inside that sheet. Opening Filter preserves the selected mode. Advanced mode uses a single editable condition list with per-row removal and one confirmed Clear action in the footer. Complex criteria remain in Advanced mode; the basic chips expose metadata-selected fields and let the user add more. The Columns popup supports keyboard entry, search, reordering and focus return on close; embedded report column editors keep the surrounding editor focus. Query columns start at 150 px unless a `SIZE` adornment or a saved/resized width supplies another value. `TABLE_QUERY`, `TABLE_GET`, `TABLE_COUNT`, `TABLE_INSERT`, `TABLE_UPDATE`, `TABLE_DELETE`, `TABLE_EXPORT`, and `QUERY_STATS` gate operations alongside permissions. For tables with `usesVariants`, choose a backend-provided variant before querying; it is stored under `qqq.tableVariant.<table>` and sent to query, process and record view/edit/copy GET requests. Backend field-download URLs carry the same variant; inline BLOB data comes from the variant-specific record response. [REC-063](../acceptance/feature-matrix.md) verifies view/edit/copy against duplicate record IDs in two variants. URL parameters `page`, `pageSize`, `filter`, and `q` carry shareable query state ([`use-record-query.ts`](../../src/lib/hooks/use-record-query.ts)). Saved views have backend persistence, quick-view shortcuts and counts, modified-state comparison, and their own routes when the backend capabilities exist ([`use-saved-views.ts`](../../src/lib/hooks/use-saved-views.ts)). Browser local storage holds personal display preferences and last view state, not authoritative records or permissions.
Record pages render sections, field values, associated records, widgets, metadata-defined actions, help, and audit history. `QTableSection.alternatives.RECORD_VIEW` replaces the base section for this screen. Visible sections appear in metadata order; fields, sections and widget cards use `gridColumns` as twelfths on large screens and stack on narrow screens. When widths are not configured, Overview uses half-width cards on large screens; a selected section uses a full-width card with two field columns from the small breakpoint, stacking fields on phones. Explicit field, section, or widget widths take precedence over those presentation defaults. A collapsible section uses `collapsible.isCollapsible` and `initiallyOpen`, then remembers the choice under `qqq.recordView.collapsibleSectionOpenStates.<table>.<section>`; a section's rule overrides its widget's rule. The sticky desktop section sidebar is off by default; only `showRecordSidebar: true` in the table dashboard settings enables it. `recordViewActionsPlacement` can be set on the table or `supplementalInstanceMetaData.materialDashboard`, with the instance winning; `INLINE_WITH_PAGE_TITLE` places desktop actions beside the local record title, while the default `IN_IDENTITY_SECTION` keeps them in the identity controls. The identity card omits the primary-key T1 field and T1 values already contained in the record label; display values take precedence over raw values for that comparison. Desktop Copy and Delete buttons are omitted when the Actions menu already contains the corresponding enabled action, including in a submenu. An absent or disabled menu action preserves its available direct button. The Audit action reads through `GetAuditsForRecord` when permitted, otherwise through the `audit` table; its dialog groups entries by the viewer's local date, supports oldest/newest sort saved under `audit.sortDirection`, and reports when the 1,000-detail cap truncates results. Create/edit/copy forms use a centered container capped at `max-w-4xl` and follow field metadata, section layout, adjusters, possible-value choices, and server validation; `FILE_UPLOAD` width `full` spans the form grid, while `half` uses the default half width. Copy defaults to Base Copy, which duplicates editable fields; choose Full Copy to edit and submit loaded named associations with the new record. A widget section can edit association rows or form values through `WidgetFormContext` when its type and defaults allow it. Process launch actions can receive selected record IDs or a filter. `gotoFieldNames` in Material supplemental table metadata enables a Go To dialog for the primary key or configured unique keys. Developer views are capability and permission dependent. Consult [record and query acceptance rows](../acceptance/feature-matrix.md) for exact supported cases; do not infer coverage from the existence of a component alone.
Record pages render sections, field values, associated records, widgets, metadata-defined actions, help, and audit history. `QTableSection.alternatives.RECORD_VIEW` replaces the base section for this screen. Visible sections appear in metadata order; fields, sections and widget cards use `gridColumns` as twelfths on large screens and stack on narrow screens. When widths are not configured, Overview uses half-width cards on large screens; a selected section uses a full-width card with two field columns from the small breakpoint, stacking fields on phones. Explicit field, section, or widget widths take precedence over those presentation defaults. A collapsible section uses `collapsible.isCollapsible` and `initiallyOpen`, then remembers the choice under `qqq.recordView.collapsibleSectionOpenStates.<table>.<section>`; a section's rule overrides its widget's rule. The sticky desktop section sidebar is off by default; only `showRecordSidebar: true` in the table dashboard settings enables it. `recordViewActionsPlacement` can be set on the table or `supplementalInstanceMetaData.materialDashboard`, with the instance winning; `INLINE_WITH_PAGE_TITLE` places desktop actions beside the local record title, while the default `IN_IDENTITY_SECTION` keeps them in the identity controls. The identity card omits the primary-key T1 field and T1 values already contained in the record label; display values take precedence over raw values for that comparison. Desktop Copy and Delete buttons are omitted when the Actions menu already contains the corresponding enabled action, including in a submenu. An absent or disabled menu action preserves its available direct button. The Audit action reads through `GetAuditsForRecord` when permitted, otherwise through the `audit` table; its dialog groups entries by the viewer's local date, supports oldest/newest sort saved under `audit.sortDirection`, and reports when the 1,000-detail cap truncates results. Create/edit/copy forms use a centered container capped at `max-w-4xl` and follow field metadata, section layout, adjusters, possible-value choices, and server validation; `FILE_UPLOAD` width `full` spans the form grid, while `half` uses the default half width. Copy defaults to Base Copy, which duplicates editable fields; choose Full Copy to edit and submit loaded named associations with the new record. A widget section can edit association rows or form values through `WidgetFormContext` when its type and defaults allow it. Process launch actions can receive selected record IDs or a filter. `gotoFieldNames` in Material supplemental table metadata enables a Go To dialog for the primary key or configured unique keys. Developer views are capability and permission dependent. The record developer view (`/app/{table}/{id}/dev`) keeps the record screen's header and actions and adds a sticky section navigation over fields, API access (v1 routes and application APIs from their OpenAPI specs, filtered by the user's table permissions, with curl, fetch and Java examples that only use placeholder credentials), the panels returned by `GET /qqq/v1/table/{table}/{pk}/developer/panels` (rendered generically from their blocks: KEY_VALUES, TEXT, CODE, TABLE, LINKS and ACTIONS; app-relative or http(s) links only), relations, recent audits, associated scripts, raw JSON and a searchable table-metadata explorer. Modules and qbits add panels by registering a `DeveloperPanelProviderInterface` in QQQ; no frontend change is needed. Consult [record and query acceptance rows](../acceptance/feature-matrix.md) for exact supported cases; do not infer coverage from the existence of a component alone.
Query changes replace the current URL immediately, preserving unrelated parameters and the hash. Reload and browser back/forward restore the visible filter, sort, search and pagination state; changing those controls does not add a separate history entry.
@@ -312,6 +312,21 @@ These examples are payload fragments emitted by an application's backend rendere
{"gridColumns":6,"defaultValues":{"gridCols:sizeClass:xs":12,"gridCols:sizeClass:md":6}}
```
**Readable widths (Next UI).** Without per-breakpoint overrides, a widget's span follows the width of the dashboard grid itself (CSS container queries, so an open sidebar counts), not the viewport. Two `defaultValues` hints tune it, and neither needs a QQQ change:
- `gridMinWidth`: the narrowest the widget may be, in rem (a number, or `"28rem"` / `"448px"`). The widget shares a row only when it, and a neighbour of the same minimum, each get at least that much; otherwise it takes the full row. Values snap up to the tiers 16, 20, 24, 28, 32, 40, 48 or 64 rem; anything wider always takes the full row. Two `gridColumns: 6` widgets with `gridMinWidth: 28` pair from a 57.5rem (920 px) grid and go to thirds (`gridColumns: 4`) from 86.5rem.
- `gridProfile`: `widgets` (charts, statistics: half from a 40rem grid), `cards` or `table` (tables: half from 66rem), or `fields` (small tiles: half from 30rem). The default is `cards` for table-type widgets and `widgets` for the rest.
`gridCols:sizeClass:*` wins over `gridMinWidth`, which wins over `gridProfile`.
```json
{"type":"table","gridColumns":6,"defaultValues":{"gridMinWidth":28}}
```
Table widgets (`table`, `multiTable`) stop growing at 96rem (1536 px) so columns stay readable on ultra-wide screens; set `defaultValues.tableMaxWidth` to another width (rem, or a CSS length in `rem`, `px`, `ch`, `em` or `%`) or to `"none"`. Where a table widget is narrower than 30rem, or than its declared column widths need (6rem per `fr` or unsized column plus the fixed widths), each row renders as a stacked card of `label: value` lines instead of a table that scrolls sideways.
To cap a whole dashboard page on ultra-wide screens, give the app a supplemental app metadata of type `dashboardLayout` (a backend `QSupplementalAppMetaData` subclass whose `getType()` returns `dashboardLayout` and that is included in frontend metadata) with `maxContentWidth` (rem, or a CSS length). The page then centres at that width: `{"supplementalAppMetaData":{"dashboardLayout":{"maxContentWidth":160}}}`.
See [`widgetColumnClasses`](../../src/components/widgets/widget-utils.ts). Parent grids use the same sizing. A `parentWidget` payload such as `{"childWidgetNameList":["totals","trend"],"layoutType":"TABS"}` resolves those names in the instance widget registry. Children inherit parent request parameters and dropdown selections and fetch their own data; tab selection is remembered locally. A child omitted from permitted metadata cannot be configured into existence by listing its name.
**Dropdown data.** Metadata and response lists are paired by position; keep their lengths and order aligned. For the `region` dropdown example above, a response fragment is:
+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 RC17 preparation checkpoint: **[1.0.0-RC.16](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.16)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC15 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 [RC17](1.0.0-RC.17.md); confirm availability in the [RC17 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.17) before installing. RC17 adds the notification center (bell inbox, notification list and preferences for qbit-alerts user notifications) ([PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89) (notification center)). RC16 added the record picker for large possible-value fields, metadata-driven form validation, date-first filters with presets, the Alerts bell and record panel, resizable navigation and search preview thumbnails ([PR79](https://github.com/QRun-IO/qqq-frontend-next/pull/79) (resizable desktop navigation), [PR84](https://github.com/QRun-IO/qqq-frontend-next/pull/84) (search preview thumbnails), [PR86](https://github.com/QRun-IO/qqq-frontend-next/pull/86) (CSS budget 24 KB), [PR87](https://github.com/QRun-IO/qqq-frontend-next/pull/87) (range-error wording follows the backend), [PR85](https://github.com/QRun-IO/qqq-frontend-next/pull/85) (Alerts bell and record panel), [PR82](https://github.com/QRun-IO/qqq-frontend-next/pull/82) (date-first filters), [PR81](https://github.com/QRun-IO/qqq-frontend-next/pull/81) (quiet, metadata-driven form validation), [PR80](https://github.com/QRun-IO/qqq-frontend-next/pull/80) (record picker for large possible-value fields)). RC15 added the QQQ Assistant panel, the record developer view, search-as-you-type quick search and a search page, readable dashboard widget widths, one page scroll container and SQL Workbench dark mode ([PR58](https://github.com/QRun-IO/qqq-frontend-next/pull/58) (README adoption guide), [PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55) (readable dashboard widget widths), [PR54](https://github.com/QRun-IO/qqq-frontend-next/pull/54) (one page scroll container), [PR77](https://github.com/QRun-IO/qqq-frontend-next/pull/77) (Dependabot limited to three open PRs per ecosystem), [PR78](https://github.com/QRun-IO/qqq-frontend-next/pull/78) (SQL Workbench dark mode and standard controls), [PR61](https://github.com/QRun-IO/qqq-frontend-next/pull/61) (record developer view), [PR60](https://github.com/QRun-IO/qqq-frontend-next/pull/60) (QQQ Assistant panel, phase 1), [PR59](https://github.com/QRun-IO/qqq-frontend-next/pull/59) (search-as-you-type quick search, richer results and a search page)). RC14 was an urgent fix: record search no longer fails when one result has no label ([PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57)). RC13 added the SQL Workbench ([PR47](https://github.com/QRun-IO/qqq-frontend-next/pull/47)), FILE_PREVIEW thumbnails and lightbox ([PR49](https://github.com/QRun-IO/qqq-frontend-next/pull/49), QQQ #1056), responsive layouts with readable child lists and compact dates ([PR50](https://github.com/QRun-IO/qqq-frontend-next/pull/50)) and the instance home app ([PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53), QQQ #1059), and the other changes listed in the RC13 notes. These changes are absent from immutable RC12; the RC14 search fix is absent from immutable RC13. 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.
+43
View File
@@ -0,0 +1,43 @@
# Next UI 1.0.0-RC.13 — preparation
At this preparation checkpoint, RC13 is being prepared for application testing and **is not yet published**. [RC12](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.12) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.13` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.13`. 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 `84c95e524a93056864e60863f5ab45357969e786` (merge of [PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53)). Merged since RC12: [PR47](https://github.com/QRun-IO/qqq-frontend-next/pull/47) (SQL Workbench), [PR49](https://github.com/QRun-IO/qqq-frontend-next/pull/49) (FILE_PREVIEW thumbnails and lightbox), [PR50](https://github.com/QRun-IO/qqq-frontend-next/pull/50) (responsive layouts, readable child lists, compact dates) and [PR53](https://github.com/QRun-IO/qqq-frontend-next/pull/53) (instance home app). The single page scroll container ([PR54](https://github.com/QRun-IO/qqq-frontend-next/pull/54), its WebKit acceptance not yet green) and widget width readability ([PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55)) are not included; they follow in RC14.
## Backend requirements
RC13 runs on a published QQQ 4.1 candidate: **4.1.0-RC.1** or later. Standalone branding images need **4.1.0-RC.2** or later (QQQ #1009). **FILE_PREVIEW thumbnails and the instance home app need QQQ 4.1.0-RC.3 or later**, which carries the `FILE_PREVIEW` adornment ([qqq#1056](https://github.com/QRun-IO/qqq/issues/1056)) and `QInstance.homeAppName` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)); on older backends neither appears and the rest of RC13 works unchanged. The SQL Workbench page needs the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit, **1.0.0-RC.1** or later, on the backend. RC13 does not publish QQQ 4.1.
## 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
- Support an instance home app. When the backend designates one with `QInstance.homeAppName` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)), and the user may open it, `/app` shows that app's dashboard (a client-side redirect, matching backends that redirect full page loads of `/app/`), the sidebar logo and the breadcrumb Home crumb link there, and the sidebar drops its separate Dashboard entry. Without a home app, or with one the user may not open, `/app` remains Next's overview.
- 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
- 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.
## Bundle budget
The record developer route `/app/_/_/dev` and the table developer route `/app/_/dev` each have a 360 KB first-load cap (measured 354.9 KB and 350.8 KB on the combined tree); every other route keeps the 350 KB default. The SQL Workbench editor (CodeMirror 6) and formatter load on demand under separate `lazyChunks` budgets; the FILE_PREVIEW lightbox is a lazy chunk.
## Verification checkpoint
PR checks on the RC13 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. During integration the WebKit/tablet NAV-016, NAV-023 and NAV-056 access-control diagnostics and one Firefox image-decode diagnostic (SEC-026) recurred intermittently and passed on rerun; they belong to the tracked [#985](https://github.com/QRun-IO/qqq/issues/985) / [#951](https://github.com/QRun-IO/qqq/issues/951) investigations and no assertion was relaxed. Tag acceptance and public-artifact verification are recorded in the [RC13 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) after delivery. No RC12 test count or artifact digest is attributed to RC13.
## Remaining RC limits
The RC12 limits carry forward unchanged: 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.
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.14 — preparation
At this preparation checkpoint, RC14 is being prepared as an urgent fix for application testing and **is not yet published**. [RC13](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.13) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.14` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.14`. 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 `b699204d8736f5cfcc61358c38e86d89ab3d5541` (merge of [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57)). Merged since RC13: [PR52](https://github.com/QRun-IO/qqq-frontend-next/pull/52) (Next.js 16.3.8 security update and sharp) and [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57) (record search tolerates results without a label). RC14 is cut early because global record search is broken in production on RC13. The README adoption guide (PR58), the record developer view (PR61), the single page scroll container (PR54), widget width readability (PR55) and the NAV-056 diagnostic fix are not included; they follow in RC15.
## Backend requirements
Unchanged from RC13: 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); **4.1.0-RC.3** or later for FILE_PREVIEW thumbnails ([qqq#1056](https://github.com/QRun-IO/qqq/issues/1056)) and the instance home app ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)); [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) **1.0.0-RC.1** or later for the SQL Workbench page. The search fix needs no backend change; the server-side companion [qqq#1068](https://github.com/QRun-IO/qqq/pull/1068) stops emitting blank labels. RC14 does not publish QQQ 4.1.
## Product fixes
- Record search no longer fails when one result has no label. QQQ serializes JSON without empty values, so a record whose label is empty arrived without `recordLabel`, and the strict response schema rejected the whole response ("Record search failed. Try again."). The response envelope is still checked strictly, but each result is now validated on its own: `tableName` and `recordId` are required (a numeric `recordId` is accepted), a result without a label is shown as "{table label} {record id}", and a result that cannot link to a record is skipped with a console warning while the others render.
## Dependencies
- Next.js 16.3.6 to 16.3.8 (security fixes, including the image-optimization SSRF advisory GHSA-cjq9-62q9-8jv4) and the matching `sharp` update ([PR52](https://github.com/QRun-IO/qqq-frontend-next/pull/52)).
## Verification checkpoint
PR checks on the RC14 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. During integration the WebKit/tablet NAV-023 diagnostic recurred intermittently and passed on rerun. **Known failure accepted for this release:** the WebKit NAV-056 diagnostic (appearance preferences explain an application theme override) failed on [PR57](https://github.com/QRun-IO/qqq-frontend-next/pull/57) and also fails on main without RC14's changes; the owner explicitly approved merging and releasing RC14 with it because production record search was broken. It is tracked under [#985](https://github.com/QRun-IO/qqq/issues/985), a fix is in progress for RC15, and no assertion was relaxed. Tag acceptance and public-artifact verification are recorded in the [RC14 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14) after delivery. No RC13 test count or artifact digest is attributed to RC14.
## Remaining RC limits
The RC13 limits carry forward unchanged: 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.
+41
View File
@@ -0,0 +1,41 @@
# Next UI 1.0.0-RC.15 — preparation
At this preparation checkpoint RC15 **is not yet published**. [RC14](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.14) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.15` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.15`. 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 `5de69be`. Merged since RC14: [PR58](https://github.com/QRun-IO/qqq-frontend-next/pull/58) (README adoption guide), [PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55) (readable dashboard widget widths), [PR54](https://github.com/QRun-IO/qqq-frontend-next/pull/54) (one page scroll container), [PR77](https://github.com/QRun-IO/qqq-frontend-next/pull/77) (Dependabot limited to three open PRs per ecosystem), [PR78](https://github.com/QRun-IO/qqq-frontend-next/pull/78) (SQL Workbench dark mode and standard controls), [PR61](https://github.com/QRun-IO/qqq-frontend-next/pull/61) (record developer view), [PR60](https://github.com/QRun-IO/qqq-frontend-next/pull/60) (QQQ Assistant panel, phase 1), [PR59](https://github.com/QRun-IO/qqq-frontend-next/pull/59) (search-as-you-type quick search, richer results and a search page).
## Backend requirements
A published QQQ 4.1 candidate, **4.1.0-RC.1** or later; **4.1.0-RC.3** or later for FILE_PREVIEW and the instance home app; **4.1.0-RC.4** or later for record developer panels ([qqq#1076](https://github.com/QRun-IO/qqq/pull/1076)); [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) 1.0.0-RC.1 or later for the SQL Workbench page (1.0.0-RC.4 or later opens developer-panel SQL in the Runner); [qbit-assistant](https://github.com/QRun-IO/qbit-assistant) 0.1.0-RC.3 or later for the assistant panel (without it nothing renders). RC15 does not publish QQQ 4.1.
## Features
- **QQQ Assistant panel (Phase 1: read-only questions and navigation).** When the `qbit-assistant` backend is installed and `GET /qqq/v1/assistant/config` enables it for the user, a header button (with unread badge), `Ctrl/⌘+J` and a command palette "Ask Assistant" item open a chat panel. It docks beside the page from 1024 px (resizable 360-640 px, its own scrolling message list) and is a full-height sheet below that. Replies stream over SSE with resume after a dropped connection and a Stop button; tool steps, record chips, records/record/count/history renders and chart widgets come from structured payloads, and markdown renders without raw HTML or images, with links only to same-origin `/app` paths. `?assistant=open&assistantPrompt=...` opens a new conversation with the prompt pre-filled. Without the backend nothing renders and the shortcut is inert. The panel code loads on first open (about 1.7 KB added to first load).
- **Search** ([PR59](https://github.com/QRun-IO/qqq-frontend-next/pull/59)): search-as-you-type quick search with richer results (highlights, details, per-table status from QQQ 4.1.0-RC.4 record search) and a URL-addressable search page; the search page and previews load on demand.
- **SQL Workbench dark mode and standard controls** ([PR78](https://github.com/QRun-IO/qqq-frontend-next/pull/78)).
## Product fixes
- **Readable dashboard widget widths** ([PR55](https://github.com/QRun-IO/qqq-frontend-next/pull/55)): per-widget minimum widths, ultra-wide caps and a table card layout.
- **Record developer view** (`/app/{table}/{id}/dev`): the record screen's navigation (back to the record, record title, Edit, Actions, Audit, breadcrumbs) and a sticky section bar (a section picker on phones, wrapping links elsewhere) over Fields (display and raw values, types, possible-value sources and adornments, filterable), **API Access** (the v1 routes this screen uses and every application API that exposes the table, read from its OpenAPI spec and limited to what the user may do, with copyable curl, fetch and Java examples built from the record's values, placeholder credentials only, and links to the API docs and specs), **developer panels** from QQQ's provider contract ([qqq#1076](https://github.com/QRun-IO/qqq/pull/1076): record security, ESB events, and any qbit's panel such as quick search, SQL workbench or the assistant, shown only when installed and active for the table; QQQ before 4.1.0-RC.4 has no panels endpoint, so the view shows none and asks for it only once per session), Relations, recent Audit, associated scripts, raw JSON and a searchable Table Metadata explorer. Readable from 360 to 3200px with no sideways scrolling. The view loads on demand, keeping the route's first-load JavaScript at 333.5 KB (budget 360 KB).
- One scroll container per page: on long pages the document no longer grows a second scrollbar beside the content area's (and no longer slides the header off-screen). The app shell and `<main>` are now positioned, so absolutely positioned descendants such as `sr-only` text (the company footer's "opens in a new tab") stay inside the scrolling area instead of stretching the document. Covered by `tests/e2e/single-scroll-container.spec.ts` at 390-3200px.
## Browser testing and infrastructure
- Dependabot keeps at most three open PRs per ecosystem ([PR77](https://github.com/QRun-IO/qqq-frontend-next/pull/77)).
- The WebKit NAV-056 diagnostic race is fixed in the acceptance tests ([PR63](https://github.com/QRun-IO/qqq-frontend-next/pull/63)).
- README: "Adopting in another app" ([PR58](https://github.com/QRun-IO/qqq-frontend-next/pull/58)).
## Bundle budget
Owner decision 2026-10-12: the total JavaScript cap (every on-demand feature) is 1100 KB gzip and grows with features; first-load route caps, the 23 KB shared CSS cap and the lazy-chunk caps stay strict. See `perf-budget.json`.
## Verification checkpoint
PR checks on the RC15 preparation source (typecheck, lint, unit tests and coverage, mocked e2e, bundle budgets, Storybook, image builds and the real-backend acceptance workflow) are recorded on the preparation PR. Tag acceptance and public-artifact verification are recorded in the RC15 GitHub release after delivery. No RC14 test count or artifact digest is attributed to RC15.
## Remaining RC limits
The RC14 limits carry forward unchanged: 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).
+35
View File
@@ -0,0 +1,35 @@
# Next UI 1.0.0-RC.16 — preparation
At this preparation checkpoint RC16 **is not yet published**. [RC15](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.15) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.16` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.16`. 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 `6dea1c3` (merge of [PR80](https://github.com/QRun-IO/qqq-frontend-next/pull/80)). Merged since RC15: [PR79](https://github.com/QRun-IO/qqq-frontend-next/pull/79) (resizable desktop navigation), [PR84](https://github.com/QRun-IO/qqq-frontend-next/pull/84) (search preview thumbnails), [PR86](https://github.com/QRun-IO/qqq-frontend-next/pull/86) (CSS budget 24 KB), [PR87](https://github.com/QRun-IO/qqq-frontend-next/pull/87) (range-error wording follows the backend), [PR85](https://github.com/QRun-IO/qqq-frontend-next/pull/85) (Alerts bell and record panel), [PR82](https://github.com/QRun-IO/qqq-frontend-next/pull/82) (date-first filters), [PR81](https://github.com/QRun-IO/qqq-frontend-next/pull/81) (quiet, metadata-driven form validation), [PR80](https://github.com/QRun-IO/qqq-frontend-next/pull/80) (record picker for large possible-value fields).
## Backend requirements
A published QQQ 4.1 candidate, **4.1.0-RC.1** or later; **4.1.0-RC.4** or later for record developer panels; **4.1.0-RC.5** or later for the record picker ([qqq#1099](https://github.com/QRun-IO/qqq/pull/1099)), the form validation limits it reads from metadata ([qqq#1100](https://github.com/QRun-IO/qqq/pull/1100)) and the date filter `StartOfPeriod` presets ([qqq#1115](https://github.com/QRun-IO/qqq/pull/1115)); on earlier backends these features fall back to the RC15 behavior. The Alerts bell and record panel render only when the instance metadata includes the `alerts` app ([qbit-alerts](https://github.com/QRun-IO/qbit-alerts) 0.1.0-RC.1 or later); otherwise they send no requests. RC16 does not publish QQQ 4.1.
## Features
- **Record picker** ([PR80](https://github.com/QRun-IO/qqq-frontend-next/pull/80)): possible-value fields over large tables open a searchable, filterable, paged picker dialog (lazy-loaded, with thumbnails where the table has images).
- **Form validation** ([PR81](https://github.com/QRun-IO/qqq-frontend-next/pull/81)): quiet, metadata-driven validation (required fields, too-long behavior, rejecting value ranges) before any request; server messages are still shown once.
- **Date-first filters** ([PR82](https://github.com/QRun-IO/qqq-frontend-next/pull/82)): date and date-time filters with presets, an include-time toggle, and the `X-QQQ-UserTimezone` header.
- **Alerts bell and record panel** ([PR85](https://github.com/QRun-IO/qqq-frontend-next/pull/85)): a header bell and a record panel for qbit-alerts, gated on the `alerts` app in metadata.
- **Resizable navigation** ([PR79](https://github.com/QRun-IO/qqq-frontend-next/pull/79)) and **search preview thumbnails** ([PR84](https://github.com/QRun-IO/qqq-frontend-next/pull/84)).
## Product fixes
- Range errors use the backend wording ([PR87](https://github.com/QRun-IO/qqq-frontend-next/pull/87)).
## Bundle budget
Shared CSS cap 24 KB ([PR86](https://github.com/QRun-IO/qqq-frontend-next/pull/86), measured 23.07 KB after PR79); total JavaScript cap 1100 KB (owner decision 2026-10-12). Route and lazy-chunk caps are unchanged.
## Verification checkpoint
Each merged PR passed real-backend acceptance (Chromium, Firefox, WebKit, mobile/tablet) on its own head against QQQ develop with the 4.1.0-RC.5 batch. PR checks on the RC16 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC16 GitHub release after delivery.
## Remaining RC limits
The RC15 limits carry forward unchanged: 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).
+27
View File
@@ -0,0 +1,27 @@
# Next UI 1.0.0-RC.17 — preparation
At this preparation checkpoint RC17 **is not yet published**. [RC16](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.16) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.17` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.17`. 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 `6ed2575` (merge of [PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89)). Merged since RC16: [PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89) (notification center).
## Backend requirements
Unchanged from RC16 (QQQ **4.1.0-RC.5** or later for the record picker, validation limits and date presets). The notification center renders only when the instance metadata includes the `alerts` app, and needs [qbit-alerts](https://github.com/QRun-IO/qbit-alerts) **0.1.0-RC.2** or later (user notifications); without it nothing renders and no requests are sent. RC17 does not publish QQQ 4.1.
## Features
- **Notification center** ([PR89](https://github.com/QRun-IO/qqq-frontend-next/pull/89)): a bell inbox and notification list with read state and preferences for qbit-alerts user notifications. The center loads on demand (lazy chunk `notificationCenter`, 15.2 KB gzip).
## Bundle budget
**Both the total JavaScript and the first-load budgets are within 2 KB of their caps:** total JS 1098.0 / 1100 KB and `/app` first load 349.2 / 350 KB. The next Next feature needs a measured cap raise (under the owner decision of 2026-10-12) or a code-splitting pass first.
## Verification checkpoint
PR89 passed real-backend acceptance on Chromium, Firefox, WebKit and mobile/tablet. PR checks on the RC17 preparation source are recorded on the preparation PR; tag acceptance and public-artifact verification are recorded in the RC17 GitHub release after delivery.
## Remaining RC limits
The RC16 limits carry forward unchanged; see [#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).
+5 -50
View File
@@ -1,62 +1,17 @@
# 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).
[RC17 preparation](1.0.0-RC.17.md) collects every change merged since [RC16](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.16). RC16 is published and immutable. Changes merged after the RC17 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
## 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).
None yet.
## 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)
None yet.
## 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.
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "qqq-frontend-next",
"version": "1.0.0-RC.11",
"version": "1.0.0-RC.17",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
@@ -54,12 +54,14 @@
"dompurify": "^3.4.16",
"lodash": "^4.18.1",
"lucide-react": "^0.485.0",
"next": "16.3.6",
"next": "16.3.8",
"rapidoc": "9.3.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-markdown": "10.1.0",
"recharts": "^2.15.4",
"rehype-sanitize": "6.0.0",
"sonner": "^2.0.7",
"sql-formatter": "15.6.6",
"tailwind-merge": "^3.5.0",
+41 -5
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. SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB), so that route's cap is 355 KB.",
"$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). Readable widget widths (feature/widget-width-readability, on PR #50): the gridMinWidth container-query ladder (about 80 @container rules) and the table-widget card layout raise shared CSS from 20.4 KB (PR #50 CI) to 21.1 KB gzip, over the 21 KB cap, so the CSS cap becomes 22 KB (about 4% headroom); JS routes are unchanged. Raise a cap only with a measured reason in the commit. QQQ Assistant panel (2026-10-12): its Tailwind utilities (panel, sheet, markdown typography) add 0.9 KB gzip to the shared CSS (21.3 KB measured against the 21 KB cap); merged with the widget width ladder the shared CSS measured 21.3 KB (2026-10-12 local export), inside the 22 KB cap; the panel's JS is budgeted under lazyChunks.assistant. Assistant panel plus SQL Workbench dark mode (#78, 2026-10-12): main with #78 measured 21.5 KB of shared CSS, and merging the assistant panel measured 22.3 KB (local export), over the 22 KB cap, so the CSS cap becomes 23 KB (about 3% headroom); JS routes and lazy chunks are unchanged. Owner decision 2026-10-12 (\"raise the limits on size as needed, but still keeping things healthy and small as possible\"): totalJsGzipKB is 1100. The total includes every on-demand feature and grows with features; first-load route caps (255/350/360/390), the 23 KB CSS cap and the lazy-chunk caps stay strict because they govern page speed. Any other cap raise still needs a measured reason, and feature PRs keep code-splitting and report their bundle delta. Resizable navigation (#79, 2026-10-12): main measured 23.07 KB of shared CSS after #79 (CI and local export), over the 23 KB cap; the RC.16 candidates (#80 record picker, #81 form validation, #82 date filters, #84 search thumbnails, #85 alerts bell) each add small amounts, so under the owner decision the CSS cap becomes 24 KB (about 4% headroom). Search preview thumbnails (2026-10-12, owner-approved total cap 1100 KB): restoring FILE_PREVIEW thumbnails, CHIP values and dates in the lazy search record preview measured total JS 1052.0 KB against main's 1042.7 KB (+9.3 KB: Turbopack re-bundles the shared file-preview, gallery, adornment and date modules into more on-demand screen chunks), with every route first load and the CSS (23.0 KB) unchanged; the total cap moves to 1100 KB, first-load and CSS caps stay.",
"firstLoadJsGzipKB": 350,
"routes": {
"/": 255,
@@ -12,13 +12,14 @@
"/app/_": 350,
"/app/_/_": 350,
"/app/_/_/copy": 390,
"/app/_/_/dev": 360,
"/app/_/_/edit": 350,
"/app/_/dev": 360,
"/app/_/create": 350,
"/app/_/savedView/_": 350,
"/app/_/_/dev": 355
"/app/_/savedView/_": 350
},
"cssGzipKB": 21,
"totalJsGzipKB": 1050,
"cssGzipKB": 24,
"totalJsGzipKB": 1100,
"largestChunkGzipKB": 100,
"lazyChunks": {
"rapidoc": {
@@ -31,10 +32,45 @@
"marker": "qqq-sql-workbench-editor",
"gzipKB": 135
},
"recordPicker": {
"$comment": "The record picker dialog (RecordPickerDialog: search, filters, thumbnail grid and list, preview, upload) for fields whose possible value source declares a picker, loaded when Browse/Change is clicked: import() in PossibleValueSelect. Turbopack emits one variant of the async chunk per parent context that bundles PossibleValueSelect (record forms, process forms, bulk-load mapping), so the budget covers all variants; a session loads one (about 5.9 KB gzip, with the dialog-only record-picker-query helpers). Measured 17.0 KB gzip across the three variants at introduction; budget about 6% above. The inline field (thumbnails, Browse/Preview/View) adds 2.5 KB to total JS (main 1005.4 KB, this branch 1007.9 KB on 2026-10-12) and nothing to any route first load or the shared CSS.",
"marker": "qqq-record-picker-grid",
"gzipKB": 18
},
"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
},
"assistant": {
"$comment": "The QQQ Assistant panel (qbit-assistant): chat UI, SSE streaming client, react-markdown + rehype-sanitize, loaded on first open through import() in AssistantMount; the shell keeps only the /assistant/config probe, header button and Ctrl/⌘+J (about 1.7 KB first load). Measured 50.8 KB gzip at introduction; budget about 8% above.",
"marker": "assistant-composer-form",
"gzipKB": 55
},
"searchPage": {
"$comment": "The search results page (/app/search: filters, sort, result cards, split preview pane, side sheet), loaded on demand: import() in app/search/page.tsx. Measured 13.4 KB gzip at introduction (QRun-IO/qqq-frontend-next#59); budget about 8% above.",
"marker": "search-result-card",
"gzipKB": 14.5
},
"quickSearchPanel": {
"$comment": "The quick search results panel of the header box and the '/' dialog (groups, highlights, completions, failure and fallback notices), loaded when the panel first opens: import() in GlobalSearch and SearchDialog. Measured 9.4 KB gzip at introduction (#59); budget about 8% above.",
"marker": "search-results-status",
"gzipKB": 10.2
},
"commandRecordResults": {
"$comment": "The command palette's record groups, loaded when the palette searches records: import() in CommandMenu. Measured 7.5 KB gzip at introduction (#59); budget about 8% above.",
"marker": "command-menu-searching",
"gzipKB": 8.1
},
"notificationCenter": {
"$comment": "The notification center (qbit-alerts phase b): the bell popover (import() in AlertsBell on first open), the sticky-notification banners (import() in NotificationBannerSlot when the summary has banners) and the /app/notifications page (next/dynamic in its route). Turbopack bundles the shared parts (severity icons, per-item actions, the optimistic inbox mutation, MarkdownView) into each of the three async chunks, so the budget covers all three; the marker is the per-item read/unread button they share. Measured 15.2 KB gzip across the three (popover 4.3, banners 4.3, page 6.7) on 2026-10-13; budget about 8% above. The shell keeps the badge, poller with backoff, summary normalizer and banner slot: +0.7 KB first load on the /app routes (/app/_/_ 348.5 -> 349.2 KB, inside its 350 KB cap); the new /app/notifications route measures 339.5 KB; total JS 1092.9 -> 1098.0 KB excluding these chunks; CSS unchanged at 23.2 KB.",
"marker": "notification-toggle-read",
"gzipKB": 16.5
},
"recordPreview": {
"$comment": "The search result record preview (first-tier fields with file thumbnails, chips and dates; quick actions), loaded when a preview first shows: import() in SearchPage. It runs its own two queries (the useTableMetaData and useRecord keys) rather than importing those hooks, which measured +12.4 KB more total JS. Measured 7.1 KB gzip with thumbnails and chips (3.7 KB without); budget about 8% above.",
"marker": "search-record-preview",
"gzipKB": 7.7
}
}
}
+1027 -291
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -47,7 +47,7 @@
</developers>
<properties>
<revision>1.0.0-RC.11</revision>
<revision>1.0.0-RC.17</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
@@ -14,7 +14,7 @@
* limitations under the License.
*/
// Tests for the record developer view (raw values and associated scripts)
// Tests for the record developer view (header parity, sections, API access, provider panels, raw values and associated scripts)
import { render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -52,9 +52,19 @@ vi.mock('@/lib/api/developer', () => ({
storeRecordAssociatedScript: vi.fn(),
storeScriptRevision: vi.fn(),
testScript: vi.fn(),
getTableApis: vi.fn(async () => []),
getApiVersions: vi.fn(),
getApiTableSpec: vi.fn(),
apiTableSpecUrl: () => '/api/v1/person/openapi.json',
apiMiddlewareBaseURL: () => '',
}))
vi.mock('@/lib/api/developer-panels', () => ({
getRecordDeveloperPanels: vi.fn(async () => []),
}))
import { getRecordDeveloperData } from '@/lib/api/developer'
import { getRecordDeveloperPanels, type DeveloperPanel } from '@/lib/api/developer-panels'
import { getRecord } from '@/lib/api/tables'
const record = { tableName: 'person', recordLabel: 'Ada Lovelace', values: { id: 1, firstName: 'Ada', email: null } }
@@ -119,14 +129,14 @@ describe('RecordDeveloperViewPage', () => {
serveInstance(['storeScriptRevision', 'testScript'])
renderPage()
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' }, { timeout: 5000 })).toBeInTheDocument()
const card = await waitFor(() => {
const element = document.querySelector('[data-qqq-id="associated-script-firstName"]')
if (!element) throw new Error('no card')
return element as HTMLElement
})
}, { timeout: 5000 })
expect(within(card).getByRole('heading', { name: 'First Name' })).toBeInTheDocument()
await waitFor(() => expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).not.toBeNull())
await waitFor(() => expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).not.toBeNull(), { timeout: 5000 })
expect(within(card).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Code', 'Logs', 'Test', 'Docs'])
expect(screen.getByTestId('page-header')).toHaveTextContent('Ada Lovelace Developer Mode')
expect(getRecordDeveloperData).toHaveBeenCalledWith('person', '1')
@@ -136,11 +146,11 @@ describe('RecordDeveloperViewPage', () => {
serveInstance([])
renderPage()
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { name: 'Record Raw Values as JSON' }, { timeout: 5000 })).toBeInTheDocument()
const json = document.querySelector('[data-qqq-id="json-output"]') as HTMLElement
expect(JSON.parse(json.textContent ?? '')).toEqual({ id: 1, firstName: 101, email: null })
const table = document.querySelector('[data-qqq-id="record-dev-field-table"]') as HTMLElement
expect(within(table).getByText('101')).toBeInTheDocument()
const row = document.querySelector('[data-qqq-id="record-dev-field-table"] [data-qqq-id="dev-field-firstName"]') as HTMLElement
expect(row.querySelectorAll('td')[3]).toHaveTextContent('101')
expect(getRecord).not.toHaveBeenCalled()
})
@@ -155,7 +165,7 @@ describe('RecordDeveloperViewPage', () => {
const element = document.querySelector('[data-qqq-id="associated-script-firstName"]')
if (!element) throw new Error('no card')
return element as HTMLElement
})
}, { timeout: 5000 })
await waitFor(() => expect(within(card).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Code', 'Logs', 'Docs']))
expect(document.querySelector('[data-qqq-id="button-edit-script-firstName"]')).toBeNull()
})
@@ -165,7 +175,90 @@ describe('RecordDeveloperViewPage', () => {
vi.mocked(getRecordDeveloperData).mockRejectedValue(new Error('Permission denied.'))
renderPage()
expect(await screen.findByText('Failed to load record: Permission denied.')).toHaveAttribute('role', 'alert')
expect(await screen.findByText('Failed to load record: Permission denied.', {}, { timeout: 5000 })).toHaveAttribute('role', 'alert')
expect(screen.queryByRole('heading', { name: 'Record Raw Values as JSON' })).not.toBeInTheDocument()
})
it('has the record screen header: back link, record title, actions and audit (no view-mode toggle)', async () => {
serveInstance([])
renderPage()
const back = await screen.findByRole('link', { name: 'Back to Ada Lovelace' }, { timeout: 5000 })
expect(back).toHaveAttribute('href', '/app/person/1')
expect(screen.getByRole('heading', { level: 1, name: 'Ada Lovelace' })).toBeInTheDocument()
expect(screen.queryByRole('radiogroup', { name: 'View mode' })).not.toBeInTheDocument()
expect(document.querySelector('[data-qqq-id="record-view-desktop-actions"]')).not.toBeNull()
expect(screen.getByText(/Developer view/)).toBeInTheDocument()
})
it('navigates sections from a sticky section nav', async () => {
serveInstance([])
renderPage()
const nav = await screen.findByRole('navigation', { name: 'Developer view sections' }, { timeout: 5000 })
await waitFor(() => expect(within(nav).getAllByRole('link').map((link) => link.textContent)).toEqual(
expect.arrayContaining(['Fields', 'API', 'Scripts', 'JSON', 'Table Metadata'])))
expect(screen.getByRole('heading', { name: 'API Access' })).toBeInTheDocument()
})
it('lists the v1 operations the user may perform, with curl examples using placeholder credentials', async () => {
serveInstance([])
renderPage()
const api = await waitFor(() => {
const element = document.querySelector('[data-qqq-id="dev-api-access"]')
if (!element) throw new Error('no api section')
return element as HTMLElement
})
await waitFor(() => expect(within(api).getByText('Get this record')).toBeInTheDocument())
for (const label of ['Query by primary key', 'Count matching records', 'Insert a copy', 'Update this record', 'Delete this record']) {
expect(within(api).getByText(label)).toBeInTheDocument()
}
const snippet = api.querySelector('[data-qqq-id="dev-api-snippet-get"]') as HTMLElement
expect(snippet.textContent).toContain('curl -X GET')
expect(snippet.textContent).toContain('/qqq/v1/table/person/1')
expect(snippet.textContent).toContain('sessionUUID=<your-session-uuid>')
})
it('renders no provider panels when none are available (qbits absent)', async () => {
serveInstance([])
renderPage()
await screen.findByRole('heading', { name: 'API Access' }, { timeout: 5000 })
await waitFor(() => expect(getRecordDeveloperPanels).toHaveBeenCalledWith('person', '1'))
expect(document.querySelector('[data-qqq-id^="dev-section-panel-"]')).toBeNull()
})
it('renders each provider panel (ESB, quick search, SQL workbench, assistant) with a nav entry', async () => {
const panels: DeveloperPanel[] = [
{ id: 'esb', title: 'ESB Events', order: 300, statusLabel: 'Publishing', statusTone: 'SUCCESS', blocks: [
{ type: 'KEY_VALUES', items: [{ label: 'type', value: 'qqq.table.person.updated', monospace: true }] },
{ type: 'CODE', label: 'Sample event', language: 'json', code: '{"subject":"1"}' },
] },
{ id: 'quickSearch', title: 'Quick Search', order: 400, statusLabel: 'Indexed', blocks: [
{ type: 'ACTIONS', actions: [{ label: 'Reindex this record', processName: 'quickSearchReindexRecords_person' }] },
] },
{ id: 'sqlWorkbench', title: 'SQL Workbench', order: 450, blocks: [
{ type: 'LINKS', links: [{ label: 'Open in SQL Workbench', href: '/app/sqlWorkbench?sql=SELECT+1' }, { label: 'Bad', href: 'javascript:alert(1)' }] },
] },
{ id: 'assistant', title: 'Assistant', order: 600, error: 'This panel could not be built.', blocks: [] },
]
vi.mocked(getRecordDeveloperPanels).mockResolvedValue(panels)
serveInstance([])
renderPage()
const nav = await screen.findByRole('navigation', { name: 'Developer view sections' }, { timeout: 5000 })
await waitFor(() => expect(within(nav).getByRole('link', { name: 'ESB Events' })).toBeInTheDocument())
for (const title of ['Quick Search', 'SQL Workbench', 'Assistant']) {
expect(within(nav).getByRole('link', { name: title })).toBeInTheDocument()
expect(within(nav).getByRole('option', { name: title })).toBeInTheDocument()
}
expect(screen.getByRole('heading', { name: /ESB Events/ })).toBeInTheDocument()
expect(screen.getByText('qqq.table.person.updated')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Reindex this record/ }).getAttribute('href')).toContain('/app/quickSearchReindexRecords_person')
expect(screen.getByRole('link', { name: 'Open in SQL Workbench' })).toHaveAttribute('href', '/app/sqlWorkbench?sql=SELECT+1')
expect(screen.queryByRole('link', { name: 'Bad' })).not.toBeInTheDocument()
expect(screen.getByText('This panel could not be built.')).toHaveAttribute('role', 'alert')
})
})
@@ -15,232 +15,26 @@
*/
/**
* @file RecordDeveloperView page — raw record values and table metadata as JSON, plus the
* record's associated scripts (versions, code, editing, logs, testing, docs).
* @file RecordDeveloperView page — serves `/app/[slug]/[recordId]/dev`. The view (record header,
* fields, API access, developer panels, relations, audit, scripts, JSON and table metadata) is
* loaded on demand, as the record view is, so the route's first-load JavaScript stays small.
*/
'use client'
import React, { useCallback, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
import React, { lazy, Suspense } from 'react'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { useQContext } from '@/lib/context/q-context'
import { SCRIPT_LOG_TABLE, getRecordDeveloperData } from '@/lib/api/developer'
import { loadTableMetaData } from '@/lib/api/metadata'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { AssociatedScriptViewer } from '@/components/records/AssociatedScriptViewer'
import { scriptLogsQueryHref } from '@/components/scripts/script-utils'
const RecordDeveloperView = lazy(() => import('@/components/developer/RecordDeveloperView').then((module) => ({ default: module.RecordDeveloperView })))
/**
* Renders a developer debug view for a single record, showing all field values
* in a table and the raw record and table metadata as collapsible JSON blocks.
* Renders the record developer view once its code has loaded.
*
* @returns A composed page that assembles:
* - A field-values summary table (field name, type, raw value for every field in the record)
* - A `<JsonBlock>` for the raw record values (expanded by default)
* - An `<AssociatedScriptViewer>` card per associated script declared on the table
* - A `<JsonBlock>` for the full table metadata (collapsed by default)
* - A loading spinner while either query is in-flight, and an error panel if the record fetch fails
* @returns The view, with a loading status meanwhile.
*/
export default function RecordDeveloperViewPage() {
const params = useRouteParams<{ slug: string; recordId: string }>()
const { setPageHeader } = useQContext()
const { slug, recordId } = params
const queryClient = useQueryClient()
const { data: metaData, isLoading: metaLoading } = useQuery({
queryKey: queryKeys.tableMetadata(slug),
queryFn: () => loadTableMetaData(slug),
staleTime: 1000 * 60 * 30,
})
const { data: instance } = useMetaData()
// Material's RecordDeveloperView reads the record from the developer-mode route: the same
// response carries the raw values and the associated scripts.
const { data: developerData, isLoading: recordLoading, error: recordError } = useQuery({
queryKey: queryKeys.recordDeveloper(slug, recordId),
queryFn: () => getRecordDeveloperData(slug, recordId),
meta: HANDLES_OWN_ERRORS,
retry: false,
})
const record = developerData?.record
// "View All" logs link, when the session can read the script log table
const logsViewAllHref = instance?.tables?.[SCRIPT_LOG_TABLE] ? scriptLogsQueryHref : undefined
const headerLabel = record?.recordLabel || (metaData ? `${metaData.label} ${recordId}` : `${slug} #${recordId}`)
useEffect(() => {
setPageHeader(`${headerLabel} Developer Mode`)
}, [headerLabel, setPageHeader])
const reloadRecord = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(slug, recordId) }),
[queryClient, slug, recordId]
)
const isLoading = recordLoading || metaLoading
const canEditScripts = Boolean(instance?.processes?.storeScriptRevision)
const canTestScripts = Boolean(instance?.processes?.testScript)
return (
<div className="space-y-6" data-qqq-id={`record-dev-${slug}-${recordId}`}>
<div className="flex items-center gap-3">
<Code className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<h2 className="text-2xl font-semibold text-foreground">
Record Developer View:{' '}
<span className="font-mono text-primary">{slug}</span>
{' '}
<span className="font-mono text-muted-foreground">#{recordId}</span>
</h2>
</div>
{isLoading && (
<div
className="flex items-center justify-center py-12"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)}
{recordError && (
<div
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
role="alert"
>
Failed to load record: {getErrorMessage(recordError)}
</div>
)}
{!isLoading && record && (
<div className="space-y-4">
{/* Field values summary */}
{metaData && (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="min-w-full text-sm" data-qqq-id="record-dev-field-table">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium text-foreground">
Field
</th>
<th className="px-4 py-2 text-left font-medium text-foreground">
Type
</th>
<th className="px-4 py-2 text-left font-medium text-foreground">
Value
</th>
</tr>
</thead>
<tbody>
{Object.entries(record.values).map(([fieldName, value]) => {
const field = metaData.fields[fieldName]
return (
<tr
key={fieldName}
className="border-t border-border"
>
<td className="px-4 py-2 font-mono text-xs text-primary">
{fieldName}
</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">
{field?.type ?? '—'}
</td>
<td className="px-4 py-2 font-mono text-xs text-foreground">
{value === null || value === undefined
? <span className="text-muted-foreground italic">null</span>
: String(value)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<JsonBlock label="Record Raw Values as JSON" value={record.values} />
{metaData && developerData?.associatedScripts.map((associated) => {
const fieldName = associated.associatedScript.fieldName
const scriptId = record.values[fieldName]
return (
<AssociatedScriptViewer
key={fieldName}
tableName={slug}
recordId={recordId}
fieldLabel={metaData.fields[fieldName]?.label ?? fieldName}
data={associated}
scriptId={typeof scriptId === 'string' || typeof scriptId === 'number' ? scriptId : null}
canCreate={Boolean(metaData.editPermission)}
canEdit={canEditScripts}
canTest={canTestScripts}
logsViewAllHref={logsViewAllHref}
onChanged={reloadRecord}
/>
)
})}
{metaData && <JsonBlock label="Table Metadata" value={metaData} defaultOpen={false} />}
</div>
)}
</div>
)
}
/**
* Collapsible card that renders a JSON value as a formatted `<pre>` block.
*
* @param props - Component props.
* @param props.label - Heading displayed in the toggle button.
* @param props.value - The value to serialize and display as JSON.
* @param props.defaultOpen - Whether the panel starts expanded. Defaults to `true`.
* @returns A collapsible JSON inspector card.
*/
function JsonBlock({
label,
value,
defaultOpen = true,
}: {
label: string
value: unknown
defaultOpen?: boolean
}) {
const [open, setOpen] = React.useState(defaultOpen)
return (
<div className="overflow-hidden rounded-xl border border-border">
<h3 className="m-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring"
aria-expanded={open}
data-qqq-id="button-json-block-toggle"
>
<span className="flex items-center gap-2">
<Code className="h-4 w-4" aria-hidden="true" />
{label}
</span>
{open ? (
<ChevronUp className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronDown className="h-4 w-4" aria-hidden="true" />
)}
</button>
</h3>
{open && (
<pre
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
data-qqq-id="json-output"
>
{JSON.stringify(value, null, 2)}
</pre>
)}
</div>
<Suspense fallback={<div role="status" aria-label="Loading developer view" className="py-12 text-center">Loading developer view…</div>}>
<RecordDeveloperView />
</Suspense>
)
}
@@ -33,6 +33,7 @@ import React, { lazy, Suspense, useEffect } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { AlertsRecordPanelSlot } from '@/components/alerts/AlertsRecordPanelSlot'
import { addRecentRecord, removeRecentRecord } from '@/lib/utils/recent-records'
import { recordAnalytics } from '@/lib/analytics'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
@@ -199,6 +200,7 @@ export default function RecordViewPage() {
{record && associations.isError && (
<p role="alert" className="mb-4 text-destructive">Related records could not be loaded.</p>
)}
<AlertsRecordPanelSlot metaData={metaData} tableName={slug} recordId={recordId} />
<Suspense fallback={<div role="status" aria-label="Loading record view" className="py-12 text-center">Loading record…</div>}><RecordView
instance={metaData}
tableMetaData={tableMetaData}
+1 -1
View File
@@ -46,7 +46,7 @@ import { NotFoundState } from '@/components/layout/NotFoundState'
// Metadata selects one screen. Keep the other three screens out of this route's first load.
const RecordQuery = lazy(() => import('@/components/query/RecordQuery').then((module) => ({ default: module.RecordQuery })))
const ProcessRun = lazy(() => import('@/components/process/ProcessRun').then((module) => ({ default: module.ProcessRun })))
import { ProcessRun } from '@/components/process/ProcessRunLazy'
const AppHome = lazy(() => import('@/components/widgets/AppHome').then((module) => ({ default: module.AppHome })))
const ReportRun = lazy(() => import('@/components/reports/ReportRun').then((module) => ({ default: module.ReportRun })))
@@ -0,0 +1,47 @@
/*
* 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 Notifications page — the notification center's full list (qbit-alerts), with filters
* and paging in the URL (`status`, `category`, `severity`, `offset`). A fixed page under `/app`
* like `/app/search`, so the static export emits it and the dashboard layout wraps it.
*/
'use client'
import React from 'react'
import dynamic from 'next/dynamic'
import { Loader2 } from 'lucide-react'
/** The page loads on demand, so its code stays out of every route's first load. */
const NotificationsPage = dynamic(() => import('@/components/alerts/NotificationsPage').then((module) => module.NotificationsPage), {
ssr: false,
loading: () => (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-page-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading notifications…
</p>
),
})
/**
* Renders the notifications page at `/app/notifications`.
*
* @returns The page.
*/
export default function NotificationsRoute() {
return <NotificationsPage />
}
+84
View File
@@ -0,0 +1,84 @@
/*
* 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 /app home page: Next's overview, or the instance's home app (QRun-IO/qqq#1059)
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const replace = vi.fn()
vi.mock('next/navigation', () => ({
usePathname: () => '/app',
useRouter: () => ({ push: vi.fn(), replace }),
}))
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
vi.mock('@/lib/auth/use-auth', () => ({ useAuth: () => ({ user: { name: 'Avery Example' } }) }))
const metaData = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn(async () => metaData.value) }))
import DashboardPage from './page'
/**
* Instance metadata with two apps, optionally designating a home app.
*
* @param homeAppName - The home app.
* @returns The metadata.
*/
function instance(homeAppName?: string): Record<string, unknown> {
return {
apps: { home: { name: 'home', label: 'Home' }, cms: { name: 'cms', label: 'CMS' } },
appTree: [{ name: 'home', label: 'Home', type: 'APP', children: [] }, { name: 'cms', label: 'CMS', type: 'APP', children: [] }],
tables: {}, processes: {}, reports: {}, widgets: {},
...(homeAppName ? { homeAppName } : {}),
}
}
/** Renders the page with a fresh query client. */
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}><DashboardPage /></QueryClientProvider>)
}
beforeEach(() => {
replace.mockReset()
})
describe('/app home page', () => {
it('shows the overview when the instance designates no home app', async () => {
metaData.value = instance()
renderPage()
await waitFor(() => expect(screen.getByText('CMS')).toBeInTheDocument())
expect(document.querySelector('[data-qqq-id="dashboard-home"]')).toBeInTheDocument()
expect(replace).not.toHaveBeenCalled()
})
it('goes to the home app dashboard instead of the overview when the instance designates one', async () => {
metaData.value = instance('home')
renderPage()
await waitFor(() => expect(replace).toHaveBeenCalledWith('/app/home'))
expect(document.querySelector('[data-qqq-id="dashboard-home"]')).not.toBeInTheDocument()
expect(document.querySelector('[data-qqq-id="dashboard-home-redirect"]')).toHaveAttribute('aria-busy', 'true')
})
it('keeps the overview when the designated home app is not one the user may open', async () => {
metaData.value = { ...instance(), homeAppName: 'secret' }
renderPage()
await waitFor(() => expect(screen.getByText('CMS')).toBeInTheDocument())
expect(replace).not.toHaveBeenCalled()
})
})
+15 -1
View File
@@ -22,6 +22,7 @@
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import {
Workflow,
@@ -44,6 +45,7 @@ import { canInsertRecords, canRunProcess } from '@/lib/auth/permissions'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import type { SidebarRoute } from '@/lib/hooks/use-routes'
import { getRecentRecords } from '@/lib/utils/recent-records'
import { DEFAULT_HOME_PATH } from '@/lib/utils/home-app'
import type { RecentRecord } from '@/lib/utils/recent-records'
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
@@ -102,7 +104,15 @@ export default function DashboardPage() {
staleTime: 1000 * 60 * 30,
})
const { sidebarRoutes, navTargets } = useAppTreeRoutes(metaData)
const { sidebarRoutes, navTargets, homePath } = useAppTreeRoutes(metaData)
const router = useRouter()
const hasHomeApp = homePath !== DEFAULT_HOME_PATH
// An instance with a home app (homeAppName) shows that app's dashboard as its home page
// (the backend redirects full page loads of /app; this covers client-side navigation).
useEffect(() => {
if (hasHomeApp) router.replace(homePath)
}, [hasHomeApp, homePath, router])
const appRoutes = sidebarRoutes.filter((route) => route.nodeType === 'APP')
const appName = metaData?.branding?.appName || 'QQQ Admin'
@@ -145,6 +155,10 @@ export default function DashboardPage() {
{ id: 'widgets', label: 'Widgets', value: widgetNames.size, hint: 'Dashboard components', icon: <TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
]
if (hasHomeApp) {
return <div aria-busy="true" data-qqq-id="dashboard-home-redirect" />
}
return (
<div className="space-y-8" data-qqq-id="dashboard-home">
{/* Welcome header */}
+18 -164
View File
@@ -15,179 +15,33 @@
*/
/**
* @file Search page — every page (app, table, process, report) and recently viewed record matching
* `?q=`, plus the records a backend record search finds when the metadata advertises searchable tables.
* @file Search page — every page (app, table, process, report) and recently viewed record
* matching `?q=`, plus the records a backend record search finds when the metadata
* advertises searchable tables. URL state: `q`, `tables`, `view=pages`, `sort=recent`.
*/
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { Search } from 'lucide-react'
import React from 'react'
import dynamic from 'next/dynamic'
import { Loader2 } from 'lucide-react'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useQContext } from '@/lib/context/q-context'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import { useRecordSearch } from '@/lib/hooks/use-record-search'
import { getRecentRecords } from '@/lib/utils/recent-records'
import type { RecentRecord } from '@/lib/utils/recent-records'
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
import { searchableTables } from '@/lib/utils/record-search'
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
/** Fallback icon kind for each app-tree node type. */
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
/** Records listed per table on the search page. */
const PAGE_RECORDS_PER_TABLE = 25
/** The results page loads on demand, so its code stays out of every route's first load. */
const SearchPage = dynamic(() => import('@/components/search/SearchPage').then((module) => module.SearchPage), {
ssr: false,
loading: () => (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="search-page-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading search…
</p>
),
})
/**
* Renders the search page at `/app/search`.
*
* Pages and recently viewed records match locally by label. When the metadata
* lists searchable tables, the records a backend record search finds are listed
* too; otherwise no search request is made. The `q` parameter is kept in the URL
* so results are shareable and survive refresh.
*
* @returns The search input and matching pages and records.
* @returns The search page.
*/
export default function SearchResultsPage() {
const searchParams = useSearchParams()
const router = useRouter()
const { setPageHeader } = useQContext()
const term = searchParams.get('q') ?? ''
const [inputValue, setInputValue] = useState(term)
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const { navTargets } = useAppTreeRoutes(metaData)
useEffect(() => {
setInputValue(term)
}, [term])
useEffect(() => {
setRecentRecords(getRecentRecords())
}, [])
useEffect(() => {
setPageHeader('Search')
}, [setPageHeader])
const searchTables = useMemo(() => searchableTables(metaData), [metaData])
const recordSearch = useRecordSearch(term, searchTables, PAGE_RECORDS_PER_TABLE)
const items = useMemo(
() => (term.trim() ? buildNavigationSearchItems(navTargets, recentRecords, term, 50, recordSearch.results) : []),
[navTargets, recentRecords, term, recordSearch.results]
)
const pages = items.filter((item) => item.kind === 'page')
const found = items.filter((item) => item.kind === 'result')
const records = items.filter((item) => item.kind === 'record')
const searching = recordSearch.available && recordSearch.isSearching
/**
* Writes the input value to the `q` URL parameter.
*
* @param event - Form submit event.
*/
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const next = inputValue.trim()
router.replace(next ? `/app/search?q=${encodeURIComponent(next)}` : '/app/search')
}
return (
<div className="space-y-6" data-qqq-id="search-results-page">
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{term ? <>Search results for &ldquo;{term}&rdquo;</> : 'Search'}
</h1>
<form role="search" onSubmit={handleSubmit} className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
type="search"
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
placeholder={recordSearch.available ? 'Find an app, table, process or record...' : 'Find an app, table, process or recent record...'}
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
className="w-full rounded-xl border border-input bg-card py-3 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
data-qqq-id="search-input"
/>
</form>
{!term && (
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-prompt-state">
Enter text above to find apps, tables, processes and recently viewed records.
</p>
)}
{/* Record search progress and errors, announced politely */}
<p role="status" aria-live="polite" className={searching || (recordSearch.available && recordSearch.isError) ? 'text-sm text-muted-foreground' : 'sr-only'} data-qqq-id="search-records-status">
{searching ? 'Searching records…' : recordSearch.available && recordSearch.isError ? 'Record search failed. Try again.' : ''}
</p>
{term && items.length === 0 && !searching && (
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-empty-state">
{recordSearch.available ? 'No pages or records match' : 'No pages or recent records match'} &ldquo;{term}&rdquo;.
</p>
)}
{pages.length > 0 && (
<section aria-label="Pages" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-pages">
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Pages</h2>
<ul className="divide-y divide-border">
{pages.map((item) => (
<li key={item.path}>
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-page-${item.path.replace(/\//g, '-')}`}>
<MetadataIcon icon={item.icon} kind={item.nodeType ? ICON_KIND[item.nodeType] : 'app'} className="text-muted-foreground" />
<span className="font-medium text-foreground">{item.label}</span>
{item.context && <span className="text-xs text-muted-foreground">{item.context}</span>}
</Link>
</li>
))}
</ul>
</section>
)}
{found.length > 0 && (
<section aria-label="Records" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-found-records">
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Records</h2>
<ul className="divide-y divide-border">
{found.map((item) => (
<li key={item.path}>
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-record-${item.path.replace(/\//g, '-')}`}>
<span className="font-medium text-foreground">{item.label}</span>
{item.context && <span className="text-xs text-muted-foreground">{item.context}</span>}
</Link>
</li>
))}
</ul>
</section>
)}
{records.length > 0 && (
<section aria-label="Recently viewed" className="overflow-hidden rounded-xl border border-border bg-card shadow-sm" data-qqq-id="search-group-records">
<h2 className="border-b border-border px-6 py-3 text-sm font-semibold text-foreground">Recently viewed</h2>
<ul className="divide-y divide-border">
{records.map((item) => (
<li key={item.path}>
<Link href={item.path} className="flex items-center gap-3 px-6 py-3 text-sm transition-colors hover:bg-accent" data-qqq-id={`search-recent-${item.path.replace(/\//g, '-')}`}>
<MetadataIcon icon={item.icon} kind="table" className="text-muted-foreground" />
<span className="font-medium text-foreground">{item.label}</span>
<span className="text-xs text-muted-foreground">{item.context}</span>
</Link>
</li>
))}
</ul>
</section>
)}
</div>
)
return <SearchPage />
}
+45 -8
View File
@@ -48,6 +48,11 @@ import { buildBreadcrumbs, buildDocumentTitle } from '@/components/layout/Breadc
import { CommandMenu } from '@/components/feedback/CommandMenu'
import { SearchDialog } from '@/components/feedback/SearchDialog'
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
import { AssistantProvider, useAssistantController } from '@/components/assistant/AssistantProvider'
import { AssistantHeaderButton } from '@/components/assistant/AssistantHeaderButton'
import { AssistantMount } from '@/components/assistant/AssistantMount'
import { AlertsBell, isAlertsInstalled } from '@/components/alerts/AlertsBell'
import { NotificationBannerSlot } from '@/components/alerts/NotificationBannerSlot'
/**
* Inner layout that renders the full dashboard chrome.
@@ -98,8 +103,12 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
enabled: isAuthenticated,
})
// QQQ Assistant (qbit-assistant): hidden and inert unless its /assistant/config probe enables it
const assistant = useAssistantController(metaData, isAuthenticated)
const { enabled: assistantEnabled, toggle: toggleAssistant } = assistant
// Generate sidebar routes and path map from app tree
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets } = useAppTreeRoutes(metaData)
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, homePath } = useAppTreeRoutes(metaData)
// Every route change starts at the top of the page (Material App scrolls to 0 on each pathname)
useEffect(() => {
@@ -180,6 +189,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
* Global `keydown` handler that drives the application-level keyboard shortcuts.
*
* - Cmd+K / Ctrl+K — toggles the command palette.
* - Cmd+J / Ctrl+J — toggles the assistant panel (when available; works in text fields too).
* - Escape — closes all overlays (command palette, search dialog, help dialog, mobile sidebar).
* - `.` — opens the command palette (only when not in a text field).
* - `/` — opens the search dialog (only when not in a text field).
@@ -192,6 +202,11 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
e.preventDefault()
setCommandOpen((prev) => !prev)
}
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'j' && assistantEnabled) {
e.preventDefault()
setCommandOpen(false)
toggleAssistant()
}
if (e.key === 'Escape') {
setCommandOpen(false)
setSearchOpen(false)
@@ -215,7 +230,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
setHelpOpen(true)
}
}
}, [isInputFocused])
}, [isInputFocused, assistantEnabled, toggleAssistant])
useEffect(() => {
document.addEventListener('keydown', handleGlobalKeyDown)
@@ -254,8 +269,14 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
)
}
// One scroll container: the shell is exactly the viewport and only <main> scrolls. Both are
// positioned (`relative`) so they contain absolutely positioned descendants (`sr-only` text,
// tooltips): otherwise such an element's containing block is the viewport, `overflow-hidden`
// cannot clip it, and one near the end of a long page (the company footer's "opens in a new
// tab") makes the document taller than the window, a second scrollbar beside main's.
return (
<div className="flex h-screen flex-col overflow-hidden bg-background">
<AssistantProvider value={assistant}>
<div className="relative flex h-screen flex-col overflow-hidden bg-background" data-qqq-id="app-shell">
{/* Top-of-site banner spans the whole window, above the sidebar */}
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_SITE" className="border-x-0 border-t-0" />
<BrandedHeaderBar theme={theme} />
@@ -273,6 +294,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
homePath={homePath}
logout={logout}
userName={user?.name}
userEmail={user?.email}
@@ -283,6 +305,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
homePath={homePath}
logout={logout}
userName={user?.name}
userEmail={user?.email}
@@ -292,8 +315,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
data-qqq-id="sidebar-mobile"
/>
{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Main content area (min-w-0: the docked assistant panel never pushes it wider than the window) */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Header — breadcrumbs + search in one row */}
<Header
appName={metaData?.branding?.appName}
@@ -306,12 +329,15 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
helpButtonRef={helpButtonRef}
pathToLabelMap={pathToLabelMap}
ancestorAppMap={ancestorAppMap}
homePath={homePath}
navTargets={navTargets}
searchTables={searchTables}
assistantButton={<AssistantHeaderButton />}
notificationsButton={isAuthenticated && isAlertsInstalled(metaData) ? <AlertsBell metaData={metaData} /> : null}
/>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
<main className="relative flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
{metaLoading ? (
<div
className="flex items-center justify-center py-12"
@@ -324,23 +350,34 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
) : (
<>
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_BODY" className="mb-4 rounded-lg" />
<NotificationBannerSlot metaData={metaData} enabled={isAuthenticated} />
{children}
<CompanyFooter branding={metaData?.branding} />
</>
)}
</main>
</div>
{/* Assistant panel: docked beside the page from 1024 px, a sheet below; lazily loaded */}
<AssistantMount />
</div>
{/* Command Palette */}
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} navTargets={navTargets} metaData={metaData} />
<CommandMenu
open={commandOpen}
onClose={() => setCommandOpen(false)}
navTargets={navTargets}
metaData={metaData}
onAskAssistant={assistantEnabled ? (text) => assistant.openWith(text) : undefined}
/>
{/* Search Dialog */}
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} navTargets={navTargets} searchTables={searchTables} />
{/* Keyboard Shortcuts Help Dialog */}
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} returnFocusRef={helpButtonRef} />
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} returnFocusRef={helpButtonRef} assistantEnabled={assistantEnabled} />
</div>
</AssistantProvider>
)
}
+156
View File
@@ -0,0 +1,156 @@
/*
* 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 Alerts record panel: metadata gating (no request without the qbit's app) and the
// panel's hidden / open / watch states. The bell, popover and banners are in
// NotificationCenter.test.tsx.
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 { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import type { AlertsRecordPanel } from '@/types/alerts'
import type { QInstance } from '@/types/metadata'
import { isAlertsInstalled } from './AlertsBell'
import { AlertsRecordPanelSlot } from './AlertsRecordPanelSlot'
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
vi.mock('next/navigation', () => ({
useRouter: () => router,
usePathname: () => window.location.pathname,
useSearchParams: () => new URLSearchParams(window.location.search),
}))
/** The inbox summary (the record panel shows only when it loads). */
const SUMMARY = { unreadCount: 0, highestUnreadSeverity: null, notificationsHref: '/app/notifications', items: [], banners: [] }
const PANEL: AlertsRecordPanel = {
visible: true,
openCount: 1,
open: [{ alertId: 9, title: 'Post has no excerpt', severity: 'WARNING', status: 'OPEN', group: 'Content', age: '1 d', href: '/app/alerts_alert/9' }],
resolvedLast30Days: 2,
viewAllHref: '/app/alerts_alert?filter=x',
canManage: true,
watching: false,
}
/** Instance metadata announcing qbit-alerts (its `alerts` app, present when the user holds `alerts.view`). */
const WITH_ALERTS = { apps: { alerts: { name: 'alerts', label: 'Alerts' } }, tables: {}, processes: {} } as unknown as QInstance
/** Instance metadata without qbit-alerts (e.g. the QQQ sample app). */
const WITHOUT_ALERTS = { apps: { home: { name: 'home', label: 'Home' } }, tables: {}, processes: {} } as unknown as QInstance
/**
* Renders a component with a fresh query client.
*
* @param ui - The component.
* @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('isAlertsInstalled', () => {
it('is true only when the metadata lists the alerts app', () => {
expect(isAlertsInstalled(WITH_ALERTS)).toBe(true)
expect(isAlertsInstalled(WITHOUT_ALERTS)).toBe(false)
expect(isAlertsInstalled(undefined)).toBe(false)
})
})
describe('AlertsRecordPanelSlot', () => {
it('sends no request when the metadata does not announce Alerts', async () => {
let calls = 0
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json(SUMMARY)
}),
http.get('/qqq/v1/alerts/record-panel', () => {
calls++
return HttpResponse.json(PANEL)
})
)
const { container } = renderWithClient(<AlertsRecordPanelSlot metaData={WITHOUT_ALERTS} tableName="blog_post" recordId="1" />)
await new Promise((resolve) => setTimeout(resolve, 100))
expect(container).toBeEmptyDOMElement()
expect(calls).toBe(0)
})
it('renders nothing when the backend still answers that Alerts is not available', async () => {
let panelCalls = 0
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => new HttpResponse(null, { status: 403 })),
http.get('/qqq/v1/alerts/record-panel', () => {
panelCalls++
return HttpResponse.json(PANEL)
})
)
const { container } = renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="1" />)
await new Promise((resolve) => setTimeout(resolve, 50))
expect(container).toBeEmptyDOMElement()
expect(panelCalls).toBe(0)
})
it('renders nothing on the Alerts tables themselves, or when the record has nothing to show', async () => {
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
http.get('/qqq/v1/alerts/record-panel', () => HttpResponse.json({ ...PANEL, visible: false, open: [], openCount: 0, resolvedLast30Days: 0 }))
)
const own = renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="alerts_alert" recordId="1" />)
const none = renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="1" />)
await new Promise((resolve) => setTimeout(resolve, 50))
expect(own.container).toBeEmptyDOMElement()
expect(none.container).toBeEmptyDOMElement()
})
it('shows open alerts with actions, recent resolutions, and watches the record', async () => {
let watched: unknown = null
let panel = PANEL
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
http.get('/qqq/v1/alerts/record-panel', ({ request }) => {
const url = new URL(request.url)
expect(url.searchParams.get('tableName')).toBe('blog_post')
expect(url.searchParams.get('recordId')).toBe('5')
return HttpResponse.json(panel)
}),
http.post('/qqq/v1/alerts/record-panel/watch', async ({ request }) => {
watched = await request.json()
panel = { ...PANEL, watching: true }
return HttpResponse.json({ watching: true, created: true })
})
)
const user = userEvent.setup()
renderWithClient(<AlertsRecordPanelSlot metaData={WITH_ALERTS} tableName="blog_post" recordId="5" />)
const section = await screen.findByRole('region', { name: 'Open alerts (1)' })
expect(await axe(section)).toHaveNoViolations()
expect(within(section).getByRole('link', { name: 'Post has no excerpt' })).toHaveAttribute('href', '/app/alerts_alert/9')
expect(within(section).getByRole('link', { name: 'Acknowledge' })).toHaveAttribute('href', '/app/alertsAcknowledge?recordsParam=recordIds&recordIds=9&tableName=alerts_alert')
expect(within(section).getByRole('link', { name: 'Snooze' })).toBeInTheDocument()
expect(within(section).getByText('2 resolved in the last 30 days')).toBeInTheDocument()
await user.click(within(section).getByRole('button', { name: 'Watch alerts for this record' }))
await waitFor(() => expect(watched).toEqual({ tableName: 'blog_post', recordId: '5' }))
expect(await within(section).findByText("You watch this record's alerts")).toBeInTheDocument()
})
})
+167
View File
@@ -0,0 +1,167 @@
/*
* 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 AlertsBell — the header bell of the notification center (qbit-alerts). Like the QQQ
* Assistant, it is gated on instance metadata: unless the metadata lists the qbit's `alerts`
* app (which QQQ includes only when qbit-alerts is installed and the user holds `alerts.view`,
* the same permission its `/alerts/*` routes require), it renders nothing and sends no request
* at all. When enabled it polls the inbox summary every `pollSeconds` (60) while the tab is
* visible (TanStack Query pauses interval refetches in a hidden tab and refetches when it shows
* again), backing off exponentially to 5 minutes after errors. The popover's code loads on
* first open, so the shell stays small.
*/
'use client'
import React, { Suspense, lazy, useRef, useState } from 'react'
import { Bell } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { getAlertsInboxSummary } from '@/lib/api/alerts'
import { queryKeys } from '@/lib/query-client'
import type { AlertsInboxSummary } from '@/types/alerts'
import type { QInstance } from '@/types/metadata'
const AlertsBellPopover = lazy(() => import('./AlertsBellPopover'))
/** The app qbit-alerts publishes (`AlertsQBitConfig.APP_NAME`), gated on `alerts.view`. */
export const ALERTS_APP_NAME = 'alerts'
/**
* Whether the instance metadata announces qbit-alerts to this user: its `alerts` app is
* present. QQQ leaves an app out of the metadata when the qbit is not installed or the user
* lacks its permission (`alerts.view`, which the `/alerts/*` routes also require), so this is
* the precondition for any Alerts request.
*
* @param metaData - Instance metadata.
* @returns True when the Alerts app is present.
*/
export function isAlertsInstalled(metaData: QInstance | undefined): boolean {
const app = metaData?.apps?.[ALERTS_APP_NAME]
return Boolean(app) && typeof app === 'object'
}
/** The longest wait between polls after errors (5 minutes). */
export const MAX_POLL_MS = 300_000
/** Consecutive failed summary loads (one shared query, so one counter). */
let failures = 0
/**
* The summary loader, counting consecutive failures for the backoff.
*
* @returns The summary.
*/
async function loadSummary(): Promise<AlertsInboxSummary | null> {
try {
const summary = await getAlertsInboxSummary()
failures = 0
return summary
} catch (error) {
failures++
throw error
}
}
/**
* The wait before the next poll: `pollSeconds` normally, doubling per consecutive failure up to
* {@link MAX_POLL_MS}; no polling once the backend has said Alerts is not for this user.
*
* @param data - The last summary (null: not available; undefined: none yet).
* @param failed - Consecutive failures.
* @returns Milliseconds, or false.
*/
export function pollDelay(data: AlertsInboxSummary | null | undefined, failed: number): number | false {
if (data === null && failed === 0) return false
const base = (data?.pollSeconds ?? 60) * 1000
return failed > 0 ? Math.min(base * 2 ** failed, MAX_POLL_MS) : base
}
/**
* The bell's inbox summary (null when the backend still answers that Alerts is unavailable).
* Shared with the record panel slot and the banner slot. It never fetches (nor polls) unless
* `enabled`.
*
* @param enabled - Whether to load it: authenticated and {@link isAlertsInstalled}.
* @returns The TanStack Query result.
*/
export function useAlertsInbox(enabled: boolean) {
return useQuery<AlertsInboxSummary | null>({
queryKey: queryKeys.alertsInbox(),
queryFn: loadSummary,
enabled,
retry: false,
staleTime: 30_000,
refetchInterval: (query) => pollDelay(query.state.data, failures),
refetchIntervalInBackground: false,
refetchOnWindowFocus: (query) => (query.state.data === null ? false : 'always'),
})
}
/**
* The header bell with its unread badge (red when a CRITICAL alert is unread).
*
* @param props - `metaData`: instance metadata, which must announce Alerts ({@link isAlertsInstalled}).
* @returns The bell, or null when Alerts is not available.
*/
export function AlertsBell({ metaData }: { metaData: QInstance | undefined }) {
const { data } = useAlertsInbox(isAlertsInstalled(metaData))
const [open, setOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
if (!data) return null
const unread = data.unreadCount
const label = unread > 0 ? `Notifications, ${unread} unread` : 'Notifications, none unread'
const critical = data.highestUnreadSeverity === 'CRITICAL'
return (
<div className="relative">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((value) => !value)}
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={label}
aria-haspopup="dialog"
aria-expanded={open}
data-qqq-id="button-notifications"
>
<Bell className="h-5 w-5 text-foreground/60" aria-hidden="true" />
{unread > 0 && (
<span
className={`absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
aria-hidden="true"
data-qqq-id="alerts-unread-badge"
>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
<span className="sr-only" aria-live="polite" data-qqq-id="alerts-unread-announcer">{label}</span>
{open && (
<Suspense fallback={null}>
<AlertsBellPopover
summary={data}
onClose={() => {
setOpen(false)
buttonRef.current?.focus({ preventScroll: true })
}}
/>
</Suspense>
)}
</div>
)
}
+190
View File
@@ -0,0 +1,190 @@
/*
* 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 AlertsBellPopover — the bell's popover (loaded on first open): the latest
* notifications, newest first (severity icon, title, source, relative time), each with mark
* read/unread and dismiss when the backend sends its recipient id, plus "Mark all read" and
* "View all". Opening an item marks it read. Focus stays inside while it is open; Escape or a
* click outside closes it.
*/
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import type { AlertsInboxSummary, InboxItem } from '@/types/alerts'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest } from './NotificationParts'
/** Props for the popover. */
export interface AlertsBellPopoverProps {
/** The inbox summary to show. */
summary: AlertsInboxSummary
/** Close the popover (focus returns to the bell). */
onClose: () => void
}
/**
* The bell popover.
*
* @param props - Component properties.
* @returns The popover dialog.
*/
export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopoverProps) {
const router = useRouter()
const panelRef = useRef<HTMLDivElement>(null)
// a dropdown under the bell from 640 px; full width under the header below that (inline, so no new CSS)
const [wide, setWide] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(min-width: 640px)').matches)
useEffect(() => {
if (typeof window.matchMedia !== 'function') return
const query = window.matchMedia('(min-width: 640px)')
const onChange = () => setWide(query.matches)
query.addEventListener?.('change', onChange)
return () => query.removeEventListener?.('change', onChange)
}, [])
const placement: React.CSSProperties = wide
? { position: 'absolute', right: 0, top: '100%', marginTop: '0.5rem', width: '24rem', maxHeight: '70vh' }
: { position: 'fixed', left: '0.5rem', right: '0.5rem', top: 'calc(var(--qqq-header-height) + 0.25rem)', maxHeight: '70vh' }
useEffect(() => {
const panel = panelRef.current
panel?.querySelector<HTMLElement>('a,button')?.focus({ preventScroll: true })
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab' || !panel) return
const focusable = Array.from(panel.querySelectorAll<HTMLElement>('a,button:not([disabled])'))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
const onPointer = (event: PointerEvent) => {
if (panel && !panel.contains(event.target as Node) && !(event.target as HTMLElement).closest?.('[data-qqq-id="button-notifications"]')) onClose()
}
document.addEventListener('keydown', onKey)
document.addEventListener('pointerdown', onPointer)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('pointerdown', onPointer)
}
}, [onClose])
const actions = useInboxActions()
const openItem = (item: InboxItem) => {
const read = readRequest(item)
if (read) actions.mutate(read)
onClose()
if (item.href) router.push(item.href)
}
return (
<div
ref={panelRef}
role="dialog"
aria-modal="false"
aria-label="Notifications"
className="z-50 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
style={placement}
data-qqq-id="alerts-popover"
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">Notifications</h2>
<span className="text-xs text-muted-foreground">{summary.unreadCount > 0 ? `${summary.unreadCount} unread` : 'All read'}</span>
</div>
{summary.items.length === 0 ? (
<p className="px-4 py-6 text-sm text-muted-foreground" data-qqq-id="alerts-popover-empty">You have no notifications.</p>
) : (
<ul className="divide-y divide-border">
{summary.items.map((item) => {
const kind = linkKind(item.href)
const title = <span className={`block break-words text-foreground ${item.unread ? 'font-semibold' : ''}`}>{item.title}</span>
const meta = <span className="block text-xs text-muted-foreground">{itemMeta(item)}</span>
const linkClass = 'block min-w-0 flex-1 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:underline'
return (
<li key={item.key} className={`flex items-start gap-3 py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-75'}`} data-qqq-id={`alerts-popover-item-${item.key}`}>
<SeverityIcon severity={item.severity} />
{kind === 'app' ? (
<a
href={item.href ?? undefined}
onClick={(event) => {
event.preventDefault()
openItem(item)
}}
className={linkClass}
>
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</a>
) : kind === 'external' ? (
<a href={item.href ?? undefined} target="_blank" rel="noopener noreferrer" onClick={() => openItem({ ...item, href: null })} className={linkClass}>
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</a>
) : (
<span className="min-w-0 flex-1">
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</span>
)}
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
</li>
)
})}
</ul>
)}
<div className="flex items-center justify-between gap-2 border-t border-border px-4 py-3 text-sm">
<button
type="button"
disabled={summary.unreadCount === 0}
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
className="rounded-md px-2 py-1 text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
data-qqq-id="alerts-mark-all-read"
>
Mark all read
</button>
<a
href={summary.notificationsHref}
onClick={(event) => {
event.preventDefault()
onClose()
router.push(summary.notificationsHref)
}}
className="rounded-md px-2 py-1 font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="alerts-view-all"
>
View all
</a>
</div>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
/*
* 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 AlertsRecordPanel — the "Open alerts" panel on a record's view (loaded only when the
* Alerts qbit is available): each open alert that references the record (severity dot, title,
* age, status, Acknowledge / Snooze for managers), "N resolved in the last 30 days", and "Watch
* alerts for this record". Hidden when there is nothing open and nothing resolved in 30 days.
* One column, no inner scroll, at most five rows plus "View all".
*/
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { getAlertsRecordPanel, watchAlertsRecord } from '@/lib/api/alerts'
import { queryKeys } from '@/lib/query-client'
import { SeverityDot } from './SeverityDot'
/** Props for the panel. */
export interface AlertsRecordPanelProps {
/** The record's table. */
tableName: string
/** The record's primary key. */
recordId: string
}
/**
* The record view's "Open alerts" panel.
*
* @param props - Component properties.
* @returns The panel, or null when there is nothing to show.
*/
export default function AlertsRecordPanel({ tableName, recordId }: AlertsRecordPanelProps) {
const queryClient = useQueryClient()
const [watchError, setWatchError] = useState<string | null>(null)
const { data } = useQuery({
queryKey: queryKeys.alertsRecordPanel(tableName, recordId),
queryFn: () => getAlertsRecordPanel(tableName, recordId),
retry: false,
staleTime: 30_000,
})
if (!data || !data.visible) return null
const actionHref = (processName: string, alertId: number) =>
`/app/${processName}?recordsParam=recordIds&recordIds=${alertId}&tableName=alerts_alert`
return (
<section
aria-labelledby="alerts-record-panel-title"
className="mb-4 rounded-xl border border-border bg-card p-4"
data-qqq-id="alerts-record-panel"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 id="alerts-record-panel-title" className="text-sm font-semibold text-foreground">
{data.openCount > 0 ? `Open alerts (${data.openCount})` : 'Alerts'}
</h2>
{data.watching ? (
<span className="text-xs text-muted-foreground" data-qqq-id="alerts-record-watching">You watch this record&apos;s alerts</span>
) : (
<button
type="button"
onClick={async () => {
setWatchError(null)
try {
await watchAlertsRecord(tableName, recordId)
await queryClient.invalidateQueries({ queryKey: queryKeys.alertsRecordPanel(tableName, recordId) })
} catch {
setWatchError('Could not subscribe. Try again.')
}
}}
className="rounded-md px-2 py-1 text-xs font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="alerts-record-watch"
>
Watch alerts for this record
</button>
)}
</div>
{watchError && <p role="alert" className="mt-2 text-xs text-destructive">{watchError}</p>}
{data.open.length > 0 && (
<ul className="mt-3 divide-y divide-border">
{data.open.map((alert) => (
<li key={alert.alertId} className="flex items-start gap-3 py-2 text-sm" data-qqq-id={`alerts-record-alert-${alert.alertId}`}>
<SeverityDot severity={alert.severity} />
<span className="min-w-0 flex-1">
<Link href={alert.href} className="block break-words font-medium text-foreground underline-offset-2 hover:underline">{alert.title}</Link>
<span className="block text-xs text-muted-foreground">
{alert.status.charAt(0) + alert.status.slice(1).toLowerCase()} · {alert.age === 'just now' ? 'just now' : `${alert.age} old`}
</span>
{data.canManage && alert.status !== 'RESOLVED' && (
<span className="mt-1 flex flex-wrap gap-3 text-xs">
{alert.status === 'OPEN' && (
<Link href={actionHref('alertsAcknowledge', alert.alertId)} className="text-primary hover:underline">Acknowledge</Link>
)}
<Link href={actionHref('alertsSnooze', alert.alertId)} className="text-primary hover:underline">Snooze</Link>
</span>
)}
</span>
</li>
))}
</ul>
)}
<p className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{data.resolvedLast30Days > 0 && (
<span data-qqq-id="alerts-record-resolved">
{data.resolvedLast30Days === 1 ? '1 resolved' : `${data.resolvedLast30Days} resolved`} in the last 30 days
</span>
)}
{data.viewAllHref && (
<Link href={data.viewAllHref} className="text-primary hover:underline" data-qqq-id="alerts-record-view-all">View all</Link>
)}
</p>
</section>
)
}
@@ -0,0 +1,46 @@
/*
* 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 AlertsRecordPanelSlot — where a record view's "Open alerts" panel goes. It renders
* nothing (sends no request and loads no panel code) unless the instance metadata announces
* Alerts and the bell's inbox summary loads, and never on the Alerts qbit's own tables.
*/
'use client'
import React, { Suspense, lazy } from 'react'
import type { QInstance } from '@/types/metadata'
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
const AlertsRecordPanel = lazy(() => import('./AlertsRecordPanel'))
/**
* The slot.
*
* @param props - Instance metadata (which must announce Alerts), and the record's table and primary key.
* @returns The lazily loaded panel, or null.
*/
export function AlertsRecordPanelSlot({ metaData, tableName, recordId }: { metaData: QInstance | undefined; tableName: string; recordId: string | undefined }) {
const { data } = useAlertsInbox(isAlertsInstalled(metaData))
if (!data || !recordId || tableName.startsWith('alerts_')) return null
return (
<Suspense fallback={null}>
<AlertsRecordPanel tableName={tableName} recordId={recordId} />
</Suspense>
)
}
@@ -0,0 +1,47 @@
/*
* 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 NotificationBannerSlot — shows sticky notifications (the summary's `banners`) above the
* page until dismissed. It shares the bell's summary query, so it sends nothing of its own and
* nothing at all unless the metadata announces Alerts; the banner UI loads only when there is
* a banner to show.
*/
'use client'
import React, { Suspense, lazy } from 'react'
import type { QInstance } from '@/types/metadata'
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
const NotificationBanners = lazy(() => import('./NotificationBanners'))
/**
* The banner slot.
*
* @param props - `metaData`: instance metadata; `enabled`: the user is signed in.
* @returns The banners, or null.
*/
export function NotificationBannerSlot({ metaData, enabled }: { metaData: QInstance | undefined; enabled: boolean }) {
const { data } = useAlertsInbox(enabled && isAlertsInstalled(metaData))
if (!data?.banners.length) return null
return (
<Suspense fallback={null}>
<NotificationBanners banners={data.banners} />
</Suspense>
)
}
@@ -0,0 +1,75 @@
/*
* 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 NotificationBanners — sticky notifications shown above the page (loaded on demand):
* severity, title, markdown body (rendered without HTML), an optional link, and Dismiss
* (`POST /alerts/inbox/dismiss`), which hides it at once.
*/
'use client'
import React from 'react'
import Link from 'next/link'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import type { InboxItem } from '@/types/alerts'
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
import { ItemActions, SeverityIcon, linkKind, severityLabel } from './NotificationParts'
// the severity's accent as a left border, on the card surface so it reads in light and dark mode
const ACCENT: Record<string, string> = { INFO: '#2563eb', SUCCESS: '#16a34a', WARNING: '#d97706', ERROR: '#ef4444', CRITICAL: '#dc2626' }
/**
* The banners.
*
* @param props - `banners`: the summary's sticky items.
* @returns A region with one banner per item.
*/
export default function NotificationBanners({ banners }: { banners: InboxItem[] }) {
const actions = useInboxActions()
return (
<section aria-label="Notification banners" className="mb-4 space-y-2" data-qqq-id="notification-banners">
{banners.map((item) => {
const kind = linkKind(item.href)
const label = item.linkLabel ?? 'Open'
return (
<div
key={item.key}
role={item.severity === 'CRITICAL' || item.severity === 'ERROR' ? 'alert' : 'status'}
aria-label={`${severityLabel(item.severity)}: ${item.title}`}
className="flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm shadow-sm"
style={{ borderLeftWidth: '4px', borderLeftColor: ACCENT[item.severity] ?? ACCENT.INFO }}
data-qqq-id={`notification-banner-${item.key}`}
>
<SeverityIcon severity={item.severity} />
<div className="min-w-0 flex-1 space-y-1">
<p className="break-words font-semibold text-foreground">{item.title}</p>
{item.body && <MarkdownView source={item.body} />}
{kind === 'app' && (
<Link href={item.href ?? ''} className="inline-block font-medium text-primary underline underline-offset-2">{label}</Link>
)}
{kind === 'external' && (
<a href={item.href ?? ''} target="_blank" rel="noopener noreferrer" className="inline-block font-medium text-primary underline underline-offset-2">{label}</a>
)}
</div>
<ItemActions item={item} onAction={(request) => actions.mutate(request)} dismissOnly />
</div>
)
})}
</section>
)
}
@@ -0,0 +1,380 @@
/*
* 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 notification center's bell, popover and banner slot: metadata gating (no request
// without the qbit's app), polling with backoff and the hidden-tab pause, badge, a11y, per-item
// and bulk actions (optimistic, rolled back on failure), and older backends.
import React from 'react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import type { QInstance } from '@/types/metadata'
import { AlertsBell, MAX_POLL_MS, pollDelay } from './AlertsBell'
import { NotificationBannerSlot } from './NotificationBannerSlot'
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
vi.mock('next/navigation', () => ({
useRouter: () => router,
usePathname: () => window.location.pathname,
useSearchParams: () => new URLSearchParams(window.location.search),
}))
/** The summary as the notification center backend sends it. */
const SUMMARY = {
unreadCount: 2,
highestUnreadSeverity: 'CRITICAL',
notificationsHref: '/app/notifications',
pollSeconds: 60,
items: [
{ id: 71, notificationId: 7, title: 'Nightly backup did not run', severity: 'CRITICAL', category: 'ALERT', source: 'Backups', age: '3 h', href: '/app/alerts_alert/7', unread: true },
{ id: 61, notificationId: 6, title: 'Release 4.1 is out', severity: 'SUCCESS', category: 'RELEASE', source: 'QQQ', age: '20 min', href: 'https://qrun.io/releases', unread: true },
{ id: 21, notificationId: 2, title: 'Old warning', severity: 'WARNING', category: 'SYSTEM', source: null, age: '2 d', href: null, unread: false },
],
banners: [] as unknown[],
}
/** The summary as qbit-alerts 0.1.0-RC.1 sends it (no `id`, `group` as the source). */
const LEGACY_SUMMARY = {
unreadCount: 1,
highestUnreadSeverity: 'ERROR',
alertsHref: '/app/alerts',
items: [{ alertId: 6, title: '3 CDN purges failed', severity: 'ERROR', status: 'OPEN', group: 'CDN', age: '20 min', href: '/app/alerts_alert/6', unread: true }],
}
/** Instance metadata announcing qbit-alerts (its `alerts` app, present when the user holds `alerts.view`). */
const WITH_ALERTS = { apps: { alerts: { name: 'alerts', label: 'Alerts' } }, tables: {}, processes: {} } as unknown as QInstance
/** Instance metadata without qbit-alerts (e.g. the QQQ sample app). */
const WITHOUT_ALERTS = { apps: { home: { name: 'home', label: 'Home' } }, tables: {}, processes: {} } as unknown as QInstance
/**
* Renders a component with a fresh query client.
*
* @param ui - The component.
* @returns The render result.
*/
function renderWithClient(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
/**
* Records the bodies posted to an inbox action.
*
* @param action - `read`, `unread` or `dismiss`.
* @returns The bodies, filled as requests arrive.
*/
function captureAction(action: string): unknown[] {
const bodies: unknown[] = []
server.use(http.post(`/qqq/v1/alerts/inbox/${action}`, async ({ request }) => {
bodies.push(await request.json())
return HttpResponse.json({ updated: 1 })
}))
return bodies
}
/**
* Waits.
*
* @param ms - Milliseconds.
* @returns A promise.
*/
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
describe('pollDelay', () => {
it('polls every pollSeconds, backs off exponentially to 5 minutes, and stops when unavailable', () => {
const summary = { unreadCount: 0, highestUnreadSeverity: null, items: [], banners: [], notificationsHref: '/app/notifications', pollSeconds: 30 }
expect(pollDelay(summary, 0)).toBe(30_000)
expect(pollDelay(undefined, 0)).toBe(60_000)
expect(pollDelay(summary, 1)).toBe(60_000)
expect(pollDelay(summary, 2)).toBe(120_000)
expect(pollDelay(undefined, 3)).toBe(MAX_POLL_MS)
expect(pollDelay(summary, 10)).toBe(MAX_POLL_MS)
expect(pollDelay(null, 0)).toBe(false)
})
})
describe('AlertsBell', () => {
beforeEach(() => {
router.push.mockReset()
})
afterEach(() => {
focusManager.setFocused(undefined)
})
it('sends no request and renders nothing when the metadata does not announce Alerts', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/*', () => {
calls++
return HttpResponse.json(SUMMARY)
}))
const without = renderWithClient(<AlertsBell metaData={WITHOUT_ALERTS} />)
const loading = renderWithClient(<AlertsBell metaData={undefined} />)
const banners = renderWithClient(<NotificationBannerSlot metaData={WITHOUT_ALERTS} enabled />)
await sleep(100)
expect(calls).toBe(0)
expect(without.container).toBeEmptyDOMElement()
expect(loading.container).toBeEmptyDOMElement()
expect(banners.container).toBeEmptyDOMElement()
})
it('renders and polls the inbox summary when the metadata announces Alerts', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
}))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
await waitFor(() => expect(calls).toBeGreaterThanOrEqual(3))
})
it('pauses polling while the tab is hidden and refetches when it shows again', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
}))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await screen.findByRole('button', { name: 'Notifications, 2 unread' })
act(() => focusManager.setFocused(false))
await sleep(100)
const hidden = calls
await sleep(250)
expect(calls).toBe(hidden)
act(() => focusManager.setFocused(true))
await waitFor(() => expect(calls).toBeGreaterThan(hidden))
})
it('renders nothing when the backend still answers that Alerts is not available (403 or 404)', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return new HttpResponse(null, { status: 404 })
}))
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await waitFor(() => expect(calls).toBe(1))
expect(container).toBeEmptyDOMElement()
})
it('renders nothing for a body that is not a summary', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json({ hello: 'world' })
}))
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await waitFor(() => expect(calls).toBe(1))
expect(container).toBeEmptyDOMElement()
})
it('shows the unread count, red for critical, capped at 99+, with an accessible label', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const first = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
expect(bell).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
first.unmount()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 140, highestUnreadSeverity: 'WARNING' })))
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await screen.findByRole('button', { name: 'Notifications, 140 unread' })
expect(screen.getByText('99+', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-primary')
})
it('opens the popover, opens an item (marking it read), and closes on Escape', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
await user.click(bell)
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
expect(await axe(dialog)).toHaveNoViolations()
const items = within(dialog).getAllByRole('listitem')
expect(items[0]).toHaveTextContent('Nightly backup did not run')
expect(items[0]).toHaveTextContent('Backups · 3 h ago')
expect(within(items[0]).getByText('Critical')).toBeInTheDocument()
expect(within(items[1]).getByText('Success')).toBeInTheDocument()
expect(within(items[1]).getByRole('link')).toHaveAttribute('target', '_blank')
expect(within(items[2]).queryByRole('link')).not.toBeInTheDocument()
const link = within(items[0]).getByRole('link')
await waitFor(() => expect(link).toHaveFocus())
await user.click(link)
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/7')
await waitFor(() => expect(reads).toEqual([{ id: 71 }]))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(bell)
await screen.findByRole('dialog', { name: 'Notifications' })
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(bell).toHaveFocus()
})
it('keeps focus inside the popover', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
const first = within(dialog).getAllByRole('link')[0]
await waitFor(() => expect(first).toHaveFocus())
await user.keyboard('{Shift>}{Tab}{/Shift}')
expect(within(dialog).getByRole('link', { name: 'View all' })).toHaveFocus()
await user.tab()
expect(first).toHaveFocus()
})
it('marks one read and one unread, and dismisses one', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const reads = captureAction('read')
const unreads = captureAction('unread')
const dismissals = captureAction('dismiss')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
await user.click(within(dialog).getByRole('button', { name: 'Mark read: Nightly backup did not run' }))
await waitFor(() => expect(reads).toEqual([{ id: 71 }]))
await user.click(within(dialog).getByRole('button', { name: 'Mark unread: Old warning' }))
await waitFor(() => expect(unreads).toEqual([{ id: 21 }]))
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Release 4.1 is out' }))
await waitFor(() => expect(dismissals).toEqual([{ id: 61 }]))
})
it('applies an action at once, before the server answers', async () => {
let summary = SUMMARY
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
http.post('/qqq/v1/alerts/inbox/dismiss', async () => {
await sleep(200)
summary = { ...SUMMARY, unreadCount: 1, items: SUMMARY.items.slice(1) }
return HttpResponse.json({ updated: 1 })
})
)
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Nightly backup did not run' }))
expect(within(dialog).queryByText('Nightly backup did not run')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Notifications, 1 unread' })).toBeInTheDocument()
})
it('rolls back when an action fails', async () => {
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
http.post('/qqq/v1/alerts/inbox/read', () => new HttpResponse(null, { status: 500 }))
)
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
})
it('marks all read and opens the notifications page', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
await waitFor(() => expect(reads).toEqual([{ all: true }]))
await user.click(screen.getByRole('link', { name: 'View all' }))
expect(router.push).toHaveBeenCalledWith('/app/notifications')
})
it('shows older backends without per-item actions, marking read by alert id', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(LEGACY_SUMMARY)))
const reads = captureAction('read')
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, 1 unread' }))
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
expect(dialog).toHaveTextContent('CDN · 20 min ago')
expect(within(dialog).queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument()
await user.click(within(dialog).getByRole('link', { name: /3 CDN purges failed/ }))
await waitFor(() => expect(reads).toEqual([{ alertId: 6 }]))
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/6')
})
it('shows an empty state with no items', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 0, highestUnreadSeverity: null, items: [] })))
const user = userEvent.setup()
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
await user.click(await screen.findByRole('button', { name: 'Notifications, none unread' }))
expect(await screen.findByText('You have no notifications.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Mark all read' })).toBeDisabled()
})
})
describe('NotificationBannerSlot', () => {
it('shows sticky notifications until dismissed', async () => {
const dismissals: unknown[] = []
let summary = {
...SUMMARY,
banners: [{ id: 90, title: 'Maintenance tonight', body: 'The site is **read-only** from 22:00. [Details](/app/status)', severity: 'WARNING', category: 'SYSTEM', href: 'https://status.example.com', linkLabel: 'Status page', unread: true, sticky: true }] as unknown[],
}
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
http.post('/qqq/v1/alerts/inbox/dismiss', async ({ request }) => {
dismissals.push(await request.json())
summary = { ...summary, banners: [] }
return HttpResponse.json({ updated: 1 })
})
)
const user = userEvent.setup()
renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled />)
const banner = await screen.findByRole('status', { name: 'Warning: Maintenance tonight' })
expect(await axe(banner)).toHaveNoViolations()
expect(within(banner).getByText('read-only').tagName).toBe('STRONG')
expect(within(banner).getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/app/status')
expect(within(banner).getByRole('link', { name: 'Status page' })).toHaveAttribute('target', '_blank')
expect(within(banner).queryByRole('button', { name: /Mark/ })).not.toBeInTheDocument()
await user.click(within(banner).getByRole('button', { name: 'Dismiss: Maintenance tonight' }))
await waitFor(() => expect(screen.queryByText('Maintenance tonight')).not.toBeInTheDocument())
expect(dismissals).toEqual([{ id: 90 }])
})
it('renders nothing without banners or when signed out', async () => {
let calls = 0
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
calls++
return HttpResponse.json(SUMMARY)
}))
const none = renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled />)
await waitFor(() => expect(calls).toBe(1))
expect(none.container).toBeEmptyDOMElement()
const signedOut = renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled={false} />)
await sleep(50)
expect(calls).toBe(1)
expect(signedOut.container).toBeEmptyDOMElement()
})
})
+179
View File
@@ -0,0 +1,179 @@
/*
* 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 Notification parts shared by the popover, the notifications page and the banners (all
* loaded on demand): the severity icon, the relative time, the safe link target, and the
* per-item read/unread and dismiss buttons (shown only for items with a recipient `id`).
*/
'use client'
import React from 'react'
import { AlertCircle, AlertTriangle, CheckCircle2, Info, Mail, MailOpen, OctagonAlert, X } from 'lucide-react'
import type { InboxItem, NotificationSeverity } from '@/types/alerts'
import type { InboxActionRequest } from '@/lib/hooks/use-inbox-actions'
// mid-tone colors that read on light and dark cards; inline so the icons add no CSS
const SEVERITY: Record<NotificationSeverity, { label: string; color: string; Icon: typeof Info }> = {
INFO: { label: 'Info', color: '#2563eb', Icon: Info },
SUCCESS: { label: 'Success', color: '#16a34a', Icon: CheckCircle2 },
WARNING: { label: 'Warning', color: '#d97706', Icon: AlertTriangle },
ERROR: { label: 'Error', color: '#ef4444', Icon: AlertCircle },
CRITICAL: { label: 'Critical', color: '#dc2626', Icon: OctagonAlert },
}
/**
* The severity's label ("Critical").
*
* @param severity - The severity.
* @returns Its label.
*/
export function severityLabel(severity: string): string {
return SEVERITY[severity as NotificationSeverity]?.label ?? severity
}
/**
* A severity icon with its name for screen readers.
*
* @param props - The severity.
* @returns The icon.
*/
export function SeverityIcon({ severity }: { severity: NotificationSeverity }) {
const { label, color, Icon } = SEVERITY[severity] ?? SEVERITY.INFO
return (
<span className="flex shrink-0 items-center pt-0.5" data-qqq-id={`notification-severity-${severity}`}>
<Icon className="h-4 w-4" style={{ color }} aria-hidden="true" />
<span className="sr-only">{label}</span>
</span>
)
}
/**
* The relative time of an item: the server's age ("3 h ago"), else from its delivery time.
*
* @param item - The item.
* @param now - The current time (tests).
* @returns The text, or empty.
*/
export function relativeTime(item: Pick<InboxItem, 'age' | 'deliveredAt'>, now: number = Date.now()): string {
if (item.age) return item.age === 'just now' ? item.age : `${item.age} ago`
const at = item.deliveredAt ? Date.parse(item.deliveredAt) : NaN
if (Number.isNaN(at)) return ''
const minutes = Math.round((now - at) / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes} min ago`
const hours = Math.round(minutes / 60)
if (hours < 24) return `${hours} h ago`
return `${Math.round(hours / 24)} d ago`
}
/**
* Classifies an item's link: a same-origin path starting with a single `/` (client navigation),
* https (a new tab), or none (rendered as plain text). Backslashes, whitespace and control
* characters are refused outright, since browsers normalize `\` to `/` (`/\evil.com` would
* leave the app), as is a second character of `/` or `\`.
*
* @param href - The link.
* @returns The kind.
*/
export function linkKind(href: string | null | undefined): 'app' | 'external' | null {
if (!href || /[\\\s\u0000-\u001f\u007f]/.test(href) || href[1] === '/' || href[1] === '\\') return null
let url: URL
try {
url = new URL(href, window.location.origin)
} catch {
return null
}
if (href.startsWith('/')) return url.origin === window.location.origin ? 'app' : null
return url.protocol === 'https:' ? 'external' : null
}
/**
* The secondary line: source and relative time (and the category on the page).
*
* @param item - The item.
* @param withCategory - Include the category.
* @returns The text.
*/
export function itemMeta(item: InboxItem, withCategory = false): string {
const category = withCategory && item.category ? item.category.charAt(0) + item.category.slice(1).toLowerCase() : null
return [category, item.source, relativeTime(item)].filter(Boolean).join(' · ')
}
/** Props for {@link ItemActions}. */
export interface ItemActionsProps {
/** The item. */
item: InboxItem
/** Runs an action. */
onAction: (request: InboxActionRequest) => void
/** Hide the read/unread toggle (banners). */
dismissOnly?: boolean
}
const ICON_BUTTON = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
/**
* Mark read/unread and dismiss for one item; nothing for items without a recipient id (older
* backends).
*
* @param props - Component properties.
* @returns The buttons, or null.
*/
export function ItemActions({ item, onAction, dismissOnly = false }: ItemActionsProps) {
const id = item.id
if (id === undefined) return null
return (
<span className="flex shrink-0 items-start gap-0.5">
{!dismissOnly && (
<button
type="button"
className={ICON_BUTTON}
onClick={() => onAction({ action: item.unread ? 'read' : 'unread', target: { id } })}
aria-label={`${item.unread ? 'Mark read' : 'Mark unread'}: ${item.title}`}
title={item.unread ? 'Mark read' : 'Mark unread'}
data-qqq-id={`notification-toggle-read-${id}`}
>
{item.unread ? <MailOpen className="h-4 w-4" aria-hidden="true" /> : <Mail className="h-4 w-4" aria-hidden="true" />}
</button>
)}
<button
type="button"
className={ICON_BUTTON}
onClick={() => onAction({ action: 'dismiss', target: { id } })}
aria-label={`Dismiss: ${item.title}`}
title="Dismiss"
data-qqq-id={`notification-dismiss-${id}`}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</span>
)
}
/**
* The read target for opening an item: its recipient id, else (older backends) its alert id.
*
* @param item - The item.
* @returns The request, or null when it cannot be marked read.
*/
export function readRequest(item: InboxItem): InboxActionRequest | null {
if (!item.unread) return null
if (item.id !== undefined) return { action: 'read', target: { id: item.id } }
if (item.alertId !== undefined) return { action: 'read', target: { alertId: item.alertId } }
return null
}
@@ -0,0 +1,208 @@
/*
* 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 notifications page: metadata gating, filters and paging in the URL, the inbox
// query, safe markdown bodies, actions, and empty and error states.
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 { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
const nav = vi.hoisted(() => ({ params: new URLSearchParams(), push: vi.fn(), replace: vi.fn() }))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: nav.push, replace: nav.replace, back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }),
usePathname: () => '/app/notifications',
useSearchParams: () => nav.params,
}))
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
const meta = vi.hoisted(() => ({ apps: { alerts: { name: 'alerts', label: 'Alerts' } } as Record<string, unknown> }))
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn(async () => ({ apps: meta.apps, tables: {}, processes: {} })) }))
import { NotificationsPage, filterFromParams } from './NotificationsPage'
/** One inbox item. */
function item(id: number, overrides: Record<string, unknown> = {}) {
return { id, notificationId: id, title: `Notice ${id}`, severity: 'INFO', category: 'SYSTEM', source: 'QQQ', age: '1 h', unread: false, ...overrides }
}
/**
* Renders the page with a fresh query client.
*
* @returns The render result.
*/
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={client}>
<NotificationsPage />
</QueryClientProvider>
)
}
/**
* Serves the inbox, recording each request's query.
*
* @param page - The response.
* @returns The recorded queries.
*/
function serveInbox(page: Record<string, unknown>): URLSearchParams[] {
const queries: URLSearchParams[] = []
server.use(http.get('/qqq/v1/alerts/inbox', ({ request }) => {
queries.push(new URL(request.url).searchParams)
return HttpResponse.json(page)
}))
return queries
}
describe('filterFromParams', () => {
it('reads status, category, severity and offset, with safe defaults', () => {
expect(filterFromParams(new URLSearchParams('status=unread&category=JOB&severity=ERROR&offset=25'))).toEqual({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: 25, limit: 25 })
expect(filterFromParams(new URLSearchParams('status=bogus&offset=-4'))).toEqual({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })
})
})
describe('NotificationsPage', () => {
beforeEach(() => {
nav.params = new URLSearchParams()
nav.push.mockReset()
nav.replace.mockReset()
meta.apps = { alerts: { name: 'alerts', label: 'Alerts' } }
})
it('sends no inbox request without the Alerts app', async () => {
meta.apps = {}
const queries = serveInbox({ items: [], total: 0 })
renderPage()
expect(await screen.findByText('Notifications are not available.')).toBeInTheDocument()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(queries).toHaveLength(0)
})
it('lists notifications with markdown bodies rendered without HTML', async () => {
const queries = serveInbox({
items: [
item(5, { title: 'Export finished', severity: 'SUCCESS', category: 'JOB', body: 'Your **export** is ready. <img src=x onerror=alert(1)>', href: '/app/exports/5', linkLabel: 'Download', unread: true }),
item(4, { title: 'Key rotated', severity: 'CRITICAL', category: 'SECURITY', body: '[docs](https://qrun.io/docs) and [bad](javascript:alert(1))' }),
],
total: 2,
offset: 0,
limit: 25,
})
const reads: unknown[] = []
server.use(http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
reads.push(await request.json())
return HttpResponse.json({ updated: 1 })
}))
const user = userEvent.setup()
const { container } = renderPage()
const list = await screen.findByRole('list', { name: 'Notifications' })
expect(await axe(container)).toHaveNoViolations()
expect(queries[0].get('status')).toBe('all')
expect(queries[0].get('offset')).toBe('0')
expect(queries[0].get('limit')).toBe('25')
expect(queries[0].has('category')).toBe(false)
const items = within(list).getAllByRole('listitem')
expect(items[0]).toHaveTextContent('Job · QQQ · 1 h ago')
expect(within(items[0]).getByText('export').tagName).toBe('STRONG')
expect(container.querySelector('img')).toBeNull()
expect(within(items[0]).getByText('(unread)', { exact: false })).toBeInTheDocument()
expect(within(items[1]).getByText('Critical')).toBeInTheDocument()
expect(within(items[1]).getByRole('link', { name: 'docs' })).toHaveAttribute('href', 'https://qrun.io/docs')
expect(within(items[1]).queryByRole('link', { name: 'bad' })).not.toBeInTheDocument()
expect(screen.getByText('1–2 of 2')).toBeInTheDocument()
await user.click(within(items[0]).getByRole('link', { name: 'Download' }))
expect(nav.push).toHaveBeenCalledWith('/app/exports/5')
await waitFor(() => expect(reads).toEqual([{ id: 5 }]))
})
it('puts filters in the URL and sends them to the inbox', async () => {
nav.params = new URLSearchParams('status=unread&category=SECURITY&severity=ERROR&offset=25')
const queries = serveInbox({ items: [], total: 0, offset: 25, limit: 25 })
const user = userEvent.setup()
renderPage()
expect(await screen.findByText('No notifications match these filters.')).toBeInTheDocument()
expect(queries[0].get('status')).toBe('unread')
expect(queries[0].get('category')).toBe('SECURITY')
expect(queries[0].get('severity')).toBe('ERROR')
expect(queries[0].get('offset')).toBe('25')
expect(screen.getByRole('button', { name: 'Unread' })).toHaveAttribute('aria-pressed', 'true')
await user.click(screen.getByRole('button', { name: 'All' }))
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?category=SECURITY&severity=ERROR', { scroll: false })
await user.selectOptions(screen.getByRole('combobox', { name: 'Category' }), 'RELEASE')
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?status=unread&category=RELEASE&severity=ERROR', { scroll: false })
await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), '')
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?status=unread&category=SECURITY', { scroll: false })
})
it('pages through the inbox', async () => {
nav.params = new URLSearchParams('offset=25')
serveInbox({ items: Array.from({ length: 25 }, (_, index) => item(100 - index)), total: 60, offset: 25, limit: 25 })
const user = userEvent.setup()
renderPage()
expect(await screen.findByText('26–50 of 60')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Next' }))
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?offset=50', { scroll: false })
await user.click(screen.getByRole('button', { name: 'Previous' }))
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications', { scroll: false })
})
it('marks read, unread and all read, and dismisses at once', async () => {
serveInbox({ items: [item(1, { unread: true }), item(2)], total: 2, offset: 0, limit: 25 })
const bodies: Array<[string, unknown]> = []
server.use(http.post('/qqq/v1/alerts/inbox/:action', async ({ request, params }) => {
bodies.push([String(params.action), await request.json()])
return HttpResponse.json({ updated: 1 })
}))
const user = userEvent.setup()
renderPage()
await screen.findByRole('list', { name: 'Notifications' })
await user.click(screen.getByRole('button', { name: 'Mark read: Notice 1' }))
await user.click(screen.getByRole('button', { name: 'Mark unread: Notice 2' }))
await user.click(screen.getByRole('button', { name: 'Mark all read' }))
await user.click(screen.getByRole('button', { name: 'Dismiss: Notice 2' }))
await waitFor(() => expect(bodies).toEqual([['read', { id: 1 }], ['unread', { id: 2 }], ['read', { all: true }], ['dismiss', { id: 2 }]]))
})
it('shows older items without actions, and the empty and error states', async () => {
serveInbox({ items: [{ alertId: 3, title: 'Legacy alert', severity: 'ERROR', group: 'CDN', age: '2 h', unread: true }], total: 1 })
const first = renderPage()
const list = await screen.findByRole('list', { name: 'Notifications' })
expect(list).toHaveTextContent('CDN · 2 h ago')
expect(within(list).queryByRole('button')).not.toBeInTheDocument()
first.unmount()
serveInbox({ items: [], total: 0 })
const empty = renderPage()
expect(await screen.findByText('You have no notifications.')).toBeInTheDocument()
empty.unmount()
server.use(http.get('/qqq/v1/alerts/inbox', () => new HttpResponse(null, { status: 500 })))
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load notifications.')
serveInbox({ items: [item(9)], total: 1 })
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Notice 9')).toBeInTheDocument()
})
})
+272
View File
@@ -0,0 +1,272 @@
/*
* 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 NotificationsPage — the notification center's full list at `/app/notifications`
* (loaded on demand): All/Unread, category and severity filters, paging, markdown bodies
* (rendered without HTML), and the bell's actions. Filters and the offset live in the URL.
* Like the bell, it sends no request unless the instance metadata announces Alerts.
*/
'use client'
import React, { useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { getAlertsInbox } from '@/lib/api/alerts'
import { useQContext } from '@/lib/context/q-context'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { queryKeys } from '@/lib/query-client'
import type { InboxFilter, InboxItem } from '@/types/alerts'
import { NOTIFICATION_CATEGORIES, NOTIFICATION_SEVERITIES } from '@/types/alerts'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
import { isAlertsInstalled } from './AlertsBell'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest, severityLabel } from './NotificationParts'
/** Items per page. */
export const PAGE_SIZE = 25
const SELECT_CLASS = 'rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'
/**
* "SECURITY" → "Security".
*
* @param value - An upper-case name.
* @returns The label.
*/
const titleCase = (value: string) => value.charAt(0) + value.slice(1).toLowerCase()
/**
* Reads the filter from the URL.
*
* @param params - The search params.
* @returns The filter.
*/
export function filterFromParams(params: URLSearchParams): InboxFilter {
const offset = Number.parseInt(params.get('offset') ?? '0', 10)
return {
status: params.get('status') === 'unread' ? 'unread' : 'all',
category: params.get('category') ?? '',
severity: params.get('severity') ?? '',
offset: Number.isFinite(offset) && offset > 0 ? offset : 0,
limit: PAGE_SIZE,
}
}
/**
* The page's URL for a filter (defaults left out).
*
* @param filter - The filter.
* @returns The path with its query.
*/
function pagePath(filter: InboxFilter): string {
const params = new URLSearchParams()
if (filter.status === 'unread') params.set('status', 'unread')
if (filter.category) params.set('category', filter.category)
if (filter.severity) params.set('severity', filter.severity)
if (filter.offset > 0) params.set('offset', String(filter.offset))
const query = params.toString()
return `/app/notifications${query ? `?${query}` : ''}`
}
/**
* The loading indicator.
*
* @returns The indicator.
*/
function Loading() {
return (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading notifications…
</p>
)
}
/**
* The notifications page.
*
* @returns The page.
*/
export function NotificationsPage() {
const { setPageHeader } = useQContext()
useEffect(() => setPageHeader('Notifications'), [setPageHeader])
const { data: metaData, isLoading } = useMetaData()
if (isLoading) return <Loading />
if (!isAlertsInstalled(metaData)) {
return (
<p className="p-6 text-sm text-muted-foreground" data-qqq-id="notifications-unavailable">
Notifications are not available.
</p>
)
}
return <NotificationsList />
}
/**
* The filters, list and paging.
*
* @returns The list.
*/
function NotificationsList() {
const router = useRouter()
const searchParams = useSearchParams()
const filter = filterFromParams(new URLSearchParams(searchParams?.toString() ?? ''))
const navigate = (next: Partial<InboxFilter>) => router.replace(pagePath({ ...filter, offset: 0, ...next }), { scroll: false })
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.alertsInboxPage(filter),
queryFn: () => getAlertsInbox(filter),
placeholderData: keepPreviousData,
retry: false,
})
const actions = useInboxActions()
const first = data && data.total > 0 ? data.offset + 1 : 0
const last = data ? Math.min(data.offset + data.items.length, data.total) : 0
const filtered = filter.status === 'unread' || filter.category !== '' || filter.severity !== ''
/**
* Follows an item's link, marking it read.
*
* @param item - The item.
* @param event - The click.
*/
const follow = (item: InboxItem, event: React.MouseEvent) => {
const read = readRequest(item)
if (read) actions.mutate(read)
if (linkKind(item.href) === 'app' && item.href) {
event.preventDefault()
router.push(item.href)
}
}
return (
<div className="mx-auto w-full max-w-4xl space-y-4" data-qqq-id="notifications-page">
<div className="flex flex-wrap items-center gap-2">
<div role="group" aria-label="Show" className="inline-flex rounded-md border border-input bg-card p-0.5">
{(['all', 'unread'] as const).map((status) => (
<button
key={status}
type="button"
aria-pressed={filter.status === status}
onClick={() => navigate({ status })}
className={`rounded px-3 py-1 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${filter.status === status ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent'}`}
data-qqq-id={`notifications-status-${status}`}
>
{status === 'all' ? 'All' : 'Unread'}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Category
<select value={filter.category} onChange={(event) => navigate({ category: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-category">
<option value="">All</option>
{NOTIFICATION_CATEGORIES.map((category) => (
<option key={category} value={category}>{titleCase(category)}</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Severity
<select value={filter.severity} onChange={(event) => navigate({ severity: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-severity">
<option value="">All</option>
{NOTIFICATION_SEVERITIES.map((severity) => (
<option key={severity} value={severity}>{severityLabel(severity)}</option>
))}
</select>
</label>
<button
type="button"
className={`${BUTTON_CLASS} sm:ml-auto`}
disabled={!data?.items.some((item) => item.unread)}
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
data-qqq-id="notifications-mark-all-read"
>
Mark all read
</button>
</div>
{isLoading ? (
<Loading />
) : isError ? (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">
Could not load notifications.
<button type="button" className={BUTTON_CLASS} onClick={() => void refetch()}>
Retry
</button>
</div>
) : !data || data.items.length === 0 ? (
<p className="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground" data-qqq-id="notifications-empty">
{filtered ? 'No notifications match these filters.' : 'You have no notifications.'}
</p>
) : (
<ul className="space-y-2" aria-label="Notifications">
{data.items.map((item) => {
const kind = linkKind(item.href)
return (
<li
key={item.key}
className={`flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-80'}`}
data-qqq-id={`notifications-item-${item.key}`}
>
<SeverityIcon severity={item.severity} />
<div className="min-w-0 flex-1 space-y-1">
<h2 className={`break-words text-sm text-foreground ${item.unread ? 'font-semibold' : 'font-medium'}`}>
{item.unread && <span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-primary align-middle" aria-hidden="true" />}
{item.title}
{item.unread && <span className="sr-only"> (unread)</span>}
</h2>
<p className="text-xs text-muted-foreground">{itemMeta(item, true)}</p>
{item.body && <MarkdownView source={item.body} />}
{kind && (
<a
href={item.href ?? undefined}
target={kind === 'external' ? '_blank' : undefined}
rel={kind === 'external' ? 'noopener noreferrer' : undefined}
onClick={(event) => follow(item, event)}
className="inline-block font-medium text-primary underline underline-offset-2"
>
{item.linkLabel ?? 'Open'}
</a>
)}
</div>
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
</li>
)
})}
</ul>
)}
{data && data.total > 0 && (
<nav aria-label="Pages" className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{`${first}–${last} of ${data.total}`}</span>
<span className="flex gap-2">
<button type="button" className={BUTTON_CLASS} disabled={filter.offset === 0} onClick={() => navigate({ offset: Math.max(0, filter.offset - PAGE_SIZE) })} data-qqq-id="notifications-previous">
Previous
</button>
<button type="button" className={BUTTON_CLASS} disabled={filter.offset + PAGE_SIZE >= data.total} onClick={() => navigate({ offset: filter.offset + PAGE_SIZE })} data-qqq-id="notifications-next">
Next
</button>
</span>
</nav>
)}
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
/*
* 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 SeverityDot — a small colored dot for an alert's severity, with its name for screen
* readers.
*/
import React from 'react'
import type { AlertSeverity } from '@/types/alerts'
// mid-tone colors that read on light and dark cards; inline so the dot adds no CSS
const COLORS: Record<AlertSeverity, string> = {
CRITICAL: '#dc2626',
ERROR: '#ef4444',
WARNING: '#d97706',
INFO: '#2563eb',
}
const LABELS: Record<AlertSeverity, string> = {
CRITICAL: 'Critical',
ERROR: 'Error',
WARNING: 'Warning',
INFO: 'Info',
}
/**
* The dot.
*
* @param props - The severity.
* @returns A dot with a screen-reader label.
*/
export function SeverityDot({ severity }: { severity: AlertSeverity }) {
return (
<span className="flex shrink-0 items-center" style={{ marginTop: '0.375rem' }} data-qqq-id={`alerts-severity-${severity}`}>
<span className="rounded-full" style={{ width: '0.625rem', height: '0.625rem', backgroundColor: COLORS[severity] ?? COLORS.INFO }} aria-hidden="true" />
<span className="sr-only">{LABELS[severity] ?? severity}</span>
</span>
)
}
@@ -0,0 +1,180 @@
/*
* 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 AssistantComposer — the message box: the dismissible page-context chip, a growing
* textarea (Enter sends, Shift+Enter adds a line), and Send or Stop.
*/
'use client'
import React, { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { MapPin, SendHorizontal, Square, X } from 'lucide-react'
import type { AssistantPageInfo } from '@/lib/utils/assistant-links'
/** Props of {@link AssistantComposer}. */
interface AssistantComposerProps {
/** The current page as context, or null. */
pageInfo: AssistantPageInfo | null
/** Whether the user removed the context for this page. */
contextDismissed: boolean
/** Removes the page context. */
onDismissContext: () => void
/** Re-reads the page context (the filter can change without a navigation). */
onRefreshContext: () => void
/** Whether a reply is streaming in this conversation. */
streaming: boolean
/** Whether a stop was requested. */
stopping: boolean
/**
* Sends the text.
*
* @param text - The message.
*/
onSend: (text: string) => void
/** Stops the reply. */
onStop: () => void
/** Text to put in the box (not sent); a new `id` replaces the current text. */
prefill?: { id: number; text: string } | null
}
/** Tallest the textarea grows before it scrolls, in pixels. */
const MAX_TEXTAREA_HEIGHT = 160
/**
* The composer.
*
* @param props - Component properties.
* @param ref - The textarea, focused when the panel opens.
* @returns The composer form.
*/
function AssistantComposerInner(
{ pageInfo, contextDismissed, onDismissContext, onRefreshContext, streaming, stopping, onSend, onStop, prefill }: AssistantComposerProps,
ref: React.ForwardedRef<HTMLTextAreaElement>
) {
const [text, setText] = useState('')
const inputId = useId()
const hintId = useId()
const localRef = useRef<HTMLTextAreaElement | null>(null)
// A deep link pre-fills the box; the user reviews and sends it
useEffect(() => {
if (!prefill) return
setText(prefill.text)
const element = localRef.current
if (element) {
element.focus()
element.setSelectionRange(prefill.text.length, prefill.text.length)
}
}, [prefill])
// Grow with the text up to MAX_TEXTAREA_HEIGHT
useLayoutEffect(() => {
const element = localRef.current
if (!element) return
element.style.height = 'auto'
element.style.height = `${Math.min(element.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`
}, [text])
/** Sends the text unless empty or a reply is streaming. */
const submit = () => {
if (!text.trim() || streaming) return
onSend(text)
setText('')
}
const showContext = pageInfo && !contextDismissed
return (
<form
className="shrink-0 border-t border-border bg-card px-3 pb-3 pt-2"
onSubmit={(event) => {
event.preventDefault()
submit()
}}
data-qqq-id="assistant-composer-form"
>
{showContext && (
<div className="mb-2 flex min-w-0">
<span className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full border border-border bg-muted py-0.5 pl-2 pr-0.5 text-xs text-muted-foreground" data-qqq-id="assistant-context-chip">
<MapPin className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate">On: {pageInfo.label}</span>
<button
type="button"
onClick={onDismissContext}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-8 pointer-coarse:w-8"
aria-label={`Don't send page context (${pageInfo.label})`}
data-qqq-id="assistant-context-dismiss"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</span>
</div>
)}
<div className="flex items-end gap-2 rounded-lg border border-input bg-background px-2 py-1.5 focus-within:ring-2 focus-within:ring-ring">
<label htmlFor={inputId} className="sr-only">Message the assistant</label>
<textarea
id={inputId}
ref={(element) => {
localRef.current = element
if (typeof ref === 'function') ref(element)
else if (ref) ref.current = element
}}
rows={1}
value={text}
onChange={(event) => setText(event.target.value)}
onFocus={onRefreshContext}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
submit()
}
}}
placeholder="Ask about your data..."
aria-describedby={hintId}
className="max-h-40 min-h-6 min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-1 text-base text-foreground placeholder:text-muted-foreground focus:outline-none md:text-sm"
data-qqq-id="assistant-composer"
/>
{streaming ? (
<button
type="button"
onClick={onStop}
disabled={stopping}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 pointer-coarse:h-11 pointer-coarse:w-11"
aria-label={stopping ? 'Stopping' : 'Stop the reply'}
data-qqq-id="assistant-stop"
>
<Square className="h-3.5 w-3.5 fill-current" aria-hidden="true" />
</button>
) : (
<button
type="submit"
disabled={!text.trim()}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 pointer-coarse:h-11 pointer-coarse:w-11"
aria-label="Send"
data-qqq-id="assistant-send"
>
<SendHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
<p id={hintId} className="mt-1 hidden text-[11px] text-muted-foreground pointer-fine:block">Enter to send, Shift+Enter for a new line</p>
</form>
)
}
/** The assistant composer; its ref is the textarea. */
export const AssistantComposer = forwardRef(AssistantComposerInner)
@@ -0,0 +1,65 @@
/*
* 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 AssistantHeaderButton — the header's assistant toggle with its unread badge; renders
* nothing when the assistant is unavailable.
*/
'use client'
import React from 'react'
import { Sparkles } from 'lucide-react'
import { useAssistant } from './assistant-context'
/**
* Toggles the assistant panel; the badge counts unread replies.
*
* @returns The button, or null when the assistant is unavailable.
*/
export function AssistantHeaderButton() {
const assistant = useAssistant()
if (!assistant?.enabled) return null
const { open, unreadCount, toggle, triggerRef } = assistant
const label = unreadCount > 0 ? `Assistant, ${unreadCount} unread (Ctrl+J or ⌘J)` : 'Assistant (Ctrl+J or ⌘J)'
return (
<button
ref={triggerRef}
type="button"
onClick={(event) => {
// WebKit can activate a button without focusing it; focus returns here on close
event.currentTarget.focus({ preventScroll: true })
toggle()
}}
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={label}
aria-pressed={open}
data-qqq-id="button-assistant"
>
<Sparkles className="h-5 w-5 text-foreground/60" aria-hidden="true" />
{unreadCount > 0 && (
<span
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
aria-hidden="true"
data-qqq-id="assistant-unread-badge"
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
)
}
@@ -0,0 +1,124 @@
/*
* 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 AssistantMarkdown — renders model-written markdown safely: raw HTML is dropped, the
* tree is sanitized, images never load, and only same-origin `/app` links are links (others
* are plain text with a copy button), so model text cannot exfiltrate data or inject markup.
*/
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import { Check, Copy } from 'lucide-react'
import { internalAppHref } from '@/lib/utils/assistant-links'
/** The default (GitHub) sanitize schema without inline styles, classes or ids from the model. */
const SCHEMA = {
...defaultSchema,
clobberPrefix: 'assistant-md-',
attributes: {
...defaultSchema.attributes,
'*': (defaultSchema.attributes?.['*'] ?? []).filter((attribute) => !['className', 'id', 'style'].includes(String(attribute))),
},
}
/**
* Copies text to the clipboard and confirms it.
*
* @param props - Component properties.
* @param props.text - The text to copy.
* @returns The button.
*/
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}).catch(() => undefined)
}}
className="ml-1 inline-flex h-5 w-5 items-center justify-center rounded align-middle text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-8 pointer-coarse:w-8"
aria-label={copied ? 'Copied' : `Copy link ${text}`}
data-qqq-id="assistant-copy-link"
>
{copied ? <Check className="h-3 w-3" aria-hidden="true" /> : <Copy className="h-3 w-3" aria-hidden="true" />}
</button>
)
}
/** Element overrides: safe links, blocked images, focusable scrolling code blocks. */
const COMPONENTS: Components = {
a: ({ href, children }) => {
const internal = internalAppHref(href)
if (internal) {
return <Link href={internal} className="font-medium text-primary underline underline-offset-2 hover:text-primary/80" data-qqq-id="assistant-link">{children}</Link>
}
const text = typeof href === 'string' ? href : ''
const childText = React.Children.toArray(children).join('')
return (
<span data-qqq-id="assistant-external-link">
{children}
{text && childText !== text && <span className="break-all text-muted-foreground"> ({text})</span>}
{text && <CopyButton text={text} />}
</span>
)
},
img: ({ alt }) => <span className="text-muted-foreground">[image{alt ? `: ${alt}` : ''} not shown]</span>,
pre: ({ children }) => (
<pre tabIndex={0} className="my-2 overflow-x-auto rounded-md bg-muted p-2 text-xs focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">{children}</pre>
),
table: ({ children }) => (
<div tabIndex={0} role="region" aria-label="Table" className="my-2 overflow-x-auto focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<table className="w-full text-xs">{children}</table>
</div>
),
}
/** Typography for the rendered markdown (the app has no typography plugin). */
const PROSE = [
'min-w-0 break-words text-sm leading-relaxed text-foreground',
'[&_p]:my-2 first:[&_p]:mt-0 last:[&_p]:mb-0',
'[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5',
'[&_h1]:mt-3 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-3 [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:mt-2 [&_h3]:text-sm [&_h3]:font-semibold',
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:font-mono [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0',
'[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
'[&_th]:border-b [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_td]:border-b [&_td]:border-border [&_td]:px-2 [&_td]:py-1',
].join(' ')
/**
* Renders assistant markdown.
*
* @param props - Component properties.
* @param props.text - Markdown from the model.
* @returns The rendered markdown.
*/
export function AssistantMarkdown({ text }: { text: string }) {
return (
<div className={PROSE} data-qqq-id="assistant-markdown">
<ReactMarkdown skipHtml rehypePlugins={[[rehypeSanitize, SCHEMA]]} components={COMPONENTS}>
{text}
</ReactMarkdown>
</div>
)
}
@@ -0,0 +1,111 @@
/*
* 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.
*/
/**
* AssistantMessageView stories — the assistant panel's message rendering: user text, a reply
* with a tool step, records table, record chips and markdown, a streaming reply, and notices.
*/
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { AssistantMessage } from '@/types/assistant'
import { AssistantMessageView } from './AssistantMessageView'
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } })
const meta = {
title: 'Assistant/AssistantMessageView',
component: AssistantMessageView,
parameters: { layout: 'padded' },
tags: ['autodocs'],
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<div className="@container w-[400px] space-y-4 bg-card p-3">
<Story />
</div>
</QueryClientProvider>
),
],
} satisfies Meta<typeof AssistantMessageView>
export default meta
type Story = StoryObj<typeof meta>
const BASE = { turnId: 9, createDate: '2026-10-12T00:00:00Z' }
/** The user's question. */
export const UserMessage: Story = {
args: { message: { ...BASE, id: 10, role: 'USER', status: 'COMPLETE', parts: [{ type: 'text', text: 'Who is active?' }] } as AssistantMessage },
}
/** A finished reply with a query step, its records, chips, markdown and a navigation. */
export const Reply: Story = {
args: {
message: {
...BASE,
id: 11,
role: 'ASSISTANT',
status: 'COMPLETE',
parts: [
{
type: 'tool', toolCallId: 't1', name: 'query_records', summary: 'Queried People · 2 rows', outcome: 'OK',
render: {
type: 'records', tableName: 'person', tableLabel: 'People', truncated: false, totalCount: 2, openHref: '/app/person',
columns: [{ name: 'firstName', label: 'First Name' }, { name: 'lastName', label: 'Last Name' }, { name: 'status', label: 'Status' }],
rows: [
{ id: 1, href: '/app/person/1', values: { firstName: 'Alice', lastName: 'Johnson', status: 'Active' } },
{ id: 2, href: '/app/person/2', values: { firstName: 'Bob', lastName: 'Martinez', status: 'Active' } },
],
},
},
{ type: 'record_refs', refs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }] },
{ type: 'text', text: 'Two people are **active**. See [Alice](/app/person/1); [external](https://example.com) links stay plain text.' },
{ type: 'navigate', href: '/app/person/1' },
],
} as AssistantMessage,
},
}
/** A reply still streaming, with a running tool step. */
export const Streaming: Story = {
args: {
message: {
...BASE,
id: 12,
role: 'ASSISTANT',
status: 'STREAMING',
parts: [{ type: 'tool', toolCallId: 't2', name: 'count_records', summary: 'Counting People' }],
} as AssistantMessage,
},
}
/** A denied tool call and an error notice. */
export const DeniedAndError: Story = {
args: {
message: {
...BASE,
id: 13,
role: 'ASSISTANT',
status: 'ERROR',
parts: [
{ type: 'tool', toolCallId: 't3', name: 'get_record_history', summary: 'Record history is not available to you', outcome: 'DENIED' },
{ type: 'notice', level: 'error', text: 'The daily assistant budget was reached.' },
],
} as AssistantMessage,
},
}
@@ -0,0 +1,271 @@
/*
* 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 AssistantMessageView — one message of a conversation. Assistant messages render
* their parts through {@link PART_RENDERERS}, so later part types (approval cards) slot in
* by adding an entry; unknown part types are skipped.
*/
'use client'
import React, { useId, useState } from 'react'
import { AlertTriangle, Check, ChevronRight, CircleX, Info, Loader2, Navigation, ThumbsDown, ThumbsUp } from 'lucide-react'
import type {
AssistantMessage,
AssistantNavigatePart,
AssistantNoticePart,
AssistantPart,
AssistantRecordRefsPart,
AssistantTextPart,
AssistantToolOutcome,
AssistantToolPart,
} from '@/types/assistant'
import { sendAssistantFeedback } from '@/lib/api/assistant'
import { internalAppHref } from '@/lib/utils/assistant-links'
import { cn } from '@/lib/utils/cn'
import { AssistantMarkdown } from './AssistantMarkdown'
import { AssistantRenderView, RecordChips } from './AssistantRenders'
import Link from 'next/link'
/** Plain-language outcome of a tool call. */
const OUTCOME_LABEL: Record<AssistantToolOutcome, string> = {
OK: 'Done',
DENIED: 'Not permitted',
NOT_FOUND: 'Not found',
INVALID_INPUT: 'Invalid request',
TRUNCATED: 'Partial result',
ERROR: 'Failed',
}
/**
* A tool call as a compact, expandable step, followed by its structured result.
*
* @param props - Component properties.
* @param props.part - The tool part.
* @returns The step.
*/
function ToolStep({ part }: { part: AssistantToolPart }) {
const [expanded, setExpanded] = useState(false)
const detailsId = useId()
const running = !part.outcome
const ok = part.outcome === 'OK' || part.outcome === 'TRUNCATED'
return (
<div className="space-y-1.5" data-qqq-id="assistant-tool-step" data-outcome={part.outcome ?? 'RUNNING'}>
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
aria-controls={detailsId}
className="flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 motion-safe:animate-spin" aria-hidden="true" />
) : ok ? (
<Check className="h-3.5 w-3.5 shrink-0 text-primary" aria-hidden="true" />
) : (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" aria-hidden="true" />
)}
<span className="min-w-0 truncate">{part.summary || part.name}</span>
<span className="sr-only">{running ? ', running' : `, ${OUTCOME_LABEL[part.outcome as AssistantToolOutcome] ?? part.outcome}`}</span>
<ChevronRight className={cn('h-3 w-3 shrink-0 motion-safe:transition-transform', expanded && 'rotate-90')} aria-hidden="true" />
</button>
<dl id={detailsId} hidden={!expanded} className="ml-5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 text-xs text-muted-foreground">
<dt>Tool</dt>
<dd className="break-all font-mono">{part.name}</dd>
<dt>Result</dt>
<dd>{running ? 'Running' : OUTCOME_LABEL[part.outcome as AssistantToolOutcome] ?? part.outcome}</dd>
</dl>
{part.render && <AssistantRenderView render={part.render} />}
</div>
)
}
/**
* A notice inside a reply.
*
* @param props - Component properties.
* @param props.part - The notice part.
* @returns The notice.
*/
function NoticeView({ part }: { part: AssistantNoticePart }) {
const Icon = part.level === 'error' ? CircleX : part.level === 'warning' ? AlertTriangle : Info
return (
<p
className={cn(
'flex items-start gap-1.5 rounded-md border px-2 py-1.5 text-xs',
part.level === 'error' ? 'border-destructive/40 text-destructive' : 'border-border text-muted-foreground'
)}
role={part.level === 'error' ? 'alert' : undefined}
data-qqq-id="assistant-notice"
data-level={part.level}
>
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0 break-words">{part.text}</span>
</p>
)
}
/**
* Where the assistant took the user.
*
* @param props - Component properties.
* @param props.part - The navigate part.
* @returns A link back to that page.
*/
function NavigateView({ part }: { part: AssistantNavigatePart }) {
const href = internalAppHref(part.href)
if (!href) return null
return (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" data-qqq-id="assistant-navigate">
<Navigation className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>Opened</span>
<Link href={href} className="min-w-0 truncate font-medium text-primary hover:underline">{href}</Link>
</p>
)
}
/** Renders one part type. */
type PartRenderer<T extends AssistantPart> = (props: { part: T }) => React.ReactNode
/**
* Renderers by part type. Phase 2 adds `approval` here; anything without a renderer is skipped.
*/
export const PART_RENDERERS: { [K in AssistantPart['type']]: PartRenderer<Extract<AssistantPart, { type: K }>> } = {
text: ({ part }: { part: AssistantTextPart }) => (part.text ? <AssistantMarkdown text={part.text} /> : null),
tool: ({ part }: { part: AssistantToolPart }) => <ToolStep part={part} />,
record_refs: ({ part }: { part: AssistantRecordRefsPart }) => <RecordChips refs={part.refs} />,
navigate: ({ part }: { part: AssistantNavigatePart }) => <NavigateView part={part} />,
notice: ({ part }: { part: AssistantNoticePart }) => <NoticeView part={part} />,
}
/**
* Renders a part through {@link PART_RENDERERS}.
*
* @param props - Component properties.
* @param props.part - Any part, including types this version does not know.
* @returns The rendered part, or null.
*/
function PartView({ part }: { part: AssistantPart }) {
const render = (PART_RENDERERS as Record<string, PartRenderer<AssistantPart> | undefined>)[part.type]
return render ? <>{render({ part })}</> : null
}
/**
* Thumbs up or down on a finished reply; thumbs down asks for an optional comment.
*
* @param props - Component properties.
* @param props.messageId - The assistant message id.
* @returns The feedback controls.
*/
function FeedbackButtons({ messageId }: { messageId: number }) {
const [rating, setRating] = useState<1 | -1 | null>(null)
const [asking, setAsking] = useState(false)
const [comment, setComment] = useState('')
const [sent, setSent] = useState(false)
const commentId = useId()
/**
* Sends the rating.
*
* @param value - Up or down.
* @param text - Optional comment.
*/
const send = (value: 1 | -1, text?: string) => {
setRating(value)
setSent(true)
sendAssistantFeedback(messageId, text ? { rating: value, comment: text } : { rating: value }).catch(() => setSent(false))
}
const buttonClass = 'inline-flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11 pointer-coarse:w-11 aria-pressed:text-primary'
return (
<div className="space-y-1" data-qqq-id="assistant-feedback">
<div className="flex items-center gap-0.5">
<button type="button" className={buttonClass} aria-label="Good response" aria-pressed={rating === 1} disabled={sent && rating === 1} onClick={() => { setAsking(false); send(1) }} data-qqq-id="assistant-feedback-up">
<ThumbsUp className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button type="button" className={buttonClass} aria-label="Bad response" aria-pressed={rating === -1} disabled={sent && rating === -1} onClick={() => { setRating(-1); setAsking(true) }} data-qqq-id="assistant-feedback-down">
<ThumbsDown className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{sent && <span className="text-xs text-muted-foreground" role="status">Thanks for the feedback.</span>}
</div>
{asking && !sent && (
<form
className="flex items-center gap-1"
onSubmit={(event) => {
event.preventDefault()
setAsking(false)
send(-1, comment.trim() || undefined)
}}
>
<label htmlFor={commentId} className="sr-only">What went wrong? (optional)</label>
<input
id={commentId}
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="What went wrong? (optional)"
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoFocus
/>
<button type="submit" className="h-8 rounded-md border border-border px-2 text-xs font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">Send</button>
</form>
)}
</div>
)
}
/**
* One message.
*
* @param props - Component properties.
* @param props.message - The message.
* @returns The message article.
*/
export function AssistantMessageView({ message }: { message: AssistantMessage }) {
if (message.role === 'USER') {
const text = message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
return (
<article className="flex justify-end" aria-label="You" data-qqq-id="assistant-message" data-role="user">
<p className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm bg-muted px-3 py-2 text-sm text-foreground">{text}</p>
</article>
)
}
if (message.role === 'SYSTEM_NOTICE') {
return (
<article className="space-y-1" aria-label="Notice" data-qqq-id="assistant-message" data-role="notice">
{message.parts.map((part, index) => <PartView key={index} part={part} />)}
</article>
)
}
const streaming = message.status === 'STREAMING'
return (
<article className="min-w-0 space-y-2" aria-label="Assistant" aria-busy={streaming} data-qqq-id="assistant-message" data-role="assistant" data-status={message.status}>
{message.parts.map((part, index) => <PartView key={part.type === 'tool' ? part.toolCallId : index} part={part} />)}
{streaming && message.parts.length === 0 && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground" data-qqq-id="assistant-thinking">
<Loader2 className="h-3.5 w-3.5 motion-safe:animate-spin" aria-hidden="true" />
Working on it...
</p>
)}
{message.status === 'CANCELLED' && <p className="text-xs text-muted-foreground">Stopped.</p>}
{message.status === 'ERROR' && !message.parts.some((part) => part.type === 'notice' && part.level === 'error') && (
<p className="text-xs text-destructive" role="alert">The reply did not finish.</p>
)}
{message.status === 'COMPLETE' && message.id > 0 && <FeedbackButtons messageId={message.id} />}
</article>
)
}
@@ -0,0 +1,49 @@
/*
* 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 AssistantMount — the panel's place in the dashboard shell. The panel code (markdown,
* streaming, record renders) loads on first open, so the shell's first load stays small; once
* loaded it stays mounted, so a running reply keeps streaming while the panel is closed.
*/
'use client'
import React, { Suspense, lazy, useEffect, useState } from 'react'
import { useAssistant } from './assistant-context'
const AssistantPanel = lazy(() => import('./AssistantPanel'))
/**
* Mounts the lazily loaded assistant panel once it has been opened.
*
* @returns The panel, or null until the assistant is available and first opened.
*/
export function AssistantMount() {
const assistant = useAssistant()
const [loaded, setLoaded] = useState(false)
const open = Boolean(assistant?.open)
useEffect(() => {
if (open) setLoaded(true)
}, [open])
if (!assistant?.enabled || (!loaded && !open)) return null
return (
<Suspense fallback={null}>
<AssistantPanel />
</Suspense>
)
}
@@ -0,0 +1,353 @@
/*
* 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 assistant panel: probe gating, streaming, cancel, markdown safety, deep link
import React from 'react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import type { QInstance } from '@/types'
import { server } from '@/mocks/node'
import { AssistantProvider, useAssistantController } from './AssistantProvider'
import { AssistantHeaderButton } from './AssistantHeaderButton'
import { AssistantMount } from './AssistantMount'
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
vi.mock('next/navigation', () => ({
useRouter: () => router,
usePathname: () => window.location.pathname,
useSearchParams: () => new URLSearchParams(window.location.search),
}))
const BASE = '/qqq/v1/assistant'
const META = { tables: { person: { name: 'person', label: 'People' } }, supplementalInstanceMetaData: { assistant: {} } } as unknown as QInstance
const CONVERSATION = { id: 4, title: null, status: 'ACTIVE', pinned: false, lastMessageAt: null, unreadCount: 0, createDate: '2026-10-12T00:00:00Z' }
/**
* Formats stream events as SSE frames.
*
* @param events - `[type, data]` pairs.
* @returns Event-stream text.
*/
function sse(...events: Array<[string, Record<string, unknown>]>): string {
return events.map(([type, data]) => `id: ${data.seq}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`).join('')
}
/**
* A stream the test writes to and closes.
*
* @returns The response factory, a writer and a closer.
*/
function controllableStream() {
const encoder = new TextEncoder()
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
const body = new ReadableStream<Uint8Array>({ start(c) { controller = c } })
return {
response: () => new HttpResponse(body, { headers: { 'Content-Type': 'text/event-stream' } }),
write: (text: string) => controller?.enqueue(encoder.encode(text)),
close: () => controller?.close(),
}
}
/** What the server has stored for conversation 4 (served after a turn ends). */
const persisted: { messages: unknown[] } = { messages: [] }
/**
* The stored messages of a finished turn.
*
* @param text - The user's text.
* @param parts - The reply's parts.
* @param status - The reply's status.
* @returns The messages.
*/
function storedTurn(text: string, parts: unknown[], status = 'COMPLETE'): unknown[] {
return [
{ id: 10, role: 'USER', status: 'COMPLETE', turnId: 9, createDate: '2026-10-12T00:00:00Z', parts: [{ type: 'text', text }] },
{ id: 11, role: 'ASSISTANT', status, turnId: 9, createDate: '2026-10-12T00:00:00Z', parts },
]
}
const START = ['message.start', { seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }] as [string, Record<string, unknown>]
/**
* Harness: the dashboard's assistant wiring.
*
* @param props - Component properties.
* @param props.metaData - Instance metadata (defaults to one announcing the qbit).
* @returns The shell.
*/
function Shell({ metaData = META }: { metaData?: QInstance }) {
const assistant = useAssistantController(metaData, true)
return (
<AssistantProvider value={assistant}>
<header><AssistantHeaderButton /></header>
<main>page</main>
<AssistantMount />
</AssistantProvider>
)
}
/**
* Renders the harness.
*
* @param metaData - Instance metadata (defaults to one announcing the qbit).
* @returns The render result.
*/
function renderShell(metaData?: QInstance) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}><Shell metaData={metaData} /></QueryClientProvider>)
}
/**
* Registers the probe and conversation routes.
*
* @param config - The probe response, or a status code.
*/
function mockAssistant(config: Record<string, unknown> | number = { enabled: true }) {
persisted.messages = []
server.use(
http.get(`${BASE}/config`, () => (typeof config === 'number' ? HttpResponse.json({ error: 'x' }, { status: config }) : HttpResponse.json(config))),
http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [] })),
http.post(`${BASE}/conversations`, () => HttpResponse.json(CONVERSATION, { status: 201 })),
http.get(`${BASE}/conversations/4`, () => HttpResponse.json({ conversation: CONVERSATION, messages: persisted.messages })),
)
}
// The panel is lazily loaded; load its module once so the tests do not race the import
beforeAll(async () => {
await import('./AssistantPanel')
}, 30_000)
beforeEach(() => {
router.push.mockReset()
router.replace.mockReset()
window.history.replaceState(null, '', '/app/person')
window.localStorage.clear()
})
afterEach(() => {
window.history.replaceState(null, '', '/')
})
describe('assistant gating', () => {
it('does not probe an instance whose metadata does not announce the qbit', async () => {
const probe = vi.fn()
mockAssistant()
server.use(http.get(`${BASE}/config`, () => {
probe()
return HttpResponse.json({ enabled: true })
}))
renderShell({ tables: META.tables } as unknown as QInstance)
await new Promise((resolve) => setTimeout(resolve, 50))
expect(probe).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
})
it.each([404, 403])('renders nothing when the probe answers %i', async (status) => {
mockAssistant(status)
renderShell()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
})
it('renders nothing when the assistant is disabled for the user', async () => {
mockAssistant({ enabled: false, reason: 'NO_PERMISSION' })
renderShell()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(screen.queryByRole('button', { name: /assistant/i })).toBeNull()
})
it('shows the unread count on the header button', async () => {
mockAssistant()
server.use(http.get(`${BASE}/conversations`, () => HttpResponse.json({ conversations: [{ ...CONVERSATION, unreadCount: 2 }, { ...CONVERSATION, id: 5, unreadCount: 1 }] })))
renderShell()
expect(await screen.findByRole('button', { name: 'Assistant, 3 unread (Ctrl+J or ⌘J)' })).toBeInTheDocument()
})
})
describe('asking a question', () => {
it('streams the reply with a tool step and record chip, follows navigation, and passes axe', async () => {
mockAssistant()
const stream = controllableStream()
let turnBody: unknown
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
turnBody = await request.json()
return stream.response()
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
const composer = within(panel).getByRole('textbox', { name: 'Message the assistant' })
expect(within(panel).getByText('On: People')).toBeInTheDocument()
await user.type(composer, 'Who is active?{Enter}')
await waitFor(() => expect(turnBody).toEqual({ text: 'Who is active?', pageContext: { route: '/app/person', tableName: 'person' } }))
expect(within(panel).getByText('Who is active?')).toBeInTheDocument()
expect(within(panel).getByRole('button', { name: 'Stop the reply' })).toBeInTheDocument()
act(() => stream.write(sse(
START,
['tool.start', { seq: 2, messageId: 11, toolCallId: 't1', name: 'query_records', summary: 'Querying People' }],
['tool.result', {
seq: 3, messageId: 11, toolCallId: 't1', name: 'query_records', outcome: 'OK', summary: 'Queried People · 1 row',
recordRefs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }],
}],
['text.delta', { seq: 4, messageId: 11, text: 'One person is **active**. ' }],
['ui.navigate', { seq: 5, messageId: 11, href: '/app/person/1' }],
['text.delta', { seq: 6, messageId: 11, text: 'Opened it.' }],
)))
expect(await within(panel).findByText('active')).toBeInTheDocument()
expect(within(panel).getByText('Queried People · 1 row')).toBeInTheDocument()
const chip = within(panel).getByRole('link', { name: /Alice Johnson/ })
expect(chip).toHaveAttribute('href', '/app/person/1')
expect(router.push).toHaveBeenCalledWith('/app/person/1')
persisted.messages = storedTurn('Who is active?', [
{ type: 'tool', toolCallId: 't1', name: 'query_records', summary: 'Queried People · 1 row', outcome: 'OK' },
{ type: 'record_refs', refs: [{ tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice Johnson', href: '/app/person/1' }] },
{ type: 'text', text: 'One person is **active**. Opened it.' },
])
act(() => stream.write(sse(['message.end', { seq: 7, messageId: 11, status: 'COMPLETE' }])))
act(() => stream.close())
await waitFor(() => expect(within(panel).getByRole('button', { name: 'Send' })).toBeInTheDocument())
expect(await within(panel).findByRole('button', { name: 'Good response' })).toBeInTheDocument()
expect(within(panel).getByText('active')).toBeInTheDocument()
expect(await axe(panel)).toHaveNoViolations()
})
it('stops a reply through the cancel endpoint', async () => {
mockAssistant()
const stream = controllableStream()
let cancelled = false
server.use(
http.post(`${BASE}/conversations/4/turns`, () => stream.response()),
http.post(`${BASE}/turns/9/cancel`, () => {
cancelled = true
persisted.messages = storedTurn('Count people', [{ type: 'text', text: 'Counting' }], 'CANCELLED')
stream.write(sse(['message.end', { seq: 3, messageId: 11, status: 'CANCELLED' }]))
stream.close()
return HttpResponse.json({ status: 'CANCELLING' }, { status: 202 })
}),
)
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Count people{Enter}')
act(() => stream.write(sse(START, ['text.delta', { seq: 2, messageId: 11, text: 'Counting' }])))
await within(panel).findByText('Counting')
await user.click(within(panel).getByRole('button', { name: 'Stop the reply' }))
await waitFor(() => expect(cancelled).toBe(true))
expect(await within(panel).findByText('Stopped.')).toBeInTheDocument()
})
it('shows a refused turn as an error in the reply', async () => {
mockAssistant()
server.use(http.post(`${BASE}/conversations/4/turns`, () => HttpResponse.json({ error: 'Daily budget reached' }, { status: 429 })))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Hi{Enter}')
expect(await within(panel).findByText('Daily budget reached')).toBeInTheDocument()
})
it('omits the page context once the chip is dismissed', async () => {
mockAssistant()
let turnBody: unknown
server.use(http.post(`${BASE}/conversations/4/turns`, async ({ request }) => {
turnBody = await request.json()
const stream = controllableStream()
stream.write(sse(START, ['message.end', { seq: 2, messageId: 11, status: 'COMPLETE' }]))
stream.close()
return stream.response()
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
await user.click(within(panel).getByRole('button', { name: "Don't send page context (People)" }))
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Hi{Enter}')
await waitFor(() => expect(turnBody).toEqual({ text: 'Hi' }))
})
})
describe('markdown safety', () => {
it('renders only same-origin /app links, never raw HTML or images', async () => {
mockAssistant()
const text = [
'See [the record](/app/person/1) or [elsewhere](https://evil.example.com/x?d=secret).',
'<img src="https://evil.example.com/p.png"><b>bold html</b>',
'![tracker](https://evil.example.com/t.png) [js](javascript:alert(1))',
].join('\n\n')
server.use(http.post(`${BASE}/conversations/4/turns`, () => {
const stream = controllableStream()
persisted.messages = storedTurn('Links?', [{ type: 'text', text }])
stream.write(sse(START, ['text.delta', { seq: 2, messageId: 11, text }], ['message.end', { seq: 3, messageId: 11, status: 'COMPLETE' }]))
stream.close()
return stream.response()
}))
const user = userEvent.setup()
renderShell()
await user.click(await screen.findByRole('button', { name: /^Assistant/ }))
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
await user.type(within(panel).getByRole('textbox', { name: 'Message the assistant' }), 'Links?{Enter}')
const internal = await within(panel).findByRole('link', { name: 'the record' })
expect(internal).toHaveAttribute('href', '/app/person/1')
expect(within(panel).queryByRole('link', { name: 'elsewhere' })).toBeNull()
expect(within(panel).getByText('(https://evil.example.com/x?d=secret)')).toBeInTheDocument()
expect(within(panel).getByRole('button', { name: 'Copy link https://evil.example.com/x?d=secret' })).toBeInTheDocument()
expect(within(panel).queryByRole('link', { name: 'js' })).toBeNull()
expect(panel.querySelector('img')).toBeNull()
expect(panel.querySelector('b')).toBeNull()
expect(within(panel).getByText('[image: tracker not shown]')).toBeInTheDocument()
})
})
describe('deep link', () => {
it('opens a new conversation with the prompt pre-filled, not sent, and strips the parameters', async () => {
mockAssistant()
const turns = vi.fn()
server.use(http.post(`${BASE}/conversations/4/turns`, () => {
turns()
return HttpResponse.json({ error: 'no' }, { status: 500 })
}))
window.history.replaceState(null, '', '/app/person/1?assistant=open&assistantPrompt=Tell%20me%20about%20this%20record.&tab=2')
renderShell()
const panel = await screen.findByRole('dialog', {}, { timeout: 10_000 })
const composer = within(panel).getByRole('textbox', { name: 'Message the assistant' })
await waitFor(() => expect(composer).toHaveValue('Tell me about this record.'))
expect(router.replace).toHaveBeenCalledWith('/app/person/1?tab=2', { scroll: false })
expect(within(panel).getByText('On: People #1')).toBeInTheDocument()
expect(turns).not.toHaveBeenCalled()
})
it('is inert when the assistant is unavailable', async () => {
mockAssistant(404)
window.history.replaceState(null, '', '/app/person/1?assistant=open&assistantPrompt=Hi')
renderShell()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(screen.queryByRole('dialog')).toBeNull()
expect(router.replace).not.toHaveBeenCalled()
})
})
+565
View File
@@ -0,0 +1,565 @@
/*
* 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 AssistantPanel — the assistant chat panel (loaded lazily by AssistantMount). From
* 1024 px it docks beside the page as a resizable column (360-640 px) with its own scrolling
* message list; below that it is a full-height dialog sheet that follows the on-screen keyboard.
*/
'use client'
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { usePathname } from 'next/navigation'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Archive, ArchiveRestore, MoreHorizontal, PanelLeft, Pencil, Pin, PinOff, SquarePen, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import type { QInstance } from '@/types'
import type { AssistantConversation, AssistantConversationDetail, AssistantConversationStatus, AssistantConversationUpdate } from '@/types/assistant'
import {
deleteAssistantConversation,
getAssistantConversation,
listAssistantConversations,
updateAssistantConversation,
} from '@/lib/api/assistant'
import { useMediaQuery } from '@/lib/hooks/use-media-query'
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { assistantPageInfo, type AssistantPageInfo } from '@/lib/utils/assistant-links'
import { getRecentRecords } from '@/lib/utils/recent-records'
import { cn } from '@/lib/utils/cn'
import { ASSISTANT_DOCKED_MEDIA_QUERY, assistantKeys, useAssistant, type AssistantController } from './assistant-context'
import { AssistantComposer } from './AssistantComposer'
import { AssistantMessageView } from './AssistantMessageView'
import { ConversationRail, conversationTitle } from './ConversationRail'
import { useAssistantEngine, type AssistantEngine } from './use-assistant-engine'
import { useStreamAnnouncer } from './use-stream-announcer'
/** Docked width limits, in pixels. */
export const PANEL_MIN_WIDTH = 360
export const PANEL_MAX_WIDTH = 640
const PANEL_DEFAULT_WIDTH = 400
/** Below this panel width the conversation list replaces the messages instead of sitting beside them. */
const SIDE_BY_SIDE_MIN_WIDTH = 560
/** Room kept for the sidebar (16rem) and a readable page (25rem) beside the docked panel. */
const PAGE_RESERVE = '41rem'
/**
* Clamps a docked width.
*
* @param width - Requested width.
* @returns The width within 360-640 px.
*/
export function clampPanelWidth(width: number): number {
if (!Number.isFinite(width)) return PANEL_DEFAULT_WIDTH
return Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, Math.round(width)))
}
/**
* The visual viewport's height and top offset, so the sheet stays above the on-screen keyboard.
*
* @param active - Whether to track it.
* @returns The height and offset, or null without `visualViewport`.
*/
function useVisualViewport(active: boolean): { height: number; top: number } | null {
const [box, setBox] = useState<{ height: number; top: number } | null>(null)
useEffect(() => {
const viewport = typeof window === 'undefined' ? undefined : window.visualViewport
if (!active || !viewport) return
/**
* Copies the viewport's box.
*
* @returns Nothing.
*/
const update = () => setBox({ height: viewport.height, top: viewport.offsetTop })
update()
viewport.addEventListener('resize', update)
viewport.addEventListener('scroll', update)
return () => {
viewport.removeEventListener('resize', update)
viewport.removeEventListener('scroll', update)
}
}, [active])
return active ? box : null
}
/**
* The current page as assistant context, read from the location (the query filter can change
* without a navigation, so callers re-read it before sending).
*
* @param metaData - Instance metadata.
* @returns The page info now.
*/
function readPageInfo(metaData: QInstance | undefined): AssistantPageInfo | null {
if (typeof window === 'undefined') return null
const segments = window.location.pathname.split('/').filter(Boolean)
let recordLabel: string | undefined
if (segments.length >= 3) {
let recordId = segments[2]
try {
recordId = decodeURIComponent(recordId)
} catch {
// keep the raw segment
}
recordLabel = getRecentRecords().find((record) => record.tableName === segments[1] && record.recordId === recordId)?.recordLabel
}
return assistantPageInfo(window.location.pathname, window.location.search, metaData, recordLabel)
}
/** Props of {@link PanelBody}. */
interface PanelBodyProps {
assistant: AssistantController
engine: AssistantEngine
/** Whether the list and the messages fit side by side. */
wide: boolean
/** The title element id (labels the panel). */
titleId: string
composerRef: React.RefObject<HTMLTextAreaElement | null>
}
/**
* Header, conversation list, messages and composer, shared by the docked panel and the sheet.
*
* @param props - Component properties.
* @returns The panel body.
*/
function PanelBody({ assistant, engine, wide, titleId, composerRef }: PanelBodyProps) {
const queryClient = useQueryClient()
const { activeConversationId, setActiveConversationId, pendingRequest, consumeRequest, metaData } = assistant
const pathname = usePathname()
const [railOpen, setRailOpen] = useState(false)
const [railStatus, setRailStatus] = useState<AssistantConversationStatus>('ACTIVE')
const [renaming, setRenaming] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [dismissedRoute, setDismissedRoute] = useState<string | null>(null)
const [pageInfo, setPageInfo] = useState(() => readPageInfo(metaData))
const [prefill, setPrefill] = useState<{ id: number; text: string } | null>(null)
const listId = useId()
const scrollRef = useRef<HTMLDivElement>(null)
const stickToBottom = useRef(true)
useEffect(() => setPageInfo(readPageInfo(metaData)), [pathname, metaData])
const conversationsQuery = useQuery({
queryKey: assistantKeys.conversations(railStatus),
queryFn: () => listAssistantConversations(railStatus),
staleTime: 1000 * 60,
})
const run = activeConversationId !== null ? engine.runs[activeConversationId] : undefined
const detailQuery = useQuery({
queryKey: assistantKeys.conversation(activeConversationId ?? 0),
queryFn: () => getAssistantConversation(activeConversationId as number),
enabled: activeConversationId !== null,
staleTime: Infinity,
refetchOnWindowFocus: false,
})
const detail = activeConversationId !== null ? detailQuery.data : undefined
const messages = useMemo(() => detail?.messages ?? [], [detail])
const conversation: AssistantConversation | undefined = detail?.conversation
?? conversationsQuery.data?.find((item) => item.id === activeConversationId)
const announcement = useStreamAnnouncer(messages)
// Opening a conversation reads it: the badge drops its unread replies
useEffect(() => {
if (activeConversationId === null || !assistant.open) return
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) =>
list?.map((item) => (item.id === activeConversationId && item.unreadCount ? { ...item, unreadCount: 0 } : item)))
}, [activeConversationId, assistant.open, queryClient, messages.length])
// A reply still streaming on the server (another tab, a reload): follow it
useEffect(() => {
if (activeConversationId === null || run) return
const streaming = messages.find((message) => message.role === 'ASSISTANT' && message.status === 'STREAMING' && message.id > 0 && message.turnId !== null)
if (streaming) engine.attach(activeConversationId, streaming)
}, [activeConversationId, messages, run, engine])
// Hand-overs: the command palette sends at once, the deep link only pre-fills
useEffect(() => {
if (!pendingRequest) return
consumeRequest(pendingRequest.id)
setActiveConversationId(null)
setRailOpen(false)
if (pendingRequest.mode === 'prefill') {
setPrefill({ id: pendingRequest.id, text: pendingRequest.prompt })
return
}
const context = pendingRequest.context === undefined ? readPageInfo(metaData)?.context ?? null : pendingRequest.context
void engine.send(pendingRequest.prompt, null, context)
}, [pendingRequest, consumeRequest, setActiveConversationId, metaData, engine])
// Keep the newest message in view while the user has not scrolled up
useLayoutEffect(() => {
const element = scrollRef.current
if (element && stickToBottom.current) element.scrollTop = element.scrollHeight
}, [messages])
useEffect(() => {
stickToBottom.current = true
}, [activeConversationId])
const contextDismissed = pageInfo !== null && dismissedRoute === pageInfo.context.route
/**
* Sends from the composer, with the page context unless dismissed.
*
* @param text - The message.
*/
const send = (text: string) => {
const info = readPageInfo(metaData)
const context = info && dismissedRoute !== info.context.route ? info.context : null
stickToBottom.current = true
void engine.send(text, activeConversationId, context)
}
/**
* Applies a change to the active conversation.
*
* @param update - The change.
*/
const updateConversation = async (update: AssistantConversationUpdate) => {
if (activeConversationId === null) return
try {
const updated = await updateAssistantConversation(activeConversationId, update)
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(updated.id), (current) => (current ? { ...current, conversation: updated } : current))
await queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
if (update.status && update.status !== railStatus) setActiveConversationId(null)
} catch (error) {
toast.error(getErrorMessage(error, 'Could not update the conversation.'))
}
}
/** Deletes the active conversation. */
const deleteConversation = async () => {
if (activeConversationId === null) return
const id = activeConversationId
try {
await deleteAssistantConversation(id)
setActiveConversationId(null)
queryClient.removeQueries({ queryKey: assistantKeys.conversation(id) })
await queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
} catch (error) {
toast.error(getErrorMessage(error, 'Could not delete the conversation.'))
}
}
const showMessages = wide || !railOpen
const title = activeConversationId === null ? 'Assistant' : conversationTitle(conversation)
const iconButton = 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-11 pointer-coarse:w-11'
const menuItem = 'flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm text-foreground outline-none data-[highlighted]:bg-accent pointer-coarse:min-h-11'
return (
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2" style={{ minHeight: 'var(--qqq-header-height)' }}>
<button
type="button"
className={iconButton}
onClick={() => setRailOpen((value) => !value)}
aria-expanded={railOpen}
aria-controls={listId}
aria-label={railOpen ? 'Hide conversations' : 'Show conversations'}
data-qqq-id="assistant-rail-toggle"
>
<PanelLeft className="h-4 w-4" aria-hidden="true" />
</button>
{renaming && conversation ? (
<form
className="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault()
const value = String(new FormData(event.currentTarget).get('title') ?? '').trim()
setRenaming(false)
if (value && value !== conversation.title) void updateConversation({ title: value })
}}
>
<label className="sr-only" htmlFor={`${titleId}-rename`}>Conversation name</label>
<input
id={`${titleId}-rename`}
name="title"
defaultValue={conversation.title ?? ''}
autoFocus
maxLength={200}
onBlur={(event) => event.currentTarget.form?.requestSubmit()}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation()
setRenaming(false)
}
}}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-base text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-sm"
data-qqq-id="assistant-rename-input"
/>
</form>
) : (
<h2 id={titleId} className="min-w-0 flex-1 truncate px-1 text-sm font-semibold text-foreground" data-qqq-id="assistant-title">{title}</h2>
)}
{conversation && activeConversationId !== null && (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button type="button" className={iconButton} aria-label="Conversation actions" data-qqq-id="assistant-conversation-menu">
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end" sideOffset={4} className="z-[2100] min-w-44 rounded-lg border border-border bg-popover p-1 shadow-lg" data-qqq-id="assistant-conversation-menu-content">
<DropdownMenu.Item className={menuItem} onSelect={() => setRenaming(true)} data-qqq-id="assistant-rename">
<Pencil className="h-4 w-4 text-muted-foreground" aria-hidden="true" />Rename
</DropdownMenu.Item>
<DropdownMenu.Item className={menuItem} onSelect={() => void updateConversation({ pinned: !conversation.pinned })} data-qqq-id="assistant-pin">
{conversation.pinned ? <PinOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> : <Pin className="h-4 w-4 text-muted-foreground" aria-hidden="true" />}
{conversation.pinned ? 'Unpin' : 'Pin'}
</DropdownMenu.Item>
<DropdownMenu.Item
className={menuItem}
onSelect={() => void updateConversation({ status: conversation.status === 'ARCHIVED' ? 'ACTIVE' : 'ARCHIVED' })}
data-qqq-id="assistant-archive"
>
{conversation.status === 'ARCHIVED' ? <ArchiveRestore className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> : <Archive className="h-4 w-4 text-muted-foreground" aria-hidden="true" />}
{conversation.status === 'ARCHIVED' ? 'Unarchive' : 'Archive'}
</DropdownMenu.Item>
<DropdownMenu.Item className={cn(menuItem, 'text-destructive')} onSelect={() => setConfirmDelete(true)} data-qqq-id="assistant-delete">
<Trash2 className="h-4 w-4" aria-hidden="true" />Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
<button
type="button"
className={iconButton}
onClick={() => {
setActiveConversationId(null)
setRailOpen(false)
requestAnimationFrame(() => composerRef.current?.focus())
}}
aria-label="New conversation"
data-qqq-id="assistant-new"
>
<SquarePen className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" className={iconButton} onClick={() => assistant.setOpen(false)} aria-label="Close assistant" data-qqq-id="assistant-close">
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="flex min-h-0 flex-1">
{railOpen && (
<div id={listId} className={cn('flex min-h-0', wide ? 'w-52 shrink-0 border-r border-border' : 'min-w-0 flex-1')}>
<ConversationRail
className="min-w-0 flex-1"
conversations={conversationsQuery.data}
loading={conversationsQuery.isLoading}
activeId={activeConversationId}
status={railStatus}
onStatusChange={setRailStatus}
onSelect={(id) => {
setActiveConversationId(id)
if (!engine.runs[id]) void queryClient.invalidateQueries({ queryKey: assistantKeys.conversation(id) })
if (!wide) setRailOpen(false)
}}
/>
</div>
)}
{showMessages && (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div
ref={scrollRef}
onScroll={(event) => {
const element = event.currentTarget
stickToBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48
}}
className="@container min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-3 py-3"
tabIndex={0}
role="log"
aria-live="off"
aria-label="Messages"
data-qqq-id="assistant-messages"
>
{activeConversationId === null && (
<div className="py-6 text-center text-sm text-muted-foreground" data-qqq-id="assistant-empty">
<p className="font-medium text-foreground">Ask about your data</p>
<p className="mt-1">Find records, count and explain them, or ask to be taken to a screen. The assistant sees only what you can see.</p>
</div>
)}
{activeConversationId !== null && detailQuery.isLoading && <p className="text-xs text-muted-foreground" role="status">Loading conversation...</p>}
{activeConversationId !== null && detailQuery.isError && (
<p className="text-xs text-destructive" role="alert">{getErrorMessage(detailQuery.error, 'Could not load the conversation.')}</p>
)}
{messages.map((message) => <AssistantMessageView key={message.id} message={message} />)}
</div>
<AssistantComposer
ref={composerRef}
pageInfo={pageInfo}
contextDismissed={contextDismissed}
onDismissContext={() => setDismissedRoute(pageInfo?.context.route ?? null)}
onRefreshContext={() => setPageInfo(readPageInfo(metaData))}
streaming={Boolean(run)}
stopping={run?.status === 'cancelling'}
onSend={send}
onStop={() => activeConversationId !== null && engine.cancel(activeConversationId)}
prefill={prefill}
/>
</div>
)}
</div>
<div className="sr-only" aria-live="polite" aria-atomic="true" data-qqq-id="assistant-announcer">{announcement}</div>
<AlertDialog.Root open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-[2200] bg-black/40" />
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-[2201] w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-5 shadow-lg focus:outline-none" data-qqq-id="assistant-delete-dialog">
<AlertDialog.Title className="text-base font-semibold text-foreground">Delete this conversation?</AlertDialog.Title>
<AlertDialog.Description className="mt-1 text-sm text-muted-foreground">{conversationTitle(conversation)} and its messages are deleted. This cannot be undone.</AlertDialog.Description>
<div className="mt-4 flex justify-end gap-2">
<AlertDialog.Cancel className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11">Cancel</AlertDialog.Cancel>
<AlertDialog.Action
className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11"
onClick={() => void deleteConversation()}
data-qqq-id="assistant-delete-confirm"
>
Delete
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
</div>
)
}
/**
* The docked panel: a resizable complementary column beside the page.
*
* @param props - Component properties.
* @param props.assistant - The controller.
* @param props.engine - The turn runner.
* @returns The panel.
*/
function DockedPanel({ assistant, engine }: { assistant: AssistantController; engine: AssistantEngine }) {
const [storedWidth, setStoredWidth] = useLocalStorage<number>('qqq-assistant-width', PANEL_DEFAULT_WIDTH)
const width = clampPanelWidth(storedWidth)
const titleId = useId()
const composerRef = useRef<HTMLTextAreaElement | null>(null)
const drag = useRef<{ x: number; width: number } | null>(null)
useEffect(() => {
composerRef.current?.focus()
}, [])
const onPointerMove = useCallback((event: React.PointerEvent) => {
if (!drag.current) return
setStoredWidth(clampPanelWidth(drag.current.width + drag.current.x - event.clientX))
}, [setStoredWidth])
return (
<aside
className="relative flex h-full min-h-0 shrink-0 flex-col overflow-hidden border-l border-border bg-card"
style={{ width, maxWidth: `max(${PANEL_MIN_WIDTH}px, calc(100vw - ${PAGE_RESERVE}))` }}
aria-labelledby={titleId}
data-qqq-id="assistant-panel"
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
if ((event.target as HTMLElement).closest('[role="menu"]')) return
event.preventDefault()
assistant.setOpen(false)
}}
>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize assistant panel"
aria-valuemin={PANEL_MIN_WIDTH}
aria-valuemax={PANEL_MAX_WIDTH}
aria-valuenow={width}
tabIndex={0}
onPointerDown={(event) => {
drag.current = { x: event.clientX, width }
event.currentTarget.setPointerCapture(event.pointerId)
}}
onPointerMove={onPointerMove}
onPointerUp={() => { drag.current = null }}
onPointerCancel={() => { drag.current = null }}
onKeyDown={(event) => {
const step = event.shiftKey ? 64 : 16
if (event.key === 'ArrowLeft') setStoredWidth(clampPanelWidth(width + step))
else if (event.key === 'ArrowRight') setStoredWidth(clampPanelWidth(width - step))
else if (event.key === 'Home') setStoredWidth(PANEL_MIN_WIDTH)
else if (event.key === 'End') setStoredWidth(PANEL_MAX_WIDTH)
else return
event.preventDefault()
}}
className="absolute inset-y-0 left-0 z-10 w-1.5 -translate-x-1/2 cursor-col-resize touch-none hover:bg-primary/30 focus:outline-none focus-visible:bg-primary/40"
data-qqq-id="assistant-resize"
/>
<PanelBody assistant={assistant} engine={engine} wide={width >= SIDE_BY_SIDE_MIN_WIDTH} titleId={titleId} composerRef={composerRef} />
</aside>
)
}
/**
* The sheet below 1024 px: a modal dialog, full screen on phones and a side sheet on tablets.
*
* @param props - Component properties.
* @param props.assistant - The controller.
* @param props.engine - The turn runner.
* @returns The dialog.
*/
function SheetPanel({ assistant, engine }: { assistant: AssistantController; engine: AssistantEngine }) {
const titleId = useId()
const composerRef = useRef<HTMLTextAreaElement | null>(null)
const viewport = useVisualViewport(assistant.open)
return (
<Dialog.Root open={assistant.open} onOpenChange={(next) => { if (!next) assistant.setOpen(false) }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-[var(--qqq-z-modal,1000)] bg-black/40 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=open]:fade-in-0" />
<Dialog.Content
aria-labelledby={titleId}
aria-describedby={undefined}
onOpenAutoFocus={(event) => {
event.preventDefault()
composerRef.current?.focus({ preventScroll: true })
}}
// focus returns to the opener through the controller
onCloseAutoFocus={(event) => event.preventDefault()}
className="fixed inset-x-0 top-0 z-[var(--qqq-z-modal,1000)] flex h-dvh flex-col overflow-hidden bg-card shadow-lg focus:outline-none md:left-auto md:w-[min(30rem,100vw)] md:border-l md:border-border"
style={viewport ? { top: viewport.top, height: viewport.height } : undefined}
data-qqq-id="assistant-panel"
data-variant="sheet"
>
<Dialog.Title className="sr-only">Assistant</Dialog.Title>
<PanelBody assistant={assistant} engine={engine} wide={false} titleId={titleId} composerRef={composerRef} />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
/**
* The assistant panel: docked on wide screens, a sheet below 1024 px. The turn runner lives
* here, above both, so a reply keeps streaming while the panel is closed.
*
* @returns The panel, or null when closed on a wide screen.
*/
export default function AssistantPanel() {
const assistant = useAssistant()
const docked = useMediaQuery(ASSISTANT_DOCKED_MEDIA_QUERY)
const engine = useAssistantEngine()
if (!assistant) return null
if (docked) return assistant.open ? <DockedPanel assistant={assistant} engine={engine} /> : null
return <SheetPanel assistant={assistant} engine={engine} />
}
@@ -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 AssistantProvider — creates the shell-level assistant controller (probe, open state,
* unread count, hand-over) for the dashboard layout. The panel itself loads lazily.
*/
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import type { QInstance } from '@/types'
import type { AssistantPageContext } from '@/types/assistant'
import { getAssistantConfig, listAssistantConversations } from '@/lib/api/assistant-probe'
import { readAssistantDeepLink } from '@/lib/utils/assistant-deep-link'
import { ASSISTANT_DOCKED_MEDIA_QUERY, AssistantContext, assistantKeys, type AssistantController, type AssistantRequest } from './assistant-context'
/** localStorage key remembering that the docked panel was open. */
const OPEN_STORAGE_KEY = 'qqq-assistant-open'
/**
* Reads a localStorage flag; storage can be unavailable (private mode, blocked site data).
*
* @param key - The key.
* @returns Whether the stored value is `true`.
*/
function readFlag(key: string): boolean {
try {
return window.localStorage.getItem(key) === 'true'
} catch {
return false
}
}
/**
* Writes a localStorage flag, ignoring unavailable storage.
*
* @param key - The key.
* @param value - The value.
*/
function writeFlag(key: string, value: boolean): void {
try {
window.localStorage.setItem(key, String(value))
} catch {
// storage unavailable: the panel just does not reopen on the next visit
}
}
/**
* Whether the instance metadata announces the assistant qbit (`supplementalInstanceMetaData.assistant`,
* published by qbit-assistant), the precondition for probing `/assistant/config`.
*
* @param metaData - Instance metadata.
* @returns True when the entry is present.
*/
export function isAssistantInstalled(metaData: QInstance | undefined): boolean {
const entry = metaData?.supplementalInstanceMetaData?.assistant
return Boolean(entry) && typeof entry === 'object'
}
/**
* Creates the assistant controller. Call it once in the dashboard layout (which also needs it
* for the Ctrl/⌘+J shortcut) and pass the result to {@link AssistantProvider}.
*
* @param metaData - Instance metadata (tables, for the panel's page context).
* @param isAuthenticated - The probe runs only for a signed-in user.
* @returns The controller.
*/
export function useAssistantController(metaData: QInstance | undefined, isAuthenticated: boolean): AssistantController {
const pathname = usePathname() ?? ''
const router = useRouter()
// Only an instance that announces the qbit is probed: elsewhere /assistant/config would answer
// 404 on every page load (a console error in every browser)
const installed = isAssistantInstalled(metaData)
const { data: config = null } = useQuery({
queryKey: assistantKeys.config(),
queryFn: getAssistantConfig,
enabled: isAuthenticated && installed,
staleTime: 1000 * 60 * 10,
retry: false,
})
const enabled = installed && Boolean(config?.enabled)
const { data: conversations } = useQuery({
queryKey: assistantKeys.conversations(),
queryFn: () => listAssistantConversations(),
enabled,
staleTime: 1000 * 60,
})
const unreadCount = useMemo(() => (conversations ?? []).reduce((total, conversation) => total + (conversation.unreadCount || 0), 0), [conversations])
const [open, setOpenState] = useState(false)
const [activeConversationId, setActiveConversationId] = useState<number | null>(null)
const [pendingRequest, setPendingRequest] = useState<AssistantRequest | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const originRef = useRef<HTMLElement | null>(null)
const wasOpen = useRef(false)
const setOpen = useCallback((next: boolean) => {
if (next && typeof document !== 'undefined') {
const active = document.activeElement
originRef.current = active instanceof HTMLElement && active !== document.body && !active.closest('[data-qqq-id="assistant-panel"]') ? active : null
}
setOpenState(next)
if (typeof window !== 'undefined' && window.matchMedia?.(ASSISTANT_DOCKED_MEDIA_QUERY)?.matches) writeFlag(OPEN_STORAGE_KEY, next)
}, [])
// Closing returns focus to what had it before opening, else to the header button
useEffect(() => {
if (open || !wasOpen.current) {
wasOpen.current = open
return
}
wasOpen.current = false
const frame = requestAnimationFrame(() => {
const target = originRef.current?.isConnected ? originRef.current : triggerRef.current
originRef.current = null
const active = document.activeElement
if (target && (!active || active === document.body || !active.isConnected)) target.focus()
})
return () => cancelAnimationFrame(frame)
}, [open])
// The docked panel reopens on the next visit when it was left open
useEffect(() => {
if (enabled && readFlag(OPEN_STORAGE_KEY) && window.matchMedia?.(ASSISTANT_DOCKED_MEDIA_QUERY)?.matches) setOpenState(true)
}, [enabled])
// Disabled (kill switch, permission removed): close and hide everything
useEffect(() => {
if (!enabled) setOpenState(false)
}, [enabled])
const toggle = useCallback(() => setOpen(!open), [open, setOpen])
const openWith = useCallback((prompt: string, options?: { context?: AssistantPageContext | null; mode?: 'send' | 'prefill' }) => {
const mode = options?.mode ?? 'send'
if (mode === 'send' && !prompt.trim()) return
setPendingRequest({ id: Date.now(), prompt: prompt.trim(), context: options?.context, mode })
setOpen(true)
}, [setOpen])
// Deep link `?assistant=open&assistantPrompt=...` (the record Developer screen's "Ask the
// assistant about this record"): a new conversation on this page with the prompt pre-filled,
// not sent; both parameters are then removed from the URL. Inert while the assistant is unavailable.
useEffect(() => {
if (!enabled || typeof window === 'undefined') return
const deepLink = readAssistantDeepLink(window.location.search)
if (!deepLink) return
openWith(deepLink.prompt, { mode: 'prefill' })
router.replace(`${window.location.pathname}${deepLink.remainingSearch}${window.location.hash}`, { scroll: false })
}, [enabled, pathname, openWith, router])
const consumeRequest = useCallback((id: number) => {
setPendingRequest((current) => (current?.id === id ? null : current))
}, [])
return useMemo<AssistantController>(() => ({
config,
enabled,
open: enabled && open,
setOpen,
toggle,
activeConversationId,
setActiveConversationId,
unreadCount: enabled ? unreadCount : 0,
openWith,
pendingRequest,
consumeRequest,
triggerRef,
metaData,
}), [config, enabled, open, setOpen, toggle, activeConversationId, unreadCount, openWith, pendingRequest, consumeRequest, metaData])
}
/**
* Provides the assistant controller to the dashboard shell.
*
* @param props - Component properties.
* @param props.value - The controller from {@link useAssistantController}.
* @param props.children - The shell.
* @returns The provider.
*/
export function AssistantProvider({ value, children }: { value: AssistantController; children: React.ReactNode }) {
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>
}
@@ -0,0 +1,309 @@
/*
* 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 AssistantRenders — structured tool-result renders (records table, record, count,
* history, widget) and record chips. Everything comes from server payloads, never from
* model-written HTML; links pass the same-origin `/app` check.
*/
'use client'
import React, { Suspense, lazy } from 'react'
import Link from 'next/link'
import { ArrowRight, Table2 } from 'lucide-react'
import type {
AssistantCountRender,
AssistantHistoryRender,
AssistantRecordRef,
AssistantRecordRender,
AssistantRecordsRender,
AssistantRender,
AssistantWidgetRender,
} from '@/types/assistant'
import { internalAppHref } from '@/lib/utils/assistant-links'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { RecordHoverCard } from '@/components/records/RecordHoverCard'
const WidgetRenderer = lazy(() => import('@/components/widgets/WidgetRenderer').then((m) => ({ default: m.WidgetRenderer })))
/** Widget types the panel renders inline (charts and statistics); others link to their screen. */
const INLINE_WIDGET_TYPES = new Set([
'barChart', 'horizontalBarChart', 'stackedBarChart', 'lineChart', 'smallLineChart', 'pieChart', 'chart', 'statistics', 'multiStatistics',
])
/** Classes of a link styled as a small action. */
const ACTION_LINK = 'inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded pointer-coarse:min-h-11'
/**
* A same-origin link, or plain text when the href fails the check.
*
* @param props - Component properties.
* @param props.href - The server-built href.
* @param props.children - Link content.
* @param props.className - Classes.
* @param props.qqqId - The `data-qqq-id`.
* @returns The link or a span.
*/
function SafeLink({ href, children, className, qqqId }: { href: string | undefined; children: React.ReactNode; className?: string; qqqId?: string }) {
const internal = internalAppHref(href)
if (!internal) return <span className={className} data-qqq-id={qqqId}>{children}</span>
return <Link href={internal} className={className} data-qqq-id={qqqId}>{children}</Link>
}
/**
* Record references as chips that open the record; hovering previews it.
*
* @param props - Component properties.
* @param props.refs - The references.
* @returns The chip list.
*/
export function RecordChips({ refs }: { refs: AssistantRecordRef[] }) {
if (!refs.length) return null
return (
<ul className="flex flex-wrap gap-1.5" aria-label="Records" data-qqq-id="assistant-record-refs">
{refs.map((ref) => {
const href = internalAppHref(ref.href)
const chip = (
<span className="inline-flex max-w-full items-center gap-1.5 truncate">
<Table2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="truncate">{ref.label || `${ref.tableLabel} #${ref.id}`}</span>
<span className="sr-only">, {ref.tableLabel}</span>
</span>
)
const chipClass = 'inline-flex max-w-full items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-foreground pointer-coarse:min-h-11'
return (
<li key={`${ref.tableName}:${ref.id}`} className="max-w-full">
{href ? (
<RecordHoverCard tableName={ref.tableName} primaryKey={ref.id} tableMetaData={{ label: ref.tableLabel }}>
<Link href={href} className={`${chipClass} hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring`} data-qqq-id="assistant-record-chip">
{chip}
</Link>
</RecordHoverCard>
) : (
<span className={chipClass} data-qqq-id="assistant-record-chip">{chip}</span>
)}
</li>
)
})}
</ul>
)
}
/**
* Query results: a table where the panel is wide enough, cards where it is narrow.
*
* @param props - Component properties.
* @param props.render - The records payload.
* @returns The records view.
*/
function RecordsView({ render }: { render: AssistantRecordsRender }) {
const shown = render.rows.length
const total = render.totalCount ?? shown
const caption = `${render.tableLabel}: ${shown === total ? `${total} ${total === 1 ? 'row' : 'rows'}` : `${shown} of ${total} rows`}`
const [firstColumn, ...otherColumns] = render.columns
return (
<div className="@container rounded-lg border border-border bg-card" data-qqq-id="assistant-records">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<span className="truncate text-xs font-medium text-foreground">{caption}</span>
<SafeLink href={render.openHref} className={ACTION_LINK} qqqId="assistant-open-query">
Open in Query screen<ArrowRight className="h-3 w-3" aria-hidden="true" />
</SafeLink>
</div>
{/* wide: a table (scrolls sideways inside its own box when it has many columns) */}
<div className="hidden overflow-x-auto @lg:block" tabIndex={0} role="region" aria-label={caption}>
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-border text-muted-foreground">
{render.columns.map((column) => <th key={column.name} scope="col" className="whitespace-nowrap px-3 py-1.5 font-medium">{column.label}</th>)}
</tr>
</thead>
<tbody>
{render.rows.map((row) => (
<tr key={String(row.id)} className="border-b border-border last:border-0">
{render.columns.map((column, index) => (
<td key={column.name} className="max-w-[16rem] truncate px-3 py-1.5 text-foreground">
{index === 0 ? (
<SafeLink href={row.href} className="font-medium text-primary hover:underline">{row.values[column.name] ?? String(row.id)}</SafeLink>
) : (row.values[column.name] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* narrow: one card per row */}
<ul className="divide-y divide-border @lg:hidden">
{render.rows.map((row) => (
<li key={String(row.id)} className="px-3 py-2">
{firstColumn && (
<SafeLink href={row.href} className="block truncate text-sm font-medium text-primary hover:underline">
{row.values[firstColumn.name] ?? String(row.id)}
</SafeLink>
)}
<dl className="mt-1 grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
{otherColumns.slice(0, 4).map((column) => (
<React.Fragment key={column.name}>
<dt className="truncate text-muted-foreground">{column.label}</dt>
<dd className="truncate text-foreground">{row.values[column.name] ?? ''}</dd>
</React.Fragment>
))}
</dl>
</li>
))}
</ul>
{render.truncated && <p className="border-t border-border px-3 py-1.5 text-xs text-muted-foreground">More rows match; open the query screen to see them all.</p>}
</div>
)
}
/**
* One record's fields.
*
* @param props - Component properties.
* @param props.render - The record payload.
* @returns The record card.
*/
function RecordView({ render }: { render: AssistantRecordRender }) {
return (
<div className="rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-record">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-muted-foreground">{render.tableLabel} #{render.id}</span>
<SafeLink href={render.href} className={ACTION_LINK}>Open<ArrowRight className="h-3 w-3" aria-hidden="true" /></SafeLink>
</div>
<dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs">
{render.fields.map((field) => (
<React.Fragment key={field.name}>
<dt className="truncate text-muted-foreground">{field.label}</dt>
<dd className="break-words text-foreground">{field.value ?? '—'}</dd>
</React.Fragment>
))}
</dl>
</div>
)
}
/**
* A record count.
*
* @param props - Component properties.
* @param props.render - The count payload.
* @returns The count card.
*/
function CountView({ render }: { render: AssistantCountRender }) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-count">
<div className="min-w-0">
<p className="text-xl font-semibold tabular-nums text-foreground">{render.count.toLocaleString()}</p>
<p className="truncate text-xs text-muted-foreground">{render.tableLabel}</p>
</div>
<SafeLink href={render.openHref} className={ACTION_LINK} qqqId="assistant-open-query">
Open in Query screen<ArrowRight className="h-3 w-3" aria-hidden="true" />
</SafeLink>
</div>
)
}
/**
* A record's change history.
*
* @param props - Component properties.
* @param props.render - The history payload.
* @returns The history list.
*/
function HistoryView({ render }: { render: AssistantHistoryRender }) {
if (!render.entries.length) return <p className="text-xs text-muted-foreground">No changes recorded.</p>
return (
<ol className="space-y-2 rounded-lg border border-border bg-card px-3 py-2" data-qqq-id="assistant-history">
{render.entries.map((entry, index) => (
<li key={index} className="text-xs">
<p className="text-muted-foreground">{formatTimestamp(entry.timestamp)} · {entry.user}</p>
<p className="text-foreground">{entry.message}</p>
{entry.details.length > 0 && (
<ul className="mt-0.5 space-y-0.5 pl-3">
{entry.details.map((detail, detailIndex) => (
<li key={detailIndex} className="break-words text-muted-foreground">
<span className="font-medium text-foreground">{detail.field}</span>: {detail.oldValue ?? '—'} {'→'} {detail.newValue ?? '—'}{detail.truncated ? ' …' : ''}
</li>
))}
</ul>
)}
</li>
))}
</ol>
)
}
/**
* Formats an ISO timestamp for display, keeping unparseable values as they are.
*
* @param value - The timestamp.
* @returns The formatted date and time.
*/
function formatTimestamp(value: string): string {
try {
return formatDateTime(value) || value
} catch {
return value
}
}
/**
* A widget payload rendered by the dashboard's own chart and statistics widgets.
*
* @param props - Component properties.
* @param props.render - The widget payload.
* @returns The widget, or its label when the type is not rendered inline.
*/
function WidgetView({ render }: { render: AssistantWidgetRender }) {
if (!INLINE_WIDGET_TYPES.has(render.widgetType)) {
return <p className="text-xs text-muted-foreground">{render.label}: this widget type is shown on its dashboard only.</p>
}
return (
<div className="min-w-0 rounded-lg border border-border bg-card p-2" data-qqq-id="assistant-widget">
<p className="mb-1 truncate text-xs font-medium text-foreground">{render.label}</p>
<Suspense fallback={<div className="h-40 rounded bg-muted motion-safe:animate-pulse" />}>
<WidgetRenderer widgetMetaData={{ name: render.widgetName, label: render.label, type: render.widgetType, hasPermission: true }} data={render.data} />
</Suspense>
</div>
)
}
/**
* Renders a tool result payload.
*
* @param props - Component properties.
* @param props.render - The payload.
* @returns The matching view, or null for an unknown type.
*/
export function AssistantRenderView({ render }: { render: AssistantRender }) {
switch (render.type) {
case 'records':
return <RecordsView render={render} />
case 'record':
return <RecordView render={render} />
case 'count':
return <CountView render={render} />
case 'history':
return <HistoryView render={render} />
case 'widget':
return <WidgetView render={render} />
default:
return null
}
}
@@ -0,0 +1,174 @@
/*
* 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 ConversationRail — the panel's conversation list (pinned, recent, archived) as a
* listbox: arrow keys move, Enter or Space opens, unread conversations show a dot.
*/
'use client'
import React, { useId, useRef } from 'react'
import { Pin } from 'lucide-react'
import type { AssistantConversation, AssistantConversationStatus } from '@/types/assistant'
import { cn } from '@/lib/utils/cn'
/** Props of {@link ConversationRail}. */
interface ConversationRailProps {
conversations: AssistantConversation[] | undefined
loading: boolean
activeId: number | null
/** Which list is shown. */
status: AssistantConversationStatus
/**
* Switches between active and archived conversations.
*
* @param status - The list to show.
*/
onStatusChange: (status: AssistantConversationStatus) => void
/**
* Opens a conversation.
*
* @param id - The conversation id.
*/
onSelect: (id: number) => void
className?: string
}
/**
* A conversation's display title.
*
* @param conversation - The conversation.
* @returns Its title, or a placeholder for an untitled one.
*/
export function conversationTitle(conversation: AssistantConversation | undefined | null): string {
return conversation?.title?.trim() || 'New conversation'
}
/**
* The conversation list.
*
* @param props - Component properties.
* @returns The rail.
*/
export function ConversationRail({ conversations, loading, activeId, status, onStatusChange, onSelect, className }: ConversationRailProps) {
const listRef = useRef<HTMLDivElement>(null)
const labelId = useId()
const list = conversations ?? []
const pinned = list.filter((conversation) => conversation.pinned)
const recent = list.filter((conversation) => !conversation.pinned)
const ordered = [...pinned, ...recent]
const focusIndex = Math.max(0, ordered.findIndex((conversation) => conversation.id === activeId))
/**
* Arrow-key navigation between options.
*
* @param event - The key event.
*/
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const options = Array.from(listRef.current?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])
const current = options.indexOf(document.activeElement as HTMLElement)
let next = -1
if (event.key === 'ArrowDown') next = Math.min(options.length - 1, current + 1)
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1)
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = options.length - 1
else if ((event.key === 'Enter' || event.key === ' ') && current >= 0) {
event.preventDefault()
onSelect(Number(options[current].dataset.conversationId))
return
}
if (next >= 0 && options[next]) {
event.preventDefault()
options.forEach((option, index) => option.setAttribute('tabindex', index === next ? '0' : '-1'))
options[next].focus()
}
}
/**
* One option.
*
* @param conversation - The conversation.
* @param index - Its position in keyboard order.
* @returns The option.
*/
const option = (conversation: AssistantConversation, index: number) => {
const selected = conversation.id === activeId
const unread = conversation.unreadCount > 0 && !selected
return (
<div
key={conversation.id}
role="option"
aria-selected={selected}
tabIndex={index === focusIndex ? 0 : -1}
data-conversation-id={conversation.id}
onClick={() => onSelect(conversation.id)}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11',
selected ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent'
)}
data-qqq-id="assistant-conversation"
>
{conversation.pinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden="true" />}
<span className={cn('min-w-0 flex-1 truncate', unread && 'font-semibold')}>{conversationTitle(conversation)}</span>
{unread && (
<>
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" aria-hidden="true" data-qqq-id="assistant-conversation-unread" />
<span className="sr-only">, unread</span>
</>
)}
</div>
)
}
return (
<nav className={cn('flex min-h-0 flex-col bg-sidebar', className)} aria-label="Conversations" data-qqq-id="assistant-rail">
<div className="flex shrink-0 gap-1 border-b border-border p-1.5" role="group" aria-label="Show">
{(['ACTIVE', 'ARCHIVED'] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={status === value}
onClick={() => onStatusChange(value)}
className="flex-1 rounded px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-pressed:bg-background aria-pressed:text-foreground pointer-coarse:min-h-11"
>
{value === 'ACTIVE' ? 'Recent' : 'Archived'}
</button>
))}
</div>
<span id={labelId} className="sr-only">{status === 'ACTIVE' ? 'Conversations' : 'Archived conversations'}</span>
<div ref={listRef} role="listbox" aria-labelledby={labelId} onKeyDown={onKeyDown} className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-1.5" data-qqq-id="assistant-conversation-list">
{loading && <p className="px-2 py-1 text-xs text-muted-foreground" role="status">Loading...</p>}
{!loading && ordered.length === 0 && (
<p className="px-2 py-1 text-xs text-muted-foreground">{status === 'ACTIVE' ? 'No conversations yet.' : 'No archived conversations.'}</p>
)}
{pinned.length > 0 && (
<div role="group" aria-label="Pinned">
<p className="px-2 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground" aria-hidden="true">Pinned</p>
{pinned.map((conversation, index) => option(conversation, index))}
</div>
)}
{recent.length > 0 && (
<div role="group" aria-label={status === 'ACTIVE' ? 'Recent' : 'Archived'}>
{pinned.length > 0 && <p className="px-2 pb-0.5 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground" aria-hidden="true">Recent</p>}
{recent.map((conversation, index) => option(conversation, pinned.length + index))}
</div>
)}
</div>
</nav>
)
}
@@ -0,0 +1,134 @@
/*
* 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 Assistant context — the shell-level state of the assistant panel (feature probe,
* open state, active conversation, unread count, hand-over requests), shared by the header
* button, the command palette, the keyboard shortcut and the lazily loaded panel.
*/
'use client'
import { createContext, useContext, type RefObject } from 'react'
import type { QInstance } from '@/types'
import type { AssistantConfig, AssistantConversationStatus, AssistantPageContext } from '@/types/assistant'
/** The panel docks beside the page at this width and up; below it opens as a sheet. */
export const ASSISTANT_DOCKED_MEDIA_QUERY = '(min-width: 1024px)'
/** TanStack Query keys of the assistant. */
export const assistantKeys = {
/**
* Root of every assistant query.
*
* @returns The key.
*/
all: () => ['qqq', 'assistant'] as const,
/**
* The feature probe.
*
* @returns The key.
*/
config: () => [...assistantKeys.all(), 'config'] as const,
/**
* A conversation list.
*
* @param status - Active or archived conversations.
* @returns The key.
*/
conversations: (status: AssistantConversationStatus = 'ACTIVE') => [...assistantKeys.all(), 'conversations', status] as const,
/**
* One conversation and its messages.
*
* @param id - The conversation id.
* @returns The key.
*/
conversation: (id: number) => [...assistantKeys.all(), 'conversation', id] as const,
}
/**
* A prompt handed to the panel in a new conversation: sent at once (command palette "Ask
* Assistant") or only pre-filled in the composer (the `?assistant=open` deep link).
*/
export interface AssistantRequest {
/** Distinguishes requests with the same text. */
id: number
prompt: string
/** `send` asks right away; `prefill` puts the prompt in the composer for the user to send. */
mode: 'send' | 'prefill'
/** Page context for the request; `undefined` uses the current page. */
context?: AssistantPageContext | null
}
/** The shell-level assistant state and actions. */
export interface AssistantController {
/** The probe result; null while loading or when the qbit is absent. */
config: AssistantConfig | null
/** Whether the assistant is available: everything is hidden and inert otherwise. */
enabled: boolean
open: boolean
/**
* Opens or closes the panel; closing returns focus to where it was.
*
* @param open - The new state.
*/
setOpen: (open: boolean) => void
/** Opens a closed panel, closes an open one. */
toggle: () => void
activeConversationId: number | null
/**
* Shows a conversation (null: a new, empty one).
*
* @param id - The conversation id.
*/
setActiveConversationId: (id: number | null) => void
/** Unread replies across conversations, for the header badge. */
unreadCount: number
/**
* Opens the panel on a new conversation and asks `prompt` (or only pre-fills it).
*
* @param prompt - The question.
* @param options - The page context to send (defaults to the current page) and whether to send.
* @param options.context - The context, or null for none.
* @param options.mode - `send` (default) or `prefill`.
*/
openWith: (prompt: string, options?: { context?: AssistantPageContext | null; mode?: 'send' | 'prefill' }) => void
/** A hand-over the panel has not processed yet. */
pendingRequest: AssistantRequest | null
/**
* Marks a hand-over as processed.
*
* @param id - The request id.
*/
consumeRequest: (id: number) => void
/** The header button, where focus returns when nothing else had it. */
triggerRef: RefObject<HTMLButtonElement | null>
/** Instance metadata, for record previews. */
metaData?: QInstance
}
/** Context carrying the {@link AssistantController}; null outside the dashboard shell. */
export const AssistantContext = createContext<AssistantController | null>(null)
/**
* Reads the assistant controller.
*
* @returns The controller, or null outside the dashboard shell.
*/
export function useAssistant(): AssistantController | null {
return useContext(AssistantContext)
}
@@ -0,0 +1,123 @@
/*
* 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 applying stream events to messages, and the announcer's sentence detection
import { describe, expect, it } from 'vitest'
import type { AssistantMessage, AssistantStreamEvent } from '@/types/assistant'
import { endReply, messageText, optimisticTurnMessages, reduceStreamEvent, type TurnMessageIds } from './message-reducer'
import { completeSentenceLength } from './use-stream-announcer'
const REF = { tableName: 'person', tableLabel: 'People', id: 1, label: 'Alice', href: '/app/person/1' }
/**
* Applies events to a fresh optimistic turn.
*
* @param events - Stream events.
* @returns The messages and ids.
*/
function apply(...events: AssistantStreamEvent[]): { messages: AssistantMessage[]; ids: TurnMessageIds } {
const ids: TurnMessageIds = { userId: -1, assistantId: -2 }
let messages = optimisticTurnMessages('Hi', { userId: -1, assistantId: -2 }, '2026-10-12T00:00:00Z')
for (const event of events) messages = reduceStreamEvent(messages, event, ids)
return { messages, ids }
}
const START: AssistantStreamEvent = { type: 'message.start', seq: 1, turnId: 9, conversationId: 4, userMessageId: 10, messageId: 11 }
describe('reduceStreamEvent', () => {
it('swaps temporary ids for the server ids on message.start', () => {
const { messages, ids } = apply(START)
expect(messages.map((message) => [message.id, message.turnId])).toEqual([[10, 9], [11, 9]])
expect(ids).toEqual({ userId: 10, assistantId: 11 })
})
it('appends text deltas to the last text part and starts a new one after other parts', () => {
const { messages } = apply(
START,
{ type: 'text.delta', seq: 2, messageId: 11, text: 'Hel' },
{ type: 'text.delta', seq: 3, messageId: 11, text: 'lo' },
{ type: 'tool.start', seq: 4, messageId: 11, toolCallId: 't', name: 'count_records', summary: 'Counting' },
{ type: 'text.delta', seq: 5, messageId: 11, text: 'Done' },
)
expect(messages[1].parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
expect(messageText(messages[1])).toBe('HelloDone')
})
it('completes tool parts, adds record refs after them without duplicates, and ignores repeated starts', () => {
const result: AssistantStreamEvent = { type: 'tool.result', seq: 3, messageId: 11, toolCallId: 't', name: 'get_record', outcome: 'OK', summary: 'Got Alice', recordRefs: [REF] }
const { messages } = apply(
START,
{ type: 'tool.start', seq: 2, messageId: 11, toolCallId: 't', name: 'get_record', summary: 'Getting' },
{ type: 'tool.start', seq: 2, messageId: 11, toolCallId: 't', name: 'get_record', summary: 'Getting' },
result,
{ ...result, seq: 4, recordRefs: [REF, { ...REF, id: 2, label: 'Bob' }] },
{ type: 'tool.result', seq: 5, messageId: 11, toolCallId: 'u', name: 'count_records', outcome: 'DENIED', summary: 'Not allowed', render: { type: 'count', tableName: 'x', tableLabel: 'X', count: 1, openHref: '/app/x' } },
)
expect(messages[1].parts).toEqual([
{ type: 'tool', toolCallId: 't', name: 'get_record', summary: 'Got Alice', outcome: 'OK' },
{ type: 'record_refs', refs: [REF, { ...REF, id: 2, label: 'Bob' }] },
{ type: 'tool', toolCallId: 'u', name: 'count_records', summary: 'Not allowed', outcome: 'DENIED', render: { type: 'count', tableName: 'x', tableLabel: 'X', count: 1, openHref: '/app/x' } },
])
})
it('adds navigate, notice and error parts and applies the end status', () => {
const { messages } = apply(
START,
{ type: 'ui.navigate', seq: 2, messageId: 11, href: '/app/person' },
{ type: 'notice', seq: 3, messageId: 11, level: 'warning', text: 'Truncated' },
{ type: 'error', seq: 4, code: 'BUDGET', message: 'Budget reached' },
{ type: 'usage', seq: 5, turnId: 9, inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costMicros: 1 },
{ type: 'message.end', seq: 6, messageId: 11, status: 'ERROR' },
)
expect(messages[1].parts).toEqual([
{ type: 'navigate', href: '/app/person' },
{ type: 'notice', level: 'warning', text: 'Truncated' },
{ type: 'notice', level: 'error', text: 'Budget reached' },
])
expect(messages[1].status).toBe('ERROR')
})
it('leaves messages unchanged for an unknown message id', () => {
const { messages } = apply(START)
expect(reduceStreamEvent(messages, { type: 'text.delta', seq: 2, messageId: 99, text: 'x' }, { userId: 10, assistantId: 99 })).toBe(messages)
})
it('keeps the user message when attaching to a reply (no user id)', () => {
const messages = optimisticTurnMessages('Hi', { userId: 10, assistantId: 11 }, '2026-10-12T00:00:00Z')
const ids: TurnMessageIds = { userId: null, assistantId: 11 }
expect(reduceStreamEvent(messages, START, ids).map((message) => message.id)).toEqual([10, 11])
})
})
describe('endReply', () => {
it('ends a streaming reply with a notice, and keeps a finished status', () => {
const { messages } = apply(START)
const ended = endReply(messages, 11, 'ERROR', 'Lost')
expect(ended[1]).toMatchObject({ status: 'ERROR', parts: [{ type: 'notice', level: 'error', text: 'Lost' }] })
expect(endReply(ended, 11, 'CANCELLED')[1].status).toBe('ERROR')
})
})
describe('completeSentenceLength', () => {
it('finds the end of the last complete sentence', () => {
expect(completeSentenceLength('One. Two! Three')).toBe(9)
expect(completeSentenceLength('Line one\nmore')).toBe(9)
expect(completeSentenceLength('Version 1.5 is')).toBe(0)
expect(completeSentenceLength('Done.')).toBe(5)
})
})
+183
View File
@@ -0,0 +1,183 @@
/*
* 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 message-reducer — applies turn stream events to a conversation's messages (pure, so
* the live reply can be kept in the TanStack Query cache and survive the panel closing).
*/
import type { AssistantMessage, AssistantPart, AssistantRecordRef, AssistantStreamEvent } from '@/types/assistant'
/** The messages a running turn writes: the user's (until the server id is known) and the reply. */
export interface TurnMessageIds {
/** The user message id (a temporary negative id until `message.start`), or null when attaching. */
userId: number | null
/** The assistant message id (temporary until `message.start`). */
assistantId: number
}
/**
* Builds the optimistic messages of a new turn.
*
* @param text - The user's text.
* @param ids - Temporary ids.
* @param now - Creation time (ISO).
* @returns The user message and the empty streaming reply.
*/
export function optimisticTurnMessages(text: string, ids: { userId: number; assistantId: number }, now: string): AssistantMessage[] {
return [
{ id: ids.userId, role: 'USER', status: 'COMPLETE', turnId: null, createDate: now, parts: [{ type: 'text', text }] },
{ id: ids.assistantId, role: 'ASSISTANT', status: 'STREAMING', turnId: null, createDate: now, parts: [] },
]
}
/**
* Applies one stream event to the messages. `ids` is updated in place on `message.start`.
*
* @param messages - The conversation's messages.
* @param event - The stream event.
* @param ids - The ids of the running turn's messages.
* @returns The new messages (the same array when nothing changed).
*/
export function reduceStreamEvent(messages: AssistantMessage[], event: AssistantStreamEvent, ids: TurnMessageIds): AssistantMessage[] {
switch (event.type) {
case 'message.start': {
const userId = ids.userId
const assistantId = ids.assistantId
ids.assistantId = event.messageId
if (userId !== null) ids.userId = event.userMessageId
return messages.map((message) => {
if (userId !== null && message.id === userId) return { ...message, id: event.userMessageId, turnId: event.turnId }
if (message.id === assistantId) return { ...message, id: event.messageId, turnId: event.turnId }
return message
})
}
case 'text.delta':
return updateMessage(messages, ids.assistantId, (message) => {
const last = message.parts[message.parts.length - 1]
if (last?.type === 'text') return { ...message, parts: [...message.parts.slice(0, -1), { type: 'text', text: last.text + event.text }] }
return { ...message, parts: [...message.parts, { type: 'text', text: event.text }] }
})
case 'tool.start':
return updateMessage(messages, ids.assistantId, (message) => {
if (message.parts.some((part) => part.type === 'tool' && part.toolCallId === event.toolCallId)) return message
return { ...message, parts: [...message.parts, { type: 'tool', toolCallId: event.toolCallId, name: event.name, summary: event.summary }] }
})
case 'tool.result':
return updateMessage(messages, ids.assistantId, (message) => {
const parts = [...message.parts]
const tool: AssistantPart = { type: 'tool', toolCallId: event.toolCallId, name: event.name, summary: event.summary, outcome: event.outcome, ...(event.render ? { render: event.render } : {}) }
let index = parts.findIndex((part) => part.type === 'tool' && part.toolCallId === event.toolCallId)
if (index === -1) {
parts.push(tool)
index = parts.length - 1
} else {
parts[index] = tool
}
if (event.recordRefs?.length) {
const next = parts[index + 1]
if (next?.type === 'record_refs') parts[index + 1] = { type: 'record_refs', refs: mergeRefs(next.refs, event.recordRefs) }
else parts.splice(index + 1, 0, { type: 'record_refs', refs: mergeRefs([], event.recordRefs) })
}
return { ...message, parts }
})
case 'ui.navigate':
return appendPart(messages, ids.assistantId, { type: 'navigate', href: event.href })
case 'notice':
return appendPart(messages, ids.assistantId, { type: 'notice', level: event.level, text: event.text })
case 'error':
return appendPart(messages, ids.assistantId, { type: 'notice', level: 'error', text: event.message })
case 'message.end':
return updateMessage(messages, ids.assistantId, (message) => ({ ...message, status: event.status }))
default:
return messages
}
}
/**
* Ends a reply locally (the stream failed or was aborted), with an optional error notice.
*
* @param messages - The conversation's messages.
* @param assistantId - The reply.
* @param status - Its final status.
* @param notice - An error to show in the reply.
* @returns The new messages.
*/
export function endReply(messages: AssistantMessage[], assistantId: number, status: AssistantMessage['status'], notice?: string): AssistantMessage[] {
return updateMessage(messages, assistantId, (message) => ({
...message,
status: message.status === 'STREAMING' ? status : message.status,
parts: notice ? [...message.parts, { type: 'notice', level: 'error', text: notice }] : message.parts,
}))
}
/**
* The plain text of a message's text parts.
*
* @param message - A message.
* @returns The concatenated text.
*/
export function messageText(message: AssistantMessage): string {
return message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('')
}
/**
* Replaces one message.
*
* @param messages - The messages.
* @param id - The message id.
* @param update - Produces the new message.
* @returns The new messages, or the same array when the id is absent.
*/
function updateMessage(messages: AssistantMessage[], id: number, update: (message: AssistantMessage) => AssistantMessage): AssistantMessage[] {
const index = messages.findIndex((message) => message.id === id)
if (index === -1) return messages
const next = [...messages]
next[index] = update(messages[index])
return next
}
/**
* Appends a part to a message.
*
* @param messages - The messages.
* @param id - The message id.
* @param part - The part.
* @returns The new messages.
*/
function appendPart(messages: AssistantMessage[], id: number, part: AssistantPart): AssistantMessage[] {
return updateMessage(messages, id, (message) => ({ ...message, parts: [...message.parts, part] }))
}
/**
* Adds record references, skipping ones already present.
*
* @param existing - References already shown.
* @param added - New references.
* @returns The merged list.
*/
function mergeRefs(existing: AssistantRecordRef[], added: AssistantRecordRef[]): AssistantRecordRef[] {
const seen = new Set(existing.map((ref) => `${ref.tableName}:${ref.id}`))
const merged = [...existing]
for (const ref of added) {
const key = `${ref.tableName}:${ref.id}`
if (!seen.has(key)) {
seen.add(key)
merged.push(ref)
}
}
return merged
}
@@ -0,0 +1,241 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file useAssistantEngine — runs assistant turns: creates the conversation when needed, adds
* the optimistic messages, streams the reply into the TanStack Query cache (so it survives the
* panel closing and route changes), follows `ui.navigate`, cancels, and resumes a reply that
* was still streaming when the conversation was opened.
*/
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type {
AssistantConversation,
AssistantConversationDetail,
AssistantMessage,
AssistantPageContext,
AssistantStreamEvent,
} from '@/types/assistant'
import {
AssistantHttpError,
cancelAssistantTurn,
createAssistantConversation,
resumeAssistantTurn,
streamAssistantTurn,
type AssistantTurnResult,
} from '@/lib/api/assistant'
import { internalAppHref } from '@/lib/utils/assistant-links'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { assistantKeys, useAssistant } from './assistant-context'
import { endReply, optimisticTurnMessages, reduceStreamEvent, type TurnMessageIds } from './message-reducer'
/** Give up waiting for the server to confirm a cancel after this long, and drop the stream. */
const CANCEL_TIMEOUT_MS = 15_000
/** A turn streaming in one conversation. */
export interface AssistantRun {
turnId: number | null
status: 'running' | 'cancelling'
}
/** The turn runner used by the panel. */
export interface AssistantEngine {
/** Running turns by conversation id. */
runs: Record<number, AssistantRun>
/**
* Sends the user's text, starting a conversation when `conversationId` is null.
*
* @param text - The user's message.
* @param conversationId - The conversation, or null for a new one.
* @param pageContext - Where the user is (omitted when dismissed).
*/
send: (text: string, conversationId: number | null, pageContext?: AssistantPageContext | null) => Promise<void>
/**
* Stops the running turn of a conversation.
*
* @param conversationId - The conversation.
*/
cancel: (conversationId: number) => void
/**
* Re-attaches to a reply that is still streaming on the server.
*
* @param conversationId - The conversation.
* @param message - The streaming assistant message.
*/
attach: (conversationId: number, message: AssistantMessage) => void
}
/** Temporary ids of optimistic messages, below any server id. */
let nextTemporaryId = -1
/**
* Creates the turn runner.
*
* @returns The engine.
*/
export function useAssistantEngine(): AssistantEngine {
const queryClient = useQueryClient()
const router = useRouter()
const assistant = useAssistant()
const [runs, setRuns] = useState<Record<number, AssistantRun>>({})
const controllers = useRef(new Map<number, AbortController>())
const turnIds = useRef(new Map<number, number>())
const assistantRef = useRef(assistant)
assistantRef.current = assistant
// Abort every stream when the panel unmounts (sign-out); turns keep running on the server
useEffect(() => {
const active = controllers.current
return () => active.forEach((controller) => controller.abort())
}, [])
const setMessages = useCallback((conversationId: number, update: (messages: AssistantMessage[]) => AssistantMessage[]) => {
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(conversationId), (detail) =>
detail ? { ...detail, messages: update(detail.messages) } : detail)
}, [queryClient])
const setRun = useCallback((conversationId: number, run: AssistantRun | null) => {
setRuns((current) => {
const next = { ...current }
if (run) next[conversationId] = run
else delete next[conversationId]
return next
})
}, [])
/** Applies one event to the cache and follows navigation. */
const handleEvent = useCallback((conversationId: number, event: AssistantStreamEvent, ids: TurnMessageIds) => {
setMessages(conversationId, (messages) => reduceStreamEvent(messages, event, ids))
if (event.type === 'message.start') {
turnIds.current.set(conversationId, event.turnId)
setRuns((current) => (current[conversationId] ? { ...current, [conversationId]: { ...current[conversationId], turnId: event.turnId } } : current))
}
if (event.type === 'ui.navigate') {
const href = internalAppHref(event.href)
if (href) router.push(href)
}
}, [router, setMessages])
/** Reconciles with the server and reports a finished reply the user is not looking at. */
const finish = useCallback((conversationId: number, result: AssistantTurnResult | null) => {
controllers.current.get(conversationId)?.abort()
controllers.current.delete(conversationId)
turnIds.current.delete(conversationId)
setRun(conversationId, null)
// a refused or lost turn keeps its local error (the server may have nothing to show for it)
if (result) void queryClient.invalidateQueries({ queryKey: assistantKeys.conversation(conversationId) })
void queryClient.invalidateQueries({ queryKey: [...assistantKeys.all(), 'conversations'] })
const current = assistantRef.current
if (result?.status !== 'COMPLETE' || !current) return
if (current.open && current.activeConversationId === conversationId) return
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) =>
list?.map((conversation) => (conversation.id === conversationId ? { ...conversation, unreadCount: conversation.unreadCount + 1 } : conversation)))
toast('Assistant finished', {
action: {
label: 'View',
onClick: () => {
assistantRef.current?.setActiveConversationId(conversationId)
assistantRef.current?.setOpen(true)
},
},
})
}, [queryClient, setRun])
/** Streams a turn (new or resumed) and handles its end. */
const run = useCallback(async (conversationId: number, ids: TurnMessageIds, start: (controller: AbortController) => Promise<AssistantTurnResult>) => {
const controller = new AbortController()
controllers.current.set(conversationId, controller)
let result: AssistantTurnResult | null = null
try {
result = await start(controller)
if (result.status === 'ABORTED') setMessages(conversationId, (messages) => endReply(messages, ids.assistantId, 'CANCELLED'))
} catch (error) {
const message = error instanceof AssistantHttpError || error instanceof Error ? error.message : 'The assistant could not answer.'
setMessages(conversationId, (messages) => endReply(messages, ids.assistantId, 'ERROR', message))
} finally {
finish(conversationId, result)
}
}, [finish, setMessages])
const send = useCallback(async (text: string, conversationId: number | null, pageContext?: AssistantPageContext | null) => {
const trimmed = text.trim()
if (!trimmed) return
let id = conversationId
if (id === null) {
let conversation: AssistantConversation
try {
conversation = await createAssistantConversation(pageContext ? { context: pageContext } : {})
} catch (error) {
toast.error(getErrorMessage(error, 'Could not start a conversation.'))
return
}
id = conversation.id
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(id), { conversation, messages: [] })
queryClient.setQueryData<AssistantConversation[]>(assistantKeys.conversations(), (list) => [conversation, ...(list ?? []).filter((item) => item.id !== conversation.id)])
assistantRef.current?.setActiveConversationId(id)
}
const conversation = id
if (controllers.current.has(conversation)) return
const ids: TurnMessageIds = { userId: nextTemporaryId--, assistantId: nextTemporaryId-- }
const optimistic = optimisticTurnMessages(trimmed, { userId: ids.userId as number, assistantId: ids.assistantId }, new Date().toISOString())
queryClient.setQueryData<AssistantConversationDetail>(assistantKeys.conversation(conversation), (detail) =>
detail ? { ...detail, messages: [...detail.messages, ...optimistic] } : detail)
setRun(conversation, { turnId: null, status: 'running' })
await run(conversation, ids, (controller) => streamAssistantTurn(
conversation,
pageContext ? { text: trimmed, pageContext } : { text: trimmed },
{ signal: controller.signal, onEvent: (event) => handleEvent(conversation, event, ids) }
))
}, [handleEvent, queryClient, run, setRun])
const attach = useCallback((conversationId: number, message: AssistantMessage) => {
if (controllers.current.has(conversationId) || message.turnId === null) return
const turnId = message.turnId
const ids: TurnMessageIds = { userId: null, assistantId: message.id }
// the replay rebuilds the reply from its first event
setMessages(conversationId, (messages) => messages.map((item) => (item.id === message.id ? { ...item, parts: [] } : item)))
turnIds.current.set(conversationId, turnId)
setRun(conversationId, { turnId, status: 'running' })
void run(conversationId, ids, (controller) => resumeAssistantTurn(turnId, 0, {
signal: controller.signal,
onEvent: (event) => handleEvent(conversationId, event, ids),
}))
}, [handleEvent, run, setMessages, setRun])
const cancel = useCallback((conversationId: number) => {
const controller = controllers.current.get(conversationId)
if (!controller) return
const turnId = turnIds.current.get(conversationId)
setRuns((current) => (current[conversationId] ? { ...current, [conversationId]: { ...current[conversationId], status: 'cancelling' } } : current))
if (turnId === undefined) {
// the server has not started the turn yet: drop the request
controller.abort()
return
}
// the stream ends with a CANCELLED message.end; drop it if the server does not confirm in time
const timer = setTimeout(() => controller.abort(), CANCEL_TIMEOUT_MS)
controller.signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
cancelAssistantTurn(turnId).catch(() => controller.abort())
}, [])
return { runs, send, cancel, attach }
}
@@ -0,0 +1,104 @@
/*
* 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 useStreamAnnouncer — screen reader announcements for a streaming reply, throttled to
* whole sentences (at most one announcement per interval) and the rest on completion, instead
* of every token.
*/
'use client'
import { useEffect, useRef, useState } from 'react'
import type { AssistantMessage } from '@/types/assistant'
import { messageText } from './message-reducer'
/** Minimum time between two announcements while text streams. */
export const ANNOUNCE_INTERVAL_MS = 1500
/**
* The length of `text` up to and including its last sentence end (`.`, `!`, `?` or a line
* break, followed by whitespace or the end).
*
* @param text - Text not yet announced.
* @returns The length to announce, 0 when no sentence is complete.
*/
export function completeSentenceLength(text: string): number {
let end = 0
const pattern = /[.!?](?=\s|$)|\n/g
for (let match = pattern.exec(text); match; match = pattern.exec(text)) end = match.index + 1
return end
}
/**
* Tracks the latest assistant reply and returns the text to put in a polite live region.
*
* @param messages - The visible conversation's messages.
* @returns The current announcement.
*/
export function useStreamAnnouncer(messages: AssistantMessage[]): string {
const [announcement, setAnnouncement] = useState('')
const [tick, setTick] = useState(0)
const state = useRef<{ id: number | null; spoken: number; at: number; streaming: boolean }>({ id: null, spoken: 0, at: 0, streaming: false })
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current)
}, [])
useEffect(() => {
const reply = [...messages].reverse().find((message) => message.role === 'ASSISTANT')
if (!reply) return
const current = state.current
const text = messageText(reply)
if (current.id !== reply.id) {
// a new reply (or the temporary id replaced by the server's): keep what was already spoken
const continued = current.streaming && reply.status !== 'COMPLETE' && text.length >= current.spoken
state.current = { id: reply.id, spoken: continued ? current.spoken : 0, at: continued ? current.at : 0, streaming: reply.status === 'STREAMING' }
}
const tracked = state.current
if (reply.status === 'STREAMING') {
tracked.streaming = true
const pending = text.slice(tracked.spoken)
const length = completeSentenceLength(pending)
if (!length) return
const wait = tracked.at + ANNOUNCE_INTERVAL_MS - Date.now()
if (wait > 0) {
if (!timer.current) timer.current = setTimeout(() => { timer.current = null; setTick((value) => value + 1) }, wait)
return
}
tracked.spoken += length
tracked.at = Date.now()
setAnnouncement(pending.slice(0, length).trim())
return
}
if (!tracked.streaming) return
// finished: announce the rest, or how it ended
tracked.streaming = false
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
const rest = text.slice(tracked.spoken).trim()
tracked.spoken = text.length
if (reply.status === 'CANCELLED') setAnnouncement(rest ? `${rest} Stopped.` : 'Stopped.')
else if (reply.status === 'ERROR') setAnnouncement('The reply did not finish.')
else setAnnouncement(rest || 'Reply complete.')
}, [messages, tick])
return announcement
}
@@ -0,0 +1,274 @@
/*
* 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 DeveloperPanelView — renders one provider's developer panel (QRun-IO/qqq#1076) from its
* generic blocks: key/values, text, code, table, links and process actions. No provider-specific
* code: any module or qbit's panel renders the same way.
*/
'use client'
import React from 'react'
import Link from 'next/link'
import { AlertTriangle, ExternalLink, Info, Play } from 'lucide-react'
import type { DeveloperPanel, DeveloperPanelBlock, DeveloperPanelTone } from '@/lib/api/developer-panels'
import type { CodeLanguage } from '@/lib/utils/code-highlight'
import { cn } from '@/lib/utils/cn'
import { processRunHref } from '@/lib/utils/material-links'
import { CodeBlock } from '@/components/scripts/HighlightedCode'
import { CopyButton, DeveloperSection } from './DeveloperSection'
const CODE_LANGUAGES = new Set<CodeLanguage>(['javascript', 'java', 'python', 'json', 'sql', 'velocity', 'html', 'text'])
/**
* A safe link target: app-relative paths and http(s) URLs only.
* @param href - Target from the backend.
* @returns The target, or null for anything else (e.g. `javascript:`).
*/
export function safeHref(href: string | undefined): string | null {
if (!href) return null
if (href.startsWith('/') && !href.startsWith('//')) return href
try {
const url = new URL(href)
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null
} catch {
return null
}
}
/**
* A link that uses client navigation for app paths and a new tab for other sites.
* @param props - Target, label and styling.
* @param props.href - Target.
* @param props.external - Force a new tab.
* @param props.children - Link content.
* @param props.className - Extra classes.
* @returns The link, or the content alone for an unsafe target.
*/
export function PanelLink({ href, external, children, className }: { href?: string; external?: boolean; children: React.ReactNode; className?: string }) {
const target = safeHref(href)
if (!target) return <>{children}</>
if (target.startsWith('/') && !external) return <Link href={target} className={cn('text-primary hover:underline', className)}>{children}</Link>
return (
<a href={target} target="_blank" rel="noopener noreferrer" className={cn('inline-flex items-center gap-1 text-primary hover:underline', className)}>
{children}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
<span className="sr-only">(opens in a new tab)</span>
</a>
)
}
const TEXT_TONES: Record<DeveloperPanelTone, string> = {
INFO: 'border-border bg-muted/50 text-foreground',
SUCCESS: 'border-green-600/30 bg-green-600/5 text-foreground',
WARNING: 'border-amber-500/40 bg-amber-500/10 text-foreground',
ERROR: 'border-destructive/40 bg-destructive/10 text-destructive',
}
/** Props for {@link DeveloperPanelView}. */
export interface DeveloperPanelViewProps {
panel: DeveloperPanel
/** The record's table (default table for process actions). */
tableName: string
/** The record's primary key (selected record for process actions). */
primaryKey: string | number
/** Path to return to after a launched process (this page). */
returnTo: string
}
/**
* Renders a developer panel as a collapsible section.
*
* @param props - See {@link DeveloperPanelViewProps}.
* @returns The panel section.
*/
export function DeveloperPanelView({ panel, tableName, primaryKey, returnTo }: DeveloperPanelViewProps) {
return (
<DeveloperSection
id={`panel-${panel.id}`}
title={panel.title}
description={panel.description}
badge={panel.error ? { label: 'Error', tone: 'ERROR' } : panel.statusLabel ? { label: panel.statusLabel, tone: panel.statusTone } : undefined}
>
{panel.error && (
<p role="alert" className={cn('flex items-start gap-2 rounded-md border px-3 py-2 text-sm', TEXT_TONES.ERROR)}>
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
{panel.error}
</p>
)}
{panel.blocks.map((block, index) => (
<PanelBlock key={index} block={block} tableName={tableName} primaryKey={primaryKey} returnTo={returnTo} panelId={panel.id} index={index} />
))}
</DeveloperSection>
)
}
/**
* Renders one block.
*
* @param props - The block and its context.
* @returns The block's markup.
*/
function PanelBlock({ block, tableName, primaryKey, returnTo, panelId, index }: {
block: DeveloperPanelBlock
tableName: string
primaryKey: string | number
returnTo: string
panelId: string
index: number
}) {
const qqqId = `dev-panel-${panelId}-block-${index}`
const heading = block.label ? <h3 className="mb-2 text-sm font-semibold text-foreground">{block.label}</h3> : null
switch (block.type) {
case 'TEXT':
return (
<div className={cn('flex items-start gap-2 rounded-md border px-3 py-2 text-sm', TEXT_TONES[block.tone ?? 'INFO'])} data-qqq-id={qqqId}>
{block.tone === 'WARNING' || block.tone === 'ERROR'
? <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" aria-hidden="true" />
: <Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden="true" />}
<div className="min-w-0 whitespace-pre-line break-words">{block.label ? <strong>{block.label}: </strong> : null}{block.text}</div>
</div>
)
case 'KEY_VALUES':
return (
<div data-qqq-id={qqqId}>
{heading}
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-[minmax(8rem,max-content)_1fr]">
{(block.items ?? []).map((item, itemIndex) => (
<React.Fragment key={itemIndex}>
<dt className="text-xs font-medium text-muted-foreground sm:pt-0.5">{item.label}</dt>
<dd className={cn('m-0 min-w-0 break-words text-sm text-foreground [overflow-wrap:anywhere]', item.monospace && 'font-mono text-xs')}>
{item.value == null || item.value === ''
? <span className="italic text-muted-foreground">—</span>
: <PanelLink href={item.href}>{item.value}</PanelLink>}
</dd>
</React.Fragment>
))}
</dl>
</div>
)
case 'CODE': {
const language = CODE_LANGUAGES.has(block.language as CodeLanguage) ? block.language as CodeLanguage : 'text'
const code = block.code ?? ''
return (
<div data-qqq-id={qqqId}>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
{block.label ? <h3 className="m-0 text-sm font-semibold text-foreground">{block.label}</h3> : <span />}
<CopyButton text={code} label={`Copy ${block.label ?? 'code'}`} />
</div>
<CodeBlock code={code} language={language} className="max-h-96" />
</div>
)
}
case 'TABLE':
return (
<div data-qqq-id={qqqId}>
{heading}
<PanelTable columns={block.columns ?? []} rows={block.rows ?? []} />
</div>
)
case 'LINKS':
return (
<div data-qqq-id={qqqId}>
{heading}
<ul className="m-0 list-none space-y-1.5 p-0">
{(block.links ?? []).map((link, linkIndex) => (
<li key={linkIndex} className="text-sm">
<PanelLink href={link.href} external={link.external}>{link.label}</PanelLink>
{link.description && <span className="text-muted-foreground"> — {link.description}</span>}
</li>
))}
</ul>
</div>
)
case 'ACTIONS':
return (
<div data-qqq-id={qqqId}>
{heading}
<div className="flex flex-wrap gap-2">
{(block.actions ?? []).map((action, actionIndex) => (
<Link
key={actionIndex}
href={processRunHref(action.processName, {
recordId: action.includeRecordId === false ? undefined : primaryKey,
returnTo,
tableName: action.tableName ?? tableName,
})}
title={action.description}
className="inline-flex items-center gap-2 rounded-md border border-input bg-card px-3 py-2 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id={`dev-panel-action-${action.processName}`}
>
<Play className="h-4 w-4" aria-hidden="true" />
{action.label}
</Link>
))}
</div>
</div>
)
}
}
/**
* A responsive table: a real table from the small breakpoint up, stacked label/value cards on
* phones (no sideways scrolling at 360px).
*
* @param props - Columns and rows of display strings.
* @param props.columns - Column headings.
* @param props.rows - Rows.
* @returns The table.
*/
export function PanelTable({ columns, rows }: { columns: string[]; rows: string[][] }) {
if (rows.length === 0) return <p className="text-sm italic text-muted-foreground">None.</p>
return (
<>
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
<table className="min-w-full text-sm">
<thead className="bg-muted/60">
<tr>
{columns.map((column) => <th key={column} scope="col" className="whitespace-nowrap px-3 py-2 text-left text-xs font-semibold text-foreground">{column}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t border-border align-top">
{columns.map((column, columnIndex) => (
<td key={column} className="max-w-[28rem] break-words px-3 py-2 text-xs text-foreground [overflow-wrap:anywhere]">{row[columnIndex] ?? ''}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<ul className="m-0 list-none space-y-2 p-0 sm:hidden">
{rows.map((row, rowIndex) => (
<li key={rowIndex} className="rounded-lg border border-border p-3">
<dl className="m-0 space-y-1">
{columns.map((column, columnIndex) => (
<div key={column} className="flex gap-2 text-xs">
<dt className="w-28 flex-shrink-0 font-medium text-muted-foreground">{column}</dt>
<dd className="m-0 min-w-0 break-words text-foreground [overflow-wrap:anywhere]">{row[columnIndex] ?? ''}</dd>
</div>
))}
</dl>
</li>
))}
</ul>
</>
)
}
@@ -0,0 +1,264 @@
/*
* 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 Building blocks of the record developer view: an anchored, collapsible section card, the
* sticky section navigation, a status badge and a copy-to-clipboard button.
*/
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ChevronRight, ClipboardCopy } from 'lucide-react'
import type { DeveloperPanelTone } from '@/lib/api/developer-panels'
import { cn } from '@/lib/utils/cn'
/**
* DOM id of a developer section's anchor.
* @param id - The section's id.
* @returns The element id.
*/
export function developerSectionId(id: string): string {
return `dev-section-${id.replace(/[^A-Za-z0-9_-]/g, '-')}`
}
/** Props for {@link DeveloperSection}. */
export interface DeveloperSectionProps {
/** Stable id (anchor and nav target). */
id: string
/** Heading. */
title: string
/** Optional one-line description under the heading. */
description?: string
/** Optional status badge beside the heading. */
badge?: { label: string; tone?: DeveloperPanelTone }
/** Starts collapsed. */
defaultCollapsed?: boolean
/** Controls beside the heading (shown while expanded). */
actions?: React.ReactNode
children: React.ReactNode
}
/**
* A titled, collapsible card with a scroll anchor; the heading button toggles the body.
*
* @param props - See {@link DeveloperSectionProps}.
* @returns The section.
*/
export function DeveloperSection({ id, title, description, badge, defaultCollapsed = false, actions, children }: DeveloperSectionProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
const bodyId = `${developerSectionId(id)}-body`
return (
<section
id={developerSectionId(id)}
aria-labelledby={`${developerSectionId(id)}-title`}
className="scroll-mt-24 rounded-xl border border-border bg-card"
data-qqq-id={`dev-section-${id}`}
>
<div className="flex flex-wrap items-center gap-2 px-4 py-3">
<h2 className="m-0 min-w-0 flex-1" id={`${developerSectionId(id)}-title`}>
<button
type="button"
onClick={() => setCollapsed((value) => !value)}
aria-expanded={!collapsed}
aria-controls={bodyId}
className="flex w-full min-w-0 items-center gap-2 rounded-md text-left text-base font-semibold text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id={`dev-section-toggle-${id}`}
>
{collapsed ? <ChevronRight className="h-4 w-4 flex-shrink-0" aria-hidden="true" /> : <ChevronDown className="h-4 w-4 flex-shrink-0" aria-hidden="true" />}
<span className="min-w-0 break-words">{title}</span>
{badge && <StatusBadge label={badge.label} tone={badge.tone} />}
</button>
</h2>
{!collapsed && actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{!collapsed && (
<div id={bodyId} className="min-w-0 space-y-4 border-t border-border px-4 py-4">
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{children}
</div>
)}
</section>
)
}
const TONE_CLASSES: Record<DeveloperPanelTone, string> = {
INFO: 'border-border bg-muted text-muted-foreground',
SUCCESS: 'border-green-600/30 bg-green-600/10 text-green-700 dark:text-green-400',
WARNING: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400',
ERROR: 'border-destructive/40 bg-destructive/10 text-destructive',
}
/**
* A small status pill.
*
* @param props - Label and tone.
* @param props.label - Text.
* @param props.tone - Color tone (INFO by default).
* @returns The badge.
*/
export function StatusBadge({ label, tone = 'INFO' }: { label: string; tone?: DeveloperPanelTone }) {
return (
<span className={cn('inline-flex flex-shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium', TONE_CLASSES[tone])} data-qqq-id="dev-status-badge">
{label}
</span>
)
}
/**
* A button that copies text to the clipboard and briefly shows a check mark.
*
* @param props - Text and accessible label.
* @param props.text - What to copy.
* @param props.label - Accessible label (default "Copy").
* @param props.caption - Visible text (default "Copy"); set it when a row has more than one copy button.
* @returns The button.
*/
export function CopyButton({ text, label = 'Copy', caption = 'Copy' }: { text: string; label?: string; caption?: string }) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const timer = setTimeout(() => setCopied(false), 2000)
return () => clearTimeout(timer)
}, [copied])
return (
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(text).then(() => setCopied(true), () => setCopied(false))
}}
aria-label={copied ? `${label}: copied` : label}
title={label}
className="inline-flex items-center gap-1 rounded-md border border-input bg-card px-2 py-1 text-xs font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="button-dev-copy"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-600" aria-hidden="true" /> : <ClipboardCopy className="h-3.5 w-3.5" aria-hidden="true" />}
{copied ? 'Copied' : caption}
</button>
)
}
/** One entry of the section navigation. */
export interface DeveloperNavItem {
id: string
label: string
}
/**
* Sticky navigation to the page's sections; highlights the section nearest the top of the
* viewport. Phones get a section picker, wider screens a wrapping row of links, so the bar
* never scrolls sideways.
*
* @param props - The sections.
* @param props.items - Sections in page order.
* @returns The navigation bar.
*/
export function DeveloperSectionNav({ items }: { items: DeveloperNavItem[] }) {
const [active, setActive] = useState<string | null>(items[0]?.id ?? null)
// the section last picked from this bar: at the bottom of the page it stays highlighted
// while it is in view, since the last sections can never reach the top
const chosen = useRef<string | null>(null)
const navRef = useRef<HTMLElement>(null)
const ids = items.map((item) => item.id).join('|')
useEffect(() => {
const sectionIds = ids.split('|')
const scroller = navRef.current?.closest<HTMLElement>('[data-qqq-id="main-content"]') ?? null
let frame = 0
const update = () => {
frame = 0
// a section is current once its top passes below the bar, or reaches where a jump puts it
// (its scroll-mt-24 margin: 96px)
const top = scroller ? scroller.getBoundingClientRect().top : 0
const line = Math.max(navRef.current?.getBoundingClientRect().bottom ?? 0, top + 96) + 8
const viewBottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight
const atBottom = scroller
? scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2
: window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2
let current: string | null = sectionIds[0] ?? null
for (const id of sectionIds) {
const sectionTop = document.getElementById(developerSectionId(id))?.getBoundingClientRect().top
if (sectionTop !== undefined && sectionTop <= line) current = id
}
if (atBottom) {
const pick = chosen.current
const pickTop = pick ? document.getElementById(developerSectionId(pick))?.getBoundingClientRect().top : undefined
current = pick && pickTop !== undefined && pickTop < viewBottom ? pick : sectionIds[sectionIds.length - 1] ?? current
}
setActive(current)
}
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update)
}
const target: HTMLElement | Window = scroller ?? window
target.addEventListener('scroll', onScroll, { passive: true })
return () => {
target.removeEventListener('scroll', onScroll)
if (frame) cancelAnimationFrame(frame)
}
}, [ids])
const go = (id: string) => {
chosen.current = id
setActive(id)
document.getElementById(developerSectionId(id))?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return (
<nav
ref={navRef}
aria-label="Developer view sections"
className="sticky -top-6 z-10 -mx-1 border-b border-border bg-background/95 px-1 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/80"
data-qqq-id="dev-section-nav"
>
<label className="flex items-center gap-2 text-sm text-muted-foreground sm:hidden">
<span className="flex-shrink-0">Go to</span>
<select
value={active ?? ''}
onChange={(event) => go(event.target.value)}
className="min-w-0 flex-1 rounded-md border border-input bg-card px-2 py-1.5 text-sm font-medium text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="dev-nav-select"
>
{items.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
</label>
<ul className="m-0 hidden list-none flex-wrap gap-1 p-0 sm:flex">
{items.map((item) => (
<li key={item.id} className="flex-shrink-0">
<a
href={`#${developerSectionId(item.id)}`}
onClick={(event) => {
event.preventDefault()
go(item.id)
}}
aria-current={active === item.id ? 'location' : undefined}
className={cn(
'block whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active === item.id ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
data-qqq-id={`dev-nav-${item.id}`}
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
)
}
@@ -0,0 +1,299 @@
/*
* 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 RecordApiAccess — the record developer view's "API Access": the requests a developer can
* make for this record through the v1 data API (the UI's own routes, session cookie) and through
* each application API that exposes the table (from its OpenAPI spec, token auth), limited to
* what the current user may do, with copyable curl, fetch and Java examples built from the
* record's values and links to the API's docs and specs. Credentials are placeholders only.
*/
'use client'
import React, { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import type { QProcessMetaData, QRecord, QTableMetaData } from '@/types'
import apiClient from '@/lib/api/client'
import { apiMiddlewareBaseURL, apiTableSpecUrl, getApiTableSpec, getApiVersions, getTableApis, type ApiInstance } from '@/lib/api/developer'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { API_KEY_ENV, SESSION_PLACEHOLDER, TOKEN_ENV, buildSnippet, specOperations, v1Operations, type ApiAuth, type ApiOperation, type SnippetLanguage } from '@/lib/utils/api-examples'
import { cn } from '@/lib/utils/cn'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { CodeBlock } from '@/components/scripts/HighlightedCode'
import { CopyButton } from './DeveloperSection'
import { PanelLink } from './DeveloperPanelView'
const LANGUAGE_KEY = 'qqq.recordDeveloperView.snippetLanguage'
const LANGUAGES: Array<{ id: SnippetLanguage; label: string }> = [
{ id: 'curl', label: 'curl' },
{ id: 'fetch', label: 'JavaScript (fetch)' },
{ id: 'java', label: 'Java' },
]
/**
* Reads the remembered snippet language.
* @returns The language, curl by default.
*/
function storedLanguage(): SnippetLanguage {
try {
const value = window.localStorage.getItem(LANGUAGE_KEY)
return value === 'fetch' || value === 'java' ? value : 'curl'
} catch {
return 'curl'
}
}
/**
* Absolute URL of the v1 data API the UI uses.
* @returns e.g. `https://admin.example.com/qqq/v1`.
*/
function v1BaseUrl(): string {
const configured = apiClient.getInstance().defaults.baseURL ?? '/qqq/v1'
const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin
return new URL(configured, origin).toString().replace(/\/$/, '')
}
/**
* Absolute URL of an application API path.
* @param path - Root-relative path.
* @returns The absolute URL.
*/
function middlewareUrl(path: string): string {
const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin
return new URL(`${apiMiddlewareBaseURL() ?? ''}${path}`, origin).toString()
}
/** Props for {@link RecordApiAccess}. */
export interface RecordApiAccessProps {
table: QTableMetaData
record: QRecord
/** Processes the record view offers (permission-filtered). */
processes: QProcessMetaData[]
}
/**
* The API Access section body.
*
* @param props - See {@link RecordApiAccessProps}.
* @returns Source tabs, the language picker and the operations.
*/
export function RecordApiAccess({ table, record, processes }: RecordApiAccessProps) {
const [language, setLanguageState] = useState<SnippetLanguage>(() => (typeof window === 'undefined' ? 'curl' : storedLanguage()))
const [source, setSource] = useState<string>('v1')
const setLanguage = (value: SnippetLanguage) => {
setLanguageState(value)
try {
window.localStorage.setItem(LANGUAGE_KEY, value)
} catch {
// storage unavailable: the choice is simply not remembered
}
}
const apisQuery = useQuery({
queryKey: queryKeys.tableApis(table.name),
queryFn: () => getTableApis(table.name),
meta: HANDLES_OWN_ERRORS,
retry: false,
})
const apis = apisQuery.data ?? []
const selectedApi = apis.find((api) => api.name === source)
const v1 = useMemo(() => v1Operations({ baseUrl: v1BaseUrl(), table, record, processes }), [table, record, processes])
return (
<div className="space-y-4" data-qqq-id="dev-api-access">
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="API">
<SourceTab id="v1" label="QQQ data API (v1)" selected={source === 'v1'} onSelect={setSource} />
{apis.map((api) => <SourceTab key={api.name} id={api.name} label={api.label} selected={source === api.name} onSelect={setSource} />)}
{apisQuery.isLoading && <span role="status" className="text-xs text-muted-foreground">Looking for application APIs…</span>}
</div>
<div className="flex flex-wrap items-center gap-2">
<span id="dev-snippet-language" className="text-xs font-medium text-muted-foreground">Examples in</span>
<div className="flex rounded-lg border border-border bg-muted/50 p-0.5" role="radiogroup" aria-labelledby="dev-snippet-language">
{LANGUAGES.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={language === option.id}
onClick={() => setLanguage(option.id)}
className={cn('rounded-md px-2.5 py-1 text-xs font-medium', language === option.id ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')}
data-qqq-id={`dev-snippet-language-${option.id}`}
>
{option.label}
</button>
))}
</div>
</div>
{selectedApi
? <ApplicationApiOperations api={selectedApi} table={table} record={record} language={language} />
: (
<div role="tabpanel" className="space-y-3">
<AuthNote auth="session" />
<OperationList operations={v1} auth="session" language={language} empty="You have no permission to read or change records in this table." />
</div>
)}
</div>
)
}
/**
* One API source tab.
* @param props - Tab state.
* @returns The tab button.
*/
function SourceTab({ id, label, selected, onSelect }: { id: string; label: string; selected: boolean; onSelect: (id: string) => void }) {
return (
<button
type="button"
role="tab"
aria-selected={selected}
onClick={() => onSelect(id)}
className={cn('rounded-md border px-3 py-1.5 text-sm font-medium', selected ? 'border-primary bg-primary/10 text-primary' : 'border-input bg-card text-foreground hover:bg-accent')}
data-qqq-id={`dev-api-source-${id}`}
>
{label}
</button>
)
}
/**
* Explains the placeholder credentials of a source.
* @param props - The auth kind.
* @param props.auth - How requests authenticate.
* @returns The note.
*/
function AuthNote({ auth }: { auth: ApiAuth }) {
const text = auth === 'session'
? <>These are the routes this screen uses. From a browser on this site they use your session cookie; elsewhere, send the <code className="font-mono">sessionUUID</code> cookie (shown as <code className="font-mono">{SESSION_PLACEHOLDER}</code>).</>
: auth === 'apiKey'
? <>Authenticate with an API key in the <code className="font-mono">x-api-key</code> header (shown as <code className="font-mono">${API_KEY_ENV}</code>).</>
: <>Authenticate with a bearer token (shown as <code className="font-mono">${TOKEN_ENV}</code>).</>
return <p className="text-xs text-muted-foreground">{text} Only the operations you are allowed to perform are listed.</p>
}
/**
* Operations of an application API version that exposes the table, read from its OpenAPI spec.
* @param props - The API, record and snippet language.
* @returns Version picker, doc links and operations.
*/
function ApplicationApiOperations({ api, table, record, language }: { api: ApiInstance; table: QTableMetaData; record: QRecord; language: SnippetLanguage }) {
const [versionChoice, setVersionChoice] = useState<string | null>(null)
const versionsQuery = useQuery({
queryKey: queryKeys.apiVersions(api.path),
queryFn: () => getApiVersions(api.path),
meta: HANDLES_OWN_ERRORS,
retry: false,
})
const versions = versionsQuery.data?.supportedVersions ?? []
const version = versionChoice && versions.includes(versionChoice) ? versionChoice : versionsQuery.data?.currentVersion ?? versions[0]
const specQuery = useQuery({
queryKey: queryKeys.apiTableSpec(api.path, version ?? '', table.name),
queryFn: () => getApiTableSpec(api, version!, table.name),
enabled: Boolean(version),
meta: HANDLES_OWN_ERRORS,
retry: false,
})
const origin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin
const parsed = useMemo(() => specQuery.data ? specOperations({ spec: specQuery.data, origin, table, record }) : null, [specQuery.data, origin, table, record])
return (
<div role="tabpanel" className="space-y-3" data-qqq-id={`dev-api-${api.name}`}>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
{versions.length > 0 && (
<label className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
Version
<select
value={version}
onChange={(event) => setVersionChoice(event.target.value)}
className="rounded border border-input bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
data-qqq-id="dev-api-version"
>
{versions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</label>
)}
{version && (
<>
<PanelLink href={middlewareUrl(`${api.path}${version}/`)} external>API docs</PanelLink>
<PanelLink href={middlewareUrl(`${api.path}${version}/openapi.json`)} external>OpenAPI spec</PanelLink>
<PanelLink href={new URL(apiTableSpecUrl(api, version, table.name), origin).toString()} external>{table.label} spec</PanelLink>
</>
)}
</div>
{(versionsQuery.isLoading || specQuery.isLoading) && <p role="status" className="text-sm text-muted-foreground">Loading the API specification…</p>}
{(versionsQuery.isError || specQuery.isError) && (
<p role="alert" className="text-sm text-destructive">The API specification could not be loaded: {getErrorMessage(versionsQuery.error ?? specQuery.error)}</p>
)}
{parsed && (
<>
<p className="text-xs text-muted-foreground">Base URL <code className="break-all font-mono">{parsed.serverUrl}</code></p>
<AuthNote auth={parsed.auth} />
<OperationList operations={parsed.operations} auth={parsed.auth} language={language} empty="This API version offers no operations on this table that you may perform." />
</>
)}
</div>
)
}
const METHOD_CLASSES: Record<ApiOperation['method'], string> = {
GET: 'bg-sky-600/10 text-sky-700 dark:text-sky-400',
POST: 'bg-green-600/10 text-green-700 dark:text-green-400',
PUT: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
PATCH: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
DELETE: 'bg-destructive/10 text-destructive',
}
/**
* The operations, each expandable to its snippet.
* @param props - Operations, auth, language and empty text.
* @returns The list.
*/
function OperationList({ operations, auth, language, empty }: { operations: ApiOperation[]; auth: ApiAuth; language: SnippetLanguage; empty: string }) {
if (operations.length === 0) return <p className="text-sm italic text-muted-foreground">{empty}</p>
return (
<ul className="m-0 list-none space-y-2 p-0">
{operations.map((operation, index) => {
const snippet = buildSnippet(operation, auth, language)
return (
<li key={operation.id} className="rounded-lg border border-border">
<details open={index === 0} className="group">
<summary className="flex cursor-pointer list-none flex-wrap items-center gap-2 px-3 py-2 marker:hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-qqq-id={`dev-api-op-${operation.id}`}>
<span className={cn('rounded px-1.5 py-0.5 font-mono text-[11px] font-semibold', METHOD_CLASSES[operation.method])}>{operation.method}</span>
<span className="text-sm font-medium text-foreground">{operation.label}</span>
<code className="min-w-0 basis-full break-all font-mono text-xs text-muted-foreground sm:basis-auto">{operation.url}</code>
</summary>
<div className="space-y-2 border-t border-border px-3 py-3">
{operation.description && <p className="text-xs text-muted-foreground">{operation.description}</p>}
<div className="flex justify-end gap-2">
<CopyButton text={operation.url} label="Copy URL" caption="Copy URL" />
<CopyButton text={snippet} label={`Copy ${language} example`} caption="Copy example" />
</div>
<CodeBlock code={snippet} language={language === 'java' ? 'java' : language === 'fetch' ? 'javascript' : 'text'} className="max-h-96" dataQqqId={`dev-api-snippet-${operation.id}`} />
</div>
</details>
</li>
)
})}
</ul>
)
}
@@ -0,0 +1,270 @@
/*
* 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 RecordDeveloperView — the record developer view (`/app/[slug]/[recordId]/dev`, loaded on
* demand by its page so the route's first load stays small): the record screen's header and
* actions, then a sticky section navigation over the record's fields (display and raw values),
* API access (endpoints with curl, fetch and Java examples), the panels that developer panel
* providers contribute (record security, ESB, quick search, SQL workbench, assistant, ... — each
* only when its module is installed and active for the table), relations, recent audits,
* associated scripts, raw JSON and a searchable view of the table metadata.
*/
'use client'
import React, { useCallback, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Code } from 'lucide-react'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { useQContext } from '@/lib/context/q-context'
import { SCRIPT_LOG_TABLE, getRecordDeveloperData } from '@/lib/api/developer'
import { getRecordDeveloperPanels } from '@/lib/api/developer-panels'
import { loadTableMetaData } from '@/lib/api/metadata'
import { auditSource } from '@/lib/api/audits'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { getProcessesForRecordView, getRecordActionProcesses } from '@/lib/utils/process-utils'
import { isSafeRedirectPath } from '@/lib/utils/string-utils'
import { AssociatedScriptViewer } from '@/components/records/AssociatedScriptViewer'
import { RecordViewHeader } from '@/components/records/RecordViewHeader'
import { scriptLogsQueryHref } from '@/components/scripts/script-utils'
import { CodeBlock } from '@/components/scripts/HighlightedCode'
import { CopyButton, DeveloperSection, DeveloperSectionNav, type DeveloperNavItem } from '@/components/developer/DeveloperSection'
import { DeveloperPanelView } from '@/components/developer/DeveloperPanelView'
import { RecordApiAccess } from '@/components/developer/RecordApiAccess'
import { RecordFieldValues } from '@/components/developer/RecordFieldValues'
import { RecentAuditSummary, RecordRelations, hasRelations } from '@/components/developer/RecordRelations'
import { TableMetadataExplorer } from '@/components/developer/TableMetadataExplorer'
/**
* The view-mode setter the shared header needs; this screen has no card/list modes.
* @returns Nothing.
*/
const NO_VIEW_MODE = () => undefined
/**
* Renders the developer view of the record named by the route.
*
* @returns The page: loading and error states, then the record header and the developer sections.
*/
export function RecordDeveloperView() {
const params = useRouteParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const queryClient = useQueryClient()
const pathname = usePathname()
const searchParams = useSearchParams()
const { data: instance } = useMetaData()
const { data: table, isLoading: tableLoading, error: tableError } = useQuery({
queryKey: queryKeys.tableMetadata(slug),
queryFn: () => loadTableMetaData(slug),
staleTime: 1000 * 60 * 30,
})
// The developer-mode route returns the record (with display values) and its associated scripts.
const { data: developerData, isLoading: recordLoading, error: recordError } = useQuery({
queryKey: queryKeys.recordDeveloper(slug, recordId),
queryFn: () => getRecordDeveloperData(slug, recordId),
meta: HANDLES_OWN_ERRORS,
retry: false,
})
const record = developerData?.record
const panelsQuery = useQuery({
queryKey: queryKeys.recordDeveloperPanels(slug, recordId),
queryFn: () => getRecordDeveloperPanels(slug, recordId),
enabled: Boolean(record),
meta: HANDLES_OWN_ERRORS,
retry: false,
})
const panels = useMemo(() => panelsQuery.data ?? [], [panelsQuery.data])
const recordLabel = record?.recordLabel || (table ? `${table.label} #${recordId}` : `${slug} #${recordId}`)
useEffect(() => {
setPageHeader(`${recordLabel} Developer Mode`)
if (table) setTableMetaData(table)
}, [recordLabel, table, setPageHeader, setTableMetaData])
const reloadRecord = useCallback(() => Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.recordDeveloper(slug, recordId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(slug, recordId) }),
]), [queryClient, slug, recordId])
const processes = useMemo(() => (instance ? getProcessesForRecordView(instance, slug) : []), [instance, slug])
const actionProcesses = useMemo(() => getRecordActionProcesses(processes, slug), [processes, slug])
const audits = auditSource(instance)
// Back goes to the record screen (or to where the user came from on it, when that is the record screen).
const fromPath = searchParams.get('from')
const recordPath = `/app/${encodeURIComponent(slug)}/${encodeURIComponent(recordId)}`
const backHref = fromPath && isSafeRedirectPath(fromPath) && fromPath.startsWith(recordPath) ? fromPath : recordPath
const navigateFrom = useMemo(() => ({ path: pathname, label: `${recordLabel} (developer)` }), [pathname, recordLabel])
if (recordLoading || tableLoading) {
return (
<div className="flex items-center justify-center py-12" role="status" aria-live="polite" aria-busy="true">
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<span className="sr-only">Loading developer view</span>
</div>
)
}
if (recordError || tableError || !record || !table) {
return (
<div className="space-y-4" data-qqq-id={`record-dev-${slug}-${recordId}`}>
<BackLink href={backHref} label={recordLabel} />
<div className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive" role="alert">
Failed to load record: {getErrorMessage(recordError ?? tableError ?? new Error('Record not found'))}
</div>
</div>
)
}
const primaryKey = (record.values[table.primaryKeyField] ?? recordId) as string | number
const showRelations = hasRelations(table, record, instance?.tables)
const associatedScripts = developerData?.associatedScripts ?? []
const logsViewAllHref = instance?.tables?.[SCRIPT_LOG_TABLE] ? scriptLogsQueryHref : undefined
const rawJson = JSON.stringify(record.values, null, 2)
const navItems: DeveloperNavItem[] = [
{ id: 'fields', label: 'Fields' },
{ id: 'api', label: 'API' },
...panels.map((panel) => ({ id: `panel-${panel.id}`, label: panel.title })),
...(showRelations ? [{ id: 'relations', label: 'Relations' }] : []),
...(audits ? [{ id: 'audit', label: 'Audit' }] : []),
...(associatedScripts.length ? [{ id: 'scripts', label: 'Scripts' }] : []),
{ id: 'json', label: 'JSON' },
{ id: 'metadata', label: 'Table Metadata' },
]
return (
<div className="min-w-0 space-y-4" data-qqq-id={`record-dev-${slug}-${recordId}`}>
<div className="flex flex-wrap items-center gap-3">
<BackLink href={backHref} label={recordLabel} />
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-2.5 py-0.5 text-xs font-medium text-muted-foreground" data-qqq-id="record-dev-badge">
<Code className="h-3.5 w-3.5" aria-hidden="true" />
Developer view · <span className="font-mono">{table.name}</span>
</span>
</div>
<RecordViewHeader
tableMetaData={table}
record={record}
t1Fields={[]}
viewMode="tabs"
setViewMode={NO_VIEW_MODE}
showViewModeToggle={false}
hideActions={false}
processes={processes}
allProcesses={instance?.processes}
allTables={instance?.tables}
navigateFrom={navigateFrom}
auditSource={audits}
widgetMetaDataMap={instance?.widgets}
onRecordChanged={reloadRecord}
/>
<DeveloperSectionNav items={navItems} />
<DeveloperSection id="fields" title="Fields" description="Each field's value as the record screen shows it, and its raw value.">
<RecordFieldValues table={table} record={record} allTables={instance?.tables} widgetMetaDataMap={instance?.widgets} navigateFrom={navigateFrom} />
</DeveloperSection>
<DeveloperSection id="api" title="API Access" description="Requests you can make for this record, with examples built from its values.">
<RecordApiAccess table={table} record={record} processes={actionProcesses} />
</DeveloperSection>
{panelsQuery.isLoading && <p role="status" className="text-sm text-muted-foreground">Loading developer panels…</p>}
{panelsQuery.isError && (
<p role="alert" className="rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
Developer panels could not be loaded: {getErrorMessage(panelsQuery.error)}
</p>
)}
{panels.map((panel) => (
<DeveloperPanelView key={panel.id} panel={panel} tableName={table.name} primaryKey={primaryKey} returnTo={pathname} />
))}
{showRelations && (
<DeveloperSection id="relations" title="Relations" description="Records this record references, its associations and the joins exposed on its table.">
<RecordRelations table={table} record={record} allTables={instance?.tables} />
</DeveloperSection>
)}
{audits && (
<DeveloperSection id="audit" title="Audit" description="The record's most recent changes.">
<RecentAuditSummary source={audits} table={table} primaryKey={primaryKey} recordLabel={recordLabel} />
</DeveloperSection>
)}
{associatedScripts.length > 0 && (
<DeveloperSection id="scripts" title="Associated Scripts">
{associatedScripts.map((associated) => {
const fieldName = associated.associatedScript.fieldName
const scriptId = record.values[fieldName]
return (
<AssociatedScriptViewer
key={fieldName}
tableName={slug}
recordId={recordId}
fieldLabel={table.fields[fieldName]?.label ?? fieldName}
data={associated}
scriptId={typeof scriptId === 'string' || typeof scriptId === 'number' ? scriptId : null}
canCreate={Boolean(table.editPermission)}
canEdit={Boolean(instance?.processes?.storeScriptRevision)}
canTest={Boolean(instance?.processes?.testScript)}
logsViewAllHref={logsViewAllHref}
onChanged={reloadRecord}
/>
)
})}
</DeveloperSection>
)}
<DeveloperSection id="json" title="Record Raw Values as JSON" actions={<CopyButton text={rawJson} label="Copy record JSON" />}>
<CodeBlock code={rawJson} language="json" className="max-h-[32rem]" dataQqqId="json-output" />
</DeveloperSection>
<DeveloperSection id="metadata" title="Table Metadata" defaultCollapsed>
<TableMetadataExplorer table={table} />
</DeveloperSection>
</div>
)
}
/**
* Link back to the record screen.
*
* @param props - Target and record label.
* @param props.href - The record screen path.
* @param props.label - The record's label.
* @returns The link.
*/
function BackLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="inline-flex items-center gap-1.5 rounded-md text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="record-dev-back"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Back to {label}
</Link>
)
}
@@ -0,0 +1,181 @@
/*
* 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 RecordFieldValues — every field of the record on the developer view: label and name, type
* (with possible-value source and adornments), the value as the record screen shows it, and the
* raw value. A filter box narrows the list; phones get stacked cards instead of a wide table.
*/
'use client'
import React, { useMemo, useState } from 'react'
import type { QFieldMetaData, QRecord, QTableMetaData, QWidgetMetaData } from '@/types'
import { FieldValue } from '@/components/records/FieldValue'
/** Props for {@link RecordFieldValues}. */
export interface RecordFieldValuesProps {
table: QTableMetaData
record: QRecord
allTables?: Record<string, QTableMetaData>
widgetMetaDataMap?: Record<string, QWidgetMetaData>
navigateFrom: { path: string; label: string }
}
/**
* Formats a raw value as JSON text (strings unquoted).
* @param value - Raw value.
* @returns Display text.
*/
export function rawText(value: unknown): string {
if (value === null || value === undefined) return 'null'
if (typeof value === 'string') return value
return JSON.stringify(value)
}
/**
* Describes a field's type extras: possible-value source and adornments.
* @param field - The field.
* @returns Short tags.
*/
export function fieldTags(field: QFieldMetaData): string[] {
const tags: string[] = []
if (field.possibleValueSourceName) tags.push(`PVS ${field.possibleValueSourceName}`)
else if (field.inlinePossibleValueSource) tags.push('inline PVS')
for (const adornment of field.adornments ?? []) tags.push(adornment.type)
if (field.isRequired) tags.push('required')
if (!field.isEditable) tags.push('read-only')
if (field.isHidden) tags.push('hidden')
return tags
}
/**
* The field list.
*
* @param props - See {@link RecordFieldValuesProps}.
* @returns Filter box, table (sm and up) and cards (phones).
*/
export function RecordFieldValues({ table, record, allTables, widgetMetaDataMap, navigateFrom }: RecordFieldValuesProps) {
const [filter, setFilter] = useState('')
// the table's fields in metadata order, then any value the record carries without a field (joins)
const names = useMemo(() => [...new Set([...Object.keys(table.fields ?? {}), ...Object.keys(record.values)])], [table.fields, record.values])
const needle = filter.trim().toLowerCase()
const shown = names.filter((name) => {
if (!needle) return true
const field = table.fields?.[name]
return name.toLowerCase().includes(needle) || field?.label?.toLowerCase().includes(needle) || rawText(record.values[name]).toLowerCase().includes(needle)
})
return (
<div className="space-y-3">
<label className="flex max-w-sm flex-col gap-1 text-xs font-medium text-muted-foreground">
Filter fields
<input
type="search"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="Name, label or value"
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
data-qqq-id="dev-field-filter"
/>
</label>
<p className="text-xs text-muted-foreground" aria-live="polite">{shown.length} of {names.length} fields</p>
<div className="hidden overflow-x-auto rounded-lg border border-border md:block">
<table className="min-w-full text-sm" data-qqq-id="record-dev-field-table">
<thead className="bg-muted/60">
<tr>
<th scope="col" className="px-3 py-2 text-left text-xs font-semibold text-foreground">Field</th>
<th scope="col" className="px-3 py-2 text-left text-xs font-semibold text-foreground">Type</th>
<th scope="col" className="px-3 py-2 text-left text-xs font-semibold text-foreground">Value</th>
<th scope="col" className="px-3 py-2 text-left text-xs font-semibold text-foreground">Raw</th>
</tr>
</thead>
<tbody>
{shown.map((name) => {
const field = table.fields?.[name]
return (
<tr key={name} className="border-t border-border align-top" data-qqq-id={`dev-field-${name}`}>
<td className="px-3 py-2">
<div className="text-sm text-foreground">{field?.label ?? name}</div>
<div className="font-mono text-xs text-primary">{name}{name === table.primaryKeyField ? ' (primary key)' : ''}</div>
</td>
<td className="px-3 py-2">
<div className="font-mono text-xs text-muted-foreground">{field?.type ?? '—'}</div>
{field && <TagList tags={fieldTags(field)} />}
</td>
<td className="max-w-[24rem] px-3 py-2 text-sm [overflow-wrap:anywhere]">
{field ? <FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={table} /> : rawText(record.displayValues?.[name] ?? record.values[name])}
</td>
<td className="max-w-[24rem] px-3 py-2 font-mono text-xs text-foreground [overflow-wrap:anywhere]">
<RawValue value={record.values[name]} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<ul className="m-0 list-none space-y-2 p-0 md:hidden">
{shown.map((name) => {
const field = table.fields?.[name]
return (
<li key={name} className="rounded-lg border border-border p-3" data-qqq-id={`dev-field-card-${name}`}>
<div className="flex flex-wrap items-baseline justify-between gap-x-2">
<span className="text-sm font-medium text-foreground">{field?.label ?? name}</span>
<span className="font-mono text-xs text-muted-foreground">{field?.type ?? ''}</span>
</div>
<div className="font-mono text-xs text-primary">{name}</div>
{field && <TagList tags={fieldTags(field)} />}
<div className="mt-2 text-sm [overflow-wrap:anywhere]">
{field ? <FieldValue field={field} record={record} allTables={allTables} navigateFrom={navigateFrom} widgetMetaDataMap={widgetMetaDataMap} tableMetaData={table} /> : rawText(record.values[name])}
</div>
<div className="mt-1 font-mono text-xs text-muted-foreground [overflow-wrap:anywhere]">raw: <RawValue value={record.values[name]} /></div>
</li>
)
})}
</ul>
</div>
)
}
/**
* A raw value, with null in italics.
* @param props - The value.
* @param props.value - Raw value.
* @returns The text.
*/
function RawValue({ value }: { value: unknown }) {
if (value === null || value === undefined) return <span className="italic text-muted-foreground">null</span>
return <>{rawText(value)}</>
}
/**
* Small tags under a type.
* @param props - The tags.
* @param props.tags - Tag texts.
* @returns The tag list, or nothing.
*/
function TagList({ tags }: { tags: string[] }) {
if (tags.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1">
{tags.map((tag) => <span key={tag} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">{tag}</span>)}
</div>
)
}
@@ -0,0 +1,183 @@
/*
* 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 Record developer view sections for how the record relates to others (associations,
* exposed joins and the records its fields reference) and a summary of its recent audits.
*/
'use client'
import React, { useRef, useState } from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import type { QRecord, QTableMetaData } from '@/types'
import { getAuditRecords, type AuditSource } from '@/lib/api/audits'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { AuditHistoryDialog } from '@/components/records/AuditHistoryDialog'
import { PanelTable } from './DeveloperPanelView'
/**
* Whether the record has anything to show in {@link RecordRelations}.
* @param table - Table metadata.
* @param record - The record.
* @param allTables - Tables the user can see.
* @returns True when there are associations, joins or references.
*/
export function hasRelations(table: QTableMetaData, record: QRecord, allTables?: Record<string, QTableMetaData>): boolean {
return Boolean(table.associations?.length || table.exposedJoins?.length || references(table, record, allTables).length)
}
/**
* Fields of the record whose values reference records of other visible tables.
* @param table - Table metadata.
* @param record - The record.
* @param allTables - Tables the user can see.
* @returns Field label, target table and key, for set values only.
*/
function references(table: QTableMetaData, record: QRecord, allTables?: Record<string, QTableMetaData>) {
return Object.values(table.fields ?? {}).flatMap((field) => {
const target = field.possibleValueSourceName && allTables?.[field.possibleValueSourceName]
const value = record.values[field.name]
if (!target || value === null || value === undefined || value === '') return []
return [{ field, target, value: String(value), label: record.displayValues?.[field.name] ?? String(value) }]
})
}
/**
* Associations, exposed joins and references of the record.
*
* @param props - Table, record and visible tables.
* @param props.table - Table metadata.
* @param props.record - The record.
* @param props.allTables - Tables the user can see (for labels and links).
* @returns The relation lists.
*/
export function RecordRelations({ table, record, allTables }: { table: QTableMetaData; record: QRecord; allTables?: Record<string, QTableMetaData> }) {
const refs = references(table, record, allTables)
const tableLabel = (name: string) => allTables?.[name]?.label ?? name
return (
<div className="space-y-4">
{refs.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-foreground">References</h3>
<ul className="m-0 list-none space-y-1 p-0 text-sm">
{refs.map(({ field, target, value, label }) => (
<li key={field.name} className="flex flex-wrap gap-x-2">
<span className="text-muted-foreground">{field.label}</span>
<Link className="text-primary hover:underline" href={`/app/${encodeURIComponent(target.name)}/${encodeURIComponent(value)}`}>{label}</Link>
<span className="text-muted-foreground">({target.label})</span>
<Link className="text-xs text-primary hover:underline" href={`/app/${encodeURIComponent(target.name)}/${encodeURIComponent(value)}/dev`}>developer view</Link>
</li>
))}
</ul>
</div>
)}
{Boolean(table.associations?.length) && (
<div>
<h3 className="mb-2 text-sm font-semibold text-foreground">Associations</h3>
<PanelTable
columns={['Name', 'Associated table', 'Join', 'Type', 'Join on']}
rows={(table.associations ?? []).map((association) => [
association.name,
`${tableLabel(association.associatedTableName)} (${association.associatedTableName})`,
association.join?.name ?? '',
association.join?.type ?? '',
(association.join?.joinOns ?? []).map((on) => `${on.leftField} = ${on.rightField}`).join(', '),
])}
/>
</div>
)}
{Boolean(table.exposedJoins?.length) && (
<div>
<h3 className="mb-2 text-sm font-semibold text-foreground">Exposed joins</h3>
<PanelTable
columns={['Label', 'Table', 'Cardinality', 'Path']}
rows={(table.exposedJoins ?? []).map((join) => [
join.label,
join.joinTable ? `${join.joinTable.label} (${join.joinTable.name})` : '',
join.isMany ? 'many' : 'one',
(join.joinPath ?? []).map((step) => step.name).join(' > '),
])}
/>
</div>
)}
</div>
)
}
/** How many audits the summary lists. */
const RECENT_AUDITS = 5
/**
* The record's latest audits, with a button for the full history dialog.
*
* @param props - Audit source, table and record.
* @param props.source - How the user reads audits.
* @param props.table - Table metadata.
* @param props.primaryKey - The record's key.
* @param props.recordLabel - The record's label.
* @returns The summary.
*/
export function RecentAuditSummary({ source, table, primaryKey, recordLabel }: { source: Exclude<AuditSource, null>; table: QTableMetaData; primaryKey: string | number; recordLabel: string }) {
const [open, setOpen] = useState(false)
const trigger = useRef<HTMLButtonElement>(null)
const { data, isLoading, error } = useQuery({
queryKey: [...queryKeys.recordDeveloper(table.name, primaryKey), 'recentAudits'],
queryFn: () => getAuditRecords(source, table.name, primaryKey),
meta: HANDLES_OWN_ERRORS,
retry: false,
})
const audits = data?.records ?? []
return (
<div className="space-y-3">
{isLoading && <p role="status" className="text-sm text-muted-foreground">Loading audits…</p>}
{error && <p role="alert" className="text-sm text-destructive">Audits could not be loaded: {getErrorMessage(error)}</p>}
{data && audits.length === 0 && <p className="text-sm italic text-muted-foreground">This record has no audits.</p>}
{audits.length > 0 && (
<ol className="m-0 list-none space-y-2 p-0">
{audits.slice(0, RECENT_AUDITS).map((audit) => (
<li key={audit.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-l-2 border-border pl-3 text-sm">
<span className="font-mono text-xs font-semibold text-foreground">{audit.action}</span>
<DateTimeText value={audit.timestamp} type="DATE_TIME" className="text-xs text-muted-foreground" />
<span className="text-xs text-muted-foreground">{audit.user}</span>
<span className="min-w-0 basis-full break-words text-sm text-foreground">
{audit.message || `${audit.fieldChanges?.length ?? 0} field change${audit.fieldChanges?.length === 1 ? '' : 's'}`}
</span>
</li>
))}
</ol>
)}
<div className="flex flex-wrap items-center gap-3">
{data && <span className="text-xs text-muted-foreground">{data.total ?? audits.length} audit{(data.total ?? audits.length) === 1 ? '' : 's'} in all</span>}
<button
ref={trigger}
type="button"
onClick={() => setOpen(true)}
className="rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="dev-open-audit-history"
>
Full audit history
</button>
</div>
<AuditHistoryDialog open={open} onOpenChange={setOpen} source={source} tableMetaData={table} primaryKey={primaryKey} recordLabel={recordLabel} returnFocusRef={trigger} />
</div>
)
}
@@ -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.
*/
/**
* @file TableMetadataExplorer — the table's metadata on the developer view as readable, searchable
* groups (summary, fields with their possible-value sources and adornments, sections, menus and
* supplemental metadata) plus the raw JSON, instead of one large JSON block.
*/
'use client'
import React, { useMemo, useState } from 'react'
import type { QFieldMetaData, QTableMetaData } from '@/types'
import { CodeBlock } from '@/components/scripts/HighlightedCode'
import { CopyButton } from './DeveloperSection'
import { PanelTable } from './DeveloperPanelView'
/**
* Adornments of a field as short text, e.g. `CHIP {"color.ACTIVE":"success"}`.
* @param field - The field.
* @returns One line per adornment.
*/
export function adornmentText(field: QFieldMetaData): string {
return (field.adornments ?? [])
.map((adornment) => adornment.values && Object.keys(adornment.values).length ? `${adornment.type} ${JSON.stringify(adornment.values)}` : adornment.type)
.join('\n')
}
/**
* Whether any of the texts contains the needle (case-insensitive).
* @param needle - Lower-cased search text ('' matches everything).
* @param texts - Candidate texts.
* @returns True on a match.
*/
function matches(needle: string, ...texts: Array<string | undefined | null>): boolean {
return !needle || texts.some((text) => text?.toLowerCase().includes(needle))
}
/**
* The explorer.
*
* @param props - The table.
* @param props.table - Full table metadata.
* @returns Search box and collapsible groups.
*/
export function TableMetadataExplorer({ table }: { table: QTableMetaData }) {
const [search, setSearch] = useState('')
const needle = search.trim().toLowerCase()
const json = useMemo(() => JSON.stringify(table, null, 2), [table])
const fields = Object.values(table.fields ?? {}).filter((field) =>
matches(needle, field.name, field.label, field.type, field.possibleValueSourceName, adornmentText(field), field.displayFormat))
const sections = (table.sections ?? []).filter((section) => matches(needle, section.name, section.label, section.tier, ...(section.fieldNames ?? [])))
const supplemental = Object.entries({ ...(table.supplementalMetaData ?? {}), ...(table.supplementalTableMetaData ?? {}) })
.filter(([key, value]) => matches(needle, key, JSON.stringify(value)))
const capabilities = (table.capabilities ?? []).join(', ')
return (
<div className="space-y-3">
<label className="flex max-w-sm flex-col gap-1 text-xs font-medium text-muted-foreground">
Search table metadata
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Field, section, adornment, PVS…"
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
data-qqq-id="dev-metadata-search"
/>
</label>
<Group title="Summary" open={!needle}>
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[minmax(8rem,max-content)_1fr]">
{[
['Name', table.name],
['Label', table.label],
['Primary key', table.primaryKeyField],
['Capabilities', capabilities || 'all'],
['Your permissions', ['read', 'insert', 'edit', 'delete'].filter((_, index) => [table.readPermission, table.insertPermission, table.editPermission, table.deletePermission][index]).join(', ') || 'none'],
['Uses variants', table.usesVariants ? `yes (${table.variantTableLabel})` : 'no'],
['Record search fields', (table.searchFields ?? []).join(', ') || '—'],
].map(([label, value]) => (
<React.Fragment key={label}>
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
<dd className="m-0 break-words font-mono text-xs text-foreground">{value}</dd>
</React.Fragment>
))}
</dl>
</Group>
<Group title={`Fields (${fields.length})`} open={Boolean(needle)}>
<PanelTable
columns={['Name', 'Label', 'Type', 'Possible values', 'Adornments', 'Flags', 'Format']}
rows={fields.map((field) => [
field.name,
field.label,
field.type,
field.possibleValueSourceName ?? (field.inlinePossibleValueSource?.enumValues?.map((option) => `${option.id}=${option.label}`).join(', ') ?? ''),
adornmentText(field),
[field.isRequired && 'required', !field.isEditable && 'read-only', field.isHidden && 'hidden', field.isHeavy && 'heavy', field.maxLength && `max ${field.maxLength}`].filter(Boolean).join(', '),
field.displayFormat ?? '',
])}
/>
</Group>
<Group title={`Sections (${sections.length})`} open={Boolean(needle) && sections.length > 0}>
<PanelTable
columns={['Name', 'Label', 'Tier', 'Fields']}
rows={sections.map((section) => [section.name, section.label, section.tier ?? '', (section.fieldNames ?? []).join(', ')])}
/>
</Group>
{(table.menus?.length ?? 0) > 0 && !needle && (
<Group title={`Menus (${table.menus?.length})`}>
<PanelTable
columns={['Slot', 'Label', 'Items']}
rows={(table.menus ?? []).map((menu) => [menu.slot ?? '', menu.label ?? '', (menu.items ?? []).map((item) => item.label ?? item.itemType).join(', ')])}
/>
</Group>
)}
{supplemental.length > 0 && (
<Group title={`Supplemental metadata (${supplemental.length})`} open={Boolean(needle)}>
{supplemental.map(([key, value]) => (
<div key={key}>
<h4 className="mb-1 font-mono text-xs font-semibold text-foreground">{key}</h4>
<CodeBlock code={JSON.stringify(value, null, 2)} language="json" className="max-h-64" />
</div>
))}
</Group>
)}
<Group title="Raw JSON">
<div className="mb-2 flex justify-end"><CopyButton text={json} label="Copy table metadata JSON" /></div>
<CodeBlock code={json} language="json" className="max-h-[32rem]" dataQqqId="json-output" />
</Group>
</div>
)
}
/**
* A collapsible group (native details/summary: keyboard and screen-reader friendly).
* @param props - Title, initial state and content.
* @param props.title - Summary text.
* @param props.open - Starts open.
* @param props.children - Content.
* @returns The group.
*/
function Group({ title, open = false, children }: { title: string; open?: boolean; children: React.ReactNode }) {
return (
// key on open: a search that opens the group re-mounts it, so the user's own toggling still works afterwards
<details key={String(open)} open={open} className="rounded-lg border border-border">
<summary className="cursor-pointer px-3 py-2 text-sm font-medium text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">{title}</summary>
<div className="min-w-0 border-t border-border p-3">{children}</div>
</details>
)
}
+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">
+82 -5
View File
@@ -18,17 +18,35 @@
import React from 'react'
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { render as rtlRender, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const pushMock = vi.fn()
let pathname = '/app'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock }), usePathname: () => pathname }))
const searchRecordsMock = vi.fn()
vi.mock('@/lib/api/record-search', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/api/record-search')>()),
searchRecords: (...args: unknown[]) => searchRecordsMock(...args),
}))
/**
* Renders inside a fresh query client (the palette runs record search through TanStack Query).
*
* @param ui - Element to render.
* @returns The render result.
*/
function render(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return rtlRender(ui, { wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> })
}
import { CommandMenu } from './CommandMenu'
import type { NavTarget } from '@/lib/hooks/use-routes'
import type { QInstance, QTableMetaData } from '@/types'
import { addRecentRecord } from '@/lib/utils/recent-records'
import { addRecentRecord, clearRecentRecords } from '@/lib/utils/recent-records'
const app = { label: 'People App', path: '/app/peopleApp' }
const targets: NavTarget[] = [
@@ -80,12 +98,13 @@ describe('CommandMenu', () => {
]
render(<CommandMenu open onClose={vi.fn()} navTargets={more} />)
await user.type(screen.getByRole('combobox'), 'pet')
expect(screen.getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet', 'Pet Species', 'Carpet'])
expect(Array.from(document.querySelectorAll('[data-qqq-id="command-group-navigation"] [role="option"]')).map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet', 'Pet Species', 'Carpet'])
await user.type(screen.getByRole('combobox'), ' spe')
expect(screen.getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet Species'])
expect(Array.from(document.querySelectorAll('[data-qqq-id="command-group-navigation"] [role="option"]')).map((option) => option.querySelector('span.font-medium')?.textContent)).toEqual(['Pet Species'])
await user.clear(screen.getByRole('combobox'))
await user.type(screen.getByRole('combobox'), 'nothing here')
expect(screen.queryAllByRole('option')).toHaveLength(0)
// only the search-everything entry remains
expect(screen.queryAllByRole('option').map((option) => option.textContent)).toEqual(['Search everything for “nothing here”'])
expect(screen.getByRole('status')).toHaveTextContent('No results found.')
})
@@ -142,4 +161,62 @@ describe('CommandMenu', () => {
expect(pushMock).toHaveBeenCalledWith('/app/person/7')
})
})
describe('record search', () => {
const searchMeta = {
tables: {
person: { name: 'person', label: 'Person', isHidden: false, readPermission: true, primaryKeyField: 'id', fields: {}, searchFields: ['firstName'], icon: { name: 'person' } },
},
processes: {},
apps: {},
} as unknown as QInstance
beforeEach(() => {
pathname = '/app'
pushMock.mockReset()
searchRecordsMock.mockReset()
clearRecentRecords()
window.open = vi.fn() as unknown as typeof window.open
})
it('lists found records under their table with a count, and a search-everything entry', async () => {
searchRecordsMock.mockResolvedValue({
results: [{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Avery Sample', updatedAt: '2026-10-01T10:00:00Z', highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'avery@example.com', matches: [{ start: 0, end: 5 }] }] }],
tables: [{ tableName: 'person', resultCount: 1, hasMore: true, totalCount: 12, source: 'PROVIDER' }],
})
const user = userEvent.setup()
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
await user.type(screen.getByRole('combobox'), 'avery')
const record = await screen.findByRole('option', { name: /Avery Sample/ })
expect(record).toHaveTextContent('Email: avery@example.com')
expect(Array.from(record.querySelectorAll('mark')).map((mark) => mark.textContent)).toEqual(['Avery', 'avery'])
expect(document.querySelector('[data-qqq-id="command-group-records-person"]')).toHaveTextContent('Person · 12')
expect(screen.getByRole('option', { name: /See all matches in Person/ })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Search everything for/ })).toBeInTheDocument()
expect(searchRecordsMock).toHaveBeenCalledWith('avery', expect.objectContaining({ tableNames: ['person'], limitPerTable: 5 }))
})
it('opens the highlighted record in a new tab on Ctrl+Enter', async () => {
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', recordId: '7', recordLabel: 'Avery Sample' }] })
const user = userEvent.setup()
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
await user.type(screen.getByRole('combobox'), 'avery')
await screen.findByRole('option', { name: /Avery Sample/ })
// the record is the first entry, so cmdk highlights it
await user.keyboard('{Control>}{Enter}{/Control}')
expect(window.open).toHaveBeenCalledWith('/app/person/7', '_blank', 'noopener,noreferrer')
expect(pushMock).not.toHaveBeenCalled()
})
it('names the failure class when record search fails, keeping navigation results', async () => {
const { AxiosError } = await import('axios')
searchRecordsMock.mockRejectedValue(new AxiosError('Index unavailable', 'ERR_BAD_RESPONSE', undefined, null, { status: 503, data: {}, statusText: '', headers: {}, config: {} as never }))
const user = userEvent.setup()
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} metaData={searchMeta} />)
await user.type(screen.getByRole('combobox'), 'pe')
expect(await screen.findByText('Record search failed: server error (HTTP 503)')).toBeInTheDocument()
expect(screen.getByText(/Index unavailable/)).toBeInTheDocument()
expect(document.querySelector('[data-qqq-id="command-item-peopleApp"]')).toBeInTheDocument()
})
})
})
+95 -5
View File
@@ -23,16 +23,22 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Command } from 'cmdk'
import dynamic from 'next/dynamic'
import { usePathname, useRouter } from 'next/navigation'
import { Search, X } from 'lucide-react'
import { Search, Sparkles, X } from 'lucide-react'
import type { QInstance } from '@/types'
import type { NavTarget } from '@/lib/hooks/use-routes'
import { cn } from '@/lib/utils/cn'
import { buildTableActions, commandMatches, compareCommandLabels, tableScreenFor, type TableAction } from '@/lib/utils/command-palette'
import { getRecentRecords, type RecentRecord } from '@/lib/utils/recent-records'
import { searchableTables } from '@/lib/utils/record-search'
import { openInNewTab, searchPagePath } from '@/lib/search/search-url'
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
/** Record search results, loaded when the palette opens with searchable tables. */
const CommandRecordResults = dynamic(() => import('@/components/search/CommandRecordResults').then((module) => module.CommandRecordResults), { ssr: false })
/** Fallback icon kind for each app-tree node type. */
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
@@ -51,6 +57,12 @@ interface CommandMenuProps {
navTargets: NavTarget[]
/** Instance metadata, for the current table's actions (omit for navigation only). */
metaData?: QInstance
/**
* When set (the assistant is available), typed text can be handed to the assistant.
*
* @param text - The typed text.
*/
onAskAssistant?: (text: string) => void
}
/** Shared classes of a palette entry (44 px tall on touch screens). */
@@ -84,12 +96,14 @@ function GroupHeading({ children }: { children: React.ReactNode }) {
* @returns A fixed full-screen backdrop with the command palette dialog, or
* `null` when `open` is `false`. Keyboard: ↑↓ navigate, ↵ open, Esc close.
*/
export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenuProps) {
export function CommandMenu({ open, onClose, navTargets, metaData, onAskAssistant }: CommandMenuProps) {
const router = useRouter()
const pathname = usePathname()
const [search, setSearch] = useState('')
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
const [hash, setHash] = useState('')
// value of the highlighted entry (cmdk), for Ctrl/Cmd+Enter
const [selectedValue, setSelectedValue] = useState('')
// Recently viewed records and the hash (an open audit dialog hides the table actions) are read on open
useEffect(() => {
@@ -162,6 +176,24 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
const screen = useMemo(() => (open ? tableScreenFor(pathname ?? '', hash, metaData) : null), [open, pathname, hash, metaData])
const tableActions = useMemo(() => (screen && metaData ? buildTableActions(screen, metaData) : []), [screen, metaData])
// Records found by record search (only when the metadata lists searchable tables)
const tables = useMemo(() => searchableTables(metaData), [metaData])
const [recordState, setRecordState] = useState({ count: 0, searching: false })
const trimmedSearch = search.trim()
/**
* Ctrl/Cmd+Enter opens the highlighted entry in a new tab (cmdk handles plain Enter).
*
* @param event - Keydown inside the palette (runs before cmdk's own handler).
*/
const handlePaletteKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey)) return
const prefix = selectedValue.slice(0, selectedValue.indexOf(':') + 1)
if (!['page:', 'recent:', 'record:', 'search:'].includes(prefix)) return
event.preventDefault()
openInNewTab(selectedValue.slice(prefix.length))
}
// Material's filter (per-word substring) and ranking (labels that start with the search first)
const visibleActions = search ? tableActions.filter((action) => commandMatches(action.label, search)) : tableActions
const visibleTargets = useMemo(() => {
@@ -177,6 +209,12 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
.sort((a, b) => compareCommandLabels(a.recordLabel, b.recordLabel, search))
}, [recentRecords, search])
// first entries around the records, for moving cmdk's highlight when records arrive
const firstValueBefore = (screen && visibleActions[0] && `action:${visibleActions[0].key}`)
|| (visibleTargets[0] && `page:${visibleTargets[0].path}`)
|| undefined
const firstValueAfter = visibleRecent[0] ? `recent:${visibleRecent[0].path}` : undefined
/**
* Runs a table action: navigates to it, or opens the record's audit dialog (`#audit`).
*
@@ -218,14 +256,14 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
ref={dialogRef}
onKeyDown={trapFocus}
>
<Command shouldFilter={false} label="Command palette">
<Command shouldFilter={false} label="Command palette" value={selectedValue} onValueChange={setSelectedValue} onKeyDown={handlePaletteKeyDown}>
{/* Search input */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="Search for tables, actions, or recently viewed items..."
placeholder={tables.length > 0 ? 'Search for tables, actions, records, or recently viewed items...' : 'Search for tables, actions, or recently viewed items...'}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autoFocus
data-qqq-id="command-menu-search"
@@ -248,7 +286,7 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
aria-live="polite"
data-qqq-id="command-menu-results"
>
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && (
{visibleActions.length === 0 && visibleTargets.length === 0 && visibleRecent.length === 0 && recordState.count === 0 && !recordState.searching && !(onAskAssistant && search.trim()) && (
<p className="py-8 text-center text-sm text-muted-foreground" role="status" data-qqq-id="command-menu-empty">
No results found.
</p>
@@ -295,6 +333,22 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
</Command.Group>
)}
{open && tables.length > 0 && (
<CommandRecordResults
search={search}
open={open}
tables={tables}
selectedValue={selectedValue}
setSelectedValue={setSelectedValue}
firstValueBefore={firstValueBefore}
firstValueAfter={firstValueAfter}
onSelect={handleSelect}
onState={setRecordState}
itemClassName={ITEM_CLASS}
heading={(text) => <GroupHeading>{text}</GroupHeading>}
/>
)}
{visibleRecent.length > 0 && (
<Command.Group heading={<GroupHeading>Recently Viewed Records</GroupHeading>} data-qqq-id="command-group-recent">
{visibleRecent.map((record) => (
@@ -312,6 +366,38 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
))}
</Command.Group>
)}
{trimmedSearch && (
<Command.Group heading={<GroupHeading>Search</GroupHeading>} data-qqq-id="command-group-search">
<Command.Item
value={`search:${searchPagePath({ q: trimmedSearch })}`}
onSelect={() => handleSelect(searchPagePath({ q: trimmedSearch }))}
className={ITEM_CLASS}
data-qqq-id="command-search-all"
>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="flex-1 truncate">Search everything for <span className="font-medium">&ldquo;{trimmedSearch}&rdquo;</span></span>
</Command.Item>
</Command.Group>
)}
{/* Last, so Enter still opens the best navigation match; alone when nothing else matches */}
{onAskAssistant && search.trim() && (
<Command.Group heading={<GroupHeading>Assistant</GroupHeading>} data-qqq-id="command-group-assistant">
<Command.Item
value="assistant:ask"
onSelect={() => {
onClose()
onAskAssistant(search.trim())
}}
className={ITEM_CLASS}
data-qqq-id="command-ask-assistant"
>
<Sparkles className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="flex-1 truncate font-medium">Ask Assistant: {search.trim()}</span>
</Command.Item>
</Command.Group>
)}
</Command.List>
{/* Footer hint */}
@@ -325,6 +411,10 @@ export function CommandMenu({ open, onClose, navTargets, metaData }: CommandMenu
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}
open
</span>
<span className="hidden sm:inline">
<kbd className="rounded border border-border px-1 py-0.5 font-mono">Ctrl/⌘ ↵</kbd>{' '}
new tab
</span>
<span>
<kbd className="rounded border border-border px-1 py-0.5 font-mono">esc</kbd>{' '}
close
@@ -111,6 +111,8 @@ interface KeyboardShortcutsDialogProps {
* opened (Safari and iPad do not focus a tapped or clicked button).
*/
returnFocusRef?: React.RefObject<HTMLElement | null>
/** Lists the assistant shortcut (Ctrl/⌘+J) when the assistant is available. */
assistantEnabled?: boolean
}
/**
@@ -126,7 +128,12 @@ interface KeyboardShortcutsDialogProps {
* sections (max 60 vh), and a footer reminder. Each section groups related
* shortcuts under a heading with {@link Kbd} badges for each key combination.
*/
export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef }: KeyboardShortcutsDialogProps) {
export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef, assistantEnabled = false }: KeyboardShortcutsDialogProps) {
const sections = assistantEnabled
? shortcutSections.map((section) => (section.title === 'Global'
? { ...section, shortcuts: [...section.shortcuts, { keys: ['⌘', 'J'], description: 'Assistant (Ctrl+J on Windows and Linux)' }] }
: section))
: shortcutSections
const restoreFocus = useRestoreFocus(open)
/**
* Returns focus to where it was, or else to the help button.
@@ -177,7 +184,7 @@ export function KeyboardShortcutsDialog({ open, onClose, returnFocusRef }: Keybo
{/* Shortcut sections */}
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
{shortcutSections.map((section, sectionIndex) => (
{sections.map((section, sectionIndex) => (
<div key={section.title} className={cn(sectionIndex > 0 && 'mt-5')}>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{section.title}
@@ -38,16 +38,18 @@ function Harness() {
describe('SearchDialog keyboard access', () => {
beforeEach(() => localStorage.clear())
it('keeps both Tab directions in an empty dialog', async () => {
it('keeps both Tab directions inside an empty dialog (input and the close button)', async () => {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: 'Open search' }))
const input = screen.getByRole('combobox')
await waitFor(() => expect(input).toHaveFocus())
await user.tab()
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
await user.tab()
expect(input).toHaveFocus()
await user.tab({ shift: true })
expect(input).toHaveFocus()
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
})
it('wraps between the first and last controls when the query adds buttons', async () => {
@@ -57,13 +59,13 @@ describe('SearchDialog keyboard access', () => {
const input = screen.getByRole('combobox')
await user.type(input, 'no matches')
await user.tab({ shift: true })
expect(screen.getByRole('button', { name: 'Show all matches' })).toHaveFocus()
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
await user.tab()
expect(input).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'Clear search' })).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'Show all matches' })).toHaveFocus()
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
})
it('dismisses with Escape from a button and returns focus to the opener', async () => {
@@ -90,7 +92,7 @@ describe('SearchDialog keyboard access', () => {
expect(input).toHaveValue('')
expect(input).toHaveFocus()
await user.tab()
expect(input).toHaveFocus()
expect(screen.getByRole('button', { name: 'Close search' })).toHaveFocus()
await user.tab({ shift: true })
expect(input).toHaveFocus()
})
+95 -147
View File
@@ -15,30 +15,32 @@
*/
/**
* @file SearchDialog — "/" key search dialog over pages and recently viewed records, plus
* records found by the backend record search when the metadata advertises searchable tables.
* @file SearchDialog — "/" key (and mobile header) search dialog: search-as-you-type over
* pages, recently viewed records and recent searches, plus records found by the backend
* record search when the metadata advertises searchable tables.
*/
'use client'
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import React, { useEffect, useCallback, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Search, ArrowRight, X } from 'lucide-react'
import { Search, X } from 'lucide-react'
import type { NavTarget } from '@/lib/hooks/use-routes'
import { useRecordSearch } from '@/lib/hooks/use-record-search'
import { getRecentRecords } from '@/lib/utils/recent-records'
import type { RecentRecord } from '@/lib/utils/recent-records'
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
import type { SearchableTable } from '@/lib/utils/record-search'
import { NavigationSearchResults } from '@/components/layout/NavigationSearchResults'
import { searchPagePath } from '@/lib/search/search-url'
import type { QuickSearchPanelController } from '@/components/search/QuickSearchPanel'
/** Records shown per table in the dialog. */
const DIALOG_RECORDS_PER_TABLE = 5
/** The results panel, loaded when the dialog first opens. */
const QuickSearchPanel = dynamic(() => import('@/components/search/QuickSearchPanel').then((module) => module.QuickSearchPanel), { ssr: false })
/** Stable empty default for `searchTables`. */
const NO_SEARCH_TABLES: SearchableTable[] = []
/** DOM id of the results listbox. */
const LISTBOX_ID = 'search-dialog-results'
/**
* Props for the SearchDialog component.
*/
@@ -54,13 +56,11 @@ interface SearchDialogProps {
}
/**
* Modal search dialog triggered by the `/` keyboard shortcut.
* Modal search dialog triggered by the `/` keyboard shortcut and the mobile header button.
*
* Lists recently viewed records until the user types, then matching pages
* (apps, tables, processes, reports by label), records found by record search
* (when available) and matching recent records.
* ArrowUp/Down move the selection, Enter opens it (or the search page when
* nothing is selected), Escape closes.
* Lists recent searches and recently viewed records until the user types, then grouped
* results (top hits, pages, records per table, recently viewed). ↑/↓ move the selection,
* Enter opens it, Ctrl/⌘+Enter opens it in a new tab, Escape closes.
*
* @param props - Component properties.
* @returns The dialog overlay, or `null` when closed.
@@ -70,78 +70,48 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
const inputRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const [searchTerm, setSearchTerm] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
const handleNavigate = useCallback((path: string) => {
onClose()
router.push(path)
}, [router, onClose])
// Load recent records when the dialog opens; reset state
useEffect(() => {
if (open) {
const returnTo = document.activeElement
setRecentRecords(getRecentRecords().slice(0, 10))
setSearchTerm('')
setSelectedIndex(-1)
const frame = requestAnimationFrame(() => {
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
})
return () => {
cancelAnimationFrame(frame)
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
}
const [term, setTerm] = useState('')
const controllerRef = useRef<QuickSearchPanelController | null>(null)
const [activeDescendant, setActiveDescendant] = useState<string>()
const recordSearchAvailable = searchTables.length > 0
const handleController = useCallback((controller: QuickSearchPanelController | null) => {
controllerRef.current = controller
setActiveDescendant(controller?.activeDescendant)
}, [])
/**
* Defers ↑↓, Enter and Ctrl/⌘+Enter to the results panel (Enter searches everything while it loads).
*
* @param event - The input's keydown event.
*/
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (controllerRef.current) {
controllerRef.current.onInputKeyDown(event)
} else if (event.key === 'Enter' && term.trim()) {
event.preventDefault()
handleNavigate(searchPagePath({ q: term }))
}
}, [open])
const recordSearch = useRecordSearch(open ? searchTerm : '', searchTables, DIALOG_RECORDS_PER_TABLE)
const items = useMemo(
() => buildNavigationSearchItems(navTargets, recentRecords, searchTerm, 8, recordSearch.results),
[navTargets, recentRecords, searchTerm, recordSearch.results]
)
}
// Reset and focus on open; give focus back on close
useEffect(() => {
setSelectedIndex(-1)
}, [items])
/**
* Closes the dialog and navigates to the given path.
*
* @param path - The URL path to navigate to.
*/
const handleNavigate = useCallback(
(path: string) => {
onClose()
router.push(path)
},
[router, onClose]
)
/**
* Handles keyboard navigation within the result list.
*
* @param e - The synthetic keyboard event from the search input.
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < items.length) {
handleNavigate(items[selectedIndex].path)
} else if (searchTerm.trim()) {
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
}
break
}
},
[selectedIndex, items, searchTerm, handleNavigate]
)
if (!open) return
const returnTo = document.activeElement
setTerm('')
const frame = requestAnimationFrame(() => {
if (!dialogRef.current?.contains(document.activeElement)) inputRef.current?.focus()
})
return () => {
cancelAnimationFrame(frame)
if (returnTo instanceof HTMLElement && returnTo.isConnected) returnTo.focus()
}
}, [open, setTerm])
/**
* Handles dismissal from every control and keeps keyboard focus inside the modal.
@@ -168,104 +138,82 @@ export function SearchDialog({ open, onClose, navTargets, searchTables = NO_SEAR
if (!open) return null
const hasTerm = searchTerm.trim().length > 0
return (
<div
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
data-qqq-id="search-dialog-backdrop"
>
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
<div className="fixed inset-0 z-[2000] flex items-start justify-center px-2 pt-[6vh] sm:px-4 sm:pt-[12vh]" data-qqq-id="search-dialog-backdrop">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} aria-hidden="true" />
<div
ref={dialogRef}
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg overflow-hidden"
className="relative z-10 flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg sm:max-h-[76vh]"
data-qqq-id="search-dialog"
role="dialog"
aria-label="Search"
aria-modal="true"
onKeyDown={handleDialogKeyDown}
>
{/* Search input */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={recordSearch.available ? 'Search pages and records...' : 'Jump to a page or recent record...'}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
value={term}
onChange={(event) => setTerm(event.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={recordSearchAvailable ? 'Search pages and records…' : 'Jump to a page or recent record…'}
className="min-w-0 flex-1 bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none sm:text-sm"
aria-label={recordSearchAvailable ? 'Search pages and records' : 'Search pages and recent records'}
aria-expanded={true}
aria-controls="search-dialog-results"
aria-controls={LISTBOX_ID}
aria-activedescendant={activeDescendant}
aria-haspopup="listbox"
aria-autocomplete="list"
role="combobox"
autoComplete="off"
spellCheck={false}
data-qqq-id="search-dialog-input"
/>
{searchTerm && (
{term && (
<button
type="button"
onClick={() => {
inputRef.current?.focus()
setSearchTerm('')
setTerm('')
}}
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
className="rounded p-1 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
<kbd className="hidden sm:inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
<button
type="button"
onClick={onClose}
className="rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground"
aria-label="Close search"
>
esc
</kbd>
</button>
</div>
{/* Results */}
{hasTerm || items.length > 0 ? (
<NavigationSearchResults
id="search-dialog-results"
items={items}
term={searchTerm}
selectedIndex={selectedIndex}
onSelect={handleNavigate}
onHover={setSelectedIndex}
recordSearch={recordSearch}
<div className="flex min-h-0 flex-1 flex-col">
<QuickSearchPanel
listboxId={LISTBOX_ID}
open={open}
term={term}
setTerm={setTerm}
navTargets={navTargets}
searchTables={searchTables}
onNavigate={handleNavigate}
onController={handleController}
listClassName="min-h-0 flex-1"
/>
) : (
<div id="search-dialog-results" className="px-4 py-8 text-center text-sm text-muted-foreground">
{recordSearch.available ? 'Type to find an app, table, process or record...' : 'Type to find an app, table, process or recent record...'}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-border px-4 py-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex gap-4">
<span>
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd>{' '}navigate
</span>
<span>
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}open
</span>
</div>
{hasTerm && (
<button
type="button"
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 hover:bg-accent hover:text-foreground transition-colors"
>
<ArrowRight className="h-3 w-3" aria-hidden="true" />
Show all matches
</button>
)}
</div>
<div className="hidden items-center gap-4 border-t border-border px-4 py-2 text-xs text-muted-foreground sm:flex" aria-hidden="true">
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd> navigate</span>
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd> open</span>
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">Ctrl/⌘ ↵</kbd> new tab</span>
<span><kbd className="rounded border border-border px-1 py-0.5 font-mono">⇧ ↵</kbd> all results</span>
</div>
</div>
</div>
+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}
+42 -75
View File
@@ -36,12 +36,15 @@ import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import { cn } from '@/lib/utils/cn'
import { fileDownload, findAdornment, hasAdornment } from '@/lib/utils/adornment-utils'
import { formatDateTime } from '@/lib/utils/datetime-utils'
import { caseTransform, numberAdornments, transformInputValue } from '@/lib/utils/form-layout'
import { caseTransform, numberAdornments } from '@/lib/utils/form-layout'
import { selectSlotHelpContent } from '@/lib/utils/help-utils'
import { tooLongPolicy } from '@/lib/utils/field-validation'
import { useHelpHelpActive } from '@/lib/context/q-context'
import { HelpContent } from '@/components/records/HelpContent'
import { TextField } from './field-types/TextField'
import { TextAreaField } from './field-types/TextAreaField'
import { FieldFooter } from './FieldFooter'
import { NumberField } from './field-types/NumberField'
import { BooleanField } from './field-types/BooleanField'
import { DateField } from './field-types/DateField'
@@ -284,6 +287,11 @@ export function DynamicFormField({
const fieldError = errors[field.name] as FieldError | undefined
const isDisabled = disabled || !field.isEditable
const dataQqqId = field.name
// Typing stops at the limit only for a field that refuses over-long values (ERROR); otherwise
// the counter and the server's policy handle it.
const policy = enforceMaxLength ? 'error' : tooLongPolicy(field)
const capLength = field.maxLength && policy === 'error' ? field.maxLength : undefined
const lengthLimit = field.maxLength ? { control, name: field.name, max: field.maxLength, policy } : undefined
const roles = helpRoles ?? (possibleValueContext?.type === 'process' ? PROCESS_SCREEN_ROLES : ALL_SCREEN_ROLES)
const helpContent = selectSlotHelpContent(field.helpContents, roles, helpKey ?? `field:${field.name}`, helpHelpActive)
@@ -327,6 +335,8 @@ export function DynamicFormField({
initialLabel={displayValueOverrides?.[field.name] ?? record?.displayValues?.[field.name]}
inlineOptions={inlineOptions}
chipField={field}
picker={field.picker}
pickerTableName={field.pickerTableName}
context={pvContext}
error={fieldError}
disabled={isDisabled}
@@ -402,7 +412,8 @@ export function DynamicFormField({
registration={registration()}
error={fieldError}
disabled={isDisabled}
maxLength={enforceMaxLength ? field.maxLength : undefined}
maxLength={capLength}
lengthLimit={lengthLimit}
required={field.isRequired}
describedBy={helpDescribedBy}
transform={caseTransform(field)}
@@ -414,53 +425,24 @@ export function DynamicFormField({
)
case 'TEXT': {
const textRegistration = registration()
const transform = caseTransform(field)
return (
<DirtyWrapper isDirty={isDirty}>
<FieldWithHelp field={field} helpId={helpDescribedBy}>
<div className="flex flex-col gap-1">
<div className="flex items-center">
<label
htmlFor={fieldId}
className="text-sm font-medium text-foreground"
data-qqq-id={`field-label-${dataQqqId}`}
>
{field.label}
{field.isRequired && (
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
)}
</label>
{helpContent && <FieldHelpTooltip field={field} helpContent={helpContent} helpId={helpDescribedBy} />}
</div>
<textarea
placeholder={placeholder}
id={fieldId}
{...textRegistration}
onChange={(event) => {
if (transform) transformInputValue(event.currentTarget, transform)
void textRegistration.onChange(event)
}}
disabled={isDisabled}
aria-required={field.isRequired}
aria-invalid={fieldError ? true : undefined}
aria-describedby={
[fieldError ? `${fieldId}-error` : undefined, helpDescribedBy]
.filter(Boolean)
.join(' ') || undefined
}
data-qqq-id={dataQqqId}
rows={4}
className={`w-full rounded-md border px-3 py-2 text-sm text-foreground bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring disabled:cursor-not-allowed disabled:bg-muted transition-colors duration-150 resize-y ${
fieldError ? 'border-destructive focus:ring-destructive' : 'border-input'
}`}
/>
{fieldError && (
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
{fieldError.message}
</p>
)}
</div>
<TextAreaField
placeholder={placeholder}
id={fieldId}
label={field.label}
registration={registration()}
error={fieldError}
disabled={isDisabled}
maxLength={capLength}
lengthLimit={lengthLimit}
required={field.isRequired}
describedBy={helpDescribedBy}
transform={caseTransform(field)}
data-qqq-id={dataQqqId}
labelExtra={helpContent && <FieldHelpTooltip field={field} helpContent={helpContent} helpId={helpDescribedBy} />}
/>
</FieldWithHelp>
</DirtyWrapper>
)
@@ -479,7 +461,7 @@ export function DynamicFormField({
>
{field.label}
{field.isRequired && (
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
<span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>
)}
</label>
{helpContent && <FieldHelpTooltip field={field} helpContent={helpContent} helpId={helpDescribedBy} />}
@@ -498,11 +480,7 @@ export function DynamicFormField({
/>
)}
/>
{fieldError && (
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
{fieldError.message}
</p>
)}
<FieldFooter id={fieldId} error={fieldError?.message} />
</div>
</FieldWithHelp>
</DirtyWrapper>
@@ -627,7 +605,7 @@ export function DynamicFormField({
>
{field.label}
{field.isRequired && (
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
<span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>
)}
</label>
{helpContent && <FieldHelpTooltip field={field} helpContent={helpContent} helpId={helpDescribedBy} />}
@@ -650,11 +628,7 @@ export function DynamicFormField({
fieldError ? 'border-destructive focus:ring-destructive' : 'border-input'
}`}
/>
{fieldError && (
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
{fieldError.message}
</p>
)}
<FieldFooter id={fieldId} error={fieldError?.message} />
</div>
</FieldWithHelp>
</DirtyWrapper>
@@ -670,7 +644,7 @@ export function DynamicFormField({
registration={registration()}
error={fieldError}
disabled={isDisabled}
maxLength={enforceMaxLength ? field.maxLength : undefined}
maxLength={capLength}
required={field.isRequired}
placeholder={record && showReadOnly && !hasAdornment(field, 'REVEAL') ? 'Unchanged — type to replace' : placeholder}
describedBy={helpDescribedBy}
@@ -693,7 +667,8 @@ export function DynamicFormField({
registration={registration()}
error={fieldError}
disabled={isDisabled}
maxLength={enforceMaxLength ? field.maxLength : undefined}
maxLength={capLength}
lengthLimit={lengthLimit}
required={field.isRequired}
describedBy={helpDescribedBy}
transform={caseTransform(field)}
@@ -748,21 +723,13 @@ function ReadOnlyFormField({ field, fieldId, record, helpDescribedBy }: {
: field.type === 'BOOLEAN' && raw != null ? (raw === true || raw === 'true' ? 'Yes' : 'No')
: (display ?? (raw == null ? '' : String(raw)))
return (
<div className="flex flex-col gap-1">
<label htmlFor={fieldId} className="text-sm font-medium text-muted-foreground" data-qqq-id={`field-label-${field.name}`}>
<dl className="flex flex-col gap-1" aria-describedby={helpDescribedBy}>
<dt id={`${fieldId}-label`} className="text-sm font-medium text-muted-foreground" data-qqq-id={`field-label-${field.name}`}>
{field.label}
</label>
<input
id={fieldId}
type="text"
value={text}
readOnly
disabled
aria-readonly="true"
aria-describedby={helpDescribedBy}
data-qqq-id={field.name}
className="w-full cursor-not-allowed rounded-md border border-input bg-muted px-3 py-2 text-sm text-muted-foreground"
/>
</div>
</dt>
<dd id={fieldId} className="min-h-5 whitespace-pre-wrap break-words text-sm text-foreground" data-qqq-id={field.name}>
{text || <span className="text-muted-foreground" aria-label="No value">{'\u2014'}</span>}
</dd>
</dl>
)
}
@@ -76,7 +76,9 @@ describe('Record form regressions (#649)', () => {
const put = vi.spyOn(apiClient, 'patch').mockResolvedValue({ record: { tableName: 'lab', values: { id: 7 } } })
renderForm(stored)
expect(screen.getByLabelText(/^Secret/)).toHaveValue('')
expect(screen.getByLabelText(/^Id/)).toBeDisabled()
// a read-only field reads as a value, not as a disabled input
expect(screen.queryByLabelText(/^Id/)).toBeNull()
expect(screen.getByText('Id')).toBeInTheDocument()
const title = screen.getByLabelText(/^Title/)
await user.clear(title)
// Record forms leave maxLength to the server's too-long policy.
+117 -14
View File
@@ -23,7 +23,7 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { useForm } from 'react-hook-form'
import type { Resolver } from 'react-hook-form'
import type { FieldErrors, Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
@@ -42,7 +42,9 @@ import {
} from '@/lib/utils/zod-from-metadata'
import { applyAdjustedDefinitions, fieldFormAdjusters, hasTableOnLoadAdjuster, tableFieldRules } from '@/lib/utils/form-adjuster-utils'
import { cn } from '@/lib/utils/cn'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { getErrorMessage, recordWriteMessages } from '@/lib/utils/error-utils'
import { badInputMessage, mapServerErrors } from '@/lib/utils/field-validation'
import { anyOffScreen, fieldElement, inPageOrder, revealField } from '@/lib/utils/form-focus'
import { MATERIAL_BUTTON_VARIANTS, sanitizeQqqId } from '@/lib/utils/qqq-id'
import { EDIT_SCREEN_HELP_ROLES, INSERT_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
import { isImplicitSubmitKey } from '@/lib/utils/form-layout'
@@ -52,6 +54,7 @@ import { toast } from '@/lib/hooks/use-toast'
import { HoverTooltip } from '@/components/widgets/HoverTooltip'
import type { WidgetFormHost } from '@/components/widgets/widget-form-host'
import { DynamicForm } from './DynamicForm'
import { FormErrorSummary } from './FormErrorSummary'
import { FormWidgetSection, isEditableFormWidget } from './FormWidgetSection'
import { UnsavedChangesDialog } from './UnsavedChangesDialog'
@@ -332,14 +335,32 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
// Adjusters may change field definitions after the form mounts: validate with the latest schema.
const schemaRef = useRef(schema)
schemaRef.current = schema
// The schema plus what the browser itself could not read (a half-typed date, letters in a number),
// which never reaches the value, so the check looks at the input element.
const formTableForResolver = useRef(formTable)
formTableForResolver.current = formTable
const resolver = useCallback<Resolver<Record<string, unknown>>>(
(values, context, options) => zodResolver(schemaRef.current)(values, context, options),
async (values, context, options) => {
const result = await zodResolver(schemaRef.current)(values, context, options)
const unreadable: FieldErrors<Record<string, unknown>> = {}
for (const [name, config] of Object.entries(options.fields ?? {})) {
const input = config?.ref
if (input instanceof HTMLInputElement && input.validity?.badInput && !input.disabled) {
unreadable[name] = { type: 'badInput', message: badInputMessage(formTableForResolver.current.fields[name], input), ref: input }
}
}
return Object.keys(unreadable).length > 0 ? { values: {}, errors: { ...result.errors, ...unreadable } } : result
},
[]
)
// Quiet by default: a field is checked when the user leaves it (or on save), then live while it
// shows an error, so fixing it clears the message at once.
const form = useForm<Record<string, unknown>>({
resolver,
defaultValues: mergedDefaults,
mode: 'onBlur',
shouldFocusError: false,
})
const {
register,
@@ -350,7 +371,61 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
setValue,
getValues,
watch,
setError,
setFocus,
trigger,
} = form
const formElementRef = useRef<HTMLFormElement>(null)
// After a failed save the summary shows only when some invalid fields are out of sight.
const [summaryOpen, setSummaryOpen] = useState(false)
// Fields to move to once the form is unlocked again (its inputs are disabled while a save runs).
const [focusRequest, setFocusRequest] = useState<string[] | null>(null)
// Server messages that name no field (shown as the form's general error); null when none or not a field-mapped failure.
const [serverUnmatched, setServerUnmatched] = useState<string[] | null>(null)
useEffect(() => {
const subscription = form.watch((_values, { name }) => {
if (name && form.getFieldState(name).error) void trigger(name)
})
return () => subscription.unsubscribe()
}, [form, trigger])
/**
* Takes the user to the first field that needs attention, and opens the summary when others are out of sight.
* @param names - Names of the invalid fields.
*/
const focusInvalid = useCallback((names: string[]) => {
const root = formElementRef.current
if (!root) return
const elements = inPageOrder(root, names).map((name) => fieldElement(root, name)).filter((element): element is HTMLElement => element !== null)
// A control the form does not render itself (the schedule editor) takes focus through its own ref.
if (elements.length === 0) {
if (names.length > 0) setFocus(names[0])
return
}
setSummaryOpen(anyOffScreen(elements[0], elements.slice(1)))
revealField(elements[0])
}, [setFocus])
/**
* Puts the backend's refusal beside the fields it names, keeping what the user typed.
* @param error - The failed write.
* @returns The text still to report as a general error, or `null` when every message found its field.
*/
function placeServerErrors(error: Error): string | null {
const messages = recordWriteMessages(error)
const whole = getErrorMessage(error)
if (!messages || isWarningMessage(whole)) {
setServerUnmatched(null)
return whole
}
const editable = Object.values(formTableRef.current.fields).filter((field) => field.isEditable && !field.isHidden)
const { fieldErrors, unmatched } = mapServerErrors(messages, editable)
for (const [name, message] of Object.entries(fieldErrors)) setError(name, { type: 'server', message })
setServerUnmatched(unmatched)
setFocusRequest(Object.keys(fieldErrors))
return unmatched.length > 0 ? unmatched.join('; ') : null
}
// Latest dirty-field map for the update mutation (values that cannot round-trip are sent only when changed).
const dirtyFieldsRef = useRef<Record<string, unknown>>({})
@@ -601,7 +676,8 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
}
},
onError: (err: Error) => {
const message = getErrorMessage(err)
const message = placeServerErrors(err)
if (message === null) return
// a "warning..." refusal is reported as a warning on the form, not as a failure
if (isWarningMessage(message)) toast.warning(message, { duration: WARNING_TOAST_MILLIS })
else toast.error(`Failed to create ${tableMetaData.label}: ${message}`)
@@ -626,7 +702,8 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
finishUpdate(pk, firstRecordWarning(savedRecord), () => onSuccess?.(savedRecord))
},
onError: (err: Error) => {
const message = getErrorMessage(err)
const message = placeServerErrors(err)
if (message === null) return
// Material: a save error starting with "warning" is a success with that warning, back on the view
if (isWarningMessage(message) && record && !onSuccess) {
finishUpdate(record.values[tableMetaData.primaryKeyField] as string | number, message)
@@ -659,12 +736,24 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
const activeMutation = isEdit ? updateMutation : insertMutation
const failure = activeMutation.error as Error | null
const failureMessage = failure ? getErrorMessage(failure, 'An error occurred while saving.') : null
const failureMessage = serverUnmatched !== null
? (serverUnmatched.length > 0 ? serverUnmatched.join('; ') : null)
: failure ? getErrorMessage(failure, 'An error occurred while saving.') : null
// a "warning..." refusal that did not return to the view is shown as a warning, not an error
const mutationWarning = failureMessage && isWarningMessage(failureMessage) ? failureMessage : null
const mutationError = mutationWarning ? null : failure
const mutationError = mutationWarning ? null : failureMessage
const isSaving = isSubmitting || activeMutation.isPending
useEffect(() => {
if (!focusRequest || isSaving) return
setFocusRequest(null)
focusInvalid(focusRequest)
}, [focusRequest, isSaving, focusInvalid])
const formDisabledMessage = prepared.disabledMessage
const summaryItems = (() => {
const root = formElementRef.current
const names = Object.keys(errors)
return (root ? inPageOrder(root, names) : names).map((name) => ({ name, label: formTable.fields[name]?.label ?? name }))
})()
/**
* React Hook Form submit handler — runs the widgets' checks, then delegates to the
@@ -676,6 +765,7 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
(values: Record<string, unknown>) => {
const problems = [...validatorsRef.current.values()].flatMap((validate) => validate())
setWidgetErrors(problems)
setServerUnmatched(null)
if (problems.length > 0) return
if (onSubmitValues) {
onSubmitValues(values)
@@ -687,9 +777,9 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
)
/**
* Preserve the browser's distinction between malformed numeric input and a blank.
* Validates on save: a field with a problem gets its message, and the first one is scrolled into view and focused.
* @param event - The form submission event.
* @returns Metadata validation when native numeric input is readable.
* @returns The validation promise.
*/
function onFormSubmit(event: React.FormEvent<HTMLFormElement>) {
// A form in a dialog over another form (a child record) must not submit its parent too.
@@ -698,14 +788,15 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
event.preventDefault()
return
}
const invalidNumber = Array.from(event.currentTarget.querySelectorAll<HTMLInputElement>('input[type="number"]'))
// Full copy drafts keep their own inputs outside this form's resolver: the browser reports those.
const unreadableDraft = Array.from(event.currentTarget.querySelectorAll<HTMLInputElement>('fieldset[data-qqq-id^="copy-node-"] input[type="number"]'))
.find((input) => !input.matches(':disabled') && input.validity.badInput)
if (invalidNumber) {
if (unreadableDraft) {
event.preventDefault()
invalidNumber.reportValidity()
unreadableDraft.reportValidity()
return
}
return handleSubmit(onSubmit)(event)
return handleSubmit(onSubmit, (invalid) => setFocusRequest(Object.keys(invalid)))(event)
}
/**
@@ -844,6 +935,7 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
const formContent = (
<form
ref={formElementRef}
onSubmit={onFormSubmit}
onKeyDown={(event) => {
// Enter in a single-line input does not save the record (Material): only the Save button does
@@ -883,7 +975,7 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<strong>Error: </strong>
{defaultsError || copyAssociations?.error || (mutationError ? getErrorMessage(mutationError, 'An error occurred while saving.') : 'An error occurred while saving.')}
{defaultsError || copyAssociations?.error || mutationError || 'An error occurred while saving.'}
</div>
)}
{mutationWarning && (
@@ -908,6 +1000,17 @@ function EntityFormBody(props: EntityFormProps & { prepared: PreparedForm }) {
{isCopy && Object.values(tableMetaData.fields).some(field => field.type === 'PASSWORD' && field.isEditable && !field.isHidden && !field.adornments?.some(item => item.type === 'REVEAL')) &&
<p className="text-sm text-muted-foreground">Unreadable passwords are not copied. Enter new values before saving.</p>}
{summaryOpen && (
<FormErrorSummary
items={summaryItems}
onSelect={(name) => {
const root = formElementRef.current
const element = root && fieldElement(root, name)
if (element) revealField(element)
}}
/>
)}
{/* Fields */}
<DynamicForm
register={register}
@@ -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.
*/
import { render, screen, waitFor } 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 { QFieldMetaData, QRecord } from '@/types'
import apiClient from '@/lib/api/client'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { EntityForm } from './EntityForm'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), back: vi.fn() }) }))
const base = qInstance.tables.company.fields.name
function field(name: string, label: string, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
return { ...base, name, label, isEditable: true, isRequired: false, ...extra }
}
function renderForm(fields: QFieldMetaData[], props: { record?: QRecord } = {}) {
const table = structuredClone(qInstance.tables.company)
table.fields = Object.fromEntries(fields.map((item) => [item.name, item]))
table.sections = [{ name: 'main', label: 'Main', isHidden: false, fieldNames: fields.map((item) => item.name) }]
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} {...props} /></QueryClientProvider>)
}
describe('Quiet record form validation', () => {
beforeEach(() => vi.restoreAllMocks())
it('shows a required error on blur or save, not while typing, then clears it as soon as the field is fixed', async () => {
const user = userEvent.setup()
renderForm([field('code', 'Code', { isRequired: true })])
const input = screen.getByLabelText(/Code/)
await user.type(input, 'ab')
await user.clear(input)
expect(screen.queryByText('Code is required')).toBeNull()
await user.tab()
expect(await screen.findByText('Code is required')).toBeVisible()
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(input).toHaveAccessibleDescription('Code is required')
await user.type(input, 'x')
await waitFor(() => expect(screen.queryByText('Code is required')).toBeNull())
expect(input).not.toHaveAttribute('aria-invalid')
})
it('moves focus to the first invalid field on save and keeps every error live afterward', async () => {
const user = userEvent.setup()
renderForm([field('first', 'First', { isRequired: true }), field('second', 'Second', { isRequired: true })])
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('First is required')).toBeVisible()
expect(screen.getByText('Second is required')).toBeVisible()
await waitFor(() => expect(screen.getByLabelText(/First/)).toHaveFocus())
await user.type(screen.getByLabelText(/Second/), 'y')
await waitFor(() => expect(screen.queryByText('Second is required')).toBeNull())
expect(screen.getByText('First is required')).toBeVisible()
})
it('caps typing and counts only near the limit when too-long values are refused (ERROR)', async () => {
const user = userEvent.setup()
renderForm([field('code', 'Code', { maxLength: 10, behaviors: ['ERROR'] })])
const input = screen.getByLabelText(/Code/)
expect(input).toHaveAttribute('maxlength', '10')
await user.type(input, 'abcdefg')
expect(screen.queryByText(/ \/ 10/)).toBeNull()
await user.type(input, 'h')
expect(screen.getByText('8 / 10')).toBeVisible()
await user.type(input, 'ijklmn')
expect(input).toHaveValue('abcdefghij')
expect(screen.getByText('10 / 10')).toBeVisible()
})
it('lets a trimmed value run past the limit, noting that extra characters will be trimmed, without an error', async () => {
const user = userEvent.setup()
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'company', values: { id: 1 } } })
renderForm([field('code', 'Code', { maxLength: 5, behaviors: ['TRUNCATE'] })])
const input = screen.getByLabelText(/Code/)
expect(input).not.toHaveAttribute('maxlength')
await user.type(input, 'abcdefg')
expect(screen.getByText('7 / 5')).toBeVisible()
expect(screen.getByText('Extra characters will be trimmed when you save.')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalled())
expect(input).not.toHaveAttribute('aria-invalid')
})
it('words an over-long value as the backend does when an existing value is over a refused limit', async () => {
const user = userEvent.setup()
const stored = { tableName: 'company', values: { id: 1, code: 'a-value-that-is-too-long' } }
renderForm([field('id', 'Id', { type: 'INTEGER', isEditable: false }), field('code', 'Code', { maxLength: 10, behaviors: ['ERROR'] })], { record: stored })
await user.type(screen.getByLabelText(/Code/), '!')
await user.tab()
expect(await screen.findByText('The value for Code is too long (max allowed length=10)')).toBeVisible()
})
it('keeps non-numeric characters out of a whole-number field and words a broken range as the backend does', async () => {
const user = userEvent.setup()
renderForm([field('qty', 'Quantity', { type: 'INTEGER', minValue: 1, maxValue: 5 })])
const input = screen.getByLabelText(/Quantity/)
await user.type(input, 'a1e.2')
expect(input).toHaveValue(12)
await user.tab()
expect(await screen.findByText('The value for Quantity is too large (maximum allowed value is 5)')).toBeVisible()
})
it('shows a read-only field as its value, not a disabled input', () => {
renderForm([field('id', 'Id', { type: 'INTEGER', isEditable: false }), field('code', 'Code')], { record: { tableName: 'company', values: { id: 7, code: 'x' } } })
expect(screen.getByText('7')).toBeVisible()
expect(screen.queryByLabelText(/^Id/)).toBeNull()
})
it('places the server\'s message beside the field it names, keeping the typed value and showing no general alert', async () => {
const user = userEvent.setup()
vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'company', values: {}, errors: ['The value for Code is too long (max allowed length=5)'] } })
renderForm([field('code', 'Code'), field('other', 'Other')])
await user.type(screen.getByLabelText(/Code/), 'abcdefgh')
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('The value for Code is too long (max allowed length=5)')).toBeVisible()
expect(screen.getByLabelText(/Code/)).toHaveValue('abcdefgh')
await waitFor(() => expect(screen.getByLabelText(/Code/)).toHaveFocus())
expect(screen.queryByText(/Error:/)).toBeNull()
})
it('keeps a message that names no field as the form\'s general error', async () => {
const user = userEvent.setup()
vi.spyOn(apiClient, 'post').mockResolvedValue({ record: { tableName: 'company', values: {}, errors: ['Something else went wrong'] } })
renderForm([field('code', 'Code')])
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Something else went wrong')).toBeVisible()
})
})
+127
View File
@@ -0,0 +1,127 @@
/*
* 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 FieldFooter — the quiet line under a form field: its error, an optional hint that shows
* while the field has focus, and a length counter that appears only near a limit. The line opens
* and closes smoothly, so the form does not jump when a message arrives.
*/
'use client'
import React from 'react'
import { useWatch } from 'react-hook-form'
import type { Control } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { lengthMeter, truncateNote } from '@/lib/utils/field-validation'
import type { LengthMeter, TooLongPolicy } from '@/lib/utils/field-validation'
/** What a text field needs to count its characters against `maxLength`. */
export interface LengthLimit {
control: Control<Record<string, unknown>>
name: string
max: number
policy: TooLongPolicy
}
/** Props for {@link FieldFooter}. */
interface FieldFooterProps {
/** The field's id; the error gets `${id}-error` and the hint `${id}-hint`. */
id: string
/** The field's current error message. */
error?: string
/** Guidance shown while the field has focus and has no error (the parent needs the `group` class). */
hint?: string
/** A length counter, shown only when {@link lengthMeter} found one useful. */
meter?: LengthMeter
/** A quiet note, such as the server trimming extra characters. */
note?: string
/** Counts a text field's characters; the counter and the trim note then come from the live value. */
limit?: LengthLimit
}
/**
* Renders the field's footer: the error first, then any note or hint, with the counter at the end.
* Errors are announced politely; the counter is not announced at all.
*
* @param props - See {@link FieldFooterProps}.
* @returns The footer, collapsed to no height while it has nothing to say.
*/
export function FieldFooter({ limit, ...props }: FieldFooterProps) {
return limit ? <CountingFooter limit={limit} {...props} /> : <FooterLine {...props} />
}
/**
* Only a field that refuses over-long values marks its counter as an error; for others it is just information.
*
* @param meter - The counter, if one is showing.
* @param policy - The field's too-long policy.
* @returns The counter with its error styling decided.
*/
function refusedOnly(meter: LengthMeter | undefined, policy: TooLongPolicy): LengthMeter | undefined {
return meter && { ...meter, over: meter.over && policy === 'error' }
}
/**
* The footer of a field with a length limit: it watches the value, so only these fields re-render per keystroke.
*
* @param props - The footer props with the field's limit.
* @returns The footer with its live counter.
*/
function CountingFooter({ limit, ...props }: FieldFooterProps & { limit: LengthLimit }) {
const value: unknown = useWatch({ control: limit.control, name: limit.name })
const length = typeof value === 'string' ? value.length : 0
return <FooterLine {...props} meter={refusedOnly(lengthMeter(length, limit.max), limit.policy)} note={truncateNote(limit.policy, length, limit.max)} />
}
/**
* The footer's markup: the message line and the counter.
*
* @param props - The footer props without a limit (the counter is already worked out).
* @returns The footer line.
*/
function FooterLine({ id, error, hint, meter, note }: Omit<FieldFooterProps, 'limit'>) {
const open = Boolean(error || note || meter)
return (
<div
className={cn(
'-mt-1 grid transition-[grid-template-rows] duration-150 motion-reduce:transition-none',
open ? 'grid-rows-[1fr]' : hint ? 'grid-rows-[0fr] group-focus-within:grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
>
<div className="min-h-0 overflow-hidden">
<div className="flex items-start justify-between gap-3 pt-1">
<div className="min-w-0" aria-live="polite">
{error ? (
<p id={`${id}-error`} className="text-sm text-destructive">{error}</p>
) : (
<>
{note && <p className="text-xs text-muted-foreground">{note}</p>}
{hint && !note && <p id={`${id}-hint`} className="text-xs text-muted-foreground">{hint}</p>}
</>
)}
</div>
{meter && (
<span aria-hidden="true" className={cn('ml-auto shrink-0 text-xs tabular-nums', meter.over ? 'font-medium text-destructive' : 'text-muted-foreground')}>
{meter.text}
</span>
)}
</div>
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
/*
* 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 FormErrorSummary — a compact "2 fields need attention" strip with a link to each field,
* shown after a failed save only when some of those fields are out of sight.
*/
'use client'
import React from 'react'
/** A field that needs attention. */
export interface SummaryItem {
name: string
label: string
}
/** Props for {@link FormErrorSummary}. */
interface FormErrorSummaryProps {
items: readonly SummaryItem[]
/** Moves the user to a field. */
onSelect: (name: string) => void
}
/**
* Renders the summary strip; it stays in view at the top of the form while the user scrolls.
*
* @param props - See {@link FormErrorSummaryProps}.
* @returns The strip, or nothing when every field is fixed.
*/
export function FormErrorSummary({ items, onSelect }: FormErrorSummaryProps) {
if (items.length === 0) return null
return (
<div
role="status"
className="sticky top-0 z-10 flex flex-wrap items-baseline gap-x-2 gap-y-1 rounded-md border border-destructive/40 bg-background px-3 py-2 text-sm shadow-sm"
data-qqq-id="form-error-summary"
>
<span className="font-medium text-destructive">
{items.length === 1 ? '1 field needs attention' : `${items.length} fields need attention`}
</span>
<ul className="flex flex-wrap gap-x-3 gap-y-1">
{items.map((item) => (
<li key={item.name}>
<button type="button" onClick={() => onSelect(item.name)} className="text-foreground underline underline-offset-2 hover:text-primary focus:outline-none focus:ring-2 focus:ring-ring">
{item.label}
</button>
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,67 @@
/*
* 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.
*/
/**
* Form validation stories — the quiet states of a record form: clean, near a length limit
* (the counter appears only there), and with errors after a failed save.
*/
import React, { useEffect } from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import type { QFieldMetaData } from '@/types'
import { zodSchemaFromFields } from '@/lib/utils/zod-from-metadata'
import { DynamicForm } from './DynamicForm'
function makeField(overrides: Partial<QFieldMetaData> & Pick<QFieldMetaData, 'name' | 'label' | 'type'>): QFieldMetaData {
return { isEditable: true, isRequired: false, isHeavy: false, isHidden: false, adornments: [], ...overrides }
}
const FIELDS: QFieldMetaData[] = [
makeField({ name: 'name', label: 'Name', type: 'STRING', isRequired: true, maxLength: 20, behaviors: ['ERROR'] }),
makeField({ name: 'nickname', label: 'Nickname', type: 'STRING', maxLength: 12, behaviors: ['TRUNCATE'] }),
makeField({ name: 'quantity', label: 'Quantity', type: 'INTEGER', minValue: 1, maxValue: 100 }),
makeField({ name: 'price', label: 'Price', type: 'DECIMAL', displayFormat: '$%.2f' }),
makeField({ name: 'due', label: 'Due Date', type: 'DATE', isRequired: true }),
]
function Form({ values, validate }: { values: Record<string, unknown>; validate?: boolean }) {
const { register, control, formState: { errors }, trigger } = useForm<Record<string, unknown>>({
resolver: zodResolver(zodSchemaFromFields(FIELDS)),
defaultValues: values,
mode: 'onBlur',
})
useEffect(() => { if (validate) void trigger() }, [validate, trigger])
return (
<div className="max-w-xl p-4">
<DynamicForm fields={FIELDS} register={register} control={control} errors={errors} enforceMaxLength={false} />
</div>
)
}
const meta = { title: 'Forms/Validation', component: Form, parameters: { layout: 'fullscreen' } } satisfies Meta<typeof Form>
export default meta
type Story = StoryObj<typeof meta>
/** Nothing to say yet: no counters, no errors, no hints. */
export const Clean: Story = { args: { values: { name: '', nickname: '', quantity: '', price: '', due: '' } } }
/** Within the last 20% of a limit the counter appears; a trimming field notes the extra characters. */
export const NearLimit: Story = { args: { values: { name: 'Eighteen characters', nickname: 'A very long nickname', quantity: '5', price: '', due: '2026-10-12' } } }
/** After a failed save, each field says what is wrong in plain words. */
export const WithErrors: Story = { args: { values: { name: '', nickname: '', quantity: '500', price: '', due: '' }, validate: true } }
@@ -30,12 +30,24 @@ vi.mock('@/lib/api/possible-values', () => ({
fetchTablePossibleValues: vi.fn(),
fetchProcessPossibleValues: vi.fn(),
fetchPossibleValues: vi.fn(),
fetchPossibleValuesPage: vi.fn(),
}))
// the lazily loaded picker dialog: confirms option 7 when its button is pressed
vi.mock('./RecordPickerDialog', () => ({
default: ({ onConfirm, selectedIds, label }: { onConfirm: (options: QPossibleValue[]) => void; selectedIds: Array<string | number>; label: string }) => (
<div role="dialog" aria-label={`Choose ${label}`}>
<span data-testid="picker-selected">{selectedIds.join(',')}</span>
<button type="button" onClick={() => onConfirm([{ id: 7, label: 'Lighthouse', values: { thumbnailUrl: '/t/7.jpg' } }])}>Pick Lighthouse</button>
</div>
),
}))
import {
fetchTablePossibleValues,
fetchProcessPossibleValues,
fetchPossibleValues,
fetchPossibleValuesPage,
} from '@/lib/api/possible-values'
const mockFetchTable = vi.mocked(fetchTablePossibleValues)
@@ -663,3 +675,79 @@ describe('PossibleValueSelect inline possible values and chips (QRun-IO/qqq#721)
expect(screen.getByRole('option', { name: 'Low' }).querySelector('[data-chip-color]')).toBeNull()
})
})
describe('PossibleValueSelect with a record picker', () => {
const PICKER = { thumbnailField: 'thumbnailUrl', previewField: 'galleryUrl', subtitleFields: ['mimeType'] }
const HARBOR: QPossibleValue = { id: 3, label: 'Harbor', values: { thumbnailUrl: '/t/3.jpg', galleryUrl: '/g/3.jpg', mimeType: 'image/jpeg' } }
const mockPage = vi.mocked(fetchPossibleValuesPage)
/**
* A form with an image field whose source declares a picker.
* @param props - The held value, and its display label.
* @returns The field and a readout of the form value.
*/
function PickerWrapper({ defaultValue, initialLabel }: { defaultValue?: unknown; initialLabel?: string }) {
const { control, watch } = useForm<Record<string, unknown>>({ defaultValues: { featuredMediaId: defaultValue ?? null } })
return (
<>
<PossibleValueSelect
id="image"
label="Featured Image"
name="featuredMediaId"
control={control}
fieldName="featuredMediaId"
context={{ type: 'table', tableName: 'blog_post' }}
initialLabel={initialLabel}
picker={PICKER}
pickerTableName="media"
/>
<output data-testid="form-value">{String(watch('featuredMediaId'))}</output>
</>
)
}
beforeEach(() => {
mockPage.mockResolvedValue({ options: [HARBOR], hasMore: false })
})
it('shows thumbnails and subtitles in the typeahead, from a limited server-side page', async () => {
const user = userEvent.setup()
render(<PickerWrapper />)
await user.click(screen.getByRole('combobox'))
const option = await screen.findByRole('option', { name: /Harbor/ })
expect(option.querySelector('img')).toHaveAttribute('src', '/t/3.jpg')
expect(option).toHaveTextContent('image/jpeg')
expect(mockPage).toHaveBeenCalledWith({ type: 'table', tableName: 'blog_post' }, 'featuredMediaId', undefined, expect.objectContaining({ picker: { limit: 20 } }))
expect(mockFetchTable).not.toHaveBeenCalled()
})
it('shows the selected thumbnail with Change, Preview and View, even when the record gave its label', async () => {
render(<PickerWrapper defaultValue={3} initialLabel="Harbor" />)
await waitFor(() => expect(screen.getByRole('combobox').querySelector('img')).toHaveAttribute('src', '/t/3.jpg'))
expect(mockPage).toHaveBeenCalledWith(expect.anything(), 'featuredMediaId', undefined, expect.objectContaining({ ids: '3', picker: {} }))
expect(screen.getByRole('button', { name: 'Change…' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Preview' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'View' })).toHaveAttribute('href', '/app/media/3')
})
it('opens the picker dialog and sets the field to the chosen record', async () => {
const user = userEvent.setup()
render(<PickerWrapper defaultValue={3} initialLabel="Harbor" />)
await user.click(screen.getByRole('button', { name: 'Change…' }))
expect(await screen.findByRole('dialog', { name: 'Choose Featured Image' })).toBeInTheDocument()
expect(screen.getByTestId('picker-selected')).toHaveTextContent('3')
await user.click(screen.getByRole('button', { name: 'Pick Lighthouse' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByTestId('form-value')).toHaveTextContent('7')
expect(screen.getByRole('combobox')).toHaveTextContent('Lighthouse')
expect(screen.getByRole('combobox').querySelector('img')).toHaveAttribute('src', '/t/7.jpg')
})
it('offers Browse (and no Preview or View) when nothing is selected', async () => {
render(<PickerWrapper />)
expect(screen.getByRole('button', { name: 'Browse…' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Preview' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'View' })).not.toBeInTheDocument()
})
})
+104 -6
View File
@@ -20,22 +20,32 @@
'use client'
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import React, { lazy, Suspense, useState, useRef, useEffect, useCallback, useMemo } from 'react'
import type { Control, FieldError } from 'react-hook-form'
import { Controller, useWatch } from 'react-hook-form'
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
import type { QFieldMetaData, QPossibleValue } from '@/types'
import type { QFieldMetaData, QPossibleValue, QPossibleValuePicker } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import {
fetchTablePossibleValues,
fetchProcessPossibleValues,
fetchPossibleValues,
fetchPossibleValuesPage,
} from '@/lib/api/possible-values'
import { chipStyle, CHIP_COLOR_CLASSES, findAdornment } from '@/lib/utils/adornment-utils'
import { cn } from '@/lib/utils/cn'
import { pickerPreviewUrl, pickerRecordPath, pickerSubtitle, pickerThumbnailUrl } from '@/lib/utils/record-picker'
import { buttonClass } from '@/components/ui/controls'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
// the record picker dialog and the full-size preview load only when opened
const RecordPickerDialog = lazy(() => import('./RecordPickerDialog'))
import { FilePreviewLightbox } from '@/components/preview/FilePreviewLightboxLazy'
/** How many options the inline typeahead of a picker field asks for (the dialog pages the rest). */
const PICKER_INLINE_LIMIT = 20
/**
* Props for the {@link PossibleValueSelect} component.
*/
@@ -75,6 +85,13 @@ interface PossibleValueSelectProps {
chipField?: QFieldMetaData
/** Keep the label for assistive technology only (the host shows its own caption). */
hideLabel?: boolean
/**
* The record picker of the field's source (`field.picker`): options show thumbnails, the
* selection shows its thumbnail with Browse, Preview and Open, and Browse opens the picker dialog.
*/
picker?: QPossibleValuePicker
/** The table the picker reads (`field.pickerTableName`). */
pickerTableName?: string
}
/**
@@ -136,6 +153,8 @@ export function PossibleValueSelect({
required = false,
placeholder,
hideLabel = false,
picker,
pickerTableName,
'data-qqq-id': dataQqqId,
}: PossibleValueSelectProps) {
const [isOpen, setIsOpen] = useState(false)
@@ -150,6 +169,8 @@ export function PossibleValueSelect({
const inputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestSequence = useRef(0)
const [browsing, setBrowsing] = useState(false)
const [previewing, setPreviewing] = useState(false)
const formValues = useWatch({ control }) as Record<string, unknown> | undefined
// What every request carries for the field's filter: the form's current values.
const scope = useMemo(() => ({ formValues: formValues ?? {} }), [formValues])
@@ -175,7 +196,9 @@ export function PossibleValueSelect({
try {
const request = { searchTerm: term || undefined, ...scope }
let results: QPossibleValue[]
if (context.type === 'table') {
if (picker) {
results = (await fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, { ...request, picker: { limit: PICKER_INLINE_LIMIT } })).options
} else if (context.type === 'table') {
results = await fetchTablePossibleValues(context.tableName, fieldName, request)
} else if (context.type === 'process') {
results = await fetchProcessPossibleValues(context.processName, fieldName, request)
@@ -192,7 +215,7 @@ export function PossibleValueSelect({
if (sequence === requestSequence.current) setIsLoading(false)
}
},
[context, fieldName, possibleValueSourceName, scope, inlineOptions]
[context, fieldName, possibleValueSourceName, scope, inlineOptions, picker]
)
/**
@@ -236,10 +259,12 @@ export function PossibleValueSelect({
if (inline) setSelectedOption(inline)
return
}
if (initialLabel) return
if (initialLabel && !picker) return
let cancelled = false
const request = { ids: held, ...scope }
const lookup = context.type === 'table'
const lookup = picker
? fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, { ...request, picker: {} }).then((page) => page.options)
: context.type === 'table'
? fetchTablePossibleValues(context.tableName, fieldName, request)
: context.type === 'process'
? fetchProcessPossibleValues(context.processName, fieldName, request)
@@ -278,7 +303,24 @@ export function PossibleValueSelect({
// A CHIP adornment styles each option by its value (color and icon), as Material's renderOption does.
const hasChips = Boolean(chipField && findAdornment(chipField, 'CHIP'))
const pickerThumbnail = (option: QPossibleValue | null, size: string) => {
const url = pickerThumbnailUrl(option, picker)
// eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer
return url ? <img src={url} alt="" loading="lazy" decoding="async" className={cn(size, 'shrink-0 rounded bg-muted object-cover')} data-qqq-id="picker-thumbnail" /> : null
}
const optionContent = (option: QPossibleValue) => {
if (picker) {
const subtitle = pickerSubtitle(option, picker)
return (
<span className="flex min-w-0 items-center gap-2">
{pickerThumbnail(option, 'h-8 w-8')}
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{subtitle && <span className="block truncate text-xs text-muted-foreground">{subtitle}</span>}
</span>
</span>
)
}
if (!hasChips || !chipField) return <span>{option.label}</span>
const { color, icon } = chipStyle(chipField, option.id)
return (
@@ -367,6 +409,7 @@ export function PossibleValueSelect({
: heldValue ? (initialLabel ?? heldValue) : ''
return (
<>
<div ref={containerRef} className="relative">
<div
ref={triggerRef}
@@ -398,6 +441,7 @@ export function PossibleValueSelect({
error ? 'border-destructive' : 'border-input'
)}
>
{picker && heldValue && selectedOption && String(selectedOption.id) === heldValue && pickerThumbnail(selectedOption, 'mr-2 h-8 w-8')}
<span
className={cn(
'flex-1 truncate text-sm',
@@ -507,6 +551,60 @@ export function PossibleValueSelect({
</div>
)}
</div>
{picker && !disabled && (
<div className="mt-1 flex flex-wrap gap-1" data-qqq-id={dataQqqId ? `picker-actions-${dataQqqId}` : 'picker-actions'}>
<button type="button" onClick={() => { setIsOpen(false); setBrowsing(true) }} className={buttonClass('secondary', 'sm')} data-qqq-id="picker-browse">
{heldValue ? 'Change…' : 'Browse…'}
</button>
{heldValue && pickerPreviewUrl(selectedOption, picker) && (
<button type="button" onClick={() => setPreviewing(true)} className={buttonClass('ghost', 'sm')} data-qqq-id="picker-preview">Preview</button>
)}
{heldValue && pickerTableName && (
<a href={pickerRecordPath(pickerTableName, heldValue)} target="_blank" rel="noopener noreferrer" className={buttonClass('ghost', 'sm')} data-qqq-id="picker-view">View</a>
)}
</div>
)}
{picker && browsing && (
<Suspense fallback={null}>
<RecordPickerDialog
label={label}
fieldName={fieldName}
context={context}
possibleValueSourceName={possibleValueSourceName}
picker={picker}
pickerTableName={pickerTableName}
formValues={scope.formValues}
selectedIds={heldValue ? [heldValue] : []}
onConfirm={(chosen) => {
setBrowsing(false)
if (chosen[0]) {
field.onChange(chosen[0].id)
setSelectedOption(chosen[0])
}
}}
onClose={() => setBrowsing(false)}
/>
</Suspense>
)}
{picker && previewing && selectedOption && (
<Suspense fallback={null}>
<FilePreviewLightbox
items={[{
url: pickerPreviewUrl(selectedOption, picker) ?? '',
thumbnailUrl: pickerThumbnailUrl(selectedOption, picker),
fileName: selectedOption.label,
kind: 'image',
alt: selectedOption.label,
previewable: true,
thumbnailSize: 40,
}]}
index={0}
onIndexChange={() => { /* a single image */ }}
onClose={() => setPreviewing(false)}
/>
</Suspense>
)}
</>
)
}}
/>
@@ -0,0 +1,244 @@
/*
* 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 RecordPickerDialog
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { axe } from 'jest-axe'
import type { QPossibleValue, QPossibleValuePicker, QTableMetaData } from '@/types'
import { RecordPickerDialog } from './RecordPickerDialog'
vi.mock('@/lib/api/possible-values', () => ({
fetchPossibleValuesPage: vi.fn(),
fetchTablePossibleValues: vi.fn(),
}))
vi.mock('@/lib/hooks/use-metadata', () => ({
useTableMetaData: vi.fn(),
useProcessMetaData: vi.fn(),
}))
vi.mock('@/components/process/ProcessRun', () => ({
ProcessRun: ({ onComplete }: { onComplete?: (values: Record<string, unknown>) => void }) => (
<button type="button" onClick={() => onComplete?.({ mediaId: 99 })}>Finish upload</button>
),
}))
import { fetchPossibleValuesPage, fetchTablePossibleValues } from '@/lib/api/possible-values'
import { useProcessMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
const mockPage = vi.mocked(fetchPossibleValuesPage)
const mockChoices = vi.mocked(fetchTablePossibleValues)
const field = (name: string, label: string, type: string, extra: object = {}) =>
({ name, label, type, isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments: [], ...extra })
const MEDIA = {
name: 'media',
label: 'Media',
primaryKeyField: 'id',
fields: {
id: field('id', 'ID', 'LONG'),
mediaTypeId: field('mediaTypeId', 'Media Type', 'LONG', { possibleValueSourceName: 'mediaType' }),
width: field('width', 'Width', 'INTEGER'),
altText: field('altText', 'Alt Text', 'STRING'),
},
} as unknown as QTableMetaData
const PICKER: QPossibleValuePicker = {
thumbnailField: 'thumbnailUrl',
previewField: 'galleryUrl',
subtitleFields: ['mediaTypeId'],
detailFields: ['width', 'altText'],
filterFields: ['mediaTypeId', 'width'],
sorts: [{ label: 'Newest', fieldName: 'id', isAscending: false }, { label: 'Name', fieldName: 'name', isAscending: true }],
pageSize: 2,
uploadProcessName: 'uploadImage',
uploadResultIdField: 'mediaId',
}
const media = (id: number, label: string): QPossibleValue => ({
id,
label,
values: { thumbnailUrl: `/t/${id}.jpg`, galleryUrl: `/g/${id}.jpg`, mediaTypeId: 1, width: 1200, altText: `${label} alt` },
displayValues: { mediaTypeId: 'Photo', width: '1,200' },
})
/** The result options (not the native options of the sort and filter selects). */
const resultOptions = () => within(screen.getByRole('listbox', { name: /results/ })).getAllByRole('option')
const onConfirm = vi.fn()
const onClose = vi.fn()
/**
* Render the dialog for the blog post's image field.
* @param props - Overrides.
* @returns The render result.
*/
function renderDialog(props: Partial<React.ComponentProps<typeof RecordPickerDialog>> = {}) {
return render(
<RecordPickerDialog
label="Featured Image"
fieldName="featuredMediaId"
context={{ type: 'table', tableName: 'blog_post' }}
picker={PICKER}
pickerTableName="media"
onConfirm={onConfirm}
onClose={onClose}
{...props}
/>
)
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
vi.mocked(useTableMetaData).mockReturnValue({ data: MEDIA } as ReturnType<typeof useTableMetaData>)
vi.mocked(useProcessMetaData).mockReturnValue({ data: { name: 'uploadImage', label: 'Upload image', hasPermission: true } } as ReturnType<typeof useProcessMetaData>)
mockChoices.mockResolvedValue([{ id: 1, label: 'Photo' }, { id: 2, label: 'Logo' }])
mockPage.mockImplementation(async (_context, _field, _source, request) => {
if (request.ids) return { options: request.ids.split(',').map((id) => media(Number(id), `Media ${id}`)), hasMore: false }
if (request.picker.afterId === 8) return { options: [media(7, 'Seven')], hasMore: false }
return { options: [media(9, 'Nine'), media(8, 'Eight')], hasMore: true }
})
})
afterEach(() => {
vi.useRealTimers()
})
describe('RecordPickerDialog', () => {
it('shows a thumbnail grid of the first page and a preview of the focused option', async () => {
const { baseElement } = renderDialog()
expect(await screen.findByRole('option', { name: /^Nine/ })).toBeInTheDocument()
const options = resultOptions()
expect(options).toHaveLength(2)
expect(options[0].querySelector('img')).toHaveAttribute('src', '/t/9.jpg')
expect(within(options[0]).getByText('Photo')).toBeInTheDocument()
const preview = screen.getByLabelText('Preview', { selector: 'aside' })
expect(within(preview).getByRole('img')).toHaveAttribute('src', '/g/9.jpg')
expect(within(preview).getByText('Nine alt')).toBeInTheDocument()
expect(within(preview).getByRole('link', { name: /open/i })).toHaveAttribute('href', '/app/media/9')
expect(mockPage.mock.calls[0][3].picker).toEqual({ filter: { criteria: [], booleanOperator: 'AND', orderBys: [{ fieldName: 'id', isAscending: false }] } })
expect(await axe(baseElement)).toHaveNoViolations()
})
it('loads the next page by keyset, without counting', async () => {
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
await userEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findByRole('option', { name: /^Seven/ })).toBeInTheDocument()
expect(mockPage.mock.calls.at(-1)![3].picker.afterId).toBe(8)
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument()
})
it('debounces the search box into a server-side search', async () => {
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
vi.useFakeTimers({ shouldAdvanceTime: true })
fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'har' } })
fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'harbor' } })
await act(async () => { vi.advanceTimersByTime(350) })
await waitFor(() => expect(mockPage.mock.calls.at(-1)![3].searchTerm).toBe('harbor'))
expect(mockPage.mock.calls.filter((call) => call[3].searchTerm === 'har')).toHaveLength(0)
})
it('filters by the picker table fields and remembers the filters per field', async () => {
const { unmount } = renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
await userEvent.click(screen.getByRole('button', { name: 'Filters' }))
await waitFor(() => expect(screen.getByRole('option', { name: 'Logo' })).toBeInTheDocument())
await userEvent.selectOptions(screen.getByLabelText('Media Type'), '2')
await waitFor(() => expect(mockPage.mock.calls.at(-1)![3].picker.filter?.criteria).toEqual([{ fieldName: 'mediaTypeId', operator: 'EQUALS', values: ['2'] }]))
await userEvent.selectOptions(screen.getByLabelText('Sort'), '1')
await waitFor(() => expect(mockPage.mock.calls.at(-1)![3].picker.filter?.orderBys).toEqual([{ fieldName: 'name', isAscending: true }]))
unmount()
renderDialog()
await waitFor(() => expect(mockPage.mock.calls.at(-1)![3].picker.filter?.criteria).toEqual([{ fieldName: 'mediaTypeId', operator: 'EQUALS', values: ['2'] }]))
expect(screen.getByRole('button', { name: 'Filters (1)' })).toBeInTheDocument()
})
it('selects with a click and confirms with Select; a double click confirms at once', async () => {
renderDialog()
await userEvent.click(await screen.findByRole('option', { name: /^Eight/ }))
expect(resultOptions()[1]).toHaveAttribute('aria-selected', 'true')
await userEvent.click(screen.getByRole('button', { name: 'Select' }))
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 8 })])
onConfirm.mockClear()
await userEvent.dblClick(screen.getByRole('option', { name: /^Nine/ }))
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 9 })])
})
it('moves with the arrow keys and confirms with Enter', async () => {
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
const options = resultOptions()
options[0].focus()
fireEvent.keyDown(options[0], { key: 'ArrowRight' })
expect(options[1]).toHaveFocus()
fireEvent.keyDown(options[1], { key: 'Enter' })
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 8 })])
})
it('shows the current selection as chosen', async () => {
renderDialog({ selectedIds: [8] })
await waitFor(() => expect(resultOptions()[1]).toHaveAttribute('aria-selected', 'true'))
expect(screen.getByText('Media 8', { selector: '[data-qqq-id="record-picker-selection"]' })).toBeInTheDocument()
})
it('toggles several options in multiple selection', async () => {
renderDialog({ multiple: true })
await userEvent.click(await screen.findByRole('option', { name: /^Nine/ }))
await userEvent.click(screen.getByRole('option', { name: /^Eight/ }))
expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true')
expect(screen.getByText('2 selected')).toBeInTheDocument()
await userEvent.click(screen.getByRole('option', { name: /^Nine/ }))
await userEvent.click(screen.getByRole('button', { name: 'Select' }))
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 8 })])
})
it('uploads a new record and selects it', async () => {
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
await userEvent.click(screen.getByRole('button', { name: /upload image/i }))
await userEvent.click(await screen.findByRole('button', { name: 'Finish upload' }))
expect(await screen.findByText('Media 99', { selector: '[data-qqq-id="record-picker-selection"]' })).toBeInTheDocument()
expect(resultOptions()[0]).toHaveAttribute('data-value', '99')
await userEvent.click(screen.getByRole('button', { name: 'Select' }))
expect(onConfirm).toHaveBeenCalledWith([expect.objectContaining({ id: 99 })])
})
it('offers no upload without permission, and closes with Escape', async () => {
vi.mocked(useProcessMetaData).mockReturnValue({ data: { name: 'uploadImage', label: 'Upload image', hasPermission: false } } as ReturnType<typeof useProcessMetaData>)
renderDialog()
await screen.findByRole('option', { name: /^Nine/ })
expect(screen.queryByRole('button', { name: /upload image/i })).not.toBeInTheDocument()
await userEvent.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('reports a failed load with a retry', async () => {
mockPage.mockRejectedValueOnce(new Error('down'))
renderDialog()
expect(await screen.findByRole('alert')).toHaveTextContent('Results could not be loaded.')
await userEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(await screen.findByRole('option', { name: /^Nine/ })).toBeInTheDocument()
})
})
+606
View File
@@ -0,0 +1,606 @@
/*
* 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 RecordPickerDialog — the record picker for a field whose possible value source declares a
* `picker` (tables that grow large, e.g. 100,000+ media): server-side search, filters from the
* picker's table, orderings, a thumbnail grid or a list, keyset or offset paging without counts,
* a preview pane, uploading a new record, and single or multiple selection. Loaded on demand by
* {@link PossibleValueSelect}. The API contract and scale approach are in QQQ's
* docs/misc/RecordPicker.adoc.
*/
'use client'
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { Check, ExternalLink, Grid2x2, List, Loader2, Maximize2, Search, Upload, X } from 'lucide-react'
import type { QFieldMetaData, QPossibleValue, QPossibleValuePicker } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import { fetchPossibleValuesPage, fetchTablePossibleValues } from '@/lib/api/possible-values'
import { useProcessMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
import { cn } from '@/lib/utils/cn'
import {
type PickerFilterValue,
type PickerPreferences,
pickerFilterKind,
pickerPageRequest,
pickerStorageKey,
readPickerPreferences,
writePickerPreferences,
} from '@/lib/utils/record-picker-query'
import { pickerPreviewUrl, pickerRecordPath, pickerSubtitle, pickerThumbnailUrl, pickerValueText } from '@/lib/utils/record-picker'
import { buttonClass, iconButtonClass, INPUT_CLASS, SELECT_CLASS, ALERT_CLASS, LABEL_CLASS } from '@/components/ui/controls'
import { FilePreviewLightbox } from '@/components/preview/FilePreviewLightboxLazy'
import { ProcessRun } from '@/components/process/ProcessRunLazy'
/** Search debounce, in milliseconds. */
const SEARCH_DEBOUNCE_MS = 300
/** Props for {@link RecordPickerDialog}. */
export interface RecordPickerDialogProps {
/** The field's label (the dialog title is "Choose {label}"). */
label: string
/** The field the picker chooses for. */
fieldName: string
/** Where the field is (its possible-values route). */
context: PossibleValueContext
/** Source name, for a standalone lookup. */
possibleValueSourceName?: string
/** The field's picker. */
picker: QPossibleValuePicker
/** The table the picker reads. */
pickerTableName?: string
/** The form's current values (the field's own filter may use them). */
formValues?: Record<string, unknown>
/** Choose several records (an association or gallery) instead of one. */
multiple?: boolean
/** The ids already chosen. */
selectedIds?: Array<string | number>
/** Called with the chosen options when the user confirms. */
onConfirm: (options: QPossibleValue[]) => void
/** Called when the dialog closes without a choice. */
onClose: () => void
}
/**
* One filter control: a choice (the field's own possible values), yes/no, a text, or a range.
*
* @param props - The field, its value, the picker table and the change handler.
* @returns The labelled control.
*/
function PickerFilter({ field, value, tableName, onChange }: {
field: QFieldMetaData
value: PickerFilterValue | undefined
tableName: string
onChange: (value: PickerFilterValue | undefined) => void
}) {
const kind = pickerFilterKind(field)
const [choices, setChoices] = useState<QPossibleValue[]>([])
const id = `record-picker-filter-${field.name}`
useEffect(() => {
if (kind !== 'choice') return
let cancelled = false
fetchTablePossibleValues(tableName, field.name, { useCase: 'filter' })
.then((options) => { if (!cancelled) setChoices(options) })
.catch(() => { if (!cancelled) setChoices([]) })
return () => { cancelled = true }
}, [kind, tableName, field.name])
const set = (patch: PickerFilterValue) => {
const next = { ...(value ?? {}), ...patch }
onChange(next.value || next.from || next.to ? next : undefined)
}
if (kind === 'choice' || kind === 'boolean') {
return (
<label htmlFor={id} className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground">
{field.label}
<select id={id} value={value?.value ?? ''} onChange={(event) => set({ value: event.target.value })} className={SELECT_CLASS} data-qqq-id={`record-picker-filter-${field.name}`}>
<option value="">Any</option>
{kind === 'boolean'
? (<><option value="true">Yes</option><option value="false">No</option></>)
: choices.map((choice) => <option key={String(choice.id)} value={String(choice.id)}>{choice.label}</option>)}
</select>
</label>
)
}
if (kind === 'text') {
return (
<label htmlFor={id} className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground">
{field.label}
<input id={id} type="text" value={value?.value ?? ''} onChange={(event) => set({ value: event.target.value })} className={INPUT_CLASS} placeholder="Contains" data-qqq-id={`record-picker-filter-${field.name}`} />
</label>
)
}
const type = kind === 'number' ? 'number' : 'date'
return (
<fieldset className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground" data-qqq-id={`record-picker-filter-${field.name}`}>
<legend className="mb-1">{field.label}</legend>
<div className="flex gap-1">
<input type={type} aria-label={`${field.label} from`} value={value?.from ?? ''} onChange={(event) => set({ from: event.target.value })} className={INPUT_CLASS} placeholder="From" />
<input type={type} aria-label={`${field.label} to`} value={value?.to ?? ''} onChange={(event) => set({ to: event.target.value })} className={INPUT_CLASS} placeholder="To" />
</div>
</fieldset>
)
}
/**
* The record picker dialog. Escape closes it and focus is trapped while it is open (Radix);
* arrow keys move through the results (by row in the grid), Enter or a double click confirms,
* Space toggles in multiple selection.
*
* @param props - See {@link RecordPickerDialogProps}.
* @returns The dialog.
*/
export function RecordPickerDialog({
label,
fieldName,
context,
possibleValueSourceName,
picker,
pickerTableName,
formValues,
multiple = false,
selectedIds = [],
onConfirm,
onClose,
}: RecordPickerDialogProps) {
const restoreFocus = useRestoreFocus(true)
const { data: table } = useTableMetaData(pickerTableName)
const { data: uploadProcess } = useProcessMetaData(picker.uploadProcessName)
const storageKey = pickerStorageKey(pickerTableName, fieldName)
const [preferences, setPreferences] = useState<PickerPreferences>(() => readPickerPreferences(storageKey))
const [search, setSearch] = useState('')
const [term, setTerm] = useState('')
const [options, setOptions] = useState<QPossibleValue[]>([])
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const [active, setActive] = useState(0)
// the option the user last focused or clicked (the preview follows it)
const [previewId, setPreviewId] = useState<string | null>(null)
const [chosen, setChosen] = useState<QPossibleValue[]>([])
const [showFilters, setShowFilters] = useState(false)
const [uploading, setUploading] = useState(false)
const [previewing, setPreviewing] = useState<QPossibleValue | null>(null)
const sequence = useRef(0)
const resultsRef = useRef<HTMLDivElement>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const selectedKey = selectedIds.map(String).join(',')
// the remembered filters/ordering/view of this field
useEffect(() => { writePickerPreferences(storageKey, preferences) }, [storageKey, preferences])
// debounce the search box
useEffect(() => {
const timer = setTimeout(() => setTerm(search.trim()), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [search])
const filterFields = useMemo(
() => (picker.filterFields ?? []).map((name) => table?.fields?.[name]).filter((field): field is QFieldMetaData => Boolean(field)),
[picker.filterFields, table]
)
const activeFilterCount = Object.values(preferences.filters).filter(Boolean).length
/**
* Load a page: the first page replaces the results, later pages append to them.
*
* @param loaded - The options already shown ([] for the first page).
*/
const load = useCallback(async (loaded: QPossibleValue[]) => {
const current = ++sequence.current
setLoading(true)
setFailed(false)
try {
const page = await fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, {
searchTerm: term || undefined,
formValues,
picker: pickerPageRequest(picker, preferences, table?.fields, table?.primaryKeyField, loaded),
})
if (current !== sequence.current) return
const seen = new Set(loaded.map((option) => String(option.id)))
setOptions([...loaded, ...page.options.filter((option) => !seen.has(String(option.id)))])
setHasMore(page.hasMore)
} catch {
if (current === sequence.current) setFailed(true)
} finally {
if (current === sequence.current) setLoading(false)
}
}, [context, fieldName, possibleValueSourceName, term, formValues, picker, preferences, table])
// a new search, filter or ordering starts over (after the table's fields are known)
useEffect(() => {
if (pickerTableName && !table) return
setActive(0)
void load([])
}, [term, preferences.filters, preferences.sortIndex, table]) // eslint-disable-line react-hooks/exhaustive-deps
// the current selection, looked up once so it shows as chosen with its thumbnail
useEffect(() => {
if (!selectedKey) return
let cancelled = false
fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, { ids: selectedKey, formValues, picker: {} })
.then((page) => { if (!cancelled) setChosen(page.options) })
.catch(() => { /* the results still show the selection */ })
return () => { cancelled = true }
}, [selectedKey]) // eslint-disable-line react-hooks/exhaustive-deps
// infinite scroll: load the next page when the end of the results comes into view
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel || !hasMore || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting) && !loading) void load(options)
}, { root: resultsRef.current, rootMargin: '200px' })
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMore, loading, options, load])
const isChosen = (option: QPossibleValue) => chosen.some((item) => String(item.id) === String(option.id))
const focused = options[active] ?? null
const previewOption = options.find((option) => String(option.id) === previewId) ?? chosen[0] ?? options[0] ?? null
/**
* Choose an option: replace the choice, or toggle it in multiple selection.
*
* @param option - The option.
*/
const choose = (option: QPossibleValue) => {
if (!multiple) setChosen([option])
else setChosen((current) => (current.some((item) => String(item.id) === String(option.id))
? current.filter((item) => String(item.id) !== String(option.id))
: [...current, option]))
}
const confirm = (option?: QPossibleValue) => {
const result = option && !multiple ? [option] : chosen
if (!result.length) return
onConfirm(result)
}
/**
* How many results one grid row holds (from the laid-out options), for Up/Down.
*
* @returns The column count (1 in the list).
*/
const columns = () => {
if (preferences.view === 'list') return 1
const items = resultsRef.current?.querySelectorAll<HTMLElement>('[role="option"]')
if (!items || items.length < 2) return 1
const top = items[0].offsetTop
let count = 0
for (const item of Array.from(items)) {
if (item.offsetTop !== top) break
count += 1
}
return Math.max(1, count)
}
const moveTo = (index: number) => {
const next = Math.max(0, Math.min(options.length - 1, index))
setActive(next)
resultsRef.current?.querySelector<HTMLElement>(`[data-index="${next}"]`)?.focus()
if (next >= options.length - 1 && hasMore && !loading) void load(options)
}
const handleResultsKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
if (!options.length) return
let handled = true
if (event.key === 'ArrowRight') moveTo(active + 1)
else if (event.key === 'ArrowLeft') moveTo(active - 1)
else if (event.key === 'ArrowDown') moveTo(active + columns())
else if (event.key === 'ArrowUp') moveTo(active - columns())
else if (event.key === 'Home') moveTo(0)
else if (event.key === 'End') moveTo(options.length - 1)
else if (event.key === ' ' && focused) choose(focused)
else if (event.key === 'Enter' && focused) {
if (multiple) choose(focused)
else confirm(focused)
} else handled = false
if (handled) event.preventDefault()
}
const setFilter = (name: string, value: PickerFilterValue | undefined) => {
setPreferences((current) => {
const filters = { ...current.filters }
if (value) filters[name] = value
else delete filters[name]
return { ...current, filters }
})
}
/**
* Select the record an upload created, read from the upload process's result value.
*
* @param values - The completed process's values.
*/
const handleUploaded = async (values: Record<string, unknown>) => {
const id = picker.uploadResultIdField ? values[picker.uploadResultIdField] : undefined
setUploading(false)
if (id === undefined || id === null || id === '') return
try {
const page = await fetchPossibleValuesPage(context, fieldName, possibleValueSourceName, { ids: String(id), formValues, picker: {} })
const created = page.options[0]
if (!created) return
setOptions((current) => [created, ...current.filter((option) => String(option.id) !== String(created.id))])
setActive(0)
choose(created)
} catch {
/* the upload succeeded; it is found by searching */
}
}
const thumbnail = (option: QPossibleValue, size: string) => {
const url = pickerThumbnailUrl(option, picker)
return url
// eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer
? <img src={url} alt="" loading="lazy" decoding="async" className={cn(size, 'rounded object-cover bg-muted')} />
: <span aria-hidden="true" className={cn(size, 'rounded bg-muted')} />
}
const previewUrl = pickerPreviewUrl(previewOption, picker)
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}
aria-describedby={undefined}
className="fixed inset-0 z-[2001] flex flex-col overflow-hidden border border-border bg-card text-foreground shadow-xl focus:outline-none sm:inset-6 sm:rounded-lg"
data-qqq-id="record-picker-dialog"
data-multiple={multiple || undefined}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
<Dialog.Title className="min-w-0 flex-1 truncate text-sm font-semibold">{`Choose ${label}`}</Dialog.Title>
{uploadProcess?.hasPermission && !uploading && (
<button type="button" onClick={() => setUploading(true)} className={buttonClass('secondary', 'sm')} data-qqq-id="record-picker-upload">
<Upload className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">{uploadProcess.label || 'Upload new'}</span>
</button>
)}
<Dialog.Close className={iconButtonClass()} aria-label="Close" data-qqq-id="record-picker-close">
<X className="h-4 w-4" aria-hidden="true" />
</Dialog.Close>
</div>
{uploading && picker.uploadProcessName && uploadProcess ? (
<div className="min-h-0 flex-1 overflow-y-auto p-3 sm:p-4" data-qqq-id="record-picker-upload-panel">
<button type="button" onClick={() => setUploading(false)} className={cn(buttonClass('ghost', 'sm'), 'mb-2')}>Back to the list</button>
<Suspense fallback={<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-label="Loading" />}>
<ProcessRun processName={picker.uploadProcessName} processMetaData={uploadProcess} isEmbedded onComplete={(values) => { void handleUploaded(values) }} />
</Suspense>
</div>
) : (
<>
<div className="flex flex-wrap items-end gap-2 border-b border-border px-3 py-2 sm:px-4">
<label className="relative min-w-0 flex-1 basis-48">
<span className="sr-only">{`Search ${label}`}</span>
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search"
className={cn(INPUT_CLASS, 'pl-8')}
data-qqq-id="record-picker-search"
autoFocus
/>
</label>
{(picker.sorts?.length ?? 0) > 1 && (
<label className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="sr-only sm:not-sr-only">Sort</span>
<select
value={preferences.sortIndex}
onChange={(event) => setPreferences((current) => ({ ...current, sortIndex: Number(event.target.value) }))}
className={SELECT_CLASS}
aria-label="Sort"
data-qqq-id="record-picker-sort"
>
{picker.sorts!.map((sort, index) => <option key={sort.label} value={index}>{sort.label}</option>)}
</select>
</label>
)}
{filterFields.length > 0 && (
<button
type="button"
onClick={() => setShowFilters((shown) => !shown)}
aria-expanded={showFilters}
className={buttonClass('secondary', 'sm')}
data-qqq-id="record-picker-filters-toggle"
>
{activeFilterCount ? `Filters (${activeFilterCount})` : 'Filters'}
</button>
)}
<div className="flex" role="group" aria-label="View">
<button type="button" onClick={() => setPreferences((current) => ({ ...current, view: 'grid' }))} aria-pressed={preferences.view === 'grid'} aria-label="Grid view" className={cn(iconButtonClass(), preferences.view === 'grid' && 'bg-accent text-foreground')} data-qqq-id="record-picker-view-grid">
<Grid2x2 className="h-4 w-4" aria-hidden="true" />
</button>
<button type="button" onClick={() => setPreferences((current) => ({ ...current, view: 'list' }))} aria-pressed={preferences.view === 'list'} aria-label="List view" className={cn(iconButtonClass(), preferences.view === 'list' && 'bg-accent text-foreground')} data-qqq-id="record-picker-view-list">
<List className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
{showFilters && filterFields.length > 0 && pickerTableName && (
<div className="grid grid-cols-1 gap-2 border-b border-border px-3 py-2 sm:grid-cols-2 sm:px-4 lg:grid-cols-4" data-qqq-id="record-picker-filters">
{filterFields.map((field) => (
<PickerFilter key={field.name} field={field} value={preferences.filters[field.name]} tableName={pickerTableName} onChange={(value) => setFilter(field.name, value)} />
))}
{activeFilterCount > 0 && (
<button type="button" onClick={() => setPreferences((current) => ({ ...current, filters: {} }))} className={cn(buttonClass('ghost', 'sm'), 'self-end')}>Clear filters</button>
)}
</div>
)}
<div className="flex min-h-0 flex-1">
<div
ref={resultsRef}
className="min-h-0 min-w-0 flex-1 overflow-y-auto p-3 sm:p-4"
aria-busy={loading || undefined}
data-qqq-id="record-picker-results"
data-view={preferences.view}
>
{failed ? (
<p role="alert" className={ALERT_CLASS}>
Results could not be loaded.{' '}
<button type="button" className="underline" onClick={() => void load([])}>Try again</button>
</p>
) : !loading && options.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground" data-qqq-id="record-picker-empty">No matches.</p>
) : (
<ul
role="listbox"
aria-label={`${label} results`}
aria-multiselectable={multiple || undefined}
onKeyDown={handleResultsKeyDown}
className={preferences.view === 'grid' ? 'qqq-record-picker-grid grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6' : 'flex flex-col gap-1'}>
{options.map((option, index) => {
const selected = isChosen(option)
const subtitle = pickerSubtitle(option, picker)
return (
<li
key={String(option.id)}
role="option"
aria-selected={selected}
tabIndex={index === active ? 0 : -1}
data-index={index}
data-qqq-id="record-picker-option"
data-value={String(option.id)}
onClick={() => { setActive(index); setPreviewId(String(option.id)); choose(option) }}
onDoubleClick={() => confirm(option)}
onFocus={() => { setActive(index); setPreviewId(String(option.id)) }}
className={cn(
'relative cursor-pointer rounded-md border p-1.5 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
preferences.view === 'list' && 'flex items-center gap-3',
selected ? 'border-primary bg-accent' : 'border-border'
)}
>
{preferences.view === 'grid' ? thumbnail(option, 'block aspect-square w-full') : thumbnail(option, 'h-10 w-10 shrink-0')}
<span className={cn('block min-w-0', preferences.view === 'grid' && 'mt-1')}>
<span className="block truncate font-medium" title={option.label}>{option.label}</span>
{subtitle && <span className="block truncate text-xs text-muted-foreground">{subtitle}</span>}
</span>
{selected && (
<Check className="absolute right-2 top-2 h-4 w-4 rounded-full bg-primary text-primary-foreground" aria-hidden="true" />
)}
</li>
)
})}
</ul>
)}
<div ref={sentinelRef} aria-hidden="true" />
{loading && (
<p role="status" className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading...
</p>
)}
{hasMore && !loading && !failed && (
<div className="flex justify-center py-3">
<button type="button" onClick={() => void load(options)} className={buttonClass('secondary', 'sm')} data-qqq-id="record-picker-more">Load more</button>
</div>
)}
</div>
{previewOption && (
<aside className="hidden min-h-0 w-80 shrink-0 overflow-y-auto border-l border-border p-4 lg:block" aria-label="Preview" data-qqq-id="record-picker-preview">
{previewUrl && (
<button type="button" onClick={() => setPreviewing(previewOption)} className="block w-full rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={`Preview ${previewOption.label} full size`}>
{/* eslint-disable-next-line @next/next/no-img-element -- record URLs from any allowed origin; the static export has no image optimizer */}
<img src={previewUrl} alt={previewOption.label} className="max-h-80 w-full rounded-md bg-muted object-contain" />
</button>
)}
<h3 className="mt-3 break-words text-sm font-semibold">{previewOption.label}</h3>
{(picker.detailFields ?? []).length > 0 && (
<dl className="mt-2 flex flex-col gap-1 text-xs">
{picker.detailFields!.map((name) => {
const text = pickerValueText(previewOption, name)
if (!text) return null
return (
<div key={name} className="flex gap-2">
<dt className="shrink-0 text-muted-foreground">{table?.fields?.[name]?.label ?? name}</dt>
<dd className="min-w-0 break-words">{text}</dd>
</div>
)
})}
</dl>
)}
<div className="mt-3 flex flex-wrap gap-2">
{previewUrl && (
<button type="button" onClick={() => setPreviewing(previewOption)} className={buttonClass('secondary', 'sm')}>
<Maximize2 className="h-4 w-4" aria-hidden="true" />
Full size
</button>
)}
{pickerTableName && (
<a href={pickerRecordPath(pickerTableName, previewOption.id)} target="_blank" rel="noopener noreferrer" className={buttonClass('secondary', 'sm')}>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
Open
</a>
)}
</div>
</aside>
)}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2 sm:px-4">
<p className={cn(LABEL_CLASS, 'min-w-0 flex-1 truncate')} aria-live="polite" data-qqq-id="record-picker-selection">
{chosen.length === 0 ? 'Nothing selected' : multiple ? `${chosen.length} selected` : chosen[0].label}
</p>
{previewOption && previewUrl && (
<button type="button" onClick={() => setPreviewing(previewOption)} className={cn(buttonClass('secondary'), 'lg:hidden')} aria-label={`Preview ${previewOption.label} full size`}>
<Maximize2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
<Dialog.Close className={buttonClass('secondary')} data-qqq-id="record-picker-cancel">Cancel</Dialog.Close>
<button type="button" onClick={() => confirm()} disabled={chosen.length === 0} className={buttonClass('primary')} data-qqq-id="record-picker-select">
Select
</button>
</div>
</>
)}
{previewing && (
<Suspense fallback={null}>
<FilePreviewLightbox
items={[{
url: pickerPreviewUrl(previewing, picker) ?? '',
thumbnailUrl: pickerThumbnailUrl(previewing, picker),
fileName: previewing.label,
kind: 'image',
alt: previewing.label,
previewable: true,
thumbnailSize: 40,
}]}
index={0}
onIndexChange={() => { /* a single image */ }}
onClose={() => setPreviewing(null)}
/>
</Suspense>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
export default RecordPickerDialog
@@ -30,6 +30,7 @@ import { Controller } from 'react-hook-form'
import { Minus } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
/**
* Props for the {@link BooleanField} component.
@@ -127,7 +128,7 @@ export function BooleanField({
)
return (
<div className="flex flex-col gap-1">
<div className="group flex flex-col gap-1">
<div className="flex items-center gap-2">
<Controller
name={name}
@@ -195,14 +196,10 @@ export function BooleanField({
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
</div>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
<FieldFooter id={id} error={error?.message} />
</div>
)
}
@@ -24,6 +24,7 @@ import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
/**
* Props for the {@link DateField} component.
@@ -67,14 +68,14 @@ export function DateField({
describedBy,
}: DateFieldProps) {
return (
<div className="flex flex-col gap-1">
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<input
id={id}
@@ -96,11 +97,7 @@ export function DateField({
: 'border-input'
)}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
<FieldFooter id={id} error={error?.message} />
</div>
)
}
@@ -24,6 +24,7 @@ import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
/**
* Props for the {@link DateTimeField} component.
@@ -73,14 +74,14 @@ export function DateTimeField({
const describedByIds = [hintId, error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined
return (
<div className="flex flex-col gap-1">
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<input
id={id}
@@ -106,11 +107,7 @@ export function DateTimeField({
<p id={hintId} className="mt-0.5 text-xs text-muted-foreground">
Your local time ({timeZone})
</p>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
<FieldFooter id={id} error={error?.message} />
</div>
)
}
@@ -28,6 +28,7 @@ import { Controller } from 'react-hook-form'
import { Upload, X, File as FileIcon } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
/**
* Props for the {@link FileUploadField} component.
@@ -89,10 +90,10 @@ export function FileUploadField({
const labelId = `${id}-label`
return (
<div className="flex flex-col gap-1">
<div className="group flex flex-col gap-1">
<span id={labelId} className="text-sm font-medium text-foreground" data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</span>
<Controller
name={name}
@@ -213,11 +214,7 @@ export function FileUploadField({
)
}}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
<FieldFooter id={id} error={error?.message} />
</div>
)
}
@@ -24,6 +24,8 @@ import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { blockedNumberKeys } from '@/lib/utils/field-validation'
import { FieldFooter } from '../FieldFooter'
/**
* Props for the {@link NumberField} component.
@@ -48,9 +50,9 @@ interface NumberFieldProps {
* Use `1` for integers/longs; use `"any"` for decimals.
*/
step?: string | number
/** Minimum allowed value from field metadata; displayed as hint text below the input. */
/** Minimum allowed value from field metadata; shown as a hint while the field has focus. */
minValue?: number | string | null
/** Maximum allowed value from field metadata; displayed as hint text below the input. */
/** Maximum allowed value from field metadata; shown as a hint while the field has focus. */
maxValue?: number | string | null
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
'data-qqq-id'?: string
@@ -69,8 +71,8 @@ interface NumberFieldProps {
* (with `step="any"`). Values remain input strings until schema validation,
* preserving an empty input separately from a numeric zero.
*
* When `minValue` or `maxValue` is provided, a hint line is rendered below
* the input showing the allowed range or bound before any error message.
* When `minValue` or `maxValue` is provided, the allowed range or bound shows quietly
* under the input while it has focus.
*
* @param props - See {@link NumberFieldProps}.
* @returns The rendered numeric input field with label, optional constraint hint, and optional error message.
@@ -91,21 +93,25 @@ export function NumberField({
prefix,
suffix,
}: NumberFieldProps) {
const hintId = (minValue != null || maxValue != null) ? `${id}-hint` : undefined
const hint = minValue != null && maxValue != null
? `Range: ${minValue} \u2013 ${maxValue}`
: minValue != null ? `Minimum: ${minValue}` : maxValue != null ? `Maximum: ${maxValue}` : undefined
const hintId = hint && !error ? `${id}-hint` : undefined
const blockedKeys = blockedNumberKeys(step === 1, minValue)
const prefixId = prefix ? `${id}-prefix` : undefined
const suffixId = suffix ? `${id}-suffix` : undefined
const describedByIds = [prefixId, suffixId, hintId, error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined
const adornmentClass = 'inline-flex shrink-0 items-center border border-input bg-muted px-3 text-sm text-muted-foreground'
return (
<div className="flex flex-col gap-1">
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<div className="flex">
{prefix && (
@@ -117,9 +123,14 @@ export function NumberField({
id={id}
type="number"
step={step}
inputMode={step === 1 ? 'numeric' : 'decimal'}
{...registration}
// the mouse wheel scrolls the page instead of changing a focused number (as in Material)
onWheel={(event) => event.currentTarget.blur()}
// typing a letter, a second sign or (for whole numbers) a decimal point does nothing
onKeyDown={(event) => {
if (!event.ctrlKey && !event.metaKey && !event.altKey && blockedKeys.includes(event.key)) event.preventDefault()
}}
disabled={disabled}
placeholder={placeholder}
aria-required={required}
@@ -146,20 +157,7 @@ export function NumberField({
</span>
)}
</div>
{(minValue != null || maxValue != null) && (
<p id={hintId} className="mt-0.5 text-xs text-muted-foreground">
{minValue != null && maxValue != null
? `Range: ${minValue} \u2013 ${maxValue}`
: minValue != null
? `Minimum: ${minValue}`
: `Maximum: ${maxValue}`}
</p>
)}
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
<FieldFooter id={id} error={error?.message} hint={hint} />
</div>
)
}
@@ -25,6 +25,7 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { Eye, EyeOff } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
import { transformInputValue } from '@/lib/utils/form-layout'
/**
@@ -87,14 +88,14 @@ export function PasswordField({
: registration.onChange
return (
<div className="flex flex-col gap-1">
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<div className="relative">
<input
@@ -140,11 +141,7 @@ export function PasswordField({
)}
</button>
</div>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
<FieldFooter id={id} error={error?.message} />
</div>
)
}
@@ -24,6 +24,7 @@ import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
import type { QPossibleValue } from '@/types'
/**
@@ -75,14 +76,14 @@ export function SelectField({
'data-qqq-id': dataQqqId,
}: SelectFieldProps) {
return (
<div className="flex flex-col gap-1">
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<select
id={id}
@@ -114,11 +115,7 @@ export function SelectField({
</option>
))}
</select>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
<FieldFooter id={id} error={error?.message} />
</div>
)
}
@@ -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 TextAreaField — multi-line text form field (QQQ `TEXT`) with label, quiet length counter and error line.
*/
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { transformInputValue } from '@/lib/utils/form-layout'
import { FieldFooter } from '../FieldFooter'
import type { LengthLimit } from '../FieldFooter'
/** Props for the {@link TextAreaField} component. */
interface TextAreaFieldProps {
/** The HTML `id` for the `<textarea>` and its label. */
id: string
/** Human-readable field label. */
label: string
/** Return value of `register(fieldName)` from React Hook Form. */
registration: UseFormRegisterReturn
/** Validation error; when present triggers error styling and an error message. */
error?: FieldError
/** When `true`, the field is non-interactive. */
disabled?: boolean
/** Placeholder text shown when the field is empty. */
placeholder?: string
/** Forwarded to the `maxlength` attribute (set only when the field refuses over-long values). */
maxLength?: number
/** Counts characters against the field's limit; the counter shows only near or past it. */
lengthLimit?: LengthLimit
/** When `true`, an asterisk is shown and `aria-required` is set. */
required?: boolean
/** `data-qqq-id` forwarded to the textarea. */
'data-qqq-id'?: string
/** Id of help text that describes this control. */
describedBy?: string
/** Changes the value as the user types (a case behavior), keeping the caret. */
transform?: (value: string) => string
/** Content shown beside the label (the field's help button). */
labelExtra?: React.ReactNode
}
/**
* Renders a multi-line text input.
*
* @param props - See {@link TextAreaFieldProps}.
* @returns The labelled textarea with its footer.
*/
export function TextAreaField({
id,
label,
registration,
error,
disabled = false,
placeholder,
maxLength,
lengthLimit,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
transform,
labelExtra,
}: TextAreaFieldProps) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center">
<label htmlFor={id} className="text-sm font-medium text-foreground" data-qqq-id={`field-label-${dataQqqId}`}>
{label}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
{labelExtra}
</div>
<textarea
id={id}
placeholder={placeholder}
{...registration}
onChange={(event) => {
if (transform) transformInputValue(event.currentTarget, transform)
void registration.onChange(event)
}}
disabled={disabled}
maxLength={maxLength}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
data-qqq-id={dataQqqId}
rows={4}
className={cn(
'w-full resize-y rounded-md border bg-background px-3 py-2 text-sm text-foreground transition-colors duration-150',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring disabled:cursor-not-allowed disabled:bg-muted',
error ? 'border-destructive focus:ring-destructive' : 'border-input'
)}
/>
<FieldFooter id={id} error={error?.message} limit={lengthLimit} />
</div>
)
}
@@ -24,6 +24,8 @@ import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
import type { LengthLimit } from '../FieldFooter'
import { transformInputValue } from '@/lib/utils/form-layout'
/**
@@ -42,8 +44,10 @@ interface TextFieldProps {
disabled?: boolean
/** Placeholder text shown when the field is empty. */
placeholder?: string
/** Forwarded to the `<input>` `maxlength` attribute. */
/** Forwarded to the `<input>` `maxlength` attribute (set only when the field refuses over-long values). */
maxLength?: number
/** Counts characters against the field's limit; the counter shows only near or past it. */
lengthLimit?: LengthLimit
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
required?: boolean
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
@@ -71,6 +75,7 @@ export function TextField({
disabled = false,
placeholder,
maxLength,
lengthLimit,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
@@ -83,14 +88,14 @@ export function TextField({
}
: registration.onChange
return (
<div className="flex flex-col gap-1">
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<input
id={id}
@@ -116,11 +121,7 @@ export function TextField({
: 'border-input'
)}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
<FieldFooter id={id} error={error?.message} limit={lengthLimit} />
</div>
)
}
@@ -178,3 +178,14 @@ describe('Breadcrumbs', () => {
}
})
})
describe('Breadcrumbs home crumb', () => {
it('links to the instance home app when there is one, and to the overview otherwise', () => {
currentPathname = '/app/person'
const { unmount } = render(<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} homePath="/app/home" />)
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/app/home')
unmount()
render(<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} />)
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/app')
})
})
+5 -3
View File
@@ -35,6 +35,8 @@ export interface BreadcrumbsProps {
pathToLabelMap: Record<string, string>
/** Maps node paths to their enclosing apps, outermost first. */
ancestorAppMap?: Record<string, ParentAppInfo[]>
/** Where the home crumb links: the instance's home app, or the `/app` overview (default). */
homePath?: string
}
/**
@@ -158,7 +160,7 @@ export function buildDocumentTitle(crumbs: Breadcrumb[], pageTitle: string | und
* `<Link>` crumbs (ancestor pages) followed by a `<span aria-current="page">`
* for the current page, or `null` on the dashboard root and before labels load.
*/
export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {} }: BreadcrumbsProps) {
export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homePath = '/app' }: BreadcrumbsProps) {
const pathname = usePathname()
const breadcrumbs = buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap)
const trailRef = useRef<HTMLElement>(null)
@@ -189,9 +191,9 @@ export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {} }: Bre
aria-label="Breadcrumb"
data-qqq-id="breadcrumbs"
>
{/* Home crumb (Material's home icon), back to the dashboard */}
{/* Home crumb (Material's home icon), back to the home page (the home app, or the overview) */}
<Link
href="/app"
href={homePath}
aria-label="Home"
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:flex-shrink-0"
data-qqq-id="breadcrumb-home"
+216 -64
View File
@@ -14,14 +14,16 @@
* limitations under the License.
*/
// Tests for GlobalSearch: local "jump to" search over navigation targets and recent records,
// plus backend record search when (and only when) searchable tables are given
// Tests for GlobalSearch: search-as-you-type over pages, recent records and recent searches,
// plus backend record search when (and only when) searchable tables are given: grouping,
// top hits, counts, highlights, keyboard (incl. Ctrl+Enter), failure classes and fallbacks
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AxiosError, type AxiosResponse } from 'axios'
const pushMock = vi.fn()
vi.mock('next/navigation', () => ({
@@ -32,11 +34,15 @@ vi.mock('next/navigation', () => ({
}))
const searchRecordsMock = vi.fn()
vi.mock('@/lib/api/tables', () => ({ searchRecords: (...args: unknown[]) => searchRecordsMock(...args) }))
vi.mock('@/lib/api/record-search', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/api/record-search')>()),
searchRecords: (...args: unknown[]) => searchRecordsMock(...args),
}))
import { GlobalSearch } from './GlobalSearch'
import type { NavTarget } from '@/lib/hooks/use-routes'
import { addRecentRecord, clearRecentRecords } from '@/lib/utils/recent-records'
import { addRecentSearch, getRecentSearches } from '@/lib/search/recent-searches'
import type { SearchableTable } from '@/lib/utils/record-search'
const peopleApp = { label: 'People App', path: '/app/peopleApp' }
@@ -49,6 +55,12 @@ const navTargets: NavTarget[] = [
{ key: 'greetInteractive', label: 'Greet Interactive', path: '/app/greetInteractive', nodeType: 'PROCESS', ancestors: [peopleApp, greetingsApp] },
]
/**
* Renders the header search in a fresh query client.
*
* @param searchTables - Searchable tables (omit: no record search).
* @returns The render result.
*/
function renderSearch(searchTables?: SearchableTable[]) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
@@ -60,7 +72,19 @@ function renderSearch(searchTables?: SearchableTable[]) {
const input = () => screen.getByRole('combobox', { name: 'Search pages and recent records' })
const recordInput = () => screen.getByRole('combobox', { name: 'Search pages and records' })
const people: SearchableTable[] = [{ name: 'person', label: 'Person' }, { name: 'pet', label: 'Pet' }]
const people: SearchableTable[] = [{ name: 'person', label: 'Person', icon: { name: 'person' } }, { name: 'pet', label: 'Pet' }]
const optionLabels = (container: HTMLElement) => within(container).getAllByRole('option').map((option) => option.querySelector('span.font-medium')?.textContent ?? option.textContent)
// jsdom's accessible name splits highlighted text ("Greet Inter active"): find options by text content
const option = (pattern: RegExp, container: HTMLElement = document.body) => {
const found = within(container).queryAllByRole('option').find((candidate) => pattern.test(candidate.textContent ?? ''))
if (!found) throw new Error(`No option matches ${pattern}`)
return found
}
const findOption = async (pattern: RegExp) => {
await screen.findAllByRole('option')
return vi.waitFor(() => option(pattern))
}
const selected = () => screen.getAllByRole('option').find((option) => option.getAttribute('aria-selected') === 'true')
describe('GlobalSearch', () => {
const fetchSpy = vi.fn()
@@ -68,9 +92,11 @@ describe('GlobalSearch', () => {
beforeEach(() => {
pushMock.mockReset()
clearRecentRecords()
localStorage.removeItem('qqq-recent-searches')
fetchSpy.mockReset()
searchRecordsMock.mockReset()
vi.stubGlobal('fetch', fetchSpy)
window.open = vi.fn() as unknown as typeof window.open
})
afterEach(() => {
@@ -83,86 +109,152 @@ describe('GlobalSearch', () => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
it('matches navigation targets by label and shows their app context', async () => {
it('lists matching pages, best label matches first, with their type and app context', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'pe')
const options = within(screen.getByRole('group', { name: 'Pages' })).getAllByRole('option')
expect(options.map((option) => option.querySelector('span span')?.textContent)).toEqual(['People App', 'Person', 'Pet', 'Greetings App', 'Greet Interactive'])
expect(options[1]).toHaveTextContent('People App / Greetings App')
expect(options[1].querySelector('svg')).toHaveAttribute('data-qqq-icon', 'person')
// "People App", "Person" and "Pet" start with the term: top hits (at most three, closest first)
expect(optionLabels(await screen.findByRole('group', { name: 'Top hits' }))).toEqual(['Pet', 'Person', 'People App'])
const pages = screen.getByRole('group', { name: 'Pages' })
expect(optionLabels(pages)).toEqual(['Greetings App', 'Greet Interactive'])
expect(within(pages).getAllByRole('option')[1]).toHaveTextContent('Process · People App / Greetings App')
expect(within(screen.getByRole('group', { name: 'Top hits' })).getAllByRole('option')[1].querySelector('svg')).toHaveAttribute('data-qqq-icon', 'person')
})
it('matches targets through their enclosing app label', async () => {
it('highlights the matched text', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'greetings')
const labels = screen.getAllByRole('option').map((option) => option.querySelector('span span')?.textContent)
expect(labels).toEqual(['Greetings App', 'Person', 'Pet', 'Greet Interactive'])
await user.type(input(), 'inter')
expect((await findOption(/Greet Interactive/)).querySelector('mark')).toHaveTextContent('Inter')
})
it('navigates to the clicked target and clears the input', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'Greet Inter')
await user.click(screen.getByRole('option', { name: /^Greet/ }))
await user.click(option(/^Greet Interactive/))
expect(pushMock).toHaveBeenCalledWith('/app/greetInteractive')
expect(input()).toHaveValue('')
})
it('supports arrow keys and Enter', async () => {
it('selects the first result while typing so Enter opens the top hit, and arrows move', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'pet')
expect(selected()).toHaveTextContent('Pet')
expect(input()).toHaveAttribute('aria-activedescendant', selected()!.id)
await user.keyboard('{ArrowDown}')
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{Enter}')
expect(selected()).toHaveTextContent('Search everything for “pet”')
await user.keyboard('{ArrowUp}{Enter}')
expect(pushMock).toHaveBeenCalledWith('/app/pet')
})
it('opens the search page on Enter with nothing selected', async () => {
it('opens the selected result in a new tab with Ctrl+Enter and keeps the search open', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'zzz{Enter}')
await user.type(input(), 'pet')
await user.keyboard('{Control>}{Enter}{/Control}')
expect(window.open).toHaveBeenCalledWith('/app/pet', '_blank', 'noopener,noreferrer')
expect(pushMock).not.toHaveBeenCalled()
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
it('offers "search everything" as the only result when nothing matches, and Enter opens the search page', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'zzz')
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or recent records match “zzz”')
await user.keyboard('{Enter}')
expect(pushMock).toHaveBeenCalledWith('/app/search?q=zzz')
expect(getRecentSearches()).toEqual(['zzz'])
})
it('keeps the keyboard selection when results appear under a resting pointer', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'pe')
const options = screen.getAllByRole('option')
// Results rendering under the pointer fire mouseenter without pointer movement
fireEvent.mouseEnter(screen.getAllByRole('option')[0])
expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'false')
fireEvent.mouseMove(screen.getAllByRole('option')[1])
expect(screen.getAllByRole('option')[1]).toHaveAttribute('aria-selected', 'true')
fireEvent.mouseEnter(options[2])
expect(options[0]).toHaveAttribute('aria-selected', 'true')
fireEvent.mouseMove(options[2])
expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true')
})
it('shows an empty message when nothing matches', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'zzz')
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or recent records match “zzz”')
})
it('lists recently viewed records before typing and filters them by label', async () => {
it('lists recent searches and recently viewed records before typing; a recent search fills the box', async () => {
addRecentRecord({ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Sample', path: '/app/person/1' })
addRecentSearch('greet')
const user = userEvent.setup()
renderSearch()
await user.click(input())
expect(within(screen.getByRole('group', { name: 'Recently viewed' })).getByRole('option', { name: /Avery Sample/ })).toBeInTheDocument()
await user.type(input(), 'avery')
await user.click(screen.getByRole('option', { name: /Avery Sample/ }))
expect(pushMock).toHaveBeenCalledWith('/app/person/1')
expect(selected()).toBeUndefined()
await user.click(within(screen.getByRole('group', { name: 'Recent searches' })).getByRole('option', { name: 'greet' }))
expect(input()).toHaveValue('greet')
expect(option(/Greet Interactive/)).toBeInTheDocument()
})
it('closes on Escape', async () => {
it('clears recent searches', async () => {
addRecentSearch('greet')
const user = userEvent.setup()
renderSearch()
await user.click(input())
await user.click(screen.getByRole('button', { name: 'Clear recent searches' }))
expect(screen.queryByRole('group', { name: 'Recent searches' })).not.toBeInTheDocument()
expect(getRecentSearches()).toEqual([])
})
it('closes on Escape, and a second Escape clears the text', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'pe')
expect(screen.getByRole('listbox')).toBeInTheDocument()
expect(await screen.findByRole('listbox')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(input()).toHaveValue('pe')
await user.keyboard('{Escape}')
expect(input()).toHaveValue('')
})
it('after Escape closes the dropdown, Enter searches everything instead of opening a hidden option', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'pet')
await screen.findByRole('listbox')
await user.keyboard('{Escape}{Enter}')
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
})
it('typing a new term drops a selection made by pointer for the previous one', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'pe')
await screen.findByRole('listbox')
fireEvent.mouseMove(option(/Search everything/))
expect(selected()).toHaveTextContent('Search everything')
await user.type(input(), 't')
expect(selected()).toHaveTextContent('Pet')
})
it('suggests completions of the last word from matching labels; choosing one fills the box', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'greet in')
const suggestions = await screen.findByRole('group', { name: 'Suggestions' })
expect(optionLabels(suggestions)).toEqual(['greet interactive'])
// a completion is never preselected: Enter still opens the top hit
expect(selected()).toHaveTextContent('Greet Interactive')
await user.click(option(/greet interactive/, suggestions))
expect(input()).toHaveValue('greet interactive')
})
it('Shift+Enter opens the results page even with a result selected', async () => {
const user = userEvent.setup()
renderSearch()
await user.type(input(), 'pet')
await screen.findByRole('listbox')
await user.keyboard('{Shift>}{Enter}{/Shift}')
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
})
it('never calls the backend without searchable tables (no record search capability)', async () => {
@@ -176,59 +268,119 @@ describe('GlobalSearch', () => {
})
describe('with record search', () => {
it('labels the box for records and lists found records by label with their table, after pages', async () => {
searchRecordsMock.mockResolvedValue([
{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Petersen' },
{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Pepper' },
])
it('groups found records by table with counts, highlights matched fields and links to more', async () => {
searchRecordsMock.mockResolvedValue({
results: [
{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: 'Avery Petersen', highlights: [{ fieldName: 'email', fieldLabel: 'Email', text: 'avery@pets.io', matches: [{ start: 6, end: 8 }] }] },
{ tableName: 'person', tableLabel: 'Person', recordId: '2', recordLabel: 'Sam Pepe', details: [{ fieldName: 'city', displayValue: 'Austin' }] },
{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Biscuit' },
],
tables: [{ tableName: 'person', resultCount: 2, hasMore: true, totalCount: 31 }, { tableName: 'pet', resultCount: 1, hasMore: false }],
})
const user = userEvent.setup()
renderSearch(people)
await user.type(recordInput(), 'pe')
const records = within(await screen.findByRole('group', { name: 'Records' })).getAllByRole('option')
expect(records.map((option) => option.querySelector('span span')?.textContent)).toEqual(['Avery Petersen', 'Pepper'])
expect(records[0].querySelectorAll('span span')[1]).toHaveTextContent('Person')
expect(records[1].querySelectorAll('span span')[1]).toHaveTextContent('Pet')
const personGroup = await screen.findByRole('group', { name: /^Person/ })
expect(personGroup).toHaveAccessibleName('Person 31 matches')
expect(optionLabels(personGroup)).toEqual(['Avery Petersen', 'Sam Pepe', 'See all 31 matches in Person'])
const avery = option(/Avery Petersen/, personGroup)
expect(avery).toHaveTextContent('Email: avery@pets.io')
expect(Array.from(avery.querySelectorAll('mark')).map((mark) => mark.textContent)).toEqual(['Pe', 'pe'])
expect(option(/Sam Pepe/, personGroup)).toHaveTextContent('Austin')
expect(within(personGroup).getByRole('option', { name: /See all 31/ })).toHaveAttribute('href', '/app/search?q=pe&tables=person')
expect(screen.getByRole('group', { name: /^Pet/ })).toHaveAccessibleName('Pet 1 matches')
expect(searchRecordsMock).toHaveBeenCalledTimes(1)
expect(searchRecordsMock).toHaveBeenCalledWith('pe', { tableNames: ['person', 'pet'], limitPerTable: 5 })
const groups = screen.getAllByRole('group').map((group) => group.getAttribute('aria-label'))
expect(groups).toEqual(['Pages', 'Records'])
expect(searchRecordsMock).toHaveBeenCalledWith('pe', expect.objectContaining({ tableNames: ['person', 'pet'], limitPerTable: 6, offset: 0, sort: 'relevance', signal: expect.any(AbortSignal) }))
})
it('opens a found record by keyboard', async () => {
searchRecordsMock.mockResolvedValue([{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }])
it('keeps "nothing selected" after ArrowUp when records arrive later, so Enter opens the results page', async () => {
// records answer well after the page matches are listed and the user pressed ArrowUp
searchRecordsMock.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 400))
return { results: [{ tableName: 'pet', tableLabel: 'Pet', recordId: '4', recordLabel: 'Petunia' }] }
})
const user = userEvent.setup()
renderSearch(people)
await user.type(recordInput(), 'zulu')
await screen.findByRole('option', { name: /Zed Zulu/ })
await user.keyboard('{ArrowDown}')
expect(screen.getByRole('option', { name: /Zed Zulu/ })).toHaveAttribute('aria-selected', 'true')
await user.type(recordInput(), 'pet')
await vi.waitFor(() => expect(selected()).toHaveTextContent('Pet'))
await user.keyboard('{ArrowUp}')
expect(selected()).toBeUndefined()
await vi.waitFor(() => option(/Petunia/), { timeout: 3000 })
expect(selected()).toBeUndefined()
await user.keyboard('{Enter}')
expect(pushMock).toHaveBeenCalledWith('/app/search?q=pet')
})
it('promotes an exact record match to top hits and opens it with Enter', async () => {
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', recordId: '7', recordLabel: 'Zed Zulu' }] })
const user = userEvent.setup()
renderSearch(people)
await user.type(recordInput(), 'zed zulu')
const top = await screen.findByRole('group', { name: 'Top hits' })
expect(option(/Zed Zulu/, top)).toHaveTextContent('Person')
expect(selected()).toHaveTextContent('Zed Zulu')
await user.keyboard('{Enter}')
expect(pushMock).toHaveBeenCalledWith('/app/person/7')
expect(getRecentSearches()).toEqual(['zed zulu'])
})
it('never preselects a record left over from the previous term while the new search runs', async () => {
searchRecordsMock.mockImplementation(async (term: string) => (term === 'acme'
? { results: [{ tableName: 'person', recordId: '1', recordLabel: 'Acme Buyer' }] }
: new Promise(() => undefined)))
const user = userEvent.setup()
renderSearch(people)
await user.type(recordInput(), 'acme')
await findOption(/Acme Buyer/)
await user.clear(recordInput())
await user.type(recordInput(), 'zeta')
await user.keyboard('{Enter}')
expect(pushMock).toHaveBeenCalledWith('/app/search?q=zeta')
expect(pushMock).not.toHaveBeenCalledWith('/app/person/1')
})
it('does not search a one-character term, and drops recent records that were found', async () => {
addRecentRecord({ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu', path: '/app/person/7' })
searchRecordsMock.mockResolvedValue([{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }])
searchRecordsMock.mockResolvedValue({ results: [{ tableName: 'person', tableLabel: 'Person', recordId: '7', recordLabel: 'Zed Zulu' }] })
const user = userEvent.setup()
renderSearch(people)
await user.type(recordInput(), 'z')
await new Promise((resolve) => setTimeout(resolve, 400))
expect(searchRecordsMock).not.toHaveBeenCalled()
await user.type(recordInput(), 'e')
await screen.findByRole('group', { name: 'Records' })
await user.type(recordInput(), 'u')
await screen.findByRole('group', { name: /^Person/ })
expect(screen.queryByRole('group', { name: 'Recently viewed' })).not.toBeInTheDocument()
// (jsdom names the highlighted label "Ze d Zulu": match the unhighlighted part)
expect(screen.getAllByRole('option', { name: /Zulu/ })).toHaveLength(1)
expect(screen.getAllByRole('option').filter((candidate) => /Zulu/.test(candidate.textContent ?? ''))).toHaveLength(1)
})
it('announces searching and failures, and says what nothing matched', async () => {
searchRecordsMock.mockRejectedValue(new Error('boom'))
it('names the failure class and the backend message, keeps page matches, and retries', async () => {
searchRecordsMock.mockRejectedValueOnce(new AxiosError('Error in record search provider', 'ERR_BAD_RESPONSE', undefined, null, { status: 500, data: {}, statusText: '', headers: {}, config: {} } as unknown as AxiosResponse))
const user = userEvent.setup()
renderSearch(people)
await user.type(recordInput(), 'qqqq')
await user.type(recordInput(), 'pet')
expect(screen.getByRole('status')).toHaveTextContent('Searching records…')
await screen.findByText('Record search failed. Try again.')
expect(screen.getByRole('listbox')).toHaveTextContent('No pages or records match “qqqq”')
const failure = await screen.findByText('Record search failed: server error (HTTP 500)')
expect(failure.closest('[data-qqq-id="search-failure"]')).toHaveAttribute('data-failure-kind', 'server')
expect(screen.getByText('Error in record search provider')).toBeInTheDocument()
expect(option(/^Pet/)).toBeInTheDocument()
searchRecordsMock.mockResolvedValueOnce({ results: [{ tableName: 'pet', recordId: '4', recordLabel: 'Petunia' }] })
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(await findOption(/Petunia/)).toBeInTheDocument()
expect(screen.queryByText(/Record search failed/)).not.toBeInTheDocument()
})
it('shows fallback results with the provider failure class when the index failed', async () => {
searchRecordsMock.mockResolvedValue({
results: [{ tableName: 'pet', recordId: '4', recordLabel: 'Petunia' }],
tables: [{ tableName: 'pet', resultCount: 1, hasMore: false, source: 'FALLBACK' }],
provider: { status: 'FAILED', failureClass: 'ConnectException', message: 'Connection refused' },
})
const user = userEvent.setup()
renderSearch(people)
await user.type(recordInput(), 'petu')
expect(await screen.findByText('Search index unavailable — showing basic matches instead')).toBeInTheDocument()
expect(screen.getByText('ConnectException: Connection refused')).toBeInTheDocument()
expect(option(/Petunia/)).toBeInTheDocument()
})
})
})
+121 -128
View File
@@ -15,32 +15,45 @@
*/
/**
* @file GlobalSearch — header search over apps, tables, processes, reports and recently
* viewed records (matched locally), plus records found by the backend record search when
* the metadata advertises searchable tables. Without searchable tables no search request is made.
* @file GlobalSearch — header search-as-you-type over apps, tables, processes, reports and
* recently viewed records (matched locally), plus records found by the backend record
* search when the metadata advertises searchable tables (without them no search request
* is made). The input is part of every page; the results panel loads on first focus.
*/
'use client'
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Search, ArrowRight } from 'lucide-react'
import { Search, X } from 'lucide-react'
import type { NavTarget } from '@/lib/hooks/use-routes'
import { useRecordSearch } from '@/lib/hooks/use-record-search'
import { cn } from '@/lib/utils/cn'
import { getRecentRecords } from '@/lib/utils/recent-records'
import type { RecentRecord } from '@/lib/utils/recent-records'
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
import type { SearchableTable } from '@/lib/utils/record-search'
import { NavigationSearchResults } from './NavigationSearchResults'
import { searchPagePath } from '@/lib/search/search-url'
import type { QuickSearchPanelController } from '@/components/search/QuickSearchPanel'
/** Records shown per table in the dropdown. */
const DROPDOWN_RECORDS_PER_TABLE = 5
/** The results panel, loaded when the search first opens. */
const QuickSearchPanel = dynamic(() => import('@/components/search/QuickSearchPanel').then((module) => module.QuickSearchPanel), { ssr: false })
/** Stable empty default for `searchTables`. */
const NO_SEARCH_TABLES: SearchableTable[] = []
/** DOM id of the results listbox. */
const LISTBOX_ID = 'header-search-results'
/** Keyboard hints under the results. */
const FOOTER = (
<div className="hidden items-center gap-4 border-t border-border px-3 py-1.5 text-[0.7rem] text-muted-foreground sm:flex" aria-hidden="true">
<span><kbd className="rounded border border-border px-1 font-mono">↑↓</kbd> navigate</span>
<span><kbd className="rounded border border-border px-1 font-mono">↵</kbd> open</span>
<span><kbd className="rounded border border-border px-1 font-mono">Ctrl/⌘ ↵</kbd> new tab</span>
<span><kbd className="rounded border border-border px-1 font-mono">⇧ ↵</kbd> all results</span>
<span><kbd className="rounded border border-border px-1 font-mono">esc</kbd> close</span>
</div>
)
/**
* Props for the GlobalSearch component.
*/
@@ -54,41 +67,46 @@ export interface GlobalSearchProps {
}
/**
* Inline header search with a dropdown of matching pages (apps, tables,
* processes, reports by label), records found by record search (when available)
* and recently viewed records.
* Inline header search with a dropdown of grouped results: top hits, pages, records per
* table (with counts and "see all" links), recently viewed records and recent searches.
*
* - With no text, the dropdown lists recently viewed records.
* - ArrowUp/ArrowDown move the selection; Enter opens the selected result, or
* the search page (`/app/search?q=…`) when nothing is selected; Escape closes.
* Keyboard: ↓/↑ move the selection (the first result is selected while typing), Enter
* opens it, Ctrl/⌘+Enter opens it in a new tab, Escape closes (and then clears).
* "Search everything" (or Enter before results load) opens the search page.
*
* @param props - Component properties.
* @returns A `<div>` containing the search combobox and, when open, a listbox dropdown.
* @returns A `<div>` containing the search combobox and, when open, its results.
*/
export function GlobalSearch({ navTargets, searchTables = NO_SEARCH_TABLES, className }: GlobalSearchProps) {
const router = useRouter()
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
// mounted once opened, so the panel keeps its state (recents, cache) between openings
const [panelMounted, setPanelMounted] = useState(false)
const [term, setTerm] = useState('')
const controllerRef = useRef<QuickSearchPanelController | null>(null)
const [activeDescendant, setActiveDescendant] = useState<string>()
const [hasContent, setHasContent] = useState(false)
const recordSearchAvailable = searchTables.length > 0
// Load recent records when the dropdown opens
useEffect(() => {
if (isOpen) {
setRecentRecords(getRecentRecords().slice(0, 10))
}
if (isOpen) setPanelMounted(true)
}, [isOpen])
const recordSearch = useRecordSearch(isOpen ? searchTerm : '', searchTables, DROPDOWN_RECORDS_PER_TABLE)
const items = useMemo(
() => buildNavigationSearchItems(navTargets, recentRecords, searchTerm, 8, recordSearch.results),
[navTargets, recentRecords, searchTerm, recordSearch.results]
)
const handleNavigate = useCallback((path: string) => {
setIsOpen(false)
inputRef.current?.blur()
router.push(path)
}, [router])
// Close dropdown on outside click
const handleController = useCallback((controller: QuickSearchPanelController | null) => {
controllerRef.current = controller
setActiveDescendant(controller?.activeDescendant)
setHasContent(controller?.hasContent ?? false)
}, [])
// Close on outside click
useEffect(() => {
/**
* Closes the dropdown when the user clicks outside the search container.
@@ -97,130 +115,105 @@ export function GlobalSearch({ navTargets, searchTables = NO_SEARCH_TABLES, clas
*/
function handleClickOutside(event: MouseEvent) {
if (!(event.target instanceof Node)) return
if (containerRef.current && !containerRef.current.contains(event.target)) {
setIsOpen(false)
}
if (containerRef.current && !containerRef.current.contains(event.target)) setIsOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// Reset selection when the result list changes
useEffect(() => {
setSelectedIndex(-1)
}, [items])
/**
* Navigates to the given path, closes the dropdown, and clears the search term.
* Keyboard handling: opens the dropdown, closes it on Escape (a second Escape clears the
* text), and otherwise defers to the results panel (↑↓, Enter, Ctrl/⌘+Enter).
*
* @param path - The URL path to navigate to.
* @param event - The input's keydown event.
*/
const handleNavigate = useCallback(
(path: string) => {
setIsOpen(false)
setSearchTerm('')
router.push(path)
},
[router]
)
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
event.preventDefault()
if (isOpen) setIsOpen(false)
else if (term) setTerm('')
else inputRef.current?.blur()
return
}
if (!isOpen && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
event.preventDefault()
setIsOpen(true)
return
}
if (controllerRef.current && isOpen) {
controllerRef.current.onInputKeyDown(event)
} else if (event.key === 'Enter' && term.trim()) {
// the panel is closed or still loading: Enter searches everything (never a hidden option)
event.preventDefault()
setTerm('')
handleNavigate(searchPagePath({ q: term }))
}
}
/**
* Handles keyboard navigation within the search dropdown.
*
* @param e - The synthetic keyboard event from the text input.
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true)
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < items.length) {
handleNavigate(items[selectedIndex].path)
} else if (searchTerm.trim()) {
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
inputRef.current?.blur()
break
}
},
[isOpen, selectedIndex, items, searchTerm, handleNavigate]
)
const hasTerm = searchTerm.trim().length > 0
const showDropdown = isOpen && (hasTerm || items.length > 0)
const showDropdown = isOpen && (term.trim().length > 0 || hasContent)
return (
<div ref={containerRef} className={cn('relative', className)} data-qqq-id="header-search">
{/* Search input */}
<div className="flex items-center gap-2 rounded-full border border-input bg-background px-4 py-2 text-sm transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-1">
<Search className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
value={term}
onChange={(event) => {
setTerm(event.target.value)
setIsOpen(true)
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={recordSearch.available ? 'Search...' : 'Jump to...'}
className="w-36 bg-transparent text-foreground placeholder:text-muted-foreground outline-none"
aria-label={recordSearch.available ? 'Search pages and records' : 'Search pages and recent records'}
placeholder={recordSearchAvailable ? 'Search…' : 'Jump to…'}
className="w-36 bg-transparent text-foreground outline-none placeholder:text-muted-foreground lg:w-48 2xl:w-64"
aria-label={recordSearchAvailable ? 'Search pages and records' : 'Search pages and recent records'}
aria-expanded={showDropdown}
aria-controls="header-search-results"
aria-controls={LISTBOX_ID}
aria-activedescendant={showDropdown ? activeDescendant : undefined}
aria-haspopup="listbox"
aria-autocomplete="list"
role="combobox"
autoComplete="off"
spellCheck={false}
data-qqq-id="input-header-search"
/>
{term && (
<button
type="button"
onClick={() => {
setTerm('')
inputRef.current?.focus()
}}
className="rounded p-0.5 text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Clear search"
data-qqq-id="button-header-search-clear"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</div>
{/* Dropdown */}
{showDropdown && (
<div className="absolute top-full right-0 mt-2 w-[22rem] max-w-[80vw] overflow-hidden rounded-xl border border-border bg-card shadow-lg z-50">
<NavigationSearchResults
id="header-search-results"
items={items}
term={searchTerm}
selectedIndex={selectedIndex}
onSelect={handleNavigate}
onHover={setSelectedIndex}
recordSearch={recordSearch}
{panelMounted && (
<div
hidden={!showDropdown}
className="absolute right-0 top-full z-50 mt-2 w-[min(36rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-border bg-card shadow-lg"
data-qqq-id="header-search-dropdown"
>
<QuickSearchPanel
listboxId={LISTBOX_ID}
open={isOpen}
term={term}
setTerm={setTerm}
navTargets={navTargets}
searchTables={searchTables}
onNavigate={handleNavigate}
onController={handleController}
listClassName="max-h-[min(70vh,32rem)]"
footer={FOOTER}
hideWhenEmpty
/>
{hasTerm && (
<div className="border-t border-border px-3 py-2">
<button
type="button"
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
data-qqq-id="button-header-search-all"
>
<ArrowRight className="h-3 w-3" aria-hidden="true" />
Show all matches
</button>
</div>
)}
</div>
)}
</div>
+14 -16
View File
@@ -20,8 +20,8 @@
'use client'
import React, { useRef, useState } from 'react'
import { Bell, Command, Menu, Search, HelpCircle } from 'lucide-react'
import React, { useRef } from 'react'
import { Command, Menu, Search, HelpCircle } from 'lucide-react'
import { GlobalSearch } from '@/components/layout/GlobalSearch'
import Breadcrumbs from '@/components/layout/Breadcrumbs'
@@ -53,10 +53,16 @@ export interface HeaderProps {
pathToLabelMap?: Record<string, string>
/** Maps node paths to their enclosing apps, passed through to Breadcrumbs. */
ancestorAppMap?: Record<string, ParentAppInfo[]>
/** Where the breadcrumb home crumb links (the instance's home app, or `/app`). */
homePath?: string
/** Navigable app-tree nodes for the header search. */
navTargets?: NavTarget[]
/** Tables the backend record search covers, for the header search (empty: local search only). */
searchTables?: SearchableTable[]
/** The assistant toggle, shown before the help button when the assistant is available. */
assistantButton?: React.ReactNode
/** The notifications bell (the Alerts qbit's), shown last when Alerts is available. */
notificationsButton?: React.ReactNode
}
/**
@@ -75,8 +81,7 @@ export interface HeaderProps {
* {@link GlobalSearch} + notifications bell on the right. The hamburger and
* GlobalSearch are each conditionally visible based on the `md` breakpoint.
*/
export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, onSearchOpen, onCommandOpen, onHelpOpen, helpButtonRef, pathToLabelMap = {}, ancestorAppMap = {}, navTargets = [], searchTables }: HeaderProps) {
const [notificationCount] = useState(0)
export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, onSearchOpen, onCommandOpen, onHelpOpen, helpButtonRef, pathToLabelMap = {}, ancestorAppMap = {}, homePath, navTargets = [], searchTables, assistantButton, notificationsButton }: HeaderProps) {
// toasts appear below the header, never over its controls
const headerRef = useRef<HTMLElement>(null)
useToastTopBelow(headerRef)
@@ -103,7 +108,7 @@ export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, on
>
<Menu className="h-5 w-5" aria-hidden="true" />
</button>
<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} />
<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} homePath={homePath} />
</div>
{/* Right section: search + help + notifications */}
@@ -139,6 +144,8 @@ export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, on
</button>
)}
{assistantButton}
{/* Keyboard shortcuts hint */}
<button
ref={helpButtonRef}
@@ -151,17 +158,8 @@ export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, on
<HelpCircle className="h-5 w-5 text-foreground/60" aria-hidden="true" />
</button>
{/* Notifications bell */}
<button
className="relative rounded-lg p-2 hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={notificationCount > 0 ? `${notificationCount} notifications` : 'No notifications'}
data-qqq-id="button-notifications"
>
<Bell className="h-5 w-5 text-foreground/60" aria-hidden="true" />
{notificationCount > 0 && (
<span className="absolute right-1.5 top-1.5 flex h-2 w-2 items-center justify-center rounded-full bg-destructive" />
)}
</button>
{/* Notifications bell (the Alerts qbit's; renders nothing when Alerts is not available) */}
{notificationsButton}
</div>
</header>
)
@@ -0,0 +1,107 @@
/*
* 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 navigation resize handle: separator semantics, keyboard resizing, reset and persistence
import React from 'react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { NAV_WIDTH_KEY, NAV_WIDTH_VAR } from '@/lib/utils/nav-width'
import { NavResizeHandle } from './NavResizeHandle'
/** A panel with the handle; jsdom has no layout, so the panel measures as 0 px wide. */
function Harness() {
return (
<div data-testid="panel">
<NavResizeHandle controls="nav" />
</div>
)
}
const panelVar = () => screen.getByTestId('panel').style.getPropertyValue(NAV_WIDTH_VAR)
const handle = () => screen.getByRole('separator', { name: 'Resize navigation' })
beforeEach(() => {
window.innerWidth = 1440
})
afterEach(() => {
localStorage.clear()
})
describe('NavResizeHandle', () => {
it('is a focusable vertical separator with its value range', () => {
render(<Harness />)
const el = handle()
expect(el).toHaveAttribute('aria-orientation', 'vertical')
expect(el).toHaveAttribute('aria-controls', 'nav')
expect(el).toHaveAttribute('aria-valuenow', '256')
expect(el).toHaveAttribute('aria-valuemin', '200')
expect(el).toHaveAttribute('aria-valuemax', '480')
expect(el).toHaveAttribute('tabindex', '0')
})
it('restores the stored width before paint', () => {
localStorage.setItem(NAV_WIDTH_KEY, '320')
render(<Harness />)
expect(panelVar()).toBe('320px')
})
it('resizes with the arrow keys and remembers the width', () => {
render(<Harness />)
fireEvent.keyDown(handle(), { key: 'ArrowRight' })
expect(panelVar()).toBe('272px')
expect(handle()).toHaveAttribute('aria-valuenow', '272')
fireEvent.keyDown(handle(), { key: 'ArrowRight', shiftKey: true })
expect(panelVar()).toBe('336px')
fireEvent.keyDown(handle(), { key: 'ArrowLeft' })
expect(panelVar()).toBe('320px')
expect(localStorage.getItem(NAV_WIDTH_KEY)).toBe('320')
})
it('jumps to the limits with Home and End', () => {
render(<Harness />)
fireEvent.keyDown(handle(), { key: 'End' })
expect(panelVar()).toBe('480px')
fireEvent.keyDown(handle(), { key: 'Home' })
expect(panelVar()).toBe('200px')
})
it('caps End at 40% of a narrower window', () => {
window.innerWidth = 1024
render(<Harness />)
expect(handle()).toHaveAttribute('aria-valuemax', '409')
fireEvent.keyDown(handle(), { key: 'End' })
expect(panelVar()).toBe('409px')
})
it('resets to the default and forgets the width on double-click', () => {
localStorage.setItem(NAV_WIDTH_KEY, '400')
render(<Harness />)
fireEvent.doubleClick(handle())
expect(panelVar()).toBe('')
expect(localStorage.getItem(NAV_WIDTH_KEY)).toBeNull()
expect(handle()).toHaveAttribute('aria-valuenow', '256')
})
it('ignores keys that do not resize', () => {
render(<Harness />)
fireEvent.keyDown(handle(), { key: 'Enter' })
expect(panelVar()).toBe('')
expect(localStorage.getItem(NAV_WIDTH_KEY)).toBeNull()
})
})
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff. Show more