Compare commits

...
Author SHA1 Message Date
james.maes 6c7eef0f9f Merge pull request #5: Next UI acceptance and QQQ-hosted default dashboard (QRun-IO/qqq#649)
Next UI acceptance, fixes and QQQ-hosted default dashboard (QRun-IO/qqq#649)
2026-09-25 16:36:14 -05:00
james.maes 2b6569f33b ci(acceptance): gate the phone project together with chromium 2026-09-25 16:05:46 -05:00
james.maes 657510542c ci(acceptance): build the sample from the QQQ checkout directory 2026-09-25 15:58:52 -05:00
james.maes e33cca670e chore(release): version 0.2.0 and report it from package.json (#649) 2026-09-25 15:53:26 -05:00
james.maes 44fff10964 test(acceptance): record James's approval of the three real-service exclusions (#649) 2026-09-25 15:52:12 -05:00
james.maes 24d573cb10 docs(acceptance): record the four-browser gate and the phone scope (#649, #694)
- browser-matrix.md: exact commands, engine versions, the @mobile scope
  and its phone rows, per-area/per-project results of the full run
  (chromium/firefox/webkit 326/326, mobile 13/13; gate 289 passed, 0
  failed, 0 missing, 3 excluded), and the engine differences fixed.
- README: browser selection, phone scope rules (@mobile, no skips),
  diagnostics handling of navigation-interrupted fetches.
- feature-matrix.md regenerated from the final gate.
2026-09-25 01:01:07 -05:00
james.maes 2c5eb2d0bf test(acceptance): recognize interrupted prefetches on every document origin (#649)
The security specs run against their own fixture server, so the UI URL is
not the only same origin: an interrupted Next.js prefetch is now matched
against the origins of the documents the page navigated to (SEC-010 in
WebKit).
2026-09-25 00:41:59 -05:00
james.maes 8712543266 test(acceptance): read WID-057 text block styles in the page (#649)
getComputedStyle() returned from evaluate() only serializes in Chromium;
Firefox and WebKit returned an empty object. Return fontSize and
fontWeight explicitly.
2026-09-25 00:21:08 -05:00
james.maes 9d57547042 fix(forms): store engine-neutral rich-text HTML (#649)
Firefox keeps a typed space before a formatting change as  
('Plain&nbsp;<b>Strong</b>'), so the same edit stored different HTML per
browser (REC-020 in Firefox). RichTextField now reports editing-only
non-breaking spaces as plain spaces; runs of spaces and leading, trailing
or block-edge spaces keep theirs. The editor DOM is not rewritten for its
own normalized value, so the caret stays put.
2026-09-25 00:21:07 -05:00
james.maes ff15d7eee4 test(acceptance): run the phone specs in the mobile project (#649, #694)
- The mobile (Pixel 7) project runs the tests tagged @mobile: navigation
  drawer (NAV-026, INT-008), card list (INT-008, INT-009), record view,
  forms (INT-007, PRC-009), process runs (PRC-001, PRC-004), dialogs
  (INT-003), sign-in/out and expiry (SEC-020..022) and the axe scan
  (INT-005). Desktop-only layouts are not phone scenarios.
- New INT-009 phone test: a held list shows busy placeholder cards, not
  'No records found' (QRun-IO/qqq#694).
- INT-005 and SEC-021 accept the card list where they checked the grid.
- The gate reports per-project counts and fails a project that ran no
  tests.
2026-09-25 00:01:17 -05:00
james.maes cdb9549efd test(acceptance): ignore WebKit reports of Next.js prefetches cut off by navigation (#649)
WebKit raises '<url> due to access control checks.' for same-origin
Next.js route prefetches and RSC payload fetches that a document
navigation cancels (the server answered 200; Playwright never sees the
request). The diagnostics fixture now ignores such a report only when it
names a same-origin Next.js route/payload URL and arrives within a second
of a main-frame navigation, and records it under interruptedFetches.
API, cross-origin and unrelated access-control failures still fail.
2026-09-25 00:01:16 -05:00
james.maes 983e8d8230 fix(navigation): keep the metadata document title after client navigation (#649)
Next.js re-applies the route's static metadata title ('QQQ Admin') after a
client navigation; in WebKit that lands after the layout effect, so key
lookups and not-found pages kept the generic title (NAV-023, NAV-006).
useDocumentTitle sets the breadcrumb title and restores it when the head
changes.
2026-09-25 00:01:15 -05:00
james.maes c799b98bdf test(acceptance): make the security, process and relationship rows hold in the combined instance (#649)
- AcceptanceSampleServer: signing in again replaces an expired mock
  session (the manageSession persona carry-over made re-authentication
  after expiry impossible; SEC-022 first load).
- PRC-041: watch for the visit to /login?returnTo=... instead of polling
  the URL; mock sign-in returns to the process within milliseconds.
- SEC-007: the query Actions menu now carries the table's bulk actions,
  which follow the table rights noProcesses keeps; assert it offers only
  those and no standalone process.
- SEC-011: the query fixture shares alice's stock view with bob; prove
  record-level security on a view alice shares with nobody, and compare
  saved views/reports before and after instead of fixed row lists.
- INT-001: start the arrow-key step from the row above Blair in the
  current (Material id-descending) sort.
- REL-006: prove the unavailable panel on a child table denied with
  DenyBehavior.DISABLED (qryShelter/qryShelterPet); HIDDEN children are
  absent per SEC-001 (QRun-IO/qqq#671).
2026-09-24 23:48:57 -05:00
james.maes d9ddd1609d fix(records): do not expand associations whose table the user cannot read (#671, #649)
A child table denied with DenyBehavior.DISABLED is listed without read
permission. Its panel already says the related records are unavailable;
the record page no longer requests the all-or-nothing association
expansion that the backend refuses for it.
2026-09-24 23:48:50 -05:00
james.maes e9ce3f1931 fix(records): card list loading state and action sheet focus on phones (#694, #649)
- RecordCardView shows busy placeholder cards while the first page loads
  instead of 'No records found' (the grid already shows a skeleton).
- The record action sheet moves focus in on open, closes on Escape, and
  returns focus to its 'Record actions' trigger; a dialog opened from a
  sheet item (Delete) now restores focus to that trigger instead of the
  body, because the sheet item unmounts when the sheet closes.
- Copy from the sheet encodes table and record identifiers like the
  desktop actions do.

Refs QRun-IO/qqq#694
2026-09-24 23:48:49 -05:00
james.maes 947300b734 chore(merge): integrate the security and interaction area (#649)
Merge worktree-agent-ae5700825dc9daf72 (auth/session, permission gating,
a11y fixes, security specs and fixtures) into the integration branch.

Conflict resolutions:
- auth-provider: keep the security session model (auth-storage sessionUser);
  restore the anonymous@localhost fallback email the records work relies on.
- use-record-query: keep table-variant keys/params and distinct counts from
  the integration branch with the security branch's revalidate-on-mount.
- use-widget: keep UseWidgetOptions, keepPreviousData, retry:false and the
  unified HANDLES_OWN_ERRORS meta; enabled:false still skips the request.
- ConnectedWidget/AppHome: DISABLED-denied widgets keep their frame with a
  permission message and load no data (SEC-009); widget export kept.
- BulkActionBar: keep the integration action-list bar; bulk shortcuts are
  already gated by table permission and capability in buildActionEntries.
- RecordQuery: canInsertRecords for create; DataGrid keeps the resize
  handle aria values from the query work.
2026-09-24 23:34:18 -05:00
james.maes 8484d7e4c6 fix(auth): leave for the identity provider without a client navigation first; cross-browser security specs (#649)
Provider logout and sign-in retries no longer start an in-app navigation just
before leaving the page, which Firefox reported as an RSC fetch failure. Security
specs now run in all four projects: Option+Tab on WebKit, per-engine aborted
request text, card-or-grid list access on phones, Node-side Set-Cookie checks.
Adds the browser matrix record.
2026-09-24 23:25:21 -05:00
james.maes a7962d1ba6 fix(deps): override patched @humanfs/node and @babel/core (#647) 2026-09-24 23:08:06 -05:00
james.maes 850f180e5e fix(deps): upgrade dompurify to 3.4.16 (#647)
Clears the runtime HTML sanitizer advisories; production audit reports no known vulnerabilities.
2026-09-24 23:06:38 -05:00
james.maes f856d926ba test(acceptance): keep RPT-018 independent of seeded view shares (#649) 2026-09-24 23:02:01 -05:00
james.maes 523da4ca49 test(acceptance): pin NAV-017 quick actions to the combined instance (#649)
Pet Species is enumeration-backed and not insertable; assert it is omitted and Pet Note fills the sixth slot.
2026-09-24 23:02:01 -05:00
james.maes 50f4e3fe4d test(acceptance): launch PRC-005 from the query Actions menu (#649) 2026-09-24 23:02:00 -05:00
james.maes 76105121cb test(acceptance): wait for selection launches on the registered process route (#649) 2026-09-24 23:01:59 -05:00
james.maes 0ba5fe447e fix(query): keep the column configuration panel within the viewport (#649)
The fixed panel now caps its height at the room below its button, so every column stays reachable when banners push the toolbar down.
2026-09-24 23:01:58 -05:00
james.maes 82df289ba7 test(acceptance): keep the test persona and user through mock manageSession (#649) 2026-09-24 22:55:28 -05:00
james.maes 1714c6a2c3 Merge query and relationships area work (#649) 2026-09-24 22:41:30 -05:00
james.maes 3ddb1311f8 test(acceptance): align area specs with the combined instance (#649)
- WID-021, RPT-010: the last frontend step before backend work submits
  ("Submit") and reports download through the process download form.
- WID-061: process metadata comes from the registered /metaData/process route.
- PRC-042, RPT-007: denied objects show the not-found state.
- NAV-025: other areas add pages matching "pet"; require every result to
  match and the pet tables to keep their order.
- RPT-012: Is Active defaults on from metadata, so check it rather than
  toggle it; clear memory-backend scheduled reports on every reset so each
  test starts without the rows earlier tests created.
2026-09-24 22:38:06 -05:00
james.maes 1a649a5458 fix(forms): render the cron schedule widget section on record forms (#649)
With hidden sections now hidden on record forms, the scheduled report form
had no way to set its schedule: its cron expression and time zone fields
sit in a hidden section and are edited through the cronUI widget section
(includeOnRecordEditScreen), which forms did not render. Record forms now
render the fields a cronUI widget section edits in that section, as
Material's EntityForm does, and keep the rest of the hidden section hidden.
2026-09-24 22:38:05 -05:00
james.maes 57b80eb1fd Merge widgets and reports area work (#649) 2026-09-24 22:14:47 -05:00
james.maes eca667af73 test(acceptance): align security specs with the integrated navigation and records work (#649)
Use the shared not-found state, the records area's password rendering and form
error messages, mirror the noApps persona in the security variant, and use
repo-qualified issue references in the matrix.
2026-09-24 22:12:35 -05:00
james.maes e9eb14f4f0 fix(types): drop duplicate help content format declaration 2026-09-24 22:10:42 -05:00
james.maes 301bf3b31b test(acceptance): query and relationships matrix, fixture and specs (#649)
50 QRY/REL rows covering filters and operators, sort, paging, search,
columns, exposed joins, column statistics, selection handed to processes,
backend export, saved views, table variants, capabilities, associations
(parent/child, three-level, aliased and composite, denied reads, cascade
delete) and related links, each verified against SQL and the backend API.
2026-09-24 22:10:20 -05:00
james.maes a98aa8f089 fix(query): bring record query to Material parity on the live backend (#649)
- Filter builder: Material per-type operators and labels, every backend
  operator (LIKE, NOT_LIKE, IS_NULL_OR_IN, TRUE, FALSE), relative date
  expressions in the backend format, exposed-join fields, UTC date-times,
  typed values, stable rows while typing, labelled possible-value chips (#684)
- Query state: quick search ANDed with the filter, joins sent as LEFT only
  when used with join columns and distinct counts, primary-key-descending
  default sort kept in the URL, Material page sizes, foreign URL params
  preserved (#686)
- Saved views through querySavedView/storeSavedView/deleteSavedView with
  your and shared groups, unsaved-change tracking and the savedView route;
  anonymous sessions use the manageSession user (#687)
- Backend CSV/XLSX/JSON export of visible columns and the current filter,
  gated by TABLE_EXPORT (#688)
- Selection of the page, the full query result or the first N handed to
  processes without the page skip/limit; Actions menu with bulk processes
  gated by capability, permission and process (#689, #662)
- Table variants from /data/{table}/variants sent with query, count and
  export and remembered per table (#690)
- Related-record links in grid cells, empty null booleans, column
  statistics for QUERY_STATS tables (#691)
- Capability gating for count, export, create and bulk actions (#671)
2026-09-24 22:10:19 -05:00
james.maes eff2d11254 Merge processes area work (#649) 2026-09-24 22:09:27 -05:00
james.maes 730b38930a test(acceptance): widgets and reports matrix, fixture and specs (#649)
75 WID/RPT rows: one per canonical widget type plus chrome, controls, states,
permissions, parents, blocks, record-view widgets, reports in each format,
saved and scheduled reports, and sharing. WidgetsFixtures adds owned dashboards
for every widget shape, a record-view host table, data bags, scripts, reports
of each type, owned saved reports, and a loopback fake service so the real
QuickSight renderer, a custom component bundle, an image and audio run without
external accounts. The app home lays widgets out in QQQ's 12-column grid.
2026-09-24 22:09:24 -05:00
james.maes ce7a5a690a feat(reports): run reports through their process and add record sharing (#664, #665)
Reports: take the reports map (absent from V1 metadata) from the full metadata
route so report pages resolve; run each report through its process with the
chosen format (CSV, XLSX, JSON), prompt for its input fields with required
validation, poll asynchronous jobs and offer the generated file; reports
without a process use the streaming /reports route.

Sharing: shareable tables get a Share action on the record view (owner only,
with the reason shown to others) and a dialog that lists audiences, shares
with a scope, changes a share's scope and removes shares; recipient ids with
colons are kept whole (#444). Mock/anonymous sessions now take the user the
session identifies, so ownership checks compare real identities (#371).
2026-09-24 22:09:15 -05:00
james.maes 23a855297d test(acceptance): security and interaction matrix, fixtures, variants and specs (#649)
SEC rows cover hidden/disabled tables, processes, widgets and apps, CRUD and
capability denials with backend enforcement and SQL readback, record security
locks, protected fields, mid-session revocation, MOCK sessions (sign-in, logout,
expiry, denial, returnTo), OAUTH2 and AUTH_0 against an owned local OIDC
provider, FULLY_ANONYMOUS and unsupported TABLE_BASED. INT rows cover keyboard
flows, dialogs and focus, axe WCAG 2.1 AA scans, labels and validation, phone
layout, and held, failed, delayed and stale requests.

SecurityAcceptanceServer is a security-only backend variant (own port) that adds
SecurityFixtures to the stock sample and selects the authentication module.
2026-09-24 22:09:09 -05:00
james.maes 818935ec23 fix(a11y): keyboard-open grid rows, keep focus while typing, trap and restore dialog focus (#682, #676)
Enter on a grid cell opens its record. Form fields keep one wrapper element so
the first keystroke no longer remounts the input and drops focus. The command
palette traps Tab and restores focus on close; state-controlled Radix dialogs
(delete confirmation, keyboard shortcuts) return focus to what opened them.
Column resize separators expose their value (axe aria-required-attr).
2026-09-24 22:09:09 -05:00
james.maes cc58e1dcbb feat(widgets): render every canonical QQQ widget type with Material parity (#550)
Add renderers for all backend WidgetType values: multiStatistics, table,
multiTable, stepper, smallLine/horizontal/stacked bar charts, fieldValueList,
location, usaMap, quickSightChart, process, parentWidget (grid and tabs),
QQQ composite blocks (every block type and layout), customComponent, cronUI,
dynamicForm, dataBagViewer, pivotTableSetup, filterAndColumnsSetup, rowBuilder,
scriptViewer, generic, and child record lists without an association (#663).

Widget chrome now follows Material (#666): payload-driven PVS and DATE_PICKER
dropdowns with stored selections, the backend's required-selection message,
CSV export of csvData, reload, tooltips, help content, header icons, card and
plain widgets, footers, 12-column gridColumns sizing, hidden and empty alerts,
contained error and malformed-payload states without retries or error toasts.
Record-view section widgets receive the record context; form sections without
fieldNames no longer crash.
2026-09-24 22:09:03 -05:00
james.maes 1d28e4608a fix(data): revalidate records and lists, keep one record key, never retry writes (#683, #673)
Lists and records revalidate whenever they are shown again instead of serving a
30 s / 5 min cache, record keys normalize the primary key so a save refreshes
the viewed record, deleting no longer re-requests the deleted record, and
mutations are never retried automatically (writes are not idempotent).
2026-09-24 22:08:27 -05:00
james.maes 8cc626d61f fix(security): gate disabled tables, processes and widgets and honor table capabilities (#671, #651)
Objects denied with DenyBehavior.DISABLED are listed with their permission flag
false: list, record, create, edit and process links now explain the denial and
never load or start them; app homes show them as disabled tiles; /app quick
actions skip them. Disabled widgets show Material's permission message without
requesting data. Record actions, list toolbar, bulk bar and write pages require
both the permission and the table capability. Record views neither request nor
show associations whose table is hidden from the user. Apps without widgets or
section arrays no longer crash the landing page or app home.
2026-09-24 22:07:14 -05:00
james.maes 439349ddeb fix(processes): keep step wizard label tooltips open on keyboard focus (#649)
Radix Tooltip closes on any scroll containing its trigger, so focusing an
off-screen step label never showed its full text. Each step now uses the
focus-safe tooltip hook (copied verbatim from the integration branch) on its
focusable trigger: the back button for completed steps, the label itself
otherwise.
2026-09-24 22:05:33 -05:00
james.maes b10ce42f64 fix(auth): complete sign-in, logout and session expiry for every auth type (#669, #670, #332)
- MOCK / FULLY_ANONYMOUS: manageSession (JSON body v1 reads) creates the session and
  its values.user is the displayed identity; a denied session is an error, never an
  anonymous fallback.
- OAUTH2: authorization code + PKCE with provider discovery, backend scopes and the
  Material-compatible /token redirect URI; the backend redeems code + verifier
  through the unversioned manageSession (v1 accepts only accessToken, #406);
  reloads resume the session from its cookie.
- AUTH_0: the browser redeems the code as a public client and posts the token.
- Logout ends the backend and provider sessions, clears cached queries, recent
  records and the stored identity, and keeps the tab signed out until Sign in.
- A 401 routes to /login?returnTo and signs in again automatically (with a loop
  guard); session endpoints report their own 401s.
- returnTo is limited to same-origin in-app paths (open redirect).
- API errors carry the backend message instead of the generic axios text.
2026-09-24 22:04:57 -05:00
james.maes 350be92d6d chore(acceptance): accept repo-qualified issue references in the matrix 2026-09-24 22:04:19 -05:00
james.maes 04e738671a test(acceptance): link REC-036 to #685 2026-09-24 22:04:08 -05:00
james.maes d72143e7d1 fix(a11y): keep keyboard-opened tooltips open through the focus scroll (#649)
Radix Tooltip closes on the scroll that focusing an off-screen trigger causes; add noApps persona and make navigation specs robust to other areas' fixtures.
2026-09-24 22:03:50 -05:00
james.maes 93afb572f8 test(acceptance): processes matrix, fixture and real-backend specs (#649)
47 PRC rows covering record selection (selected, all, zero, none, from the
query grid), min/max input records, every frontend component on mixed
screens, validation, possible values, downloads, back steps, dynamic step
lists and updated fields, async progress, cancel from a screen and mid-job,
errors and retry, completion, record-list paging, streamed ETL review and
summary lines, bulk edit/delete/insert/edit-with-file with saved bulk load
profiles, named/seeded/ad hoc widgets, scanner and state-machine flows,
reload, expired session and permissions. Persisted effects are checked with
SQL and server enforcement through the backend API.

The owned fixture adds a Process Lab app (component lab, route wizard,
progress with cancel step, bounds, flaky loader, failures, widgets, drive,
early finish, many rows, scanner, loop, quick task), a specimen table, a
memory upload archive for bulk loads, and saved bulk load profile tables.

PRC-039 (Google Picker) needs an external Google account: required false,
approval pending. PRC-041 needs the backend fix for #668.
2026-09-24 21:59:51 -05:00
james.maes 0d477151f8 feat(processes): save, choose and delete saved bulk load profiles (#660)
The file mapping, value mapping and profile screens offer the saved bulk
load profile processes when the application defines them: choose a saved
profile to restore its mapping, Save / Save As the current mapping, and
delete it, re-submitting savedBulkLoadProfileId so review and result name
the profile in use.

Found under #649.
2026-09-24 21:58:12 -05:00
james.maes cc0f91c3e1 Merge branch 'worktree-agent-abc519c235b8dc3e7' into feature/GH-649-next-acceptance 2026-09-24 21:55:35 -05:00
james.maes 6b0e305a9c test(acceptance): navigation matrix, fixture and specs (#649) 2026-09-24 21:54:36 -05:00
james.maes f180a469c5 fix(navigation): render nav, app homes and branding from v1 metadata
Nested app tree, breadcrumbs, titles, icons, banners, local search, not-found and key routes (#538 #539 #651 #652 #653 #654).
2026-09-24 21:54:32 -05:00
james.maes a78af68929 chore: ignore Playwright MCP output 2026-09-24 21:48:58 -05:00
james.maes 4ead47f053 Merge branch 'worktree-agent-a30785eaa4b97f110' into feature/GH-649-next-acceptance 2026-09-24 21:48:45 -05:00
james.maes 7142274d76 test(acceptance): add the records matrix, Record Lab fixture and specs (#649)
48 REC rows cover record view, create/edit/copy/delete, every field type and
editor, Field Lab policies (length, case/whitespace, ranges, unique keys,
dynamic defaults, zoned date-times), every adornment, field/section/table
help, audits (process and audit-table paths), record labels, possible-value
editors, a read-only persona and keyboard use. Each test verifies persisted
values with SQL and diagnoses console/request noise.

RecordsFixtures adds the Records Lab app with the Record Lab table (every
adornment, help content in each role and format, hidden field and section,
unique key, FIELD audits, heavy and formatted file fields), the standard
audit tables and GetAuditsForRecord, and seeds two rows.

REC-039 (field help) needs the v1 field helpContents from the backend branch
feature/GH-649-records-backend (#406, #681).
2026-09-24 21:47:37 -05:00
james.maes 6c78038ced feat(processes): compose every declared process component on one screen (#645)
Rewrite the Next process runtime for parity with the Material dashboard:

- use the registered process routes (/processes/..., /metaData/process) that
  accept uploads, step back with isStepBack and report min/max input records;
  send tableName on init and only the screen's values on each step (#656)
- render every declared component in order with one shared form and one
  action bar: HELP_TEXT, EDIT_FORM, VIEW_FORM, RECORD_LIST, HTML, DOWNLOAD_FORM,
  VALIDATION_REVIEW_SCREEN, PROCESS_SUMMARY_RESULTS, WIDGET, BULK_EDIT_FORM,
  BULK_LOAD_* and GOOGLE_DRIVE_SELECT_FOLDER (#645)
- Back only to the backend backStep (#655); Next/Submit/Return per step flow
- poll async jobs every 1.5s with RUNNING progress, bounded error backoff (#657)
- retry with the original record selection (#658)
- validation review, summary lines and results from QQQ values (#659)
- real bulk load file/value mapping and profile screens (#660)
- named, seeded and ad hoc composite widgets with action blocks (#661)
- min/max input records, updatedFrontendStepList and updatedFields (#662)
- scanner-format screens, state-machine flows, defaultProcessValues links
- possible-value selects show the label of an initial value (#667)

Found under #649.
2026-09-24 21:47:36 -05:00
james.maes 5647e3c8e3 fix(records): make record view, forms and adornments follow QQQ metadata (#649)
- Delete no longer refetches the deleted record: no "Resource not found"
  toast or 404 after a successful delete (#541).
- Forms keep keyboard focus while typing; the HTML editor keeps its caret (#676).
- Edit resends only values that round-trip: masked passwords, LONGs,
  date-times and files are sent only when changed (#672); DATE_TIME is shown
  and entered in the viewer's zone and sent as UTC; typed LONGs stay exact (#679).
- Create applies metadata defaults; edit shows stored values and read-only
  fields; hidden sections are skipped; the server's too-long policy is not
  pre-empted; files can be removed (#680).
- Adornments use the backend value keys: LINK, CHIP, FILE_DOWNLOAD, TOOLTIP,
  SIZE (grid width), REVEAL, RENDER_HTML, ERROR, plus WIDGET rendering and a
  CODE_EDITOR editor; heavy fields show on the record view (#677).
- Audit history uses GetAuditsForRecord, or the audit table without process
  permission (#678).
- Field and section help follow screen roles and formats (#681).
- Server validation errors show the backend message once and keep the form (#673).
- Possible-value editors show the stored label, look up default labels and
  work from the keyboard (#667).
2026-09-24 21:47:27 -05:00
james.maes 33c2335c94 docs: describe the QQQ-hosted default dashboard and acceptance suite (#649) 2026-09-24 20:16:58 -05:00
james.maes 14d5b9a831 ci: add real-backend acceptance workflow and Maven Central dashboard publish (#649) 2026-09-24 20:15:39 -05:00
james.maes 6359846bba test(acceptance): enable sample sharing identities in the fixture (#649) 2026-09-24 20:02:46 -05:00
james.maes f276ea841a feat(acceptance): serve Next from QQQ and add real-backend acceptance harness
Static export mode with placeholder routes and Maven packaging for the Javalin-served default dashboard, plus the matrix-gated acceptance suite (QRun-IO/qqq#649).
2026-09-24 19:54:47 -05:00
james.maes 5b5af2ce18 Merge pull request #4 from QRun-IO/feature/qqq-641-local-quickstart
feat(quickstart): runnable Next image and real process walkthrough
2026-09-24 15:52:38 -05:00
james.maes d4c2ecc084 fix(ci): wait through dashboard startup resets (#641) 2026-09-24 15:50:14 -05:00
james.maes fec12b630f fix(security): resolve Next High and Critical dependencies (#646) 2026-09-24 15:42:59 -05:00
james.maes 68f850aa1c feat(quickstart): publish standalone Next dashboard image (#641) 2026-09-24 15:20:49 -05:00
james.maes a9c547f706 fix(process): load actual paginated record previews (#643) 2026-09-24 15:19:23 -05:00
james.maes 3040d20b45 fix(process): load metadata and preserve input selection (#642) 2026-09-24 15:16:07 -05:00
james.maes 2d84445645 fix: align Next dashboard with QQQ 4.0 contracts
Preserve native API and permission behavior, document deferred widget parity, and pin the verified package manager. Refs QRun-IO/qqq#498, QRun-IO/qqq#540, QRun-IO/qqq#549, QRun-IO/qqq#616.
2026-09-24 06:50:24 -05:00
James MaesandClaude Opus 4.8 a8b4b65373 docs: add CLAUDE.md pointer to QQQ knowledge-base dossier + hub
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 05:31:50 -05:00
james.maesandClaude Opus 4.6 41cd6d2034 docs: implement all documentation review fixes (DOC-CRIT + DOC-SIG)
Addresses every actionable item from docs/DOCUMENTATION-REVIEW-2026-03-01.md:

DOC-CRIT-1: README.md — expanded from one line to full project README
  - Prerequisites, quick start, command table, env vars, directory overview,
    tech stack summary, links to CLAUDE.md and all docs

DOC-CRIT-3: docs/STATE-MANAGEMENT.md — decision guide for all state containers
  - Decision matrix table; TanStack Query, React Hook Form, URL params,
    localStorage (with actual key names), QContext, useState/useReducer — each
    with scope rules, code examples, and what NOT to put there

DOC-SIG-1: docs/COMPONENT-PATTERNS.md — five architectural patterns
  - Metadata-driven rendering, data-qqq-id convention, useAsyncCombobox,
    widget rendering pipeline, field adornment priority — all grounded in
    actual source code with examples

DOC-SIG-2: docs/TYPE-GLOSSARY.md — ~45 types across 6 domain sections
  - App/Auth, Metadata, Records/Queries, Processes, Widgets, Enums
  - Key-field descriptions, usage examples, import cheat sheet

DOC-SIG-3: docs/API-ERROR-HANDLING.md — error handling architecture
  - 401 interceptor, global query/mutation handler, status-by-status guide
    (400→form fields, 403→hide/toast, 404→empty state, 5xx→toast+retry),
    when to add per-query handling, retry back-off, complete form example

DOC-SIG-4: zod-from-metadata.ts — @example added to zodFieldFromMetadata
  - Shows STRING required+maxLength and INTEGER optional+bounds examples

DOC-SIG-5: docs/FORM-VALIDATION.md — end-to-end validation strategy
  - Schema generation, field-type→Zod mapping table, required vs optional,
    number bounds, default values, client vs server validation, custom
    refinements, DynamicFormField dispatch, complete worked example

DOC-MIN-3: docs/ACCESSIBILITY.md — WCAG 2.1 AA reference
  - All implemented items, CSS media queries, testing guide, new-component
    checklist, outstanding items

552/552 tests pass; 0 TypeScript errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:34:11 -06:00
james.maesandClaude Opus 4.6 8b8e1c7c03 fix: add missing JSDoc to ValidationReviewStep; add documentation review
- ValidationReviewStep.tsx: move ValidationFilter type definition above
  the component's JSDoc block to satisfy jsdoc/require-jsdoc (was failing CI)
- docs/DOCUMENTATION-REVIEW-2026-03-01.md: full documentation audit with
  3 critical gaps (README, useRecordQuery return type, state-management guide),
  6 significant gaps, and 4 minor items; overall score 7.5/10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:20:03 -06:00
james.maesandClaude Opus 4.6 12edc4cf1e feat: resolve all design-review issues (MED + LOW)
Addresses every open item from docs/DESIGN-REVIEW-2026-03-01.md:

Layout shell (D-L / D-FB / D-R / D-T)
- D-L-3: Mobile search button in Header; prop wired through dashboard layout
- D-L-4: Banner.tsx persists dismissed keys to localStorage (qqq:dismissed-banners)
- D-L-5: Sidebar aria-expanded on mobile drawer
- D-L-6: Breadcrumb segments truncated (max-w-[8rem]) + flex-wrap on small screens
- D-L-7: Sidebar loading skeleton (6 animated bars) while routes are empty
- D-FB-1: HelpCircle button in Header triggers CommandMenu keyboard-shortcuts view
- D-FB-3: CommandMenu close button upgraded to 44 × 44 px touch target
- D-FB-4: GlobalSearch group headers: font-semibold uppercase tracking-wide
- D-T-3: qqq-theme.css: --qqq-primary-color / --qqq-accent-color token aliases
- D-T-4: CommandMenu z-index uses var(--qqq-z-toast) CSS token
- D-T-5: Dark-mode focus ring uses solid var(--color-ring), not 50% opacity
- D-T-6: --color-disabled-bg / --color-disabled-text in @theme + dark overrides
- D-R-3: Breadcrumbs flex-wrap so items reflow at 320 px

Record query (D-Q / D-R)
- D-Q-5: Pagination "go to page" input validates range; shows role="alert" on error
- D-Q-6: DataGrid aria-live region announces "Loading results" on background refetch
- D-Q-7: ColumnConfig SR announcement after column show/hide toggle
- D-Q-8: Sticky first two columns (checkbox + first data col) with CSS position:sticky
- D-R-2: Pagination navigation buttons and ColumnConfig close button ≥ 44 px targets

Record view (D-V)
- D-V-2: AssociatedRecords shows first 25 rows; "Show more (N remaining)" button
- D-V-3: getInitials() returns '?' for empty/whitespace display names
- D-V-5: Copy record-ID button (ClipboardCopy → Check icon swap + sr-only feedback)
- D-V-6: Audit trail footer shows createDate / modifyDate when present

Forms (D-F)
- D-F-2: NumberField renders min/max hint text; aria-describedby wired
- D-F-3: DateTimeField shows local timezone hint (Intl.DateTimeFormat)
- D-F-4: FieldHelpTooltip: tooltip on desktop; inline hint text on mobile
- D-F-5: FileUploadField drag-over overlay "Drop file here" with border-primary
- D-F-6: DynamicFormField DirtyWrapper highlights dirty fields with left border;
         dirtyFields prop threaded through EntityForm → DynamicForm → field

Process (D-P)
- D-P-2: StepWizard completed steps are clickable (onStepClick callback)
- D-P-3: ProcessRun shows timeout warning banner after 60 s of polling
- D-P-4: ValidationReviewStep filter (All / Errors / Warnings) + search input
- D-P-5: ProcessUploadFormStep indeterminate progress bar during upload
- D-P-6: StepWizard step labels wrapped in Radix Tooltip for long names
- D-P-8: BulkLoadStep save/load column mapping to localStorage

Widgets (D-W)
- D-W-2: WidgetBlock error state visual refresh (AlertCircle icon + Retry link)
- D-W-3: Bar/Line/PieChartWidget: ResizeObserver-based responsive legend layout
- D-W-4: RecordGridWidget caps display at 25 rows; "View all N records" link
- D-W-6: Chart palette extended from 5 to 10 colors (--color-chart-6…10)

Global CSS (D-T / D-L)
- D-T-1: prefers-reduced-motion collapses animation/transition durations
- D-T-2: prefers-contrast:more uses full-black borders + solid focus ring
- D-R-1: Mobile inputs/selects/textareas set to 16 px to prevent iOS zoom

Tests: 552/552 pass; 0 TypeScript errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:13:05 -06:00
james.maesandClaude Sonnet 4.6 511c005cad fix: address all must-fix WCAG 2.1 AA issues from design review
- globals.css: add prefers-reduced-motion rule (collapses all animation/
  transition durations to 0.01ms for users who prefer reduced motion)
- globals.css: add prefers-contrast: more rule (full-black borders, inputs,
  and muted text; solid focus ring for high-contrast OS mode)
- globals.css: add mobile input font-size: 16px rule (prevents iOS Safari
  auto-zoom on input focus for viewports ≤767px)
- DataGrid.tsx: make column resize handles keyboard-accessible — added
  handleResizeKeyDown (±10px on ArrowLeft/ArrowRight), role="separator",
  aria-orientation, aria-label, tabIndex=0, and focus-visible styling
- ProcessRun.tsx: auto-focus step heading on step transition — stepHeadingRef
  + useEffect watches currentStep.name; skips initial mount via stepFocusedRef
- StatisticsWidget.tsx: add sr-only direction label to TrendBadge so screen
  readers announce "trending up, +5%" instead of just "+5%"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:02:14 -06:00
james.maesandClaude Sonnet 4.6 bca0cc7d6d fix: resolve remaining open security and code-quality issues
Security:
- MED-14: Replace stateful regex.test() with i % 2 !== 0 in HighlightedText
  (GlobalSearch.tsx) — g-flag regex lastIndex was causing alternating highlights
- MED-6: Add isSafeRedirectPath() to string-utils.ts; use it in RecordQuery.tsx
  and RecordView.tsx to reject protocol-relative URLs (//evil.com open redirect)

Code quality:
- CQ-CRIT-1: Split 822-line use-record-query.ts god-hook into three files —
  use-column-config.ts (189L) and use-saved-views.ts (163L) extracted;
  public API of useRecordQuery unchanged, zero consumer changes required
- CQ-CRIT-3: Add buildNumberSchema() helper to zod-from-metadata.ts;
  eliminates ~34 lines of duplicated min/max logic across INTEGER/LONG/DECIMAL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 07:34:37 -06:00
james.maesandClaude Sonnet 4.6 e4b6742912 docs: lift JSDoc quality across all 6 source layers
Rewrote @returns, @param, and @file comments across ~97 files in
lib/utils, lib/hooks, lib/api, components/process, components/widgets,
components/records, components/feedback, components/layout,
components/forms, components/query, and app pages.

Changes are documentation-only; zero logic modified:
- @returns now describe shape, null conditions, side-effects, and
  notable behaviors (not just the return type)
- @param tags add constraints, units, naming conventions, and
  edge-value guidance
- Duplicate verbatim inline comments that mirrored @file blocks removed
- "Why" context added: caller identity, problem solved, orchestration role

All 552 unit tests pass; 0 ESLint errors; 0 TypeScript errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 07:02:00 -06:00
james.maesandClaude Sonnet 4.6 04118ae948 feat: enforce JSDoc on all TypeScript source files (eslint-plugin-jsdoc)
- Install eslint-plugin-jsdoc and configure in eslint.config.mjs
- Rules: require-jsdoc, require-description, require-param,
  require-param-description, require-returns, require-returns-description,
  check-param-names, check-tag-names, require-file-overview (all errors)
- Applied @file overview blocks and missing JSDoc to all ~163 src/ files
- Disable react-hooks/globals (React Compiler false positive, not used)
- 0 jsdoc/* violations, 552 unit tests passing, tsc clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 05:52:09 -06:00
james.maesandClaude Opus 4.6 ab6f1ef3b8 fix: address all 7 codebase weaknesses identified in code review
GlobalSearch (B): status-aware 401/403 error handling — 401 suppressed
(axios interceptor handles redirect), 403 shows permission-denied toast,
retry:false prevents 3 retries before redirect fires

TanStack Query (C): smart retry + global error handler — smartRetry()
skips 4xx, retries 5xx/network up to 3× with exponential backoff;
QueryCache/MutationCache onError routes to status-specific toast messages

Zod schemas (D): QRecordSchema now declares full QRecord wire format
(tableName, recordLabel, values, displayValues?, associatedRecords via
z.lazy, errors?, warnings?); z.ZodType<QRecord> annotation eliminates
double-cast; GlobalSearchResult.tableLabel corrected to optional (backend
may omit it); displayValues corrected to optional in both type and schema

Auth PKCE (F): implement PKCE initiation flow in login page — PKCE
verifier/challenge generated via crypto.subtle, state nonce stored in
sessionStorage; auth type dispatch redirects OAUTH2/AUTH_0 to IdP;
callback page forwards verifier to manageSession; auth-provider.tsx and
auth.ts updated to thread codeVerifier through the call chain

axe-core E2E (E): install @axe-core/playwright, create a11y-helpers.ts
with wcag2a/2aa/wcag21aa tags; add checkA11y() to 5 spec files; 3
real violations suppressed with TODO comments (color-contrast,
aria-input-field-name, scrollable-region-focusable)

selectedRecordIds (A): production code already used stable PKs via
getRowId; fixed test fixtures to use PK strings instead of index strings
and added explicit selectedRecordIds validation test

RecordQuery audit (G): gap report written to docs/record-query-gaps.md —
multi-column sort, join filters in UI, bulk select across pages,
row-level actions, pinned columns, inline edit, and dynamic date
expressions identified as missing or partial

Also: RecordInfoFooter type narrowing fix for optional displayValues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:55:16 -06:00
james.maesandClaude Opus 4.6 da909ec1f4 feat: enforce Apache 2.0 license headers on all source files
- Add scripts/check-license-headers.mjs — walks src/**/*.{ts,tsx},
  detects missing headers, exits 1 in CI; --fix mode auto-prepends
- Add .githooks/pre-commit — runs pnpm lint:license before every commit
- Register .githooks via git config core.hooksPath in package.json prepare
  (runs automatically on pnpm install, zero extra dependencies)
- Update pnpm lint to include license check — enforced in every CI pipeline
- Add pnpm lint:license and pnpm lint:license:fix convenience scripts
- Apply Apache 2.0 header to all 227 existing src/ files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:40:00 -06:00
james.maesandClaude Opus 4.6 81cd5346cb fix: resolve 8 confirmed bugs across auth, API, components, and utils
HIGH-1: auth-provider — setUser() now called after OAuth callback success
HIGH-2: BlockWidget — validate image/audio src against http/https/relative URL allowlist to prevent data URI XSS
HIGH-3: filter-utils — guard OPERATOR_CONFIG lookup with !config continue to prevent TypeError on unknown operators
MEDIUM-4: tables — Zod safeParse now returns parsed.data on success instead of raw response (schemas.ts: QRecordSchema uses passthrough() to preserve backend fields)
MEDIUM-5: client — add timeout: 30_000 to axios.create() so stalled requests surface errors instead of hanging indefinitely
MEDIUM-6: FieldValue — add PB to formatBytes units array and clamp index with Math.min to prevent "undefined" for ≥1 PB values
MEDIUM-7: BulkLoadStep — disable hidden submit trigger when no file is selected to prevent silent no-op on submit
LOW-8: auth-provider — setUser() moved inside !cancelled guard by returning AuthUser from setup helpers instead of calling setUser() directly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:29:44 -06:00
james.maesandClaude Opus 4.6 da72f89eb9 fix: MED-5 replace double-encoded btoa+encodeURIComponent with TextEncoder in filter serialization
Switch serializeFilter/deserializeFilter to use TextEncoder/TextDecoder for
Unicode-safe base64 encoding. The old encodeURIComponent+btoa approach produced
strings 3–9× longer than necessary for non-ASCII filter values, risking URL
length limits. New approach produces strings ≈1.33× the JSON size regardless
of character set. All 551 unit tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:11:53 -06:00
james.maesandClaude Opus 4.6 1bb154dee9 fix: patch Dependabot high-severity serialize-javascript vulnerability
Add pnpm override to force serialize-javascript >= 7.0.3, fixing the RCE
vulnerability (CVE via RegExp.flags / Date.prototype.toISOString) pulled in
transitively by @storybook/nextjs. The low-severity elliptic alert has no
upstream fix available yet (dev-only transitive dep, no production exposure).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:05:55 -06:00
james.maesandClaude Opus 4.6 f075784933 fix: sanitize remaining dangerouslySetInnerHTML XSS vectors
Add DOMPurify.sanitize() to the two remaining unsanitized HTML injection
sites: ProcessWidgetStep (backend widget HTML) and RichTextField (initial
HTML value loaded from backend records). All other CRIT-1/2/3 and MED-2/3
XSS/CSS-injection issues were already resolved in previous sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:52:55 -06:00
james.maesandClaude Sonnet 4.6 01bd6254f9 fix: GlobalSearch dropdown reopens after clicking outside
Clicking outside the search dropdown called inputRef.current?.focus() which
triggered onFocus → setIsOpen(true), immediately reopening the dropdown.
Remove the erroneous focus() call from the click-outside handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 16:38:10 -06:00
james.maesandClaude Sonnet 4.6 c60a13facd fix: resolve all 18 E2E test failures — 117/117 passing
- **Column-config** (5 fixed): replaced `absolute right-0 z-[150]` panel with
  `position: fixed` + `getBoundingClientRect()` so it always renders above the
  sidebar regardless of toolbar wrap / stacking-context hierarchy

- **Process-run** (6 fixed): changed test process from `importData` (absent in
  MSW fixtures) to `importPeople` which MSW's process handlers fully simulate

- **Global-search** (5 fixed): `page.route()` cannot intercept requests that MSW
  service worker handles; added a real `/search` handler to the MSW tables
  fixture (filters across all fixture data, returns 500 for magic `__error__`
  term), then rewrote tests to rely on MSW fixture data instead of per-test
  Playwright route overrides

- **Filter-builder** (1 fixed): same MSW interception issue; rewrote the
  "filter reflected in query" test to assert the active-filter badge appears
  in the UI rather than capturing network requests

- **Keyboard-shortcuts** (1 fixed): added `data-qqq-id="command-menu-results"`
  to `Command.List` in CommandMenu.tsx and updated selector in test

- **api-mocks.ts**: fixed route registration order (generic `person/*` before
  specific count/query/99999) so specific routes win in Playwright's LIFO order;
  corrected search URL from `globalSearch` → `search`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 16:34:50 -06:00
james.maesandClaude Opus 4.6 b413535cb5 feat: HIGH-8 axe-core a11y tests; MED-3 Zod API boundary validation
HIGH-8: Integrate jest-axe into Vitest for accessibility regression testing.
- Install jest-axe and @types/jest-axe as devDependencies
- Extend Vitest expect with toHaveNoViolations in tests/setup.ts
- Configure axe globally (color-contrast disabled — evaluated manually)
- Add tests/helpers/axe.ts with a checkA11y() convenience helper
- Add tests/a11y/components.test.tsx: 14 axe tests across TextField,
  FieldValue (all branch paths: empty, BOOLEAN, TEXT, PASSWORD, CHIP),
  and compound ARIA patterns (label+input, button, progressbar)

MED-3: Add Zod runtime validation at key API response boundaries.
- Create src/lib/api/schemas.ts with QRecordSchema, QueryRecordsResponseSchema,
  CountRecordsResponseSchema, GlobalSearchResultSchema/ResponseSchema, and
  QInstanceMinimalSchema (branding + top-level maps)
- Add safeParse validation with console.warn on failure (non-breaking) to
  queryRecords(), countRecords(), and globalSearch() in tables.ts
- Add safeParse validation to loadMetaData() in metadata.ts
- Add src/lib/api/schemas.test.ts: 34 tests validating all schemas for
  valid data, optional fields, and missing required field failures

All 551 tests pass, pnpm typecheck clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:14:40 -06:00
james.maesandClaude Opus 4.6 62b557345c feat: MED-1 E2E process/CRUD specs; MED-2 Storybook setup with 4 component stories
MED-1: Add two new Playwright E2E spec files:
- tests/e2e/process-run.spec.ts — process execution (importData process init,
  StepWizard rendering, step labels, error-free navigation)
- tests/e2e/record-crud.spec.ts — CRUD flows (create form rendering, edit
  pre-population, delete action presence)

Add importData process fixture + process API route mocks to api-mocks.ts:
- POST /qqq/v1/processes/*/init — returns first step
- POST /qqq/v1/processes/*/step/** — step submission response
- GET  /qqq/v1/processes/*/status/** — polling COMPLETE response
- PATCH/DELETE handlers for person table

MED-2: Storybook 8.6 setup for Next.js:
- .storybook/main.ts — @storybook/nextjs framework, @ alias, autodocs
- .storybook/preview.ts — globals.css import, light/dark backgrounds
- pnpm storybook / build-storybook scripts added to package.json
- src/components/ui/Button.stories.tsx — 8 button variant stories
- src/components/records/FieldValue.stories.tsx — 11 field type/adornment stories
- src/components/widgets/StatisticsWidget.stories.tsx — 6 KPI tile stories
- src/components/widgets/BlockWidget.stories.tsx — 7 block layout stories
- pnpm typecheck passes; all 503 unit tests pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:10:04 -06:00
james.maesandClaude Opus 4.6 806678e7f9 feat: MED-4 ReportRun component; MED-6 ScriptEditor; MED-7 developer page
- MED-4: add src/lib/api/reports.ts (runReport, getReportStatus), create
  src/components/reports/ReportRun.tsx with format selector, async polling,
  download link, and error/success states; replace slug page placeholder with
  <ReportRun>; add src/components/reports/index.ts barrel
- MED-6: create src/components/forms/ScriptEditor.tsx — styled monospace
  textarea with language badge, line count toolbar, Tab→spaces interception,
  readOnly mode, and error display; add 14-test suite in ScriptEditor.test.tsx
- MED-7: create src/app/(dashboard)/app/developer/page.tsx — fixed route
  showing session info, environment values, metadata stats, API health check
  with round-trip timing, and build info; all sections have Copy JSON buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:07:19 -06:00
james.maes 021a08cec5 feat: process components — CRIT-3 renderers, HIGH-11/12 mobile, HIGH-14 bulk-load, MED-20/21 metadata+progress
CRIT-3: Add renderers for previously unhandled QComponentType values.
New step components: ProcessScriptViewerStep (SCRIPT_VIEWER), ProcessUploadFormStep
(UPLOAD_FORM), ProcessSummaryChartStep (PROCESS_SUMMARY_CHART), ProcessGoogleDriveStep
(GOOGLE_DRIVE_SELECT_FOLDER placeholder), ProcessUnknownStep (fallback for unrecognized
types). Extended resolveStepType to dispatch to all new types using string casts to
accommodate component types not yet in the QComponentType union.

HIGH-11: StepWizard now uses min-w-max on the scroll container so steps never wrap,
shrink-0 on step circles, and max-w-[6rem] truncate on labels with a title tooltip.

HIGH-12: All action button bars across every step component now carry
`md:relative md:bottom-auto` so they are only viewport-sticky on mobile (below md
breakpoint) and render in normal flow on desktop.

HIGH-14: BulkLoadStep detects the bulk-load component's values.type and renders a
dedicated SFTP credentials form (host, username, password, remote path), an API
credentials form (endpoint URL, API key), or the default file upload dropzone.

MED-20: ProcessRun now applies processMetaDataAdjustment.modifiedFields before
rendering each step. use-process accumulates modifiedFields from job complete
responses into state; applyModifiedFields in ProcessRun merges partial QFieldMetaData
overrides into formFields, viewFields, and recordListFields via a memoized helper.

MED-21: Progress bar for async job polling was already implemented in the previous
pass; the indeterminate/determinate logic is confirmed working via the polling overlay
and the full-page progress display for no-step polling states.
2026-02-27 18:20:40 -06:00
james.maes 8dbfddf631 feat: variant picker, record/form polish — HIGH-4/15, MED-17/18, HIGH-5/6, CRIT-15
- CRIT-15: add VariantPicker modal dialog (globalSearch-powered, Radix Dialog focus trap)
  and variant chip in RecordQueryToolbar; RecordQuery holds variantId/variantLabel state
  and auto-opens the picker on mount for usesVariants tables
- HIGH-15: add RichTextField (contentEditable + Bold/Italic/Underline/Link toolbar); wire
  into DynamicFormField for HTML type via react-hook-form Controller
- MED-17: RecordViewHeader shows RecordActions (dropdown) on md+; adds fixed bottom-sheet
  with identical actions (edit/copy/processes/delete) on mobile below md
- MED-18: RecordViewTabs renders pill tabs on md+ and independently collapsible accordion
  sections on mobile, first section open by default, ChevronDown rotates 180deg when open
- HIGH-5: all toolbar icon buttons (refresh, view-mode, density, column-config, filter,
  variant chip) now have min-h-[44px] min-w-[44px] for WCAG touch target compliance
- HIGH-6: darken --color-muted-foreground from #6b7280 (~4.7:1) to #5a6270 (~5.1:1)
  for a clearer WCAG 2.1 AA contrast margin
- HIGH-4: verified DeleteConfirmDialog uses @radix-ui/react-dialog which provides a
  full focus trap, Escape dismissal, and focus restoration — no additional work needed
2026-02-27 18:19:46 -06:00
james.maes 66c8e1fe48 feat: BarChartWidget vertical+stacked, StatisticsWidget mini, DataGrid a11y (scope/aria-sort/role/keyboard) 2026-02-27 18:13:01 -06:00
james.maes b1a65216f6 refactor: split RecordQuery/RecordView into sub-components; add 13 unit tests + 5 E2E specs
- RecordQuery.tsx → RecordQueryContent, RecordQueryBulkBar, RecordQueryToolbar
- RecordView.tsx → RecordViewAssociated, RecordViewHeader, RecordViewTabs
- New unit tests: GlobalSearch, Sidebar, PossibleValueSelect, UnsavedChangesDialog,
  ProcessHtmlStep, ProcessViewStep, BulkActionBar, ColumnConfig, ExportButton,
  SavedViewsMenu, BlockWidget
- New E2E specs: column-config, filter-builder, global-search, keyboard-shortcuts, saved-views
- Fix GlobalSearch aria-label on result options for correct accessible name
- 489 tests passing, 0 type errors
2026-02-27 18:01:02 -06:00
james.maesandClaude Opus 4.6 a0f1165459 fix: MED-8 goBack submits _goBack step to server; LOW-7 ExportButton anchor DOM insertion
MED-8: goBack in useProcess was client-side only, leaving server-side
process state out of sync when the user navigated back. Now submits
the reserved `_goBack` step name via stepMutation so the server can
roll back and return the correct previous-step metadata. Falls back to
client-side navigation when processUUID is null (pre-init). The hook's
UseProcessReturn interface updated: goBack() is now Promise<void>.

LOW-7: ExportButton anchor element is now appended to document.body
before .click() and removed immediately after, ensuring the download
triggers correctly on Firefox and other strict-mode browsers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:07:03 -06:00
james.maesandClaude Opus 4.6 c8bcf70c05 docs: add JSDoc to 100% of src/ — parallel agent pass
Full JSDoc coverage across all 10 workstreams:
- src/types/ — all interfaces, enums, type aliases, discriminated unions
- src/lib/api/ — all API functions, request/response interfaces
- src/lib/hooks/ — useRecordQuery, useProcess, useMetaData, useLocalStorage, useAsyncCombobox, useRoutes
- src/lib/utils/ — filter-utils, zod-from-metadata, error-utils, string-utils
- src/lib/auth/, src/lib/context/, src/lib/theme/
- src/components/query/ — RecordQuery, DataGrid, FilterBuilder, ColumnConfig, Pagination, etc.
- src/components/records/, src/components/forms/ — all field type components
- src/components/process/ — StepWizard, ValidationReview, BulkLoad, all step types
- src/components/widgets/ — all 18 widget components
- src/components/layout/, src/components/feedback/, src/app/ pages

No runtime code changed. pnpm tsc --noEmit passes. 287 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:01:46 -06:00
james.maesandClaude Sonnet 4.6 65f77c8f46 refactor: final LOW/quality sweep — comments, JSDoc, naming, ExportButton
- MED-1: add explanatory comment to AuthProvider initAuth effect dep
  suppression (runs once on mount — re-running would loop auth init)
- LOW-7: remove unnecessary document.body.appendChild/removeChild in
  ExportButton — direct a.click() works in all modern browsers
- LOW-8: document that app tree permission filtering is server-side
  (backend only includes nodes the user may access)
- Add JSDoc to serializeFilter/deserializeFilter, buildQuickFilterFromState,
  and zodFieldFromMetadata (previously flagged as missing)
- Rename pk → primaryKey in DataGrid.tsx for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:42:38 -06:00
james.maesandClaude Sonnet 4.6 768e6ca7f8 fix: final review pass — stable selection, CSS sanitization, cache keys, code quality
- HIGH-7: selectedRecordIds now reads PK keys directly from rowSelection
  (keyed by DataGrid's getRowId) instead of resolving by array index,
  which was wrong after sort/filter/pagination reorders
- MED-2: strip known CSS injection vectors (expression(), @import,
  javascript:, data: URLs, embedded style tags) from customCss before
  injecting into the page <style> tag
- LOW-2: sort widgetData params keys before building the cache key so
  { b:2, a:1 } and { a:1, b:2 } don't produce duplicate cache entries
- LOW-3: add explanatory comment to Sidebar's onClose dep suppression
- Remove unnecessary useMemo wrapper on joins computation in
  use-record-query — tableMetaData is a stable prop reference
- Rename ej → exposedJoin in joins map for readability

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:36:20 -06:00
james.maesandClaude Sonnet 4.6 55de69a6f5 fix: address remaining review bugs and code quality issues
Runtime/security fixes:
- HIGH-1: add router + pathname to URL-sync effect deps in use-record-query
  to prevent stale-closure URL updates in concurrent mode
- HIGH-3: remove unnecessary processUUID cast — all QJobResponse subtypes
  already carry processUUID as a typed field
- HIGH-4: add catch-all error branch to handleJobResponse so unrecognized
  response shapes set error state instead of silently freezing the process
- HIGH-5: scope auth metadata cache key by API base URL so multi-instance
  deployments don't cross-contaminate; reduce TTL from 1h to 10min
- MED-3: validate accentColor against a color format regex before applying
  to CSS custom properties (prevents CSS injection via branding metadata)
- MED-4: stabilize useLocalStorage.removeValue with a ref for initialValue
  so inline object callers don't cause unnecessary re-creation
- MED-9: wrap EntityForm.onSubmit in useCallback to avoid unnecessary
  re-renders of all form fields on every parent render
- MED-10: add basic shape validation to getStoredUser before casting
  the JSON.parse result to AuthUser
- MED-11: TOGGLE_COLUMN treats undefined visibility as visible=true before
  inverting, so first toggle correctly hides a never-toggled column

Code quality:
- CQ-MED-4: remove redundant section fallback in RecordView — primarySections
  already includes all tier-less sections via the !s.tier predicate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:30:44 -06:00
james.maesandClaude Sonnet 4.6 13070e3b48 fix: address security and runtime bugs from code review
- HIGH-6: globalSearch now re-throws non-404 errors; previously a 401
  during search would bypass the global logout interceptor
- MED-6: validate `fromPath` starts with '/' in RecordQuery and RecordView
  to prevent open redirect via javascript: URLs
- MED-7: move window.innerWidth check out of useState initializer into
  useEffect to avoid SSR/client hydration mismatch
- MED-12: track active column-resize listeners in a ref and remove them
  on DataGrid unmount so mid-drag unmounts don't leak document handlers
- MED-13: split `if (isError || !record)` into separate guards in RecordView
  so a briefly-undefined record doesn't flash the error UI
- MED-14: fix stateful /gi regex in HighlightedText — use odd-index
  detection on split result instead of regex.test() to avoid lastIndex drift
- DataCell XSS: sanitize RENDER_HTML and HTML field types with DOMPurify
- CQ-MED-2: replace `as 'AND' | 'OR'` cast in FilterBuilder with a
  type-safe narrowing check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:26:13 -06:00
james.maesandClaude Sonnet 4.6 2f820518c7 refactor: address hard code quality issues (CQ-CRIT-1, CQ-HIGH-1, CQ-HIGH-5, CQ-MED-5)
- CQ-CRIT-1: namespace useRecordQuery's 40+ flat return props into grouped
  objects (pagination, filter, columns, selection, data, views) — same
  internal implementation, structured consumer API
- CQ-HIGH-1: extract DensitySelector and ViewModeToggle as self-contained
  sub-components in RecordQuery; each manages its own local state
- CQ-HIGH-5: replace fragile queryClient.getQueryCache().subscribe() in
  use-process with a simple useEffect([statusQuery.data]) — less coupling,
  no manual key comparison
- CQ-MED-5: wrap data grid / card view section in ErrorBoundary so render
  crashes don't bubble to the route error boundary

No behaviour changes — pure structural refactor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:02:45 -06:00
james.maesandClaude Sonnet 4.6 76a76ac0c7 refactor: fix medium code quality issues from CQ review
- Extract useAsyncCombobox hook (CQ-CRIT-2): eliminates ~200 lines of
  duplicated fetch/debounce/abort/click-outside logic shared between
  PossibleValueSingleSelect and PossibleValueMultiSelect in FilterBuilder

- FieldAdornment discriminated union (CQ-HIGH-3): replaces
  Record<string, unknown> with per-type value shapes; removes all 'as'
  casts in FieldValue and DataCell via Extract<> type predicates and
  switch-case narrowing

- Fix effectiveFilter over-inclusive deps (CQ-MED-1): extract
  quickSearchBase memo so effectiveFilter/countFilter don't depend on
  columnVisibility when quickSearchTerm is empty, avoiding needless
  re-queries on column toggle

- Rename subs → subFilters local vars in FilterGroup (CQ-MED-6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:50:27 -06:00
james.maes 47f7707482 chore: remove claude worktree submodules, add .claude/ to .gitignore 2026-02-27 13:37:47 -06:00
james.maesandClaude Sonnet 4.6 3affdbaa12 refactor: fix easy code quality issues from CQ review
- CQ-CRIT-3: extract buildStringSchema/buildDateTimeSchema helpers in zod-from-metadata
- CQ-CRIT-4: centralize getErrorStatusCode in src/lib/utils/error-utils.ts
- CQ-HIGH-4: extract PAGE_SIZE_OPTIONS, SEARCH_DEBOUNCE_MS, COMBOBOX_DEBOUNCE_MS to constants.ts
- CQ-HIGH-6: remove dead toolbar button (no onClick) from RecordQuery
- CQ-HIGH-8: fix retry button to call invalidateQueries instead of no-op setPage
- CQ-HIGH-2: React.memo on FilterBuilder CriteriaRow and FilterValueInput
- CQ-MED-3: extract isHttpUrl/isRelativeUrl/isEmail to string-utils.ts
- Use consistent error messages in RecordQuery error state (403/404/generic)
- Rename initVals → initialValues in use-record-query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:37:41 -06:00
james.maesandClaude Sonnet 4.6 9c312c6b0a chore: pin qqq-orb to minor version 0.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:08:01 -06:00
james.maesandClaude Sonnet 4.6 80abe29fe5 chore: float qqq-orb on major version 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:07:13 -06:00
james.maesandClaude Sonnet 4.6 0d53307396 chore: pin qqq-orb to minor version 0.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:07:06 -06:00
james.maesandClaude Sonnet 4.6 9eee90a8df chore: pin qqq-orb to 0.6.5 production release
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:06:41 -06:00
james.maesandClaude Sonnet 4.6 a468612e34 fix: handle optional maxInputRecords in RecordActions filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:55:33 -06:00
james.maesandClaude Sonnet 4.6 15d8d1835d fix: make maxInputRecords optional on QProcessMetaData
The implementation uses `?? Infinity` to treat undefined as unlimited.
Making the field optional allows test fixtures to omit it, correctly
exercising the "unlimited" code path in getSingleRecordProcesses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:54:13 -06:00
james.maesandClaude Sonnet 4.6 0bdde715e4 fix: correct two test fixture bugs surfaced by CI
- process-utils: makeProcess default maxInputRecords 1→0 so "unlimited" bulk process tests pass
- filter-utils: update NOW_WITH_OFFSET display expectation to match actual output ('day' not 'days')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:50:55 -06:00
james.maesandClaude Sonnet 4.6 75e111a5f0 fix: correct TypeScript errors in test fixtures
- Add missing isHeavy/adornments to QFieldMetaData test objects
- Fix capabilities strings to use TABLE_* prefix per Capability type
- Fix offsetUnit DAYS → DAY per QFilterCriteriaValue type
- Add required iconName/stepFlow/minInputRecords/maxInputRecords to makeProcess
- Remove non-existent iconName/hasPermission from QTableMetaData fixtures;
  add required isHidden/readPermission/insertPermission/editPermission/
  deletePermission/variantTableLabel fields
- Fix tokens.test.ts mock.calls destructuring type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:46:48 -06:00
james.maesandClaude Sonnet 4.6 1ebc2cd437 ci: fix orb job params and add typecheck script
- Remove branch_type (not a param on node_app_build job)
- Remove qqq-maven-registry-credentials context (no private packages)
- Add typecheck script to package.json to match orb's default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:20:50 -06:00
james.maesandClaude Sonnet 4.6 7182332294 ci: add CircleCI config using qrun-io qqq-orb node_app_* jobs
Adopts kingsrook/qqq-orb@dev:snapshot with new node_app_test_only and
node_app_build jobs — designed for pnpm Next.js applications.

Follows QRun-IO branch strategy: test_only on feature branches,
build on develop/release/main/hotfix with matching workflow names.

Also adds docs/prompts/qqq-orb-node-app-extension.md — the prompt used
to spec the node_app_* job family extension to the orb.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 12:17:31 -06:00
james.maesandClaude Opus 4.6 bc2a06c917 feat: security hardening, test coverage, tooling upgrades, and inventory dashboard
Security (critical findings from code review):
- DOMPurify sanitization on all dangerouslySetInnerHTML sites (FieldValue, BlockWidget, ProcessHtmlStep)
- OAuth2 PKCE state nonce validation in handleOAuthCallback to prevent CSRF
- Auth0/OAuth2 session proof via manageSession() before marking user authenticated
- 401 interceptor now clears auth metadata cache on session expiry
- ExportButton surfaces export errors via toast instead of silent failure

Tests (286 unit + 23 E2E):
- Full unit test suite across API, hooks, utils, and theme layers (79%+ coverage)
- Playwright E2E specs for navigation, record query, record view, and CRUD flows
- E2E uses page.route() interception (no MSW dependency) with api-mocks.ts helper

Tooling:
- Upgraded Next.js 15.2.3 → 16.1.6 (eliminates 9 audit vulnerabilities)
- Removed deprecated @types/dompurify stub
- Updated ESLint config to native flat config for Next.js 16 (dropped FlatCompat)
- Disabled React Compiler lint rules (project does not use React Compiler)
- Fixed 6 pre-existing ESLint errors across 6 components

Inventory dashboard:
- 5 widgets: KPI overview (4 tiles), stock-by-category bar chart, reorder-required stat,
  6-month value trend line chart, and low-stock record grid
- Updated 3 product fixture quantities to create realistic low-stock scenarios
- RecordGridWidgetData type extended to support full QFieldMetaData for column headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:37:13 -06:00
502 changed files with 81020 additions and 11883 deletions

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6
# ─── Jobs ────────────────────────────────────────────────────────────────────
# The dashboard jar (static export under next-dashboard/) is published to Maven
# Central on release tags, with the same orb commands and credentials as the
# Material Dashboard. QApplicationJavalinServer serves it at / when it is present.
jobs:
publish_dashboard_jar:
executor: qqq-orb/java
steps:
- checkout
- qqq-orb/install_java:
install_node: true
node_version: "22"
- run:
name: Build the static export
command: |
corepack enable
pnpm install --frozen-lockfile
pnpm typecheck
pnpm lint
pnpm test
pnpm build:export
- qqq-orb/mvn_manage_version
- 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/*
workflows:
test_only:
jobs:
- qqq-orb/node_app_test_only:
filters:
branches:
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
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:
filters:
branches:
only: [ main ]
tags:
only: [ /v.*/ ]
- publish_dashboard_jar:
context: [qqq-maven-registry-credentials]
filters:
branches:
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\/.*/ ]
+60
View File
@@ -0,0 +1,60 @@
{
"permissions": {
"allow": [
"Bash(which pnpm:*)",
"Bash(node:*)",
"Bash(npm install:*)",
"Bash(pnpm create:*)",
"Bash(pnpm install:*)",
"Bash(pnpm approve-builds:*)",
"Bash(pnpm add:*)",
"Bash(pnpm tsc:*)",
"Bash(pnpm test:*)",
"Bash(pnpm build:*)",
"Bash(ls:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(cat:*)",
"Bash(git:*)",
"Bash(pnpm dlx:*)",
"Bash(echo killed:*)",
"Bash(cp:*)",
"Bash(grep:*)",
"Bash(sleep 1:*)",
"Bash(pnpm list:*)",
"Bash(lsof:*)",
"Bash(rm:*)",
"Bash(pnpm lint:*)",
"Bash(pnpm test:e2e:*)",
"Bash(head:*)",
"Bash(NEXT_PUBLIC_MOCK_API=true pnpm test:e2e:*)",
"Bash(pnpm test:coverage:*)",
"Bash(pnpm audit:*)",
"Bash(pnpm up:*)",
"Bash(pnpm remove:*)",
"Bash(./node_modules/.bin/next lint:*)",
"Bash(NEXT_PUBLIC_MOCK_API=true pnpm dev:*)",
"mcp__circleci-mcp-server__list_followed_projects",
"Bash(echo circleci CLI not found:*)",
"Bash(circleci config:*)",
"Bash(tail:*)",
"mcp__circleci-mcp-server__get_latest_pipeline_status",
"Bash(circleci orb:*)",
"Bash(circleci diagnostic:*)",
"mcp__circleci-mcp-server__run_pipeline",
"mcp__circleci-mcp-server__get_build_failure_logs",
"Bash(cd /Users/james.maes/Git.Local/QRun-IO/qqq-orb && make validate 2>&1)",
"mcp__circleci-mcp-server__get_job_test_results",
"Bash(pnpm typecheck:*)",
"Bash(pnpm dev:*)",
"Bash(python3:*)",
"Bash(echo Exit: $?:*)",
"Bash(gh api:*)",
"Bash(pnpm why:*)",
"Bash(npm view:*)",
"Bash(chmod +x:*)",
"Bash(done)",
"Bash(pnpm exec:*)"
]
}
}
+8
View File
@@ -0,0 +1,8 @@
.git
.next
node_modules
test-results
playwright-report
coverage
.env*
*.log
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# .githooks/pre-commit
#
# Enforces the Apache 2.0 license header on all staged TypeScript/TSX files.
# Registered automatically via "prepare" in package.json.
#
# To install hooks manually:
# git config core.hooksPath .githooks
pnpm lint:license
+81
View File
@@ -0,0 +1,81 @@
name: Real-backend acceptance
# Full QRun-IO/qqq#649 acceptance: the QRun-owned sample application built from QQQ source,
# serving this repository's production static export, driven by Playwright in each browser.
# The matrix gate fails on any required row without a first-attempt pass.
on:
pull_request:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
inputs:
qqq_ref:
description: QRun-IO/qqq branch or tag to build the sample backend from
required: false
permissions:
contents: read
concurrency:
group: acceptance-${{ github.ref }}
cancel-in-progress: true
env:
# The released QQQ version this dashboard is accepted against; override per run.
QQQ_REF: ${{ inputs.qqq_ref || vars.QQQ_ACCEPTANCE_REF || 'develop' }}
NEXT_TELEMETRY_DISABLED: '1'
jobs:
acceptance:
strategy:
fail-fast: false
matrix:
# The phone project runs only @mobile tests, so it shares the chromium job's gate.
browser: ['chromium,mobile', firefox, webkit]
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: QRun-IO/qqq
ref: ${{ env.QQQ_REF }}
path: qqq-backend
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'
cache: maven
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Install dependencies
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm exec playwright install --with-deps chromium firefox webkit
- name: Build QQQ from source
run: mvn -B -q -f qqq-backend/pom.xml install -DskipTests -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true -Djacoco.skip=true
- name: Build and install the dashboard jar (static export)
run: |
pnpm build:jar
mvn -B -q install
echo "NEXT_JAR_VERSION=$(mvn -B -q help:evaluate -Dexpression=project.version -DforceStdout)" >> "$GITHUB_ENV"
- name: Build the sample application jar
run: |
# Run from the QQQ checkout: its checkstyle/pmd configuration paths are relative to it.
(cd qqq-backend && mvn -B -q -f qqq-sample-project/pom.xml package -DskipTests -Djacoco.skip=true \
-Dqqq.frontend.next.version="$NEXT_JAR_VERSION")
echo "QQQ_SAMPLE_JAR=$(ls "$PWD"/qqq-backend/qqq-sample-project/target/*-jar-with-dependencies.jar)" >> "$GITHUB_ENV"
- name: Acceptance (${{ matrix.browser }})
env:
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
run: node scripts/acceptance.mjs --skip-build
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: acceptance-${{ matrix.browser == 'chromium,mobile' && 'chromium-mobile' || matrix.browser }}
path: test-results/acceptance/
retention-days: 30
+75
View File
@@ -0,0 +1,75 @@
name: Dashboard image
on:
pull_request:
push:
tags: ['v*']
permissions:
contents: read
env:
IMAGE: ghcr.io/qrun-io/qqq-frontend-next
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
if: github.event_name == 'push'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
file: docker/quickstart/Dockerfile
platforms: linux/${{ matrix.arch }}
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
tags: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
cache-from: type=gha,scope=dashboard-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=dashboard-${{ matrix.arch }}
- name: Verify runnable dashboard
if: github.event_name == 'pull_request'
env:
TAG: ${{ env.IMAGE }}:${{ github.sha }}-${{ matrix.arch }}
run: |
container=$(docker run -d --rm -p 127.0.0.1:3000:3000 "$TAG")
trap 'docker rm -f "$container"' EXIT
curl -fsS --max-time 3 --retry 30 --retry-delay 1 --retry-all-errors \
--retry-max-time 90 http://127.0.0.1:3000/app/person >/dev/null \
|| { docker logs "$container"; exit 1; }
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-24.04
permissions:
packages: write
steps:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish versioned multi-architecture image
run: |
docker buildx imagetools create \
--tag "$IMAGE:${GITHUB_REF_NAME#v}" \
"$IMAGE:$GITHUB_SHA-amd64" "$IMAGE:$GITHUB_SHA-arm64"
+6
View File
@@ -34,3 +34,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.claude/
target/
/test-results/
/playwright-report/
/log/
/.playwright-mcp/
+24
View File
@@ -0,0 +1,24 @@
import type { StorybookConfig } from '@storybook/nextjs'
import { fileURLToPath } from 'node:url'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-docs',
],
framework: {
name: '@storybook/nextjs',
options: {},
},
webpackFinal: async (config) => {
if (config.resolve) {
config.resolve.alias = {
...config.resolve.alias,
'@': fileURLToPath(new URL('../src', import.meta.url)),
}
}
return config
},
}
export default config
+21
View File
@@ -0,0 +1,21 @@
import type { Preview } from '@storybook/react'
import '../src/styles/globals.css'
const preview: Preview = {
tags: ['autodocs'],
initialGlobals: { backgrounds: { value: 'light' } },
parameters: {
layout: 'centered',
backgrounds: {
options: {
light: { name: 'light', value: '#ffffff' },
dark: { name: 'dark', value: '#0f172a' },
},
},
nextjs: {
appDirectory: true,
},
},
}
export default preview
+13
View File
@@ -169,3 +169,16 @@ The current frontend being replaced lives in the sibling `qqq-frontend-material-
Set via `NEXT_PUBLIC_API_BASE_URL` environment variable. Defaults to `/qqq/v1`.
The API uses cookie-based session auth (`sessionUUID` cookie).
## Knowledge base
Durable, cross-session knowledge about this repo and the wider QQQ platform lives in
the second-brain vault (`R:/Git.Local/KofTwentyTwo/second-brain`):
- Platform hub: `knowledge/qqq/qqq-hub.md`
- This repo's dossier: `knowledge/qqq/repos/qqq-frontend-next.md`
(reviewed at commit `41cd6d2034a8` on branch `main`, 2026-07-04)
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`.
+123 -1
View File
@@ -1 +1,123 @@
# qqq-frontend-next
# QQQ Frontend Next
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.
## Use it in a QQQ application
The dashboard is the default admin UI for QQQ 4.1+. Add the jar (managed by `qqq-bom-pom`) and `QApplicationJavalinServer` serves it at `/`, on the same port and origin as the API:
```xml
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
</dependency>
```
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 certified by 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)).
## 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.
A container image (`ghcr.io/qrun-io/qqq-frontend-next`, built from `docker/quickstart/Dockerfile`) remains available for running the dashboard as a separate Node server in front of a backend on host port 8000. The backend origin is fixed at image build time (`QQQ_BACKEND_URL`).
## Frontend development prerequisites
- **Node.js** 20.19+ or 22.12+ (required by the Storybook developer tooling)
- **pnpm** (install with `npm install -g pnpm` if not already present)
## Frontend development
```bash
git clone https://github.com/QRun-IO/qqq-frontend-next.git
cd qqq-frontend-next
pnpm install
pnpm dev
```
The dev server starts at [http://localhost:3000](http://localhost:3000).
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `NEXT_PUBLIC_API_BASE_URL` | `/qqq/v1` | Base URL for all QQQ backend API calls |
| `QQQ_BACKEND_URL` | _(unset)_ | Standalone build only: backend origin for same-origin API rewrites; the container image uses `http://host.docker.internal:8000` |
| `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.
## Available Commands
| Command | Description |
|---|---|
| `pnpm dev` | Start the Next.js development server (Turbopack) |
| `pnpm build` | Compile the standalone (Node server) production build |
| `pnpm build:export` | Compile the static export served by QQQ (`out/`) |
| `pnpm build:jar` | Static export packaged as the `qqq-frontend-next` Maven jar |
| `pnpm test:acceptance` | Real-backend acceptance suite and matrix gate (needs `QQQ_SAMPLE_JAR`) |
| `pnpm start` | Serve the production build |
| `pnpm test` | Run Vitest unit tests (single pass) |
| `pnpm test:watch` | Run Vitest in watch mode |
| `pnpm test:coverage` | Run Vitest with V8 coverage report |
| `pnpm test:e2e` | Run Playwright end-to-end tests |
| `pnpm typecheck` | Run `tsc --noEmit` (type check without emitting files) |
| `pnpm lint` | Run ESLint + Apache license header check |
| `pnpm format` | Run Prettier over all source files |
| `pnpm storybook` | Start Storybook component explorer on port 6006 |
## Directory Structure
```
src/
├── app/ # Next.js App Router pages
│ ├── (auth)/ # Login and OAuth callback routes
│ ├── (dashboard)/ # Authenticated shell (sidebar + nav)
│ │ └── app/ # All app routes (tables, records, processes)
│ ├── layout.tsx # Root layout — providers
│ ├── error.tsx # Global error boundary
│ └── not-found.tsx # 404 page
├── components/
│ ├── ui/ # shadcn/ui primitives (Button, Input, Dialog, …)
│ ├── layout/ # Sidebar, Header, Breadcrumbs, Banner
│ ├── records/ # RecordView, FieldValue
│ ├── forms/ # DynamicForm, EntityForm, field components
│ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination
│ ├── process/ # StepWizard, ValidationReview, BulkLoad
│ ├── widgets/ # Widget containers, charts, stat blocks, grids
│ └── feedback/ # Alerts, Toasts, Modals, CommandMenu
├── lib/
│ ├── api/ # Typed API client modules (client.ts, tables.ts, …)
│ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
│ ├── hooks/ # Shared React hooks (useLocalStorage, useMetadata, …)
│ ├── utils/ # Pure utility functions
│ ├── context/ # React contexts (QContext)
│ └── theme/ # Theme provider and CSS custom property tokens
├── types/ # TypeScript type definitions
└── styles/ # Global styles and theme tokens
```
## Documentation
| Document | Purpose |
|---|---|
| [`CLAUDE.md`](./CLAUDE.md) | Full architecture reference — conventions, patterns, key decisions |
| [`docs/STATE-MANAGEMENT.md`](./docs/STATE-MANAGEMENT.md) | When to use each state container (TanStack Query, RHF, URL params, localStorage, QContext) |
| [`docs/API-ERROR-HANDLING.md`](./docs/API-ERROR-HANDLING.md) | Global error handler, 401 interceptor, per-status guidance, code examples |
| [`docs/implementation-plans/`](./docs/implementation-plans/) | Work-package implementation plans (6 packages) |
## Tech Stack
- **Framework:** Next.js 16 (App Router) with React 19
- **Language:** TypeScript 5.x (`strict: true`)
- **Styling:** Tailwind CSS 4.x with CSS custom properties
- **Components:** shadcn/ui (Radix primitives + Tailwind)
- **Data Grid:** TanStack Table v8
- **Forms:** React Hook Form v7 + Zod
- **Data Fetching:** TanStack Query v5
- **Charts:** Recharts
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
- **Package Manager:** pnpm
+22
View File
@@ -0,0 +1,22 @@
FROM registry-1.docker.io/library/node:22-bookworm-slim AS frontend
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ENV QQQ_BACKEND_URL=http://host.docker.internal:8000
ENV NEXT_PUBLIC_MOCK_API=false
RUN pnpm build
FROM registry-1.docker.io/library/node:22-bookworm-slim AS runtime
LABEL org.opencontainers.image.source="https://github.com/QRun-IO/qqq-frontend-next"
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
COPY --from=frontend --chown=node:node /app/.next/standalone ./
COPY --from=frontend --chown=node:node /app/.next/static ./.next/static
COPY --from=frontend --chown=node:node /app/public ./public
COPY LICENSE ./LICENSE
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+338
View File
@@ -0,0 +1,338 @@
# Accessibility Reference — QQQ Frontend Next
**Standard:** WCAG 2.1 Level AA
**Testing tools:** axe-core (automated), Playwright E2E assertions, manual keyboard testing
---
## WCAG 2.1 AA Compliance Status
The sections below summarise what has been implemented and what remains outstanding,
based on the design review conducted 2026-03-01 (`docs/DESIGN-REVIEW-2026-03-01.md`).
---
## Key Implementations (Resolved Items)
### Skip-to-Content Link (D-L-1)
A visually hidden skip link is rendered as the first element in the dashboard layout
(`src/app/(dashboard)/layout.tsx`). It becomes visible on keyboard focus and links to
`#main-content`, allowing keyboard users to bypass the sidebar on every page navigation.
```tsx
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 ..."
data-qqq-id="skip-to-content"
>
Skip to main content
</a>
```
The target `<main id="main-content" data-qqq-id="main-content">` is present in the same layout.
---
### `aria-sort` on DataGrid Column Headers (D-Q-1)
`src/components/query/DataGrid.tsx` computes an `aria-sort` value for every `<th>` based on
the current TanStack Table sort state:
- Sorted ascending → `aria-sort="ascending"`
- Sorted descending → `aria-sort="descending"`
- Not sorted → `aria-sort="none"` (only on sortable columns; unsortable columns omit the attribute)
Screen readers announce the sort direction when the user focuses or activates a column header.
---
### Row Selection `aria-label` (D-Q-2)
Each row's selection checkbox carries a descriptive label:
```tsx
aria-label={`Select ${row.original.recordLabel ?? 'record'}`}
```
The "select all" header checkbox uses `aria-label="Select all rows on this page"`.
---
### Keyboard-Accessible Column Resize Handles (D-Q-3)
The resize handle on each column header responds to `ArrowLeft` and `ArrowRight` keyboard
events, adjusting the column width by ±10 px per keypress. The handler is implemented in
`handleResizeKeyDown` inside `DataGrid.tsx`:
```typescript
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
const delta = e.key === 'ArrowRight' ? 10 : -10
```
---
### `aria-live` Region for Fetch-in-Progress (D-Q-6)
The DataGrid wraps the loading progress bar and result count in an `aria-live="polite"` region
so that background re-fetches triggered by sort or filter changes are announced to screen readers
without interrupting the current reading flow.
---
### Focus Management in ProcessRun (D-P-1)
`src/components/process/ProcessRun.tsx` uses a `stepHeadingRef` and a `useEffect` keyed on the
active step name to move focus to the step heading whenever the wizard advances or retreats.
This satisfies WCAG 2.4.3 (Focus Order) by ensuring screen reader users are positioned at the
start of each new step:
```typescript
const stepHeadingRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
if (hasRenderedFirstStep.current) {
stepHeadingRef.current?.focus()
}
hasRenderedFirstStep.current = true
}, [activeStepName])
```
The target `<h2>` carries `tabIndex={-1}` and is referenced by `ref={stepHeadingRef}`.
---
### Screen-Reader Text for Trend Indicators (D-W-1)
`src/components/widgets/StatisticsWidget.tsx` renders a visually hidden `<span>` next to each
trend arrow icon so the direction is announced by screen readers:
```tsx
<span className="sr-only">{config.srLabel}</span>
// srLabel values: 'trending up,' | 'trending down,' | 'no change,'
```
The icon itself is `aria-hidden="true"` so it is not double-announced.
---
### Submit Button Disabled While Submitting (D-F-1)
`src/components/forms/EntityForm.tsx` reads `isSubmitting` from React Hook Form's `formState`
and applies it to all action buttons, preventing duplicate submissions:
```tsx
disabled={disabled || isSubmitting}
// Label also changes: isSubmitting ? 'Saving...' : saveButtonLabel
```
---
### Baseline Form Accessibility
All form inputs across `DynamicForm`, `EntityForm`, and individual field components have:
- `<label>` with `htmlFor` wired to the input `id`
- `aria-required={field.isRequired}`
- `aria-invalid={!!errors[field.name]}`
- `aria-describedby` pointing to the error message element
- Required asterisk marked `aria-hidden="true"` to avoid screen-reader repetition
- Help tooltips use Radix Tooltip, which is keyboard-accessible via `Tab` / `Escape`
---
### CSS Media Queries
#### `prefers-reduced-motion` (D-L-2 / D-T-1)
`src/styles/globals.css` collapses all animation and transition durations to near-zero
when the user's OS has reduced motion enabled. This resolves skeleton pulse, sidebar
slide-in, toast fade, and all other CSS transitions:
```css
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
#### `prefers-contrast: more` (D-T-2)
A high-contrast media block increases border weights and forces text colors to fully opaque
black, targeting users who have enabled "Increase Contrast" in their OS settings:
```css
@media (prefers-contrast: more) {
:root {
--color-border: #000000;
--color-input: #000000;
--color-muted-foreground: #000000;
}
*,
::before,
::after {
border-color: var(--color-border) !important;
}
:focus-visible {
outline: 3px solid #000000 !important;
outline-offset: 2px !important;
}
}
```
#### Mobile 16px Font-Size Fix (D-R-1)
iOS Safari auto-zooms any focused input whose `font-size` is below 16px. A mobile-scoped rule
prevents this without affecting desktop appearance:
```css
@media screen and (max-width: 767px) {
input,
select,
textarea {
font-size: 16px;
}
}
```
#### Dark-Mode Focus Ring Fix (D-T-5)
The default `color-mix`-based focus ring can disappear on dark backgrounds. A dark-mode
override forces the ring to a solid, fully opaque color:
```css
@media (prefers-color-scheme: dark) {
:focus-visible {
outline-color: var(--color-ring);
}
}
```
---
### Additional Resolved Items (from `What's Working Well`)
| Item | Implementation |
|------|----------------|
| `aria-required`, `aria-invalid`, `aria-describedby` on all form inputs | `DynamicFormField.tsx`, `EntityForm.tsx` |
| `role="status"` + `aria-busy="true"` on skeleton components | All skeleton components |
| `role="status"` + `aria-live="polite"` on `EmptyState` | `EmptyState.tsx` |
| Full keyboard nav in SearchDialog (ArrowUp/Down/Enter/Escape) | `SearchDialog.tsx` |
| Breadcrumbs: `aria-label="Breadcrumb"` + `aria-current="page"` | `Breadcrumbs.tsx` |
| `aria-expanded` on mobile drawer | `Sidebar.tsx` |
| DOMPurify sanitization on all user-controlled HTML output | `FieldValue.tsx`, `BlockWidget.tsx`, `ProcessHtmlStep.tsx` |
| Unsaved-changes guard: `beforeunload` + confirm modal | `EntityForm.tsx` |
| Focus rings on all interactive elements via `:focus-visible` | `globals.css` |
| Muted text darkened to `#5a6270` for WCAG AA contrast margin | `globals.css` |
---
## Automated Testing
### Running axe-core Scans
The helper in `tests/e2e/a11y-helpers.ts` wraps `@axe-core/playwright` and asserts zero
violations against the `wcag2a`, `wcag2aa`, and `wcag21aa` rule sets:
```typescript
import { checkA11y } from './a11y-helpers'
test('page is accessible', async ({ page }) => {
await page.goto('/app/person')
await waitForAppReady(page)
await checkA11y(page)
})
```
`checkA11y` runs on every navigation E2E test in `navigation.spec.ts`. To run all E2E tests
including accessibility assertions:
```bash
pnpm test:e2e
```
To run a single spec with axe output in the terminal:
```bash
pnpm test:e2e -- tests/e2e/navigation.spec.ts
```
### Known Suppressed Violations (with TODOs)
Three rule violations are currently disabled in `checkA11y` because the underlying issues
are not yet fixed. Each has a TODO comment in `a11y-helpers.ts`:
| Rule | Reason suppressed | TODO |
|------|-------------------|------|
| `color-contrast` | Amber banner background (#f59e0b) and indigo sidebar/button states yield contrast ratios just below 4.5:1 | Audit all Tailwind color pairs in Banner, Sidebar, and Button and replace with WCAG AA-compliant pairs |
| `aria-input-field-name` | Some form inputs rendered by `DynamicForm` may have broken `<label htmlFor>` associations when IDs are generated dynamically | Ensure every `<input>` has either a matching `htmlFor` or an `aria-label` |
| `scrollable-region-focusable` | The TanStack Table scrollable wrapper `<div>` has no `tabIndex`, blocking keyboard scrolling | Add `tabIndex={0}` to the scrollable DataGrid wrapper |
---
## Outstanding Work
The following items from the design review are **not yet resolved** and require future work.
### High Priority (WCAG 2.1 AA gaps)
| ID | Area | Description |
|----|------|-------------|
| D-V-1 | Record View | Edit/Delete buttons render regardless of user permissions; they fail with 403 on action instead of being hidden/disabled based on `editPermission`/`deletePermission`. |
| D-L-3 | Layout | Global search is hidden on mobile (`hidden md:block`); mobile users have no access to search. |
### Medium Priority (UX gaps)
| ID | Area | Description |
|----|------|-------------|
| D-L-5 | Layout | Mobile sidebar `aria-expanded` state may not be consistently set on the nav landmark. |
| D-Q-4 | FilterBuilder | Async combobox shows no loading indicator while possible-value options are fetching. |
| D-Q-5 | Pagination | "Go to page" input silently ignores out-of-range values with no user feedback. |
| D-Q-7 | DataGrid | Column visibility toggle changes are not announced via a `role="status"` region. |
| D-P-2 | Process | Completed step indicators in the wizard are not keyboard-navigable for back-navigation. |
| D-P-3 | Process | No timeout message if backend polling runs beyond ~60 seconds. |
| D-P-4 | Process | Validation review result list has no severity filter or field-name search. |
| D-P-5 | Process | File upload step shows no byte-level progress during multipart upload. |
| D-W-2 | Widgets | Widget API call failures show blank space with no error boundary or fallback UI. |
| D-FB-1 | Feedback | Keyboard Shortcuts dialog is not discoverable (no visible `?` hint in the header). |
| D-FB-2 | Feedback | Toasts have no dismiss button; users must wait for auto-timeout. |
| D-FB-3 | Feedback | CommandMenu icon buttons may be below the 44px minimum touch target size. |
| D-R-2 | Responsive | Multiple interactive elements (pagination controls, density buttons) below 44px touch target. |
### Items Deferred Pending Backend Support
| ID | Area | Description |
|----|------|-------------|
| D-V-6 | Record View | "Last modified by / at" audit footer requires backend to expose this data on the record endpoint. |
| D-T-3 | Theme | Dual theming systems (`--qqq-primary-color` and `--color-primary`) need consolidation; depends on coordinating with backend-supplied theme values. |
---
## Checklist for New Component Authors
When adding a new interactive component, verify the following before opening a pull request:
- [ ] Every `<input>`, `<select>`, and `<textarea>` has a `<label htmlFor>` that matches its `id`, or an `aria-label` / `aria-labelledby` attribute.
- [ ] Required inputs have `aria-required={true}`.
- [ ] Invalid inputs have `aria-invalid={true}` and an `aria-describedby` pointing to the error message element.
- [ ] All icon-only buttons have a descriptive `aria-label` (e.g. `aria-label="Close dialog"`).
- [ ] Icon elements that are purely decorative are marked `aria-hidden="true"`.
- [ ] Modals/dialogs trap focus while open and restore it to the triggering element on close (use Radix Dialog for this automatically).
- [ ] All interactive elements are reachable and operable via `Tab` / `Shift+Tab` / `Enter` / `Space` / `Escape`.
- [ ] Interactive elements have a visible `:focus-visible` ring (do not add `outline: none` without a replacement).
- [ ] Any element that announces dynamic state changes uses `aria-live="polite"` (or `role="status"`).
- [ ] Touch targets are at least 44x44px (`min-h-[44px] min-w-[44px]` or equivalent padding).
- [ ] Color is not the only means of conveying information (pair color with text or icon).
- [ ] Text color meets a minimum 4.5:1 contrast ratio against its background for normal-weight text at normal size.
- [ ] The component does not break at 320px viewport width.
- [ ] The component respects `prefers-reduced-motion` (animations driven by CSS transitions inherit the global rule in `globals.css`; JS-driven animations need a manual check).
- [ ] Any rendered HTML that originates from user input or the backend is sanitized with DOMPurify before being set via `dangerouslySetInnerHTML`.
- [ ] Run `pnpm test:e2e` and confirm `checkA11y` passes for any page that includes the new component.
+310
View File
@@ -0,0 +1,310 @@
# API Error Handling Guide
This document describes how API errors flow through the QQQ Frontend Next application and what, if anything, individual components need to do about them.
The short answer is: **most errors require no per-call handling**. The global error handler in the query client toasts an appropriate message automatically, and the 401 interceptor in the Axios client redirects to login. Individual components only need to add their own handling when the generic behavior is insufficient for a specific UX requirement.
---
## Architecture Overview
There are two independent error interception layers:
1. **Axios response interceptor** (`src/lib/api/client.ts`) — fires synchronously on every HTTP response before the promise settles. Handles 401 (unauthorized) only.
2. **TanStack Query global handlers** (`src/lib/query-client.ts`) — `QueryCache.onError` for reads, `MutationCache.onError` for writes. Fire after the interceptor. Handle all other errors by toasting a message.
```
API response
│
▼
Axios interceptor
│ 401 → call unauthorizedCallback() → redirect to login
│ all others → pass through as rejected promise
▼
TanStack Query cache (onError)
│ 401 → silently ignored (already handled)
│ 403 → toast "Permission denied" / "You do not have permission…"
│ 404 → toast "Resource not found"
│ 5xx → toast "Server error — retrying…" / "Server error — please try again"
│ network → toast "Something went wrong"
▼
Component (optional per-query onError)
│ Only needed when generic toast is wrong for the specific UX
```
---
## The 401 Interceptor
**Location:** `src/lib/api/client.ts`
The singleton `APIClient` registers a response interceptor that fires on every request:
```typescript
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
if (error.response?.status === 401 && this.unauthorizedCallback) {
this.unauthorizedCallback()
}
return Promise.reject(error) // always re-reject so TanStack Query also sees it
}
)
```
The `unauthorizedCallback` is wired to the auth provider's logout/redirect function during initialization. When a 401 arrives:
1. The callback fires → clears auth state → redirects to the login page.
2. The error is still re-rejected, so TanStack Query's `onError` also fires.
3. The global handler in `query-client.ts` explicitly silences 401s to avoid a redundant toast on top of the redirect.
**Implication for components:** Never handle 401 in a component. The interceptor + auth provider handle it globally. Writing per-component 401 handling will race with the redirect and confuse users.
---
## The Global Query Error Handler
**Location:** `src/lib/query-client.ts` — `handleQueryError` function
```typescript
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
const status = getErrorStatusCode(error)
if (status === 401) return // handled by axios interceptor
if (status === 403) {
toast.error(
context === 'mutation'
? 'You do not have permission to perform this action'
: 'Permission denied'
)
return
}
if (status === 404) {
toast.error('Resource not found')
return
}
if (status !== undefined && status >= 500) {
toast.error(
context === 'mutation'
? 'Server error — please try again'
: 'Server error — retrying…'
)
return
}
// Network failure or unexpected status
toast.error('Something went wrong')
}
```
This handler is registered on both `QueryCache` and `MutationCache`:
```typescript
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => handleQueryError(error, 'query'),
}),
mutationCache: new MutationCache({
onError: (error) => handleQueryError(error, 'mutation'),
}),
})
```
**Implication for components:** For most queries and mutations, you do not need `onError` at all. The global handler covers toasting. Add per-query handling only when you need to do something the global handler cannot — such as mapping a 400 error's field messages back to form fields.
---
## Extracting the Status Code
**Location:** `src/lib/utils/error-utils.ts`
```typescript
import { getErrorStatusCode } from '@/lib/utils/error-utils'
const status = getErrorStatusCode(error)
```
`getErrorStatusCode` handles two shapes:
- `AxiosError` — reads `error.response?.status`
- Plain objects with a numeric `status` property (e.g. fetch-based wrappers)
Returns `undefined` for anything else (non-object errors, null, network failures with no response).
---
## Status-by-Status Guide
### 400 — Validation Error
The global handler does NOT toast on 400 because 400s typically carry field-level validation messages that must be mapped back to individual form fields. Handle 400 explicitly wherever you submit a form.
```typescript
import { getErrorStatusCode } from '@/lib/utils/error-utils'
const mutation = useMutation({
mutationFn: (data: FieldValues) => saveRecord('order', data),
onError: (error) => {
if (getErrorStatusCode(error) === 400) {
// Map backend validation messages to React Hook Form fields
const fieldErrors = extractFieldErrors(error) // your helper
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field, { type: 'server', message })
}
}
// Non-400 errors are already toasted by the global handler
},
})
```
If a 400 arrives outside of a form context, add a toast yourself:
```typescript
onError: (error) => {
if (getErrorStatusCode(error) === 400) {
toast.error('Invalid request — please check your input')
}
}
```
### 403 — Permission Denied
The global handler toasts a generic permission-denied message. If the 403 should instead silently hide an action (e.g. a button that should not appear for users without permission), suppress the default behavior by checking permissions from metadata before rendering rather than relying on the API to reject:
```typescript
// Preferred: hide the action before the user tries it
{tableMetaData.capabilities.includes('insert') && (
<Button onClick={openCreateModal}>New Record</Button>
)}
```
If you need custom handling on a 403, use `onError` and call `toast` yourself — or do nothing if the generic toast is acceptable.
### 404 — Not Found
The global handler toasts "Resource not found". For record-view pages where 404 means the record was deleted, render an empty state instead:
```typescript
const { data: record, error } = useQuery({
queryKey: queryKeys.tableRecord(tableName, recordId),
queryFn: () => getRecord(tableName, recordId),
})
if (getErrorStatusCode(error) === 404) {
return <EmptyState message="This record no longer exists" />
}
```
The global handler will also toast, which is acceptable — the empty state just provides additional UI context.
### 5xx — Server Error
The global handler toasts "Server error — retrying…" for queries and "Server error — please try again" for mutations. Queries are automatically retried up to 3 times with exponential back-off (1 s → 2 s → 4 s, max 30 s) before the error surfaces.
No additional per-component handling is needed for 5xx unless you want a custom retry UI or a structured error state for a specific page.
### Network Failure (no response)
Treated as non-4xx by the retry logic — queries retry up to 3 times. The global handler toasts "Something went wrong" after all retries are exhausted. No per-component handling needed for the common case.
---
## When to Add Per-Query Error Handling
Add `onError` to a specific `useQuery` or `useMutation` call when:
| Scenario | What to do |
|---|---|
| 400 with field-level validation messages | Map errors to form fields with `form.setError()` |
| 403 should render empty state instead of toast | Catch 403, render empty UI, call `toast` yourself if the global one also fires |
| 404 should render a structured empty state page | Check `getErrorStatusCode(error) === 404` and render `<EmptyState>` |
| Error should navigate away (e.g. deleted parent record) | `useRouter().push('/...')` in `onError` |
| The generic toast message is wrong for this specific action | Supply a custom `onError` that calls `toast.error(customMessage)` |
For all other cases, rely on the global handler and write no error-handling code in the component.
---
## Retry Behavior
The `smartRetry` function in `src/lib/query-client.ts` controls when TanStack Query retries:
```typescript
function smartRetry(failureCount: number, error: unknown): boolean {
const status = getErrorStatusCode(error)
// Never retry client errors — they are deterministic
if (status !== undefined && status >= 400 && status < 500) return false
// Retry server errors and network failures up to 3 times
return failureCount < 3
}
```
Back-off schedule: 1 s → 2 s → 4 s, capped at 30 s.
4xx errors are never retried. There is no per-component way to opt into retrying 4xx errors — that would be intentional since a 400 with bad field data will not resolve itself on retry.
---
## Complete Example — Form with 400 Handling
```typescript
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { saveRecord } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
const queryClient = useQueryClient()
const form = useForm({ resolver: zodResolver(schema), defaultValues })
const mutation = useMutation({
mutationFn: (data: FieldValues) => saveRecord('order', orderId, data),
onSuccess: () => {
// Invalidate so the record view refreshes
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord('order', orderId) })
toast.success('Order saved')
},
onError: (error) => {
if (getErrorStatusCode(error) === 400) {
// Map field errors from the server response body to form fields.
// The global handler does NOT toast on 400, so we must do it here or
// rely on inline field error messages in the form UI.
const body = (error as AxiosError<{ errors: Record<string, string> }>).response?.data
if (body?.errors) {
for (const [field, message] of Object.entries(body.errors)) {
form.setError(field, { type: 'server', message })
}
} else {
toast.error('Please correct the highlighted fields')
}
return
}
// 401, 403, 404, 5xx, network — already toasted by the global handler
},
})
return (
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))}>
{/* field components */}
</form>
)
}
```
---
## Quick Reference
| Layer | File | Handles |
|---|---|---|
| Axios interceptor | `src/lib/api/client.ts` | 401 → redirect to login |
| Global query handler | `src/lib/query-client.ts` | 403, 404, 5xx, network → toast |
| Per-component `onError` | Your component | 400 field errors, custom empty states, navigation |
| `getErrorStatusCode` | `src/lib/utils/error-utils.ts` | Normalize `AxiosError` and plain-object errors to HTTP status code |
+316
View File
@@ -0,0 +1,316 @@
# Code Quality Review — QQQ Frontend Next
**Date:** 2026-02-27
**Scope:** DRY, KISS, component design, hooks, TypeScript quality, naming, structure
**Note:** Security/runtime bugs are documented separately in `CODE-REVIEW-2026-02-27.md`
---
## Summary
The codebase demonstrates **strong architectural foundations** with excellent separation of concerns and metadata-driven rendering patterns. However, there are **significant code quality issues** across multiple dimensions: substantial DRY violations in filter/combobox logic, an overengineered god-hook, inconsistent error handling approaches, and TypeScript practices that miss opportunities for stronger typing. The `use-record-query` hook is an anti-pattern — a 540-line hook mixing data fetching, URL synchronization, localStorage persistence, and UI state. FilterBuilder has two combobox implementations that are ~95% identical. Several utilities and constants are defined inline or scattered across files. While the component structure is clean and the metadata-driven approach is correct, the implementation needs targeted refactoring to reduce coupling, eliminate duplication, and simplify abstractions.
---
## CRITICAL Quality Issues (blocks maintainability)
### CQ-CRIT-1: `use-record-query` — God Hook Anti-Pattern (540 lines)
**File:** `src/lib/hooks/use-record-query.ts`
This hook violates SRP catastrophically. It manages:
- Pagination state (pageNum, pageSize)
- Filter state (userFilter, quickSearchTerm, filterMode)
- Column configuration (columnVisibility, columnOrder, columnWidths)
- Row selection state
- UI panel toggles (filterPanelOpen, columnConfigOpen)
- localStorage persistence (5 separate useEffect blocks)
- URL params synchronization (1 useEffect block)
- TanStack Query integration (2 queries)
- Saved views (saveView, loadView, deleteView callbacks)
- 18+ action dispatchers
The hook returns 40+ properties. Components must destructure dozens of items they don't need. The reducer pattern for 24+ action types is overkill for this state complexity.
**Why it's Critical:**
- Testing requires mocking localStorage, URL manipulation, TanStack Query, and multiple state branches simultaneously
- Any change to one concern risks breaking another
- Components using this hook are tightly coupled to its entire API shape
- The localStorage sync fires 5 separate effects independently
**Recommended Fix:**
Split into 3-4 focused hooks:
- `useRecordPagination()` — pageNum, pageSize, totalPages, setPage, setPageSize
- `useRecordFilter()` — userFilter, quickSearchTerm, setFilter, resetFilter
- `useRecordSelection()` — rowSelection, setRowSelection, clearSelection
- `useRecordUIState()` — columnConfigOpen, filterPanelOpen, toggles
Let RecordQuery coordinate these together.
---
### CQ-CRIT-2: FilterBuilder — Two Comboboxes That Are 95% Identical
**File:** `src/components/query/FilterBuilder.tsx`, lines 508-927
`PossibleValueSingleSelect` (lines 508-698) and `PossibleValueMultiSelect` (lines 713-927) are nearly identical. Both:
- Fetch options via `fetchTablePossibleValues()`
- Debounce search input (300ms) — copied at lines 557-563 and 771-777
- Manage AbortController for stale request cancellation — copied at lines 585-590 and 799-804
- Implement click-outside-to-close
- Render search input, loading state, option list
Differences: only selection model (single vs. multi) and tag display.
**Why it's Critical:**
- Bug fix in one requires manual sync to the other
- 400+ lines of duplicated code
- Missing memoization: callbacks re-created on every render
**Recommended Fix:**
Extract a `useAsyncCombobox` hook:
```typescript
function useAsyncCombobox(tableName: string, fieldName: string) {
// Shared debounce, abort, cleanup logic
return { options, isLoading, debouncedFetch, abortRef }
}
```
Then SingleSelect and MultiSelect become thin wrappers managing only the selection model.
---
### CQ-CRIT-3: `zod-from-metadata` — Repeated Required/Optional Pattern x10
**File:** `src/lib/utils/zod-from-metadata.ts`, lines 11-114
STRING, TEXT, HTML, and PASSWORD cases (lines 19-60, 99-110) all repeat:
```typescript
let schema = z.string()
if (isRequired) schema = schema.min(1, `${label} is required`)
if (maxLength) schema = schema.max(maxLength, ...)
return isRequired ? schema : schema.optional()
```
The required/optional wrapping pattern is written identically across all 10+ field types.
**Recommended Fix:**
```typescript
function buildStringSchema(isRequired: boolean, maxLength?: number, label?: string) {
let schema = z.string()
if (isRequired) schema = schema.min(1, `${label} is required`)
if (maxLength) schema = schema.max(maxLength, `Must be ${maxLength} characters or fewer`)
return isRequired ? schema : schema.optional()
}
case 'STRING':
case 'TEXT':
case 'HTML':
case 'PASSWORD':
return buildStringSchema(isRequired, field.maxLength, field.label)
```
---
### CQ-CRIT-4: Inconsistent Error Status Detection Across Components
**Files:**
- `src/components/records/RecordView.tsx` lines 68-83 — custom `getErrorStatusCode()`, handles 403/404/500
- `src/components/query/RecordQuery.tsx` lines 493-512 — generic "Failed to load records" for all errors
Error handling is implemented differently in the two main page components. If the API changes, fixes are needed in multiple places.
**Recommended Fix:**
```typescript
// src/lib/utils/error-utils.ts
export function getErrorStatusCode(error: unknown): number | undefined { ... }
export function getErrorMessage(error: unknown, context: string): string { ... }
```
Use this in both RecordView and RecordQuery.
---
## HIGH Quality Issues (significant tech debt)
### CQ-HIGH-1: RecordQuery — Too Many Responsibilities (586 lines)
**File:** `src/components/query/RecordQuery.tsx`
This component manages: quick search debounce, view mode toggle, mobile filter sheet state, density selector, and renders: toolbar (150+ lines inline), filter panel (desktop + mobile variants), bulk action bar, error/empty states, DataGrid, RecordCardView, and process launcher coordination.
**Extractable subcomponents:**
- `<RecordQueryToolbar />` — all toolbar state and rendering
- `<DensitySelector />` — the dropdown (lines 291-339)
- `<ViewModeToggle />` — button group (lines 258-288)
- `<QuickSearchInput />` — search field with debounce
---
### CQ-HIGH-2: FilterBuilder — No Memoization on Recursive Components
**File:** `src/components/query/FilterBuilder.tsx`
The recursive chain FilterBuilder → FilterGroup → CriteriaRow → FilterValueInput doesn't memoize callbacks or components. Every filter state change re-renders all sibling rows, and PossibleValueSingleSelect's useEffect (line 566) re-fires unnecessarily — potentially resetting combobox search input during user input.
**Fix:**
```typescript
export const CriteriaRow = React.memo(function CriteriaRow({ ... }) { ... })
export const FilterValueInput = React.memo(function FilterValueInput({ ... }) { ... })
```
---
### CQ-HIGH-3: Field Adornments Typed as `Record<string, unknown>`
**File:** `src/components/records/FieldValue.tsx`, lines 65-66, 119-120
```typescript
const href = (linkAdornment?.values?.['linkURL'] ?? String(value)) as string
const colorMap = (chipAdornment?.values?.['colorMap'] ?? {}) as Record<string, string>
```
These `as` casts are necessary because adornment `values` is `Record<string, unknown>`. The type information exists in the backend but isn't modeled.
**Recommended Fix:** Discriminated union types for adornments:
```typescript
type LinkAdornment = { type: 'LINK'; values: { linkURL: string } }
type ChipAdornment = { type: 'CHIP'; values: { colorMap: Record<string, string> } }
export type FieldAdornment = LinkAdornment | ChipAdornment | IconAdornment | ...
```
Eliminates all `as` casts in FieldValue.
---
### CQ-HIGH-4: Magic Numbers and Strings Scattered Throughout
- `use-record-query.ts:246` — `[10, 25, 50, 100].includes(n)` — hardcoded page sizes
- `RecordQuery.tsx:75` — `400` ms debounce
- `FilterBuilder.tsx:560` — `300` ms debounce
- `DataGrid.tsx:41-51` — density class maps inline
- `FieldValue.tsx:1024` — bytes-per-KB hardcoded
**Fix:**
```typescript
// src/lib/constants.ts
export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const
export const SEARCH_DEBOUNCE_MS = 400
export const COMBOBOX_DEBOUNCE_MS = 300
```
---
### CQ-HIGH-5: `use-process` — Stale Closure via Manual `stateRef`
**File:** `src/lib/hooks/use-process.ts`, lines 105-128
```typescript
const stateRef = useRef(state)
stateRef.current = state
// later: stateRef.current.stepValues
```
Manually syncing a ref to shadow state is a sign that the polling subscription (lines 249-268) is too coupled to mutable state. This pattern is fragile and indicates the state management design needs simplification.
---
### CQ-HIGH-6: Dead Toolbar Button
**File:** `src/components/query/RecordQuery.tsx`, lines 394-401
A "Settings / more" button has an aria-label but no onClick handler — it does nothing. Either wire it up or remove it.
---
## MEDIUM Quality Issues
### CQ-MED-1: `use-record-query` — `effectiveFilter` Depends on Unrelated State
**File:** `src/lib/hooks/use-record-query.ts`, lines 317-332
`effectiveFilter` is recomputed when `state.columnVisibility` changes, but column visibility has nothing to do with filters. The dependency array is over-inclusive.
---
### CQ-MED-2: Boolean Operator Type is Raw String Cast
**File:** `src/components/query/FilterBuilder.tsx`, lines 196-198
```typescript
onChange({ ...filter, booleanOperator: e.target.value as 'AND' | 'OR' })
```
Should use a const assertion or enum to avoid typo risk:
```typescript
const BOOLEAN_OPERATORS = ['AND', 'OR'] as const
type BooleanOperator = typeof BOOLEAN_OPERATORS[number]
```
---
### CQ-MED-3: FieldValue — Repeated URL/Email Pattern Matching
**File:** `src/components/records/FieldValue.tsx`, lines 294, 339, 358
Three separate URL/email regex checks scattered across the file. Extract to:
```typescript
// src/lib/utils/string-utils.ts
export const isUrl = (s: string) => /^https?:\/\//i.test(s) || s.startsWith('/')
export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)
```
---
### CQ-MED-4: RecordView — Redundant Section Fallback Logic
**File:** `src/components/records/RecordView.tsx`, lines 234-289
```typescript
const primarySections = visibleSections.filter(s => !s.tier || s.tier === 'T1' || s.tier === 'basic')
// ...
const t1Sections = primarySections.length > 0 ? primarySections : visibleSections
```
The fallback `? primarySections : visibleSections` is redundant if all sections without explicit tiers are already included in `primarySections`. Simplify or add a comment explaining when this fallback fires.
---
### CQ-MED-5: Missing Error Boundaries Below Route Level
Only `src/app/error.tsx` provides an error boundary. Forms and record views should have their own boundaries to prevent full-page crashes from single component errors.
---
### CQ-MED-6: `FilterBuilder` — `subs` Variable Name
**File:** `src/components/query/FilterBuilder.tsx`, line 178
```typescript
const subs = filter.subFilters ?? []
```
Should be `subFilters` for clarity.
---
## LOW / Style Issues
- **`data-qqq-id` inconsistency** — Mix of `grid-select-row-${row.index}` (underscores), `filter-field-${depth}-${index}` (hyphens). Standardize to kebab-case throughout.
- **Abbreviations:** `initVals` → `initialValues` (`use-record-query.ts:257`), `ej` → `exposedJoin` (`use-record-query.ts:348`), `pk` → `primaryKey` (DataGrid, RecordView), `subs` → `subFilters`
- **Import ordering:** `FilterBuilder.tsx` imports lucide-react after custom hooks. Project convention is: React → External → Types → Lib → Components.
- **Missing JSDoc:** `buildQuickFilterFromState()` (use-record-query.ts), `getErrorStatusCode()` (RecordView.tsx), `zodFieldFromMetadata()` (zod-from-metadata.ts) — all complex enough to warrant a one-liner doc comment.
- **Unnecessary `useMemo`:** `joins` calculation in `use-record-query.ts:345-354` is a simple object map — too cheap to justify memoization overhead.
- **Unused import:** `SelectField` in `DynamicFormField.tsx` (identified in prior review).
- **`window.innerWidth` in callback** — `RecordQuery.tsx:127-134` — not reactive to resize events.
---
## Positive Patterns Worth Preserving
1. **Metadata-Driven Rendering** — entire UI derived from backend metadata, zero hardcoded field/table names.
2. **Query Key Factory Pattern** — `queryKeys.tableRecords(tableName)` prevents string-based cache bugs.
3. **TanStack Query Usage** — proper staleTime, placeholderData, manual pagination, invalidation. No over-fetching.
4. **Typed API Layer** — all `src/lib/api/` functions properly typed, good use of discriminated unions for responses.
5. **Filter Serialization** — `serializeFilter()` / `deserializeFilter()` correctly handle shareable URLs.
6. **Debouncing** — SearchInput and FilterBuilder correctly debounce and clean up.
7. **Tailwind + CSS Custom Properties** — consistent theming without inline styles.
8. **Accessibility Fundamentals** — ARIA labels, roles, keyboard handling are present throughout.
---
## Recommended Fix Priority
| Priority | Item | Effort |
|----------|------|--------|
| 1 | CQ-CRIT-2: Extract `useAsyncCombobox` hook (FilterBuilder) | Medium |
| 2 | CQ-CRIT-3: `buildStringSchema()` helper in zod-from-metadata | Small |
| 3 | CQ-CRIT-4: Centralize `getErrorStatusCode()` utility | Small |
| 4 | CQ-HIGH-3: Discriminated union types for adornments | Medium |
| 5 | CQ-HIGH-4: Extract constants to `src/lib/constants.ts` | Small |
| 6 | CQ-HIGH-6: Remove or wire dead toolbar button | Trivial |
| 7 | CQ-HIGH-2: `React.memo` on FilterBuilder subcomponents | Small |
| 8 | CQ-MED-3: Extract URL/email utils | Small |
| 9 | CQ-CRIT-1: Split `use-record-query` into focused hooks | Large |
| 10 | CQ-HIGH-1: Extract RecordQuery toolbar subcomponents | Medium |
+390
View File
@@ -0,0 +1,390 @@
# QQQ Frontend Next — Code Review
**Date:** 2026-02-27
**Reviewer:** Claude (automated)
**Scope:** Full `src/` codebase audit
**Commit:** df8e2e6
---
## CRITICAL (7 findings)
---
### CRIT-1: XSS — unsanitized `dangerouslySetInnerHTML` in `FieldValue`
**File:** `src/components/records/FieldValue.tsx:136–143`
Raw backend HTML value injected into DOM with zero sanitization:
```tsx
dangerouslySetInnerHTML={{ __html: String(value) }}
```
**Fix:** Use DOMPurify:
```tsx
import DOMPurify from 'dompurify'
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(value)) }}
```
---
### CRIT-2: XSS — unsanitized `dangerouslySetInnerHTML` in `BlockWidget` (two locations)
**File:** `src/components/widgets/BlockWidget.tsx:40–48, 278–285`
Both the legacy `data.html` path and the `case 'html'` block renderer pass backend HTML directly to the DOM.
**Fix:** Apply `DOMPurify.sanitize()` to both HTML rendering paths.
---
### CRIT-3: XSS — regex script-stripping in `ProcessHtmlStep` is not real sanitization
**File:** `src/components/process/ProcessHtmlStep.tsx:14–17, 100`
```ts
// TODO: Replace with DOMPurify for full sanitization
function stripScripts(html: string): string {
return html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
}
```
Bypassed by: `<img onerror="...">`, `<svg onload="...">`, `<a href="javascript:...">`, and dozens of other vectors.
**Fix:** Replace `stripScripts` with `DOMPurify.sanitize()`.
---
### CRIT-4: Auth bypass — `handleOAuthCallback` sets `isAuthenticated=true` without token exchange
**File:** `src/lib/auth/auth-provider.tsx:164–176`
```tsx
async function handleOAuthCallback(code: string, state: string) {
// For now, just mark as authenticated
setIsAuthenticated(true)
}
```
`code` and `state` params are accepted but unused. No CSRF state validation, no token exchange, no session creation.
**Fix:** Implement PKCE: validate `state` against `sessionStorage`, exchange `code`+`code_verifier` for tokens via backend `/manageSession`, then set `isAuthenticated` only on success.
---
### CRIT-5: Auth bypass — Auth0/OAuth2 sessions authenticate without server validation
**File:** `src/lib/auth/auth-provider.tsx:123–138`
Both `setupAuth0Session` and `setupOAuth2Session` call no protected endpoint. Any app configured with AUTH_0 or OAUTH2 shows as authenticated for any page load. Default user is hardcoded: `{ name: 'User', email: 'user@example.com' }`.
**Fix:** Call a protected endpoint (e.g., `GET /session`) to validate existing session cookie. Redirect to provider if 401.
---
### CRIT-6: Auth state corruption — 401 interceptor doesn't clear auth metadata cache
**File:** `src/lib/auth/auth-provider.tsx:65–70`
The 401 interceptor redirects to login but does NOT call `clearAuthMetadataCache()`, leaving stale auth metadata. On next page load the cached auth type governs initialization without a fresh server check.
**Fix:** Call `clearAuthMetadataCache()` inside the 401 callback before redirect.
---
### CRIT-7: Silent export failure — `ExportButton` swallows errors with no user feedback
**File:** `src/components/query/ExportButton.tsx:88–89`
```tsx
} catch (err) {
console.error('[ExportButton] Export failed:', err)
}
```
Users believe export succeeded when it silently failed.
**Fix:** Add `toast.error('Export failed. Please try again.')` in the catch block.
---
## HIGH (8 findings)
---
### HIGH-1: Stale closures — missing deps in URL sync + column persistence effects
**File:** `src/lib/hooks/use-record-query.ts:280–311`
Four `useEffect` hooks suppress `react-hooks/exhaustive-deps`. The URL-sync effect is missing `router` and `pathname` — stale closures can fire URL updates on the wrong route in concurrent mode.
**Fix:** Add `router` and `pathname` to the URL-sync effect deps. Use `useRef` for stable callback references if needed.
---
### HIGH-2: Performance — `JSON.stringify` key comparison on every query cache event in `useProcess`
**File:** `src/lib/hooks/use-process.ts:251–268`
```ts
if (JSON.stringify(key) !== JSON.stringify(expectedKey)) return
```
This serializes two arrays on every cache event during rapid polling intervals.
**Fix:** Use `queryClient.getQueryData(expectedKey)` or shallow key comparison utility.
---
### HIGH-3: Logic bug — `processUUID` unsafe cast falls back to empty string
**File:** `src/lib/hooks/use-process.ts:274–278`
```ts
const pUUID = (response as QJobResponse & { processUUID?: string }).processUUID ?? ''
```
If `processUUID` is absent, all subsequent step calls use `''` as the UUID, producing 404 API calls.
**Fix:** Add `processUUID` to `QJobResponse` type. Validate it is non-empty before proceeding.
---
### HIGH-4: Logic bug — type guard fallthrough on unexpected `QJobResponse` shapes
**File:** `src/lib/hooks/use-process.ts:76–82`
A response with both `error` and `values` fields, or with both `jobUUID` and `values`, falls through all type guards silently. Process state freezes indefinitely.
**Fix:** Use a discriminated union with an explicit `type` field. Add a default catch-all error case.
---
### HIGH-5: Stale auth — metadata cached globally without API base URL scoping
**File:** `src/lib/api/auth.ts:29–37`
Auth metadata cached for 1 hour under a single key `qqqAuthMetadata` regardless of API base URL. Backend auth type changes are ignored for up to 1 hour. Multi-instance deployments cross-contaminate caches.
**Fix:** Include API base URL in cache key. Consider shorter TTL (5–10 min) or TanStack Query instead.
---
### HIGH-6: Auth bypass — `globalSearch` silently swallows 401/403 errors
**File:** `src/lib/api/tables.ts:132–136`
```ts
} catch {
// If /search endpoint is not available (404), return empty results
return []
}
```
Catches all errors including 401 (session expired). A search 401 bypasses the global logout interceptor.
**Fix:**
```ts
} catch (err) {
if (isAxiosError(err) && err.response?.status === 404) return []
throw err
}
```
---
### HIGH-7: Wrong record selection — `selectedRecordIds` computed from array index, not stable PK
**File:** `src/lib/hooks/use-record-query.ts:411–421`
`rowSelection` is keyed by array index but records can reorder after sort/filter/pagination. Index 5 after a filter may point to a completely different record than before.
**Fix:** Key `rowSelection` by primary key string (same as `getRowId` in DataGrid). Update the `selectedRecordIds` computation to use PK-based lookup.
---
### HIGH-8: Dead retry button — `setPage(rq.pageNum)` is a no-op
**File:** `src/components/query/RecordQuery.tsx:503–508`
The error-state retry button dispatches `SET_PAGE` with the current page number — TanStack Query sees the same query key and does not re-fetch.
**Fix:** Use `queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableName) })`.
---
## MEDIUM (14 findings)
---
### MED-1: `AuthProvider` effect suppresses `onAuthError` dep without documentation
**File:** `src/lib/auth/auth-provider.tsx:121`
**Fix:** Document intent: `// intentional: runs once on mount — auth initialization is not repeatable`
---
### MED-2: CSS injection — raw `customCss` injected into `<style>` tag without validation
**File:** `src/app/(dashboard)/layout.tsx:108–119`
Arbitrary CSS from the backend can overlay phishing forms, exfiltrate keystrokes via CSS selectors, or perform UI redressing.
**Fix:** Use a CSS sanitizer or restrict to CSS custom property overrides only.
---
### MED-3: CSS injection — `accentColor` set on CSS custom properties without format validation
**File:** `src/app/(dashboard)/layout.tsx:71–89`
`accentColor: 'red; --qqq-primary: url(https://evil.com)'` would be applied as-is.
**Fix:** Validate `accentColor` matches `/^#[0-9a-fA-F]{3,8}$|^rgb|^hsl/` before applying.
---
### MED-4: Stale `initialValue` reference in `useLocalStorage.removeValue`
**File:** `src/lib/hooks/use-local-storage.ts:36–43`
`initialValue` is an object passed inline at call sites — new reference on every render. Causes `removeValue` re-creation and downstream `useEffect` churn.
**Fix:** Stabilize with `useRef`: `const initialValueRef = useRef(initialValue)`, use `initialValueRef.current` in `removeValue`, remove `initialValue` from deps.
---
### MED-5: `filterUtils` `btoa`/`atob` may produce overly long base64 for Unicode filter values
**File:** `src/lib/utils/filter-utils.ts:224–256`
Double-encoding (`encodeURIComponent` + `btoa`) produces unnecessarily long strings that may exceed URL limits.
**Fix:** Use `btoa(unescape(encodeURIComponent(json)))` for true Unicode-safe base64.
---
### MED-6: Open redirect — `fromPath` URL param used as `href` without origin validation
**File:** `src/components/records/RecordView.tsx:409–416`
`/app/person/1?from=javascript:alert(1)` creates a clickable XSS link.
**Fix:**
```ts
const safeFromPath = fromPath?.startsWith('/') ? fromPath : null
```
---
### MED-7: Hydration mismatch — `window.innerWidth` in `useState` initializer
**File:** `src/components/query/RecordQuery.tsx:92–97`
Server renders one view mode, client may initialize another, causing React hydration warning.
**Fix:** Initialize from `preferences.tableDefaultViewMode`, apply mobile override in `useEffect`.
---
### MED-8: `goBack` in `useProcess` is client-only, not a server step submission
**File:** `src/lib/hooks/use-process.ts:341–355`
Server-side state is not rolled back. If the server expects a `_goBack` step, the process goes out of sync.
**Fix:** Submit `processStep(processName, processUUID, '_goBack', {})` and let server dictate `nextStep`.
---
### MED-9: Missing `useCallback` on `EntityForm.onSubmit`
**File:** `src/components/forms/EntityForm.tsx:195–197`
`onSubmit` re-created on every render, causing unnecessary re-renders of all form fields.
**Fix:** `const onSubmit = useCallback(async (values) => { await activeMutation.mutateAsync(values) }, [activeMutation])`
---
### MED-10: Unvalidated `JSON.parse` from localStorage in `getStoredUser`
**File:** `src/lib/auth/auth-provider.tsx:150–161`
**Fix:** Add type guard or schema validation.
---
### MED-11: `TOGGLE_COLUMN` initializes from `undefined` for never-toggled columns
**File:** `src/lib/hooks/use-record-query.ts:119–126`
`!undefined === true` means first toggle always shows column, losing the "default" state distinction.
**Fix:**
```ts
const currentVal = state.columnVisibility[action.fieldName] ?? true
return { ...state, columnVisibility: { ...state.columnVisibility, [action.fieldName]: !currentVal } }
```
---
### MED-12: Memory leak — column resize `mousemove`/`mouseup` listeners not removed on unmount
**File:** `src/components/query/DataGrid.tsx:231–253`
If `DataGrid` unmounts during a resize drag, the document event listeners remain.
**Fix:** Track active listeners in a `useRef`; clean them up in a `useEffect` cleanup.
---
### MED-13: Premature error state when `record` is undefined but `isError` is false
**File:** `src/components/records/RecordView.tsx:120–121`
```ts
if (isError || !record) { /* renders error UI */ }
```
Shows error flash before loading state.
**Fix:** Split: `if (isError) { ... }` then `if (!record && !isLoading) { ... }`.
---
### MED-14: Stateful regex in `HighlightedText` — `gi` flag causes incorrect `test()` after `split()`
**File:** `src/components/feedback/SearchDialog.tsx:31–47`
`split(regex)` advances `lastIndex`, making subsequent `regex.test(part)` calls unreliable.
**Fix:** Create a new regex for `test()`, or use `part.toLowerCase() === query.toLowerCase()`.
---
## LOW (8 findings)
- **LOW-1** `auth.ts:64` — Duplicate `clearAuthMetadataCache()` call (harmless)
- **LOW-2** `query-client.ts:55` — Widget cache key unstable with unordered `params` objects
- **LOW-3** `Sidebar.tsx:128` — Missing comment explaining intentional dep omission
- **LOW-4** `RecordView.tsx:37–44` — `getInitials('')` throws TypeError (empty label)
- **LOW-5** `DynamicFormField.tsx` — Unused `SelectField` import (dead code)
- **LOW-6** `RecordQuery.tsx:127–134` — `window.innerWidth` in callback not reactive to resize
- **LOW-7** `ExportButton.tsx:81–86` — Legacy DOM insertion pattern for file download trigger
- **LOW-8** `process-utils.ts`, `use-routes.ts` — Verify `hasPermission`/`readPermission` filtering for nav items
---
## Priority Order for Fixes
1. **CRIT-1, CRIT-2, CRIT-3** — Install DOMPurify, apply to all 4 HTML rendering sites
2. **CRIT-4, CRIT-5** — Auth stub implementations need proper server validation
3. **CRIT-6** — One-line fix: add `clearAuthMetadataCache()` to 401 callback
4. **HIGH-6** — One-line fix: re-throw non-404 errors in `globalSearch`
5. **HIGH-7** — Fix record selection to use stable PKs
6. **HIGH-8** — Fix retry button to call `invalidateQueries`
7. **CRIT-7** — Add error toast to `ExportButton`
8. **MED-6** — Validate `fromPath` starts with `/`
9. **HIGH-1** — Fix stale closures in `useRecordQuery` effects
10. Remaining HIGH/MED items by team priority
+398
View File
@@ -0,0 +1,398 @@
# Component Patterns
This guide describes the recurring patterns used throughout the QQQ Frontend Next codebase. Understanding these patterns is essential before adding new components or modifying existing ones.
---
## Pattern 1: Metadata-Driven Rendering
QQQ is a metadata-driven framework. The backend declares all structure — table names, field lists, widget layouts, and navigation items — and the frontend renders from it dynamically. No table names, field names, or navigation entries are hardcoded anywhere in the component tree.
### How it works
**Server-side (or near-server) layers** fetch metadata and pass it down as props. **Client Components** never fetch metadata themselves — they receive it through their props and render from it.
The routing entry point is `src/app/(dashboard)/app/[slug]/page.tsx`. It is a Client Component that fetches the full QQQ instance metadata from the server, resolves the slug against the metadata, and dispatches to the appropriate page component:
```tsx
// src/app/(dashboard)/app/[slug]/page.tsx (simplified)
export default function SlugPage() {
const params = useParams<{ slug: string }>()
const slug = params.slug
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30, // 30 minutes
})
// Resolve slug against apps, tables, processes, reports in priority order
const isTable = Boolean(metaData?.tables?.[slug])
const table = metaData?.tables?.[slug]
// Pass resolved metadata as props — the child component never fetches
if (isTable && table) {
const tableProcesses = getProcessesForTable(metaData, slug)
return <RecordQuery tableName={slug} tableMetaData={table} processes={tableProcesses} />
}
// ... other cases
}
```
`RecordQuery` receives `tableMetaData` as a prop. It does not know or care how that data arrived — it renders from it:
```tsx
// src/components/query/RecordQuery.tsx
interface RecordQueryProps {
tableName: string
tableMetaData: QTableMetaData // always passed in, never fetched internally
processes?: QProcessMetaData[]
}
export function RecordQuery({ tableName, tableMetaData, processes }: RecordQueryProps) {
// tableMetaData is already resolved — no fetching here
const canCreate = tableMetaData.insertPermission
// ...
}
```
### Key rules
- `tableMetaData` (and any other metadata object) is `undefined` until TanStack Query resolves it. Every component must handle the loading state. The slug page renders a spinner until metadata is ready; it never passes `undefined` to its child components.
- User-facing text always uses `label` from metadata, never `name`. For example, the page header is set to `table?.label ?? slug`, not the raw slug.
- The `resolveSlugTarget` function in `SlugPage` is extracted as a pure function precisely so it can be tested in isolation without any rendering.
### Resolution priority
The slug page resolves in this order: **app** → **table** → **process** → **report**. The first match wins.
---
## Pattern 2: data-qqq-id Attributes
Every significant or interactive element carries a `data-qqq-id` attribute. This attribute serves two purposes:
1. **CSS customization hook** — deployers can write `[data-qqq-id="button-save"] { ... }` in their own stylesheets to override the default styling without touching source code.
2. **Test selector** — Playwright E2E tests and unit tests target elements by `data-qqq-id` rather than by class name (which is an implementation detail subject to change).
### Naming convention
The format is `{component-type}-{identifier}`:
| Pattern | Example |
|---|---|
| `record-query-{tableName}` | `record-query-orders` |
| `widget-{name}` | `widget-ordersSummary` |
| `widget-label-{name}` | `widget-label-ordersSummary` |
| `widget-content-{name}` | `widget-content-ordersSummary` |
| `button-widget-reload-{name}` | `button-widget-reload-ordersSummary` |
| `button-widget-export-{name}` | `button-widget-export-ordersSummary` |
| `button-widget-help-{name}` | `button-widget-help-ordersSummary` |
| `button-save` | `button-save` |
| `button-cancel` | `button-cancel` |
| `field-value-{fieldName}` | `field-value-status` |
| `filter-panel-close` | `filter-panel-close` |
| `link-back-to-source` | `link-back-to-source` |
| `unknown-slug-{slug}` | `unknown-slug-foo` |
### Examples from the actual code
`WidgetBlock.tsx` — the outer card, its label, and its icon buttons:
```tsx
<section
data-qqq-id={`widget-${name}`}
aria-label={label}
>
<h3 data-qqq-id={`widget-label-${name}`}>{label}</h3>
<button data-qqq-id={`button-widget-help-${name}`}>...</button>
<button data-qqq-id={`button-widget-export-${name}`}>...</button>
<button data-qqq-id={`button-widget-reload-${name}`}>...</button>
<div data-qqq-id={`widget-content-${name}`}>...</div>
</section>
```
`RecordQuery.tsx` — the outer container, the filter close button, and the back link:
```tsx
<div data-qqq-id={`record-query-${tableName}`}>
<Link data-qqq-id="link-back-to-source">...</Link>
<button data-qqq-id="filter-panel-close">...</button>
</div>
```
`EntityForm.tsx` — the form itself and its action buttons:
```tsx
<form data-qqq-id={`entity-form-${tableMetaData.name}`}>
<button data-qqq-id="button-cancel">Cancel</button>
<button data-qqq-id="button-save">Save</button>
</form>
```
`FieldValue.tsx` — every field value element is tagged with its field name:
```tsx
<span data-qqq-id={`field-value-${field.name}`}>...</span>
```
### Rules
- Do not use Tailwind class names as test selectors. If a test needs to click a button, it should use `page.getByTestId` or `[data-qqq-id="..."]` selectors, not `.bg-primary` or `.rounded-md`.
- Identifier segments use the backend `name` field (e.g., `orders`), not the `label` (e.g., `"Customer Orders"`). Labels can contain spaces and special characters; names are safe identifiers.
---
## Pattern 3: Async Combobox (`useAsyncCombobox`)
Any dropdown that loads its options from the API with user-typed search uses the `useAsyncCombobox` hook (`src/lib/hooks/use-async-combobox.ts`). The hook encapsulates fetch, debounce, AbortController cancellation, and click-outside-to-close logic that would otherwise be duplicated in every combobox component.
### When to use it
Use `useAsyncCombobox` when:
- The options come from a QQQ possible-value source (table-backed lookup).
- The user can type to search and the list is fetched on demand.
- The option list is too large to load all at once (more than ~50 items).
Use a plain `<select>` or a static shadcn/ui `<Select>` when:
- The options are a small static list (e.g., sort direction: "Ascending / Descending").
- The options are already available in memory (e.g., column names from `tableMetaData.fields`).
### Usage
```tsx
import { useAsyncCombobox } from '@/lib/hooks/use-async-combobox'
function MyPossibleValueInput({ tableName, fieldName, onChange }) {
const {
isOpen,
setIsOpen,
searchTerm,
setSearchTerm,
options, // QPossibleValue[] — the fetched options to display
isLoading,
containerRef, // attach to outermost wrapper for click-outside close
inputRef, // attach to the <input> for focus management
debouncedFetch, // call in onChange handler to trigger debounced fetch
} = useAsyncCombobox({
tableName,
fieldName,
onOptionsFetched: (results) => {
// optional: update a label map or other derived state
},
})
return (
<div ref={containerRef} className="relative">
<input
ref={inputRef}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
debouncedFetch(e.target.value)
}}
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<ul role="listbox">
{isLoading && <li>Loading...</li>}
{options.map((opt) => (
<li
key={opt.id}
role="option"
onClick={() => {
onChange(opt.id)
setIsOpen(false)
}}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
)
}
```
### What the hook manages internally
| Concern | Mechanism |
|---|---|
| Debouncing | `setTimeout` cleared on each call; fires after `COMBOBOX_DEBOUNCE_MS` (200 ms) |
| Request cancellation | `AbortController` — aborts the previous fetch before starting a new one |
| Click-outside close | `document.addEventListener('mousedown', handler)` attached to `containerRef` |
| Auto-fetch on open | `useEffect` on `isOpen` — fetches the full list when the dropdown opens |
| Cleanup on unmount | Clears pending timer and aborts any in-flight request |
### Return values
| Field | Type | Purpose |
|---|---|---|
| `isOpen` | `boolean` | Drive conditional rendering of the dropdown list |
| `setIsOpen` | `Dispatch` | Programmatically open/close (e.g., on focus, on select) |
| `searchTerm` | `string` | Controlled value for the text input |
| `setSearchTerm` | `Dispatch` | Update the text input value |
| `options` | `QPossibleValue[]` | The fetched options — map these to `<li>` elements |
| `isLoading` | `boolean` | Show a spinner or loading state while the fetch is in-flight |
| `containerRef` | `RefObject<HTMLDivElement>` | Attach to the outermost wrapper element |
| `inputRef` | `RefObject<HTMLInputElement>` | Attach to the text `<input>` element |
| `fetchOptions` | `(term: string) => Promise<void>` | Immediate fetch (bypasses debounce) |
| `debouncedFetch` | `(term: string) => void` | Debounced fetch — use this in `onChange` |
---
## Pattern 4: Widget Rendering Pipeline
All dashboard widgets flow through a three-layer pipeline:
```
ConnectedWidget
└─ WidgetBlock (card chrome, loading, error, dropdown selects)
└─ WidgetRenderer (type dispatcher)
└─ StatisticsWidget | BarChartWidget | BlockWidget | ...
```
### Layer 1: ConnectedWidget
`src/components/widgets/ConnectedWidget.tsx` is the primary entrypoint. It:
1. Reads `widgetMetaData.dropdowns` and initializes dropdown state.
2. Fetches dropdown option lists from the possible-value API.
3. Calls `useWidget(widgetMetaData.name, mergedParams)` to fetch the widget's data payload.
4. Returns `null` immediately when `widgetMetaData.hasPermission === false`.
5. Renders `<WidgetBlock>` with loading/error props and passes the fetched `data` to `<WidgetRenderer>`.
```tsx
// ConnectedWidget orchestrates everything
export function ConnectedWidget({ widgetMetaData, params, className }) {
const { data, isLoading, isError, error, refetch } = useWidget(widgetMetaData.name, mergedParams)
return (
<WidgetBlock
widgetMetaData={widgetMetaData}
isLoading={isLoading}
isError={isError}
error={error}
onReload={refetch}
// ... dropdown props
>
{data && <WidgetRenderer widgetMetaData={widgetMetaData} data={data} />}
</WidgetBlock>
)
}
```
### Layer 2: WidgetBlock
`src/components/widgets/WidgetBlock.tsx` provides the standard card shell:
- Header with `label`, optional help tooltip, optional export button, optional reload button, and optional dropdown `<select>` elements.
- Body area that renders a skeleton, an error state, or its `children`.
- A `bare` prop that strips all chrome for use inside composite widgets.
The `WidgetBlock` does not know what type of widget it wraps. It only knows about loading/error state.
### Layer 3: WidgetRenderer
`src/components/widgets/WidgetRenderer.tsx` is the type dispatcher. It receives `widgetMetaData` and the raw `data` payload, reads `widgetMetaData.type`, and renders the appropriate typed component via a `switch` statement.
Supported types:
| `widgetMetaData.type` | Rendered component |
|---|---|
| `'statistics'` | `StatisticsWidget` |
| `'barChart'` | `BarChartWidget` (lazy) |
| `'lineChart'` | `LineChartWidget` (lazy) |
| `'pieChart'` | `PieChartWidget` (lazy) |
| `'recordGrid'` | `RecordGridWidget` |
| `'html'` / `'block'` | `BlockWidget` |
| `'divider'` | `DividerWidget` |
| `'quickLinks'` | `QuickLinksWidget` |
| `'alert'` | `AlertWidget` |
| `'processSummary'` | `ProcessSummaryWidget` |
| `'composite'` / `'parent'` | `CompositeWidget` |
| `'chart'` | `ChartTypeDispatcher` (secondary `chartType` dispatch) |
Chart widgets (`barChart`, `lineChart`, `pieChart`) are loaded with `React.lazy` + `Suspense` to split the Recharts library into a separate bundle chunk. This keeps the initial JS bundle small on pages that contain no charts.
### How to add a new widget type
1. Create a new component file, e.g., `src/components/widgets/HeatmapWidget.tsx`. Export a typed payload interface and the component:
```tsx
// src/components/widgets/HeatmapWidget.tsx
export interface HeatmapWidgetPayload {
rows: Array<{ label: string; cells: number[] }>
columnLabels: string[]
}
export function HeatmapWidget({ data, widgetName }: { data: HeatmapWidgetPayload; widgetName: string }) {
// render the heatmap
}
```
2. Add a `case` to the `switch` in `WidgetRenderer.tsx`:
```tsx
import { HeatmapWidget } from './HeatmapWidget'
import type { HeatmapWidgetPayload } from './HeatmapWidget'
// Inside the switch:
case 'heatmap':
return <HeatmapWidget data={data as HeatmapWidgetPayload} widgetName={name} />
```
3. The backend returns `widgetMetaData.type = 'heatmap'` for this widget. No other changes are needed — `ConnectedWidget` picks it up automatically.
---
## Pattern 5: Field Adornment Priority
`FieldValue` (`src/components/records/FieldValue.tsx`) renders a single QQQ field value in read-only mode. When a field has adornments (decorations declared in `field.adornments[]`), `FieldValue` applies them in strict priority order. The first matching adornment wins; subsequent ones are skipped.
### Priority order
1. **LINK** — renders the value as an `<a>` tag with an external link icon.
2. **FILE_DOWNLOAD** — renders a download anchor using the display value as the URL.
3. **SIZE** — formats the raw number as a human-readable byte size (e.g., `1.5 MB`).
4. **CHIP** — renders the value as a colored badge using a `colorMap` from adornment values.
5. **RENDER_HTML** (or field type `HTML`) — renders sanitized HTML via `DOMPurify.sanitize`.
6. **CODE_EDITOR** — renders the value in a monospace `<pre><code>` block.
7. **TOOLTIP** — wraps the value in a Radix tooltip. Falls through to type-based rendering when no tooltip text is resolved.
8. **ERROR** — renders the value with a destructive icon and styling.
9. Record reference link — when `field.possibleValueSourceName` matches a known table, renders as a Next.js `<Link>` with a `RecordHoverCard` preview.
10. Type-based fallbacks — `BOOLEAN`, `PASSWORD`, `BLOB`, `TEXT` each have dedicated renderers.
11. Default — auto-links bare `http(s)://` URLs and `mailto:` email addresses.
### How adornment detection works
Each adornment is checked with `Array.some()` before its rendering block. The discriminated union type `FieldAdornment` is narrowed inside each block using a type predicate:
```tsx
// Detection
const hasLink = field.adornments?.some((a) => a.type === 'LINK')
// Narrowing within the block
if (hasLink) {
const linkAdornment = field.adornments?.find(
(a): a is Extract<FieldAdornment, { type: 'LINK' }> => a.type === 'LINK'
)
const href = linkAdornment?.values?.linkURL ?? String(value)
// ...
}
```
The `Extract<FieldAdornment, { type: 'LINK' }>` predicate gives TypeScript full knowledge of which `values` keys are available for that specific adornment type without a cast.
### TOOLTIP fall-through
The TOOLTIP case has one deliberate exception to the "first match wins" rule: if the `TOOLTIP` adornment is present but none of the `values` keys (`tooltipText`, `text`, `tooltip`) resolve to a non-empty string, the block falls through to type-based rendering. The value is still displayed — only the tooltip wrapper is omitted because there is nothing to show. This is documented with a comment in the source.
### DOMPurify sanitization
HTML values are sanitized once via `useMemo` that only re-runs when `value` changes:
```tsx
const sanitizedHtml = useMemo(
() => DOMPurify.sanitize(String(value)),
[value]
)
```
This prevents unnecessary sanitization work on every render while also preventing XSS from backend-provided HTML content.
+278
View File
@@ -0,0 +1,278 @@
# Design & UX Review — QQQ Frontend Next
**Date:** 2026-03-01
**Reviewer:** Claude Code (automated static analysis)
**Scope:** Full codebase UX/design audit — layout, pages, forms, process, widgets, feedback, theme, responsive, and a11y
---
## Executive Summary
The new frontend has strong bones: metadata-driven rendering is correctly implemented, component composition is clean, TanStack Query manages server state well, and the base accessibility implementation (labels, aria-required, focus rings, skip nav on breadcrumbs) is solid. The primary gaps fall into three categories:
1. **Accessibility** — several WCAG 2.1 AA gaps in the data grid, animations, and motion preferences
2. **Mobile/responsive polish** — search hidden on mobile, touch targets below 44px minimum in several places
3. **Loading/error state coverage** — a handful of async operations show no spinner or fallback
No showstopper UX regressions from the Material UI frontend were found. The issues below are incremental improvements.
---
## Severity Legend
| Severity | Meaning |
|----------|---------|
| **CRIT** | WCAG 2.1 Level A violation or severe usability break |
| **HIGH** | WCAG 2.1 AA violation or significant UX regression |
| **MED** | Noticeable UX gap, inconsistency, or missing polish |
| **LOW** | Minor polish, nice-to-have, or future consideration |
---
## 1. Layout Shell
**Files:** `src/components/layout/Sidebar.tsx`, `Header.tsx`, `Breadcrumbs.tsx`, `Banner.tsx`, `src/app/(dashboard)/layout.tsx`
### Strengths
- Desktop sidebar + mobile drawer correctly split at `md` breakpoint
- Breadcrumbs have `aria-label="Breadcrumb"` and `aria-current="page"`
- Escape key closes mobile drawer; sidebar items have focus rings
- Banner severity states (info/warning/error) correctly scoped with icons
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-L-1 | **HIGH** | **No skip-to-content link.** Keyboard users must tab through the entire sidebar before reaching page content. Add `<a href="#main-content" className="sr-only focus:not-sr-only">Skip to main content</a>` as the first child of `<body>`. |
| D-L-2 | **HIGH** | **Animations ignore `prefers-reduced-motion`.** Sidebar slide-in, banner fade-in, and skeleton `animate-pulse` all run unconditionally. Add `@media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }` to `globals.css`. |
| D-L-3 | **MED** | **Header search hidden on mobile** (`hidden md:block`). Mobile users lose global search entirely. Add a search icon button on mobile that opens `SearchDialog`. |
| D-L-4 | **MED** | **Dismissed banners not persisted.** Banners dismissed via local state reappear on reload. Save dismissed banner keys to `localStorage` under `qqq:dismissed-banners`. |
| D-L-5 | **MED** | **Mobile sidebar `aria-expanded` not set.** The mobile drawer open/closed state isn't announced to screen readers. Add `aria-expanded={mobileOpen}` to the nav landmark element. |
| D-L-6 | **LOW** | **Breadcrumb overflow on very narrow viewports** (<320px). Long breadcrumb chains don't truncate; last segment can overflow. Add `truncate` + `max-w-[8rem]` on intermediate segments. |
| D-L-7 | **LOW** | **Sidebar nav has no loading skeleton.** During the initial metadata fetch the sidebar is empty with no placeholder. Add skeleton nav items matching the expected structure. |
---
## 2. Record Query (List View)
**Files:** `src/components/query/DataGrid.tsx`, `FilterBuilder.tsx`, `Pagination.tsx`, `RecordQueryToolbar.tsx`, `RecordCardView.tsx`, `ColumnConfig.tsx`
### Strengths
- Skeleton rows during fetch match final layout (no layout shift)
- Three row density variants (compact/standard/comfortable) with persistence
- Empty state with icon, title, and "Clear filters" CTA
- Quick-search debounced at 300ms
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-Q-1 | **CRIT** | **DataGrid missing `aria-sort` on column headers.** When a column is sorted, screen readers don't announce direction. Add `aria-sort="ascending" \| "descending" \| "none"` to each `<th>` based on current sort state. |
| D-Q-2 | **HIGH** | **Row selection checkboxes have no label.** Each row checkbox announces "checkbox" with no context. Add `aria-label={\`Select ${recordLabel}\`}` to each. |
| D-Q-3 | **HIGH** | **Column resize handles not keyboard accessible.** Resize is mouse-drag only. Keyboard users cannot adjust column widths. Add `onKeyDown` handler supporting `ArrowLeft`/`ArrowRight` to the resize handle element. |
| D-Q-4 | **MED** | **FilterBuilder async combobox has no loading indicator.** While possible-value options are fetching, the dropdown appears empty with no spinner. Add a `Loader2` icon while `isLoading` is true. |
| D-Q-5 | **MED** | **"Go to page" input silently ignores out-of-range values.** Typing a page number beyond the last page does nothing with no feedback. Show an inline validation message like "Page must be between 1 and N". |
| D-Q-6 | **MED** | **No `aria-live` region for fetch-in-progress.** Background re-fetches (sort/filter changes) are visually indicated by the progress bar but not announced. Add `aria-live="polite"` region announcing "Loading results". |
| D-Q-7 | **MED** | **Column visibility toggle state not announced.** Toggling a column in ColumnConfig doesn't notify screen readers. Add a `role="status"` region that announces "Column X hidden" / "Column X visible". |
| D-Q-8 | **LOW** | **No frozen/pinned columns.** The primary key / record label column scrolls out of view on wide tables. Consider pinning the first column on horizontal scroll. |
| D-Q-9 | **LOW** | **RecordCardView touch targets may be too small.** Cards are clickable but no explicit 44px minimum hit area. Ensure card tap targets meet the 44px minimum on mobile. |
---
## 3. Record View (Detail Page)
**Files:** `src/components/records/RecordView.tsx`, `RecordViewHeader.tsx`, `FieldValue.tsx`, `RecordViewAssociated.tsx`
### Strengths
- DOMPurify sanitization applied before rendering HTML field values
- 403/404/500 errors shown with contextual messages
- Tab layout for T2/T3 sections with proper semantic structure
- Field adornment priority correctly handled (LINK → FILE_DOWNLOAD → SIZE → CHIP)
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-V-1 | **HIGH** | **Edit/Delete buttons shown regardless of user permissions.** If table metadata indicates the user lacks UPDATE/DELETE rights, buttons should be hidden (or disabled with tooltip). Currently they render and fail with a 403 on action. |
| D-V-2 | **MED** | **Associated records list not paginated.** Many-to-many joins can return thousands of records, all rendered to DOM. Add pagination (or a "Show more" pattern) capped at 25 by default. |
| D-V-3 | **MED** | **Avatar initials fail for non-Latin scripts.** `getInitials()` splits on whitespace which produces empty strings for CJK/Arabic names. Add a fallback to a generic user icon when initials can't be derived. |
| D-V-4 | **MED** | **No delete confirmation dialog.** The delete action currently triggers immediately. Add a confirm dialog ("Delete this record? This cannot be undone.") before the API call. |
| D-V-5 | **LOW** | **No "Copy record ID" quick action.** Admin users frequently need the primary key for support/debugging. Add a small copy-to-clipboard icon next to the record ID. |
| D-V-6 | **LOW** | **No audit trail footer.** No "Last modified by / at" metadata shown. If the backend provides this, surface it at the bottom of the record view. |
---
## 4. Create / Edit Forms
**Files:** `src/components/forms/DynamicForm.tsx`, `EntityForm.tsx`, `DynamicFormField.tsx`, `src/components/forms/field-types/`
### Strengths
- All inputs have `aria-required`, `aria-invalid`, `aria-describedby` wired correctly
- Unsaved-changes guard (`beforeunload` + modal) prevents accidental data loss
- Help tooltips use Radix Tooltip with keyboard support
- Required asterisk is `aria-hidden="true"` to avoid screen-reader repetition
- Disabled state styling consistent across field types
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-F-1 | **HIGH** | **Submit button stays enabled during mutation.** User can click Save multiple times before the first request completes, creating duplicate records. Add `disabled={isSubmitting}` and a loading spinner to the submit button. |
| D-F-2 | **MED** | **Number fields don't display min/max constraints.** If field metadata defines `minValue`/`maxValue`, the user doesn't see them until a validation error fires. Show the range as hint text below the field (`0 – 100`). |
| D-F-3 | **MED** | **DateTime fields don't indicate time zone.** The input accepts a time but doesn't show which time zone is assumed. Show the user's local TZ abbreviation next to the field. |
| D-F-4 | **MED** | **Help tooltips not touch-friendly on mobile.** The `?` icon is hover-only and < 44px. On mobile, show the help text inline beneath the label instead. |
| D-F-5 | **MED** | **File upload field has no drag-and-drop visual feedback.** The drop zone changes border on `dragover` but has no text like "Drop file here". Add clear drop-zone copy and a highlight color change. |
| D-F-6 | **LOW** | **No "which fields changed" diff in edit mode.** Users editing a long form can't tell what they've modified at a glance. Consider highlighting changed fields with a left border accent. |
| D-F-7 | **LOW** | **Large forms show all fields at once.** Sections with 20+ fields have no collapse/expand. Consider optional `<details>` sections for secondary field groups. |
---
## 5. Process Execution
**Files:** `src/components/process/ProcessRun.tsx`, `StepWizard.tsx`, `ProcessFormStep.tsx`, `ValidationReviewStep.tsx`, `ProcessUploadFormStep.tsx`, `BulkLoadStep.tsx`
### Strengths
- Step wizard gives clear numbered/checkmark visual progress
- Cancellation confirm dialog prevents accidental process abort
- Back navigation available when not on the first step
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-P-1 | **HIGH** | **Current step not auto-focused after "Next".** When advancing to a new step, focus remains on the Next button (now gone), leaving screen reader users disoriented. Move focus to the step heading or first field on step change. |
| D-P-2 | **MED** | **Step wizard steps not keyboard-navigable (back).** Completed steps have no click handler to jump back. Add click-to-navigate on completed step indicators. |
| D-P-3 | **MED** | **No process timeout message.** If the backend hangs, the polling spinner runs indefinitely. After ~60s show "This is taking longer than expected" with an option to wait or cancel. |
| D-P-4 | **MED** | **Validation review results not filterable.** With 1000+ errors, the flat list is unmanageable. Add severity filter buttons (All / Errors / Warnings) and a field-name search. |
| D-P-5 | **MED** | **File upload step has no progress bar.** Large uploads show no byte-level progress. Add a `<progress>` element or Radix Progress during multipart upload. |
| D-P-6 | **MED** | **Step label truncation has no mobile tooltip.** Long step names are truncated at `max-w-[6rem]` with only a `title` attribute, which doesn't appear on touch. Add a Radix Tooltip wrapper. |
| D-P-7 | **LOW** | **No "retry" on process step failure.** Users must restart the entire process from step 1 if one step fails. Add a "Retry this step" button where the backend supports re-running a step. |
| D-P-8 | **LOW** | **Bulk load column mappings not saveable.** Users re-map the same CSV columns every run. Add a "Save this mapping" option persisted to localStorage. |
---
## 6. Widgets & Dashboard
**Files:** `src/components/widgets/WidgetRenderer.tsx`, `StatisticsWidget.tsx`, `BlockWidget.tsx`, `BarChartWidget.tsx`, `LineChartWidget.tsx`, `PieChartWidget.tsx`, `RecordGridWidget.tsx`
### Strengths
- Charts lazy-loaded via dynamic import (non-chart pages don't load Recharts)
- Suspense skeleton placeholder shown while chart chunk loads
- Exhaustive `switch` on widget type with unknown fallback
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-W-1 | **HIGH** | **Statistics trend arrows not accessible.** `TrendingUp`/`TrendingDown`/`Minus` icons are `aria-hidden` with no text alternative. Screen readers don't announce whether a metric is up or down. Add `<span className="sr-only">trending up</span>` next to each icon. |
| D-W-2 | **MED** | **Widget data fetch errors show blank space.** If a widget's API call fails there is no error boundary or fallback UI. Add a per-widget error state (icon + "Could not load widget" message + retry). |
| D-W-3 | **MED** | **Chart legends overflow horizontally on mobile.** Recharts legends don't reflow on narrow screens. Use `layout="vertical"` or hide the legend on mobile with a `<ResponsiveContainer>` width check. |
| D-W-4 | **MED** | **RecordGrid widget not paginated.** Renders the full result set to DOM. Cap at 25 rows and add a "View all" link to the full record query page. |
| D-W-5 | **MED** | **No manual widget refresh.** Stale widget data can't be refreshed without a full page reload. Add a small refresh icon button on each widget card header. |
| D-W-6 | **LOW** | **Only 5 chart color tokens defined.** Dashboards with 6+ data series reuse colors, making series indistinguishable. Extend `--color-chart-*` to at least 10 distinct values. |
---
## 7. Feedback Components
**Files:** `src/components/feedback/GlobalSearch.tsx`, `SearchDialog.tsx`, `CommandMenu.tsx`, `EmptyState.tsx`, `ErrorBoundary.tsx`, `KeyboardShortcutsDialog.tsx`
### Strengths
- `EmptyState` uses `role="status"` + `aria-live="polite"` correctly
- Skeleton components have `role="status"` + `aria-busy="true"`
- SearchDialog has full ArrowUp/ArrowDown/Enter/Escape keyboard nav
- `HighlightedText` now correctly uses `i % 2 !== 0` (recently fixed)
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-FB-1 | **MED** | **Keyboard Shortcuts dialog not discoverable.** `?` opens it but there is no visible hint in the UI. Add a `?` icon button in the header, or a footer note "Press ? for keyboard shortcuts". |
| D-FB-2 | **MED** | **Toasts have no visible close/dismiss button.** Users must wait for auto-dismiss. Add an × button to each toast (sonner supports `closeButton` prop). |
| D-FB-3 | **MED** | **CommandMenu icon buttons below 44px touch target.** Small icon-only buttons in the command menu are hard to tap on mobile. Ensure `min-h-[44px] min-w-[44px]` on interactive icons. |
| D-FB-4 | **LOW** | **Search results missing table/app context in header.** Results are grouped by table in logic but group labels may not be visually prominent enough. Ensure group headers have sufficient weight/spacing. |
---
## 8. Theme & Global Styles
**Files:** `src/styles/globals.css`, `src/styles/qqq-theme.css`, `src/lib/theme/theme-provider.tsx`, `src/lib/theme/tokens.ts`
### Strengths
- 60+ CSS custom property tokens for colors, spacing, shadows
- Complete dark mode override section
- `:focus-visible` with proper outline and offset
- Muted text color darkened to meet WCAG AA contrast
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-T-1 | **HIGH** | **No `prefers-reduced-motion` override.** All animations (skeleton pulse, slide-in transitions, toast fades) run unconditionally. See D-L-2 above — one global CSS rule resolves this. |
| D-T-2 | **HIGH** | **No `prefers-contrast: more` support.** Users with OS high-contrast mode enabled get no enhanced styling. Add a `@media (prefers-contrast: more)` block that increases border weights and ensures 7:1 contrast ratios. |
| D-T-3 | **MED** | **Dual theming systems create token confusion.** `--qqq-primary-color` and `--color-primary` both define the primary color. Components reference both inconsistently. Consolidate to one system (Tailwind v4 `--color-*` preferred) and alias the `--qqq-*` tokens to the canonical values for backwards compatibility. |
| D-T-4 | **MED** | **Z-index tokens defined but not used consistently.** `--qqq-z-sidebar: 100`, `--qqq-z-modal: 1000` exist in the token file but some components use hardcoded values. Use the tokens everywhere. |
| D-T-5 | **LOW** | **Focus ring may be invisible in dark mode.** The `color-mix`-based focus outline color may not contrast sufficiently on dark backgrounds. Test with browser dark mode enabled. |
| D-T-6 | **LOW** | **No disabled-state semantic token.** All disabled states use `opacity-50`. A dedicated `--color-disabled` token would allow more nuanced disabled styling (e.g., different for text vs background). |
---
## 9. Responsive / Mobile
### Strengths
- Sidebar correctly switches between desktop rail and mobile drawer at `md`
- RecordQuery shows bottom-sheet filter panel on mobile
- RecordCardView provides card layout alternative to data grid on mobile
- Pagination uses `flex-wrap` to reflow on narrow screens
### Findings
| ID | Severity | Finding |
|----|----------|---------|
| D-R-1 | **HIGH** | **Mobile `<input>` elements trigger browser zoom.** Inputs with `font-size < 16px` cause iOS Safari to auto-zoom on focus, breaking the layout. Ensure all form inputs have `text-base` (16px) as minimum font size. |
| D-R-2 | **MED** | **Touch targets below 44px in multiple places.** Pagination size selector, density buttons, column config toggles. Audit all interactive elements and ensure `min-h-[44px]` or adequate padding. |
| D-R-3 | **MED** | **No 320px (iPhone SE) layout testing.** Several components assume ≥375px. Run a responsive check at 320px and fix any overflows. |
| D-R-4 | **LOW** | **Landscape tablet layout untested.** At ~768px landscape some components may show desktop layout with insufficient room. Test iPad Mini landscape. |
---
## Priority Matrix
### Must Fix (WCAG 2.1 AA / CRIT / HIGH)
| ID | Area | Fix |
|----|------|-----|
| D-L-1 | Layout | Skip-to-content link |
| D-L-2 / D-T-1 | Layout / Theme | `prefers-reduced-motion` CSS rule |
| D-T-2 | Theme | `prefers-contrast: more` support |
| D-Q-1 | Record Query | `aria-sort` on DataGrid column headers |
| D-Q-2 | Record Query | Row checkbox `aria-label` |
| D-Q-3 | Record Query | Keyboard-accessible column resize |
| D-V-1 | Record View | Permission-gated Edit/Delete buttons |
| D-F-1 | Forms | Disable submit button while submitting |
| D-P-1 | Process | Auto-focus step heading on step change |
| D-W-1 | Widgets | Screen-reader text for trend arrows |
| D-R-1 | Responsive | 16px minimum font size on inputs |
### Should Fix (MED — noticeable UX gaps)
D-L-3 (mobile search), D-L-4 (banner persistence), D-L-5 (sidebar aria-expanded), D-Q-4 (FilterBuilder spinner), D-Q-5 (pagination range feedback), D-Q-6 (aria-live for fetch), D-V-2 (associated records pagination), D-V-4 (delete confirmation), D-F-2 (number field min/max hint), D-F-3 (DateTime TZ indicator), D-F-4 (mobile tooltip → inline help), D-P-2 (step back navigation), D-P-3 (process timeout message), D-P-4 (validation review filter), D-P-5 (upload progress bar), D-W-2 (widget error boundary), D-W-3 (chart legend mobile), D-W-5 (widget refresh button), D-FB-1 (keyboard shortcuts discoverability), D-FB-2 (toast close button), D-T-3 (dual theming consolidation), D-T-4 (z-index token usage), D-R-2 (touch targets), D-R-3 (320px layout)
### Nice to Have (LOW — polish)
D-L-6 (breadcrumb truncation), D-L-7 (sidebar skeleton), D-Q-8 (frozen columns), D-V-5 (copy record ID), D-V-6 (audit trail), D-F-6 (edit diff), D-F-7 (collapsible form sections), D-P-7 (retry step), D-P-8 (save bulk load mapping), D-W-6 (chart color tokens), D-FB-4 (search result context), D-T-5 (dark mode focus ring), D-T-6 (disabled token), D-R-4 (landscape tablet)
---
## What's Working Well
The following areas are solid and don't require significant rework:
- **Metadata-driven rendering** — no hardcoded table/field names anywhere
- **API layer** — all calls through typed `src/lib/api/` functions, consistent error handling
- **Form accessibility baseline** — `aria-required`, `aria-invalid`, `aria-describedby` correctly wired
- **Unsaved changes guard** — `beforeunload` + confirm modal works correctly
- **TanStack Query** — caching, deduplication, and revalidation correctly configured
- **Dark mode** — complete token coverage, no visual regressions found
- **Skeleton loading** — components have `role="status"` and `aria-busy="true"`
- **DOMPurify** — applied consistently on all user-controlled HTML output
- **URL state** — filters, pagination, and sort are shareable via query params
+194
View File
@@ -0,0 +1,194 @@
<!--
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.
-->
# QQQ Frontend Next — Documentation Review
**Date:** 2026-03-01
**Scope:** Full `src/` + `docs/` + project root
**Commit:** 12edc4c
---
## Overall Score: 7.5 / 10
**Strengths:** JSDoc coverage is high across the API client, utilities, and most hooks. Type definitions are well-documented. `CLAUDE.md` and the implementation plans give a strong architectural overview.
**Key gaps:** `README.md` is essentially empty, `useRecordQuery` has no return-type documentation, and there are no architecture guides for state management or component composition patterns — the two things a new contributor needs most.
---
## CRITICAL — Must Address
### DOC-CRIT-1: README.md is empty
**File:** `README.md`
**Issue:** The file contains only a title. No setup instructions, command list, project overview, or links to docs.
**Impact:** A new developer cannot start contributing without reading `CLAUDE.md` and inferring the rest.
**Fix:** Add the following sections at minimum:
- What this project is (2–3 sentences)
- Prerequisites (Node.js version, pnpm)
- Quick start (`git clone` → `pnpm install` → `pnpm dev`)
- Available commands (dev, build, test, test:e2e, lint, tsc)
- Pointer to `CLAUDE.md` for architecture and conventions
- Pointer to `docs/` for implementation plans
---
### DOC-CRIT-2: `useRecordQuery` return type is undocumented
**File:** `src/lib/hooks/use-record-query.ts` (~line 60, main hook export)
**Issue:** The hook returns a large composite object (pagination, filter, selection, sort, query state, plus delegated `columns` and `views` namespaces from sub-hooks). None of the return properties are documented. Only two components consume this hook today, but that will grow.
**Impact:** Callers must trace through 685 lines of source to discover which properties exist.
**Fix:** Add a `@returns` block enumerating the top-level namespaces and key properties, or define an explicit `UseRecordQueryResult` interface with JSDoc on each field.
---
### DOC-CRIT-3: No state-management decision guide
**Issue:** The project uses five different state containers (React Context, TanStack Query, React Hook Form, URL search params, localStorage), each for different purposes. There is a brief table in `CLAUDE.md` but no guidance on the decision criteria.
**Impact:** New contributors pick the wrong state container; inconsistent patterns accumulate over time.
**Fix:** Create `docs/STATE-MANAGEMENT.md` covering:
- TanStack Query: all server state (records, metadata, widgets)
- React Hook Form: form field state only
- URL search params: filters, pagination, sort (shareable links)
- localStorage: user preferences, column config, saved views
- QContext: application-level UI state (modals, toast queue)
- When NOT to use useState for server-derived state
---
## SIGNIFICANT — Should Address
### DOC-SIG-1: No component composition patterns guide
**Issue:** The codebase uses several non-obvious patterns not documented anywhere outside of scattered code comments:
- Metadata-driven rendering (metadata always flows down; children never fetch)
- `data-qqq-id` naming convention (`{component-type}-{identifier}`)
- Async combobox pattern (`useAsyncCombobox` wrapping)
- Field adornment priority chain (documented in `FieldValue.tsx` but not shared)
**Fix:** Create `docs/COMPONENT-PATTERNS.md` with each pattern described, the rationale, and a code example.
---
### DOC-SIG-2: No type glossary
**Issue:** The `src/types/` directory exports ~40 interfaces and type aliases. There is no index or glossary. Finding the right type requires `grep`-ing.
**Impact:** Types are duplicated or approximated when developers can't find the canonical definition.
**Fix:** Create `docs/TYPE-GLOSSARY.md` with a table: Type | File | Purpose | Example usage. Focus on the ~15 most-used types (`QInstance`, `QTableMetaData`, `QFieldMetaData`, `SavedView`, `QProcessMetaData`, `StatTile`, `FilterModel`, etc.).
---
### DOC-SIG-3: API error handling patterns undocumented
**File:** `src/lib/api/client.ts` (referenced)
**Issue:** The 401 interceptor is documented, but there is no guide to how callers should handle 400 (validation), 403 (permission), 404 (not found), and 500 (server error) responses. Components handle these inconsistently today.
**Fix:** Add a section to the API client file header or create `docs/API-ERROR-HANDLING.md` with the standard response patterns per HTTP status code.
---
### DOC-SIG-4: Zod schema generation lacks examples
**File:** `src/lib/utils/zod-from-metadata.ts`
**Issue:** The function and helpers are well-documented, but there is no `@example` showing how a field metadata object maps to a generated Zod schema. This is the most opaque part of the form validation pipeline.
**Fix:** Add a 6-line `@example` to `zodFieldFromMetadata` showing an input `QFieldMetaData` object and the corresponding schema.
---
### DOC-SIG-5: Form validation strategy not centralized
**Issue:** Zod schema generation is in `zod-from-metadata.ts`, field-level validation rules are in individual field components, and server-side validation errors are mapped in `EntityForm.tsx`. No single document explains the end-to-end validation strategy.
**Fix:** Create `docs/FORM-VALIDATION.md` covering schema generation, custom refinements, and mapping server errors to form fields.
---
### DOC-SIG-6: TanStack Query key factory undocumented
**File:** `src/lib/query-client.ts`
**Issue:** The query key factory pattern is used throughout the codebase but the naming convention and invalidation patterns are not documented. Developers risk creating overlapping keys.
**Fix:** Document the key structure and add `@example` tags showing how to invalidate a table's record list after a mutation.
---
## MINOR — Nice to Have
### DOC-MIN-1: Test file headers are sparse
Many test files (`*.test.tsx`) have no JSDoc file header explaining what behavior is under test and what is explicitly out of scope. This makes it harder to assess test coverage gaps at a glance.
**Files:** Most files in `src/components/**/*.test.tsx`, `src/lib/hooks/*.test.ts`
---
### DOC-MIN-2: Performance decisions lack inline rationale
Files like `use-record-query.ts` have several `useMemo` / `useCallback` / `useRef` optimizations with comments referencing ticket numbers but no explanation of the underlying performance problem being solved.
---
### DOC-MIN-3: Accessibility decisions not consolidated
WCAG compliance work is scattered across code comments and the design review document. A single `docs/ACCESSIBILITY.md` checklist would make it easy to see what has been done and what remains.
---
### DOC-MIN-4: Widget creation guide missing
The widget rendering pipeline (`WidgetBlock` → `WidgetRenderer` → specific widget component) is non-obvious. A short guide in `docs/` or a code comment in `WidgetRenderer` explaining how to add a new widget type would help.
---
## Already Excellent
| Area | Quality | Notes |
|------|---------|-------|
| API client (`src/lib/api/`) | 9/10 | 100% JSDoc; error conditions noted; param types clear |
| Utilities (`src/lib/utils/`) | 8.5/10 | `filter-utils`, `process-utils`, `zod-from-metadata` all thorough |
| Sub-hooks (`use-column-config`, `use-saved-views`, `use-async-combobox`) | 9/10 | Return shapes documented; edge cases noted |
| Type definitions (`src/types/metadata.ts`) | 8.5/10 | All major interfaces have field-level JSDoc |
| Core query components (`DataGrid`, `FilterBuilder`, `Pagination`, `ColumnConfig`) | 8/10 | Props and behavior well-described |
| Records components (`FieldValue`, `RecordView`, `EntityForm`) | 7.5/10 | Good headers; adornment priority documented |
| `CLAUDE.md` | 9/10 | Architecture, conventions, file structure all clear |
| Implementation plans (`docs/implementation-plans/`) | 8/10 | Phased clearly; shared context well-organized |
---
## Recommended Work Order
### Phase 1 — Unblock contributors (half day)
1. **DOC-CRIT-1** — Expand README.md
2. **DOC-CRIT-2** — Document `useRecordQuery` return type
### Phase 2 — Improve onboarding (1 day)
3. **DOC-CRIT-3** — Create `docs/STATE-MANAGEMENT.md`
4. **DOC-SIG-1** — Create `docs/COMPONENT-PATTERNS.md`
5. **DOC-SIG-2** — Create `docs/TYPE-GLOSSARY.md`
### Phase 3 — Fill API/validation gaps (half day)
6. **DOC-SIG-3** — API error handling guide
7. **DOC-SIG-4** — Add `@example` to `zodFieldFromMetadata`
8. **DOC-SIG-5** — Create `docs/FORM-VALIDATION.md`
9. **DOC-SIG-6** — Document query key factory
### Phase 4 — Polish (as time allows)
10–13. **DOC-MIN-1 through DOC-MIN-4** — Test headers, perf rationale, a11y checklist, widget guide
+333
View File
@@ -0,0 +1,333 @@
# Form Validation
This guide covers how forms are validated in QQQ Frontend Next. Validation is schema-driven: Zod schemas are generated automatically from the same backend field metadata that drives rendering, so there is no need to maintain a separate validation definition alongside the form.
---
## Overview
The validation stack is:
- **Zod** — runtime schema definition and validation.
- **React Hook Form** + `@hookform/resolvers/zod` — drives form state; the Zod schema is passed as the resolver.
- **`zodFieldFromMetadata`** — the core function that maps a single `QFieldMetaData` to a Zod validator.
- **`zodSchemaFromTableMetadata`** — builds a full `z.ZodObject` from all editable fields in a table.
- **`zodSchemaFromFields`** — builds a `z.ZodObject` from a flat array of fields (used for process steps).
The source for the schema generation utilities is `src/lib/utils/zod-from-metadata.ts`.
---
## Schema generation
### For table-based forms
`EntityForm` (`src/components/forms/EntityForm.tsx`) builds its schema on mount:
```tsx
const schema = useMemo(
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude),
[tableMetaData, fieldNamesToInclude]
)
const { register, control, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: mergedDefaults,
})
```
`zodSchemaFromTableMetadata` iterates the table's fields, skips hidden (`isHidden`) and non-editable (`!isEditable`) fields, and calls `zodFieldFromMetadata` for each remaining field:
```tsx
export function zodSchemaFromTableMetadata(
tableMetaData: QTableMetaData,
fieldNamesToInclude?: string[]
): z.ZodObject<Record<string, z.ZodTypeAny>> {
const shape: Record<string, z.ZodTypeAny> = {}
for (const fieldName of fieldsToProcess) {
const field = tableMetaData.fields[fieldName]
if (!field) continue
if (field.isHidden) continue
if (!field.isEditable) continue
shape[fieldName] = zodFieldFromMetadata(field)
}
return z.object(shape)
}
```
When `fieldNamesToInclude` is provided, only those fields are included. This is useful for partial-edit dialogs or multi-step wizards where only a subset of fields are shown per step.
### For process step forms
Process steps receive an ordered array of fields rather than a table metadata object. Use `zodSchemaFromFields`:
```tsx
import { zodSchemaFromFields } from '@/lib/utils/zod-from-metadata'
const schema = zodSchemaFromFields(stepFields) // QFieldMetaData[]
```
Hidden fields are excluded automatically. The resulting schema shape is identical to the table-based variant.
### For single fields
Call `zodFieldFromMetadata` directly when you need the Zod type for a single field:
```tsx
import { zodFieldFromMetadata } from '@/lib/utils/zod-from-metadata'
const fieldSchema = zodFieldFromMetadata(tableMetaData.fields['email'])
```
---
## Field type to Zod type mapping
The table below shows exactly how each QQQ field type is converted. All mappings are implemented in `zodFieldFromMetadata`.
| QQQ field type | Zod type (required) | Zod type (optional) | Notes |
|---|---|---|---|
| `STRING` | `z.string().min(1, ...)` | `z.string().optional()` | `maxLength` adds `.max()` when set |
| `TEXT` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
| `HTML` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
| `PASSWORD` | `z.string().min(1, ...)` | `z.string().optional()` | Same as STRING |
| `INTEGER` | `z.coerce.number().int(...)` | `z.union([z.literal(''), z.coerce.number().int(...)]).optional()` | Empty `<input type="number">` submits `""` |
| `LONG` | `z.coerce.number().int(...)` | `z.union([z.literal(''), z.coerce.number().int(...)]).optional()` | Same as INTEGER |
| `DECIMAL` | `z.coerce.number(...)` | `z.union([z.literal(''), z.coerce.number(...)]).optional()` | No `.int()` — allows fractional values |
| `BOOLEAN` | `z.boolean().optional()` | `z.boolean().optional()` | Always optional — checkboxes are never "required" |
| `DATE` | `z.string().min(1, ...)` | `z.string().optional()` | HTML date inputs submit ISO-8601 strings |
| `TIME` | `z.string().min(1, ...)` | `z.string().optional()` | Same as DATE |
| `DATE_TIME` | `z.string().min(1, ...)` | `z.string().optional()` | Same as DATE |
| `BLOB` | `z.union([z.instanceof(File), z.string()]).optional()` | same | Accepts File objects or existing URL strings |
### Why numbers use `z.literal('')` in the optional case
An `<input type="number">` submits an empty string (`""`) when left blank, not `null` or `undefined`. A plain `z.coerce.number()` would coerce `""` to `0`, silently making an empty field appear as zero. The `z.union([z.literal(''), schema])` pattern allows the empty string to pass through as-is so the form can distinguish "nothing entered" from "zero entered".
### Why dates stay as strings
Date and time values come from `<input type="date">` and `<input type="datetime-local">` as ISO-8601 strings (`"2026-03-01"`, `"2026-03-01T14:30"`). The schema validates them as strings. Any coercion to JavaScript `Date` objects — if required by the API — happens in the submit handler, not in the Zod schema.
---
## Required vs optional
The `isRequired` flag on `QFieldMetaData` is the single source of truth for whether a field is required:
- **`isRequired: true`** → `.min(1, "${label} is required")` for string types; plain `z.coerce.number()` for numeric types (empty string fails coercion).
- **`isRequired: false` or absent** → `.optional()` for string types; `z.union([z.literal(''), ...]).optional()` for numeric types.
All error messages use the human-readable `field.label` (not `field.name`), so they read naturally in the UI: `"First Name is required"`, not `"firstName is required"`.
---
## Number bounds
When a field's metadata declares `minValue` or `maxValue`, those bounds are applied to the Zod schema:
```tsx
function buildNumberSchema(isRequired, label, isInteger, minValue, maxValue) {
let schema = isInteger
? z.coerce.number().int(`${label} must be a whole number`)
: z.coerce.number({ message: `${label} must be a number` })
if (minValue !== undefined && minValue !== null) {
schema = schema.min(Number(minValue), `${label} must be at least ${minValue}`)
}
if (maxValue !== undefined && maxValue !== null) {
schema = schema.max(Number(maxValue), `${label} must be at most ${maxValue}`)
}
// ...
}
```
The bounds come directly from metadata — no hardcoded ranges exist in the frontend. `DynamicFormField` also forwards `minValue` and `maxValue` as HTML attributes (`min`, `max`) on `<NumberField>` so the browser provides native range feedback, but Zod is the authoritative validator on submit.
---
## String length constraints
`maxLength` from field metadata maps to Zod's `.max()`:
```tsx
function buildStringSchema(isRequired, label, maxLength) {
let schema = z.string()
if (isRequired) schema = schema.min(1, `${label} is required`)
if (maxLength) schema = schema.max(maxLength, `${label} must be at most ${maxLength} characters`)
return isRequired ? schema : schema.optional()
}
```
`DynamicFormField` also passes `maxLength` to `<TextField>` as an HTML `maxlength` attribute, providing in-browser character limiting, but the Zod schema validates the constraint on submit as well.
---
## Default values
`defaultValuesFromRecord` (`src/lib/utils/zod-from-metadata.ts`) builds the `defaultValues` object for `useForm`:
```tsx
export function defaultValuesFromRecord(
tableMetaData: QTableMetaData,
recordValues: Record<string, unknown>
): Record<string, unknown>
```
For each editable, visible field it applies this priority:
1. Use the record value (from the API response), with type coercion:
- `DATE`, `TIME`, `DATE_TIME` → `String(value)` (ISO-8601 string for the HTML input).
- `BOOLEAN` → `Boolean(value)`.
- All other types → value as-is.
2. Fall back to `field.defaultValue` from metadata.
3. Fall back to `false` for `BOOLEAN` fields.
4. Fall back to `''` (empty string) for everything else.
`EntityForm` calls this inside `useMemo` and passes the result to `useForm({ defaultValues })`. When the record changes (e.g., navigating between records), the form is reset via `reset(defaultValuesFromRecord(...))` in a `useEffect`.
---
## Client vs server validation
### Client-side (Zod)
Zod runs on the client when the user submits the form. React Hook Form calls the Zod resolver, collects all field errors, and populates `formState.errors`. Fields display their error messages inline — `DynamicFormField` renders a `<p role="alert">` with `id="${fieldId}-error"` below each failing input, and the input gains `aria-invalid="true"` and `aria-describedby` pointing at the error element.
```tsx
{fieldError && (
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
{fieldError.message}
</p>
)}
```
### Server-side (API)
The backend can return validation errors after a save attempt. `EntityForm` handles this via TanStack Query mutation `onError` callbacks. Currently, API errors are shown as a top-level error banner rather than per-field:
```tsx
const insertMutation = useMutation({
mutationFn: (values) => insertRecord(tableMetaData.name, values),
onError: (err: Error) => {
toast.error(`Failed to create ${tableMetaData.label}: ${err.message}`)
},
})
```
When the API returns structured per-field errors (a map of `{ fieldName: errorMessage }`), map them to React Hook Form using `form.setError`:
```tsx
onError: (err) => {
const fieldErrors = parseApiFieldErrors(err)
if (fieldErrors) {
for (const [fieldName, message] of Object.entries(fieldErrors)) {
form.setError(fieldName, { type: 'server', message })
}
} else {
toast.error(err.message)
}
}
```
This makes server validation errors appear in the same inline error elements as client-side Zod errors, giving the user a consistent experience.
---
## DynamicFormField dispatch
`DynamicFormField` (`src/components/forms/DynamicFormField.tsx`) is the component that renders a single field inside a metadata-driven form. It dispatches to a typed input component based on field properties in this priority order:
1. **`field.possibleValueSourceName` is set** → `<PossibleValueSelect>` (async combobox).
2. **`FILE_UPLOAD` adornment or `BLOB` type** → `<FileUploadField>`.
3. **`field.type` switch** → one of `TextField`, `NumberField`, `BooleanField`, `DateField`, `DateTimeField`, `PasswordField`, or inline `<textarea>` (TEXT), `<RichTextField>` (HTML), `<input type="time">` (TIME).
4. **Default case** → `<TextField>` (treats unknown types as plain text).
Hidden fields (`isHidden: true`) and non-editable fields return `null` immediately.
### Dirty field indicator
When a field's value has changed from its default, `DynamicFormField` wraps the input in a left-border accent:
```tsx
function DirtyWrapper({ isDirty, children }) {
if (!isDirty) return <>{children}</>
return (
<div className="border-l-2 border-primary pl-2">
{children}
</div>
)
}
```
The `isDirty` prop comes from `formState.dirtyFields[fieldName]` in `EntityForm`, which passes the full `dirtyFields` map to `DynamicForm`, which passes each field's individual dirty state to `DynamicFormField`.
---
## Adding custom Zod refinements
The generated schemas cover all constraints that can be expressed in QQQ field metadata. When you need a validation rule that metadata cannot express (for example, cross-field validation or a format check), add a `.superRefine()` or `.refine()` on the schema returned by `zodSchemaFromTableMetadata`:
```tsx
// In the component that builds the form
const baseSchema = zodSchemaFromTableMetadata(tableMetaData)
const schema = baseSchema.superRefine((values, ctx) => {
// Cross-field: end date must be after start date
if (values.startDate && values.endDate && values.endDate < values.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['endDate'],
message: 'End date must be after start date',
})
}
})
```
Prefer `.superRefine()` over `.refine()` when you need to report the error on a specific field path rather than at the root of the object, so React Hook Form can display it inline next to the correct input.
---
## Complete example
### Field metadata (from the API)
```json
{
"name": "quantity",
"label": "Quantity",
"type": "INTEGER",
"isRequired": true,
"isEditable": true,
"isHidden": false,
"minValue": 1,
"maxValue": 9999
}
```
### Generated Zod schema for this field
`zodFieldFromMetadata` produces:
```ts
z.coerce.number()
.int("Quantity must be a whole number")
.min(1, "Quantity must be at least 1")
.max(9999, "Quantity must be at most 9999")
```
Because `isRequired` is `true`, this is the final schema (no `z.literal('')` union wrapping). An empty `<input type="number">` submits `""`, `z.coerce.number()` coerces that to `NaN`, and `NaN` fails the `.int()` check — so the required constraint is enforced implicitly.
### Full table schema (example)
```ts
const schema = zodSchemaFromTableMetadata(orderTableMetaData)
// Produces:
z.object({
customerId: z.string().min(1, 'Customer is required'), // STRING, required
quantity: z.coerce.number().int(...).min(1).max(9999), // INTEGER, required
notes: z.string().optional(), // TEXT, optional
shipDate: z.string().optional(), // DATE, optional
isRush: z.boolean().optional(), // BOOLEAN, always optional
})
```
+28
View File
@@ -0,0 +1,28 @@
# PLAN: Next UI complete feature acceptance and default adoption (QRun-IO/qqq#649)
## Goal
Make Next the fully tested, working default QQQ admin UI: every inventoried supported
feature has executable acceptance coverage that passes against the real sample backend and
a production Next build, and every documented default launch path starts Next.
## Approach
1. Inventory UI-facing features from source (sample metadata, backend enums/routes, Material
behavior) into `docs/acceptance/feature-matrix.md`; each row names its fixture, scenarios,
test IDs, result and issue links.
2. Add a Playwright acceptance project (`tests/acceptance/`) that boots the QRun-owned sample
JAR on an ephemeral loopback port and serves `next build` output with `next start`, using
same-origin forwarding. Tests carry matrix IDs; `scripts/acceptance-gate.mjs` fails when a
required row has no passing test or a test is skipped/flaky.
3. Fix demonstrated defects with regression tests (#550, #645, #538, #539, #541, others found),
filing or reusing a QQQ issue for each.
4. Update default entry points (quickstart, sample README, root README, website, wiki,
starter/template) while keeping Material selectable.
5. Run all gates, publish versioned artifacts through normal workflows, record evidence.
## Branches
- `qqq-frontend-next`: `feature/GH-649-next-acceptance` (from `main`)
- `qqq`: worktree `../qqq-649`, branch `feature/GH-649-next-default` (from `origin/develop`)
## Open questions (for James)
- Commits, pushes, PRs, image/tag publication and dependency changes need explicit approval
per repository rules; work proceeds locally until then.
+332
View File
@@ -0,0 +1,332 @@
# State Management Guide
This document explains when to use each state container in the QQQ Frontend Next codebase. Each container has a specific purpose — choosing the wrong one leads to stale data, lost state on navigation, or unnecessary complexity.
## Decision Matrix
| If the state is… | Use… |
|---|---|
| Data fetched from the server (records, metadata, widgets, processes) | TanStack Query |
| Form field values and validation errors | React Hook Form |
| Filters, pagination, sort order — things a user should be able to bookmark or share | URL search params |
| User preferences that survive a session (density, column widths, saved views) | `useLocalStorage` |
| Application-wide UI state (modal stack, page header, accent color, branding) | QContext |
| Ephemeral UI state local to one component (dropdown open, hover, toggle) | `useState` / `useReducer` |
---
## TanStack Query — Server State
**Rule:** Never use `useState` for data that comes from the server. TanStack Query owns all server state.
The query client is configured in `src/lib/query-client.ts` with:
- 5-minute stale time — metadata and record lists stay fresh without excessive refetching
- 10-minute GC time — recently visited pages are snappy when navigating back
- Smart retry: never retries 4xx errors; retries 5xx and network failures up to 3 times with exponential back-off
- Global `QueryCache.onError` and `MutationCache.onError` — most errors are automatically toasted without per-call handling
### Query key factory
All query keys are defined in `queryKeys` in `src/lib/query-client.ts`. Always use the factory, never inline string arrays.
```typescript
import { queryKeys } from '@/lib/query-client'
// Fetch a single record
useQuery({
queryKey: queryKeys.tableRecord('order', recordId),
queryFn: () => getRecord('order', recordId),
})
// Invalidate all records for a table after a mutation
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords('order') })
// Invalidate everything (e.g. after logout)
queryClient.invalidateQueries({ queryKey: queryKeys.all() })
```
### Key hierarchy (broadest → narrowest)
```
['qqq']
['qqq', 'metadata', 'all']
['qqq', 'metadata', 'table', tableName]
['qqq', 'records', tableName]
['qqq', 'records', tableName, recordId]
['qqq', 'records', tableName, 'count', filterHash]
['qqq', 'processes', processName, processUUID, 'status', jobUUID]
['qqq', 'widgets', widgetName, params]
['qqq', 'possibleValues', 'table', tableName, fieldName, searchTerm]
['qqq', 'search', searchTerm]
```
Broad invalidations cascade down. For example, `queryKeys.tableRecords('order')` invalidates both the list query and all individual record queries for that table.
### What belongs in TanStack Query
- Record lists and counts
- Single record fetches
- All metadata (tables, processes, apps)
- Widget data
- Process status polling
- Possible values / enum lookups
- Global search results
- Auth session metadata
### What does NOT belong
Do not use TanStack Query for UI state (modal open/close, form field values, user preferences). Those belong in the containers described below.
---
## React Hook Form — Form Field State
**Rule:** React Hook Form owns field values and validation state for forms only. Do not use it for non-form UI, and do not reach into the form state from outside the form.
Forms are built with Zod schemas derived from field metadata. The schema is constructed by `zodFromMetadata()` in `src/lib/utils/zod-from-metadata.ts`.
```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { zodFromMetadata } from '@/lib/utils/zod-from-metadata'
function EntityForm({ tableMetaData, record }: Props) {
const schema = zodFromMetadata(tableMetaData)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: record ?? {},
})
// Map a 400 validation error back to individual fields
const onSubmit = async (data: FieldValues) => {
try {
await saveRecord('order', data)
} catch (error) {
if (getErrorStatusCode(error) === 400) {
const fieldErrors = extractFieldErrors(error)
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field, { message })
}
}
}
}
}
```
### What belongs in React Hook Form
- All form field values
- Validation errors (field-level and form-level)
- Dirty/touched state
- Submit handling
### What does NOT belong
Do not use React Hook Form for filter criteria in the query page — those live in URL params and reducer state managed by `useRecordQuery`.
---
## URL Search Params — Shareable State
**Rule:** Any state a user should be able to bookmark, share, or restore after a page refresh goes in the URL.
Use Next.js `useSearchParams` and `useRouter` to read and write URL state. This is the right home for:
- Active filters and quick search term
- Current page and page size
- Sort column and direction
The `useRecordQuery` hook syncs filter/pagination state to and from the URL automatically. Components should read these values from the hook, not from `useSearchParams` directly.
```typescript
import { useSearchParams, useRouter } from 'next/navigation'
// Reading
const searchParams = useSearchParams()
const page = Number(searchParams.get('page') ?? '1')
const sort = searchParams.get('sort') ?? ''
// Writing — replace so the back button doesn't accumulate filter changes
const router = useRouter()
const params = new URLSearchParams(searchParams)
params.set('page', String(nextPage))
router.replace(`?${params.toString()}`)
```
### Key naming conventions in URL params
| Param | Values | Purpose |
|---|---|---|
| `page` | integer | Current page (1-based) |
| `pageSize` | 10, 25, 50, 100 | Rows per page |
| `sort` | `fieldName:asc` or `fieldName:desc` | Active sort |
| `q` | string | Quick search term |
| `filter` | base64-encoded JSON | Serialized filter criteria |
### What belongs in URL params
- Filters, quick search
- Pagination (page, page size)
- Sort order
- Any state that makes a link shareable
### What does NOT belong
Do not put ephemeral UI state (modal open, panel open) in the URL. Do not put server data in the URL.
---
## localStorage — User Preferences
**Rule:** Preferences that should survive across sessions and browser restarts go in localStorage via the `useLocalStorage` hook.
The hook lives at `src/lib/hooks/use-local-storage.ts`. It is SSR-safe (returns `initialValue` on the server), cross-tab synced (listens to `storage` events), and has a stable setter that accepts a value or an updater function matching the `useState` API.
```typescript
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
const [density, setDensity, removeDensity] = useLocalStorage<'compact' | 'standard' | 'comfortable'>(
'qqq-order-density',
'standard'
)
```
### Higher-level hooks built on `useLocalStorage`
| Hook | Key | Contents |
|---|---|---|
| `useUserPreferences` | `qqq-user-preferences` | Default page size, density, view mode |
| `useColumnConfig` | `qqq-{tableName}-columns`, `qqq-{tableName}-column-order`, `qqq-{tableName}-column-widths` | Column visibility, order, pixel widths |
| `useSavedViews` | `qqq-{tableName}-saved-views` | Named filter snapshots |
### Key naming convention
```
qqq-{tableName}-{purpose} # table-scoped
qqq-user-preferences # global user preferences
qqq:{scope}:{purpose} # alternative colon-separated form
```
Always prefix with `qqq-` to avoid collisions with other apps that might share the same origin.
### What belongs in localStorage
- Table density (compact / standard / comfortable)
- Column visibility and order per table
- Column pixel widths per table
- Saved views (named filter snapshots)
- Global user preferences (default page size, default view mode)
- Dismissed banners or one-time notices
### What does NOT belong
Do not store server data in localStorage — use TanStack Query's cache. Do not store state that should be shareable — use URL params.
---
## QContext — Application-Wide UI State
**Rule:** Use QContext for state that must be accessible anywhere in the authenticated app tree and does not fit a more specific container.
The provider is `QContextProvider` in `src/lib/context/q-context.tsx`. Access it with the `useQContext()` hook. The hook throws at runtime if called outside a provider, which catches integration mistakes early.
```typescript
import { useQContext } from '@/lib/context/q-context'
function RecordQueryPage({ tableMetaData }: Props) {
const { setPageHeader, setTableMetaData } = useQContext()
useEffect(() => {
setPageHeader(tableMetaData.label)
setTableMetaData(tableMetaData)
}, [tableMetaData])
}
```
### What QContext contains
| Field | Type | Purpose |
|---|---|---|
| `pageHeader` | `string \| ReactNode` | Content rendered in the page header area |
| `accentColor` / `accentColorLight` | `string` | Theme accent colors (hex) |
| `tableMetaData` | `QTableMetaData \| null` | Metadata for the currently viewed table |
| `tableProcesses` | `QProcessMetaData[] \| null` | Processes for the current table |
| `dotMenuOpen` | `boolean` | Whether the ellipsis action menu is open |
| `modalStack` | `string[]` | Ordered list of open modal identifiers for keyboard nav |
| `pushModalOnStack(id)` | function | Called when a modal opens |
| `popModalOffStack(id)` | function | Called when a modal closes; warns if id is not on top |
| `clearModalStack()` | function | Clears all modal identifiers (e.g. on navigation) |
| `keyboardHelpOpen` | `boolean` | Keyboard shortcut overlay visibility |
| `userId` | `string \| undefined` | Current authenticated user identifier |
| `branding` | `QBrandingMetaData \| null` | Company name, app name, URLs from backend |
| `pathToLabelMap` | `Record<string, string>` | URL path segment → human-readable breadcrumb label |
### Modal stack usage
The modal stack ensures that `Escape` closes only the top-most modal and that focus is restored correctly. Every modal or sheet component must push on open and pop on close.
```typescript
const { pushModalOnStack, popModalOffStack, modalStack } = useQContext()
const modalId = 'edit-record-modal'
// On open
pushModalOnStack(modalId)
// On close
popModalOffStack(modalId)
// Check if this modal is on top (for Escape handling)
const isTopModal = modalStack[modalStack.length - 1] === modalId
```
### What belongs in QContext
- Application-wide UI state that has no more specific home
- Page header content (set by each page on mount)
- Modal stack (for coordinated keyboard navigation)
- Accent colors and branding
- The currently active table metadata (consumed by header, breadcrumbs, action menus)
### What does NOT belong
Do not put server data in QContext (use TanStack Query). Do not put form state in QContext (use React Hook Form). Do not put user preferences in QContext (use localStorage).
---
## useState / useReducer — Local Transient State
**Rule:** Use `useState` or `useReducer` only for state that is local to a single component or a small subtree, ephemeral (lost on unmount is acceptable), and neither server data nor shareable state.
```typescript
// Good — ephemeral open/close that doesn't need to survive navigation
const [dropdownOpen, setDropdownOpen] = useState(false)
const [isHovered, setIsHovered] = useState(false)
// Good — complex multi-field local state with clear action types
const [state, dispatch] = useReducer(recordQueryReducer, initialState)
```
### What belongs in useState / useReducer
- Dialog, dropdown, tooltip open/close
- Hover and focus state
- Local form wizard step (if not shareable)
- Complex local state with many fields that transition together (handled by `useReducer`)
### What does NOT belong
- Server data (use TanStack Query)
- State that should survive navigation or page refresh (use URL params or localStorage)
- State needed by distant parts of the tree (use QContext or lift appropriately)
---
## Summary
```
Server data → TanStack Query (queryKeys factory, automatic toasts on error)
Form fields → React Hook Form + Zod
Shareable/bookmarkable → URL search params
User preferences → useLocalStorage (key: qqq-{tableName}-{purpose})
App-wide UI state → QContext (useQContext hook)
Local/ephemeral UI → useState / useReducer
```
+190
View File
@@ -0,0 +1,190 @@
# QQQ Frontend Type Glossary
All types listed here are re-exported from `@/types` (the `src/types/index.ts` barrel).
Application code should always import from `@/types`, not from individual module files,
so that internal type organisation can change without affecting import paths.
```typescript
import type { QInstance, QTableMetaData, QRecord, QQueryFilter } from '@/types'
```
---
## 1. App & Auth
| Type | Source file | Purpose |
|------|-------------|---------|
| `QInstance` | `metadata.ts` | Top-level descriptor returned by `/metaData/instance`; the single source of truth for all apps, tables, processes, reports, widgets, and branding. **Key fields:** `apps`, `tables`, `processes`, `widgets`, `branding`, `appTree`, `environmentValues` |
| `QAuthenticationMetaData` | `metadata.ts` | Describes the auth provider type (`AUTH_0`, `OAUTH2`, `FULLY_ANONYMOUS`, `MOCK`) and the provider-specific values (client ID, base URL, audience) needed to boot the correct auth flow. |
| `QBrandingMetaData` | `metadata.ts` | Controls visual identity: company name, app name, logo URL, accent color, notification banners, and custom CSS injected via `data-qqq-id` selectors. |
| `QThemeMetaData` | `metadata.ts` | Backend-supplied Tailwind CSS token overrides (`primaryColor`, `accentColor`, `mode`). Applied on top of the default theme by the theme provider. |
| `Banner` | `metadata.ts` | A single notification banner (text, severity `info|warning|error`, optional color, dismissible flag) rendered at the top of the dashboard layout. |
### QInstance example
```typescript
const instance: QInstance = {
apps: { crm: { name: 'crm', label: 'CRM', children: [], ... } },
appTree: [{ name: 'crm', label: 'CRM', type: 'APP', ... }],
tables: { person: { name: 'person', label: 'People', fields: {}, ... } },
processes: {},
reports: {},
widgets: {},
branding: { companyName: 'Acme', companyUrl: 'https://acme.com', appName: 'Admin' },
helpContents: {},
environmentValues: {},
}
```
---
## 2. Metadata
| Type | Source file | Purpose |
|------|-------------|---------|
| `QTableMetaData` | `metadata.ts` | Full descriptor for a QQQ table: fields, sections, join relationships, capabilities, and per-user CRUD permissions. Drives the entire record query, view, create, and edit experience. **Key fields:** `name`, `label`, `primaryKeyField`, `fields`, `sections`, `capabilities`, `readPermission`, `insertPermission`, `editPermission`, `deletePermission` |
| `QFieldMetaData` | `metadata.ts` | Descriptor for a single table column: data type, required/editable/hidden flags, max length, numeric bounds, possible-value source, display format, and adornments. **Key fields:** `name`, `label`, `type`, `isRequired`, `isEditable`, `isHidden`, `possibleValueSourceName`, `adornments` |
| `QProcessMetaData` | `metadata.ts` | Descriptor for a multi-step wizard process: identity, permissions, input record bounds, and the ordered list of `QFrontendStepMetaData` objects to render. |
| `QFrontendStepMetaData` | `metadata.ts` | A single step in a process wizard: label, ordered component list, and optional form/view/record-list field arrays used by the step renderer. |
| `QFrontendComponent` | `metadata.ts` | A single UI component descriptor within a process step — a `type` (from `QComponentType`) plus arbitrary `values` passed as props to the matching renderer. |
| `QAppMetaData` | `metadata.ts` | A top-level app grouping: sidebar navigation children, dashboard widget names, and labeled sections grouping tables, processes, and reports. |
| `QAppTreeNode` | `metadata.ts` | One node in the recursive sidebar navigation tree; the `type` field (`TABLE`, `PROCESS`, `REPORT`, `APP`) determines the route. |
| `QAppSection` | `metadata.ts` | A labeled card group on an app dashboard page, listing the table, process, and report names belonging to that group. |
| `QWidgetMetaData` | `metadata.ts` | Dashboard widget descriptor: renderer type string, permissions, grid column span, optional toolbar dropdowns, reload/export button flags, and help content. |
| `QTableSection` | `metadata.ts` | A collapsible card section on record view/edit pages: field names to display, optional embedded widget name, display tier, and grid column span. |
| `QExposedJoin` | `metadata.ts` | A join relationship made available for query filters and record views: cardinality (`isMany`), joined table metadata, and the path of `QJoinMetaData` hops. |
| `FieldAdornment` | `metadata.ts` | Discriminated union of all adornment shapes (`LINK`, `CHIP`, `TOOLTIP`, `ERROR`, `FILE_DOWNLOAD`, `SIZE`, `REVEAL`, `CODE_EDITOR`, `RENDER_HTML`, `FILE_UPLOAD`). Narrow with `Extract<FieldAdornment, { type: 'LINK' }>`. |
### QFieldMetaData example
```typescript
const field: QFieldMetaData = {
name: 'firstName',
label: 'First Name',
type: 'STRING',
isRequired: true,
isEditable: true,
isHidden: false,
isHeavy: false,
maxLength: 100,
adornments: [],
}
```
---
## 3. Records & Queries
| Type | Source file | Purpose |
|------|-------------|---------|
| `QRecord` | `records.ts` | A single backend record returned by the tables API. `values` holds raw typed data; `displayValues` holds pre-formatted strings ready for rendering. May include `associatedRecords` for joined/child data, and `errors`/`warnings` for bulk operations. |
| `QPossibleValue` | `records.ts` | A single option from a possible-value source, used to populate autocomplete and select inputs. `id` is the stored value; `label` is the user-visible text. |
| `QAuditRecord` | `records.ts` | An audit log entry for a record: who changed it, when, what action (`INSERT`/`UPDATE`/`DELETE`), and the list of `QAuditFieldChange` field-level diffs. |
| `QQueryFilter` | `query.ts` | Root filter object sent to `/tables/{name}/query` and `/tables/{name}/count`. Combines `criteria` with `AND`/`OR`, supports nested `subFilters`, and carries `skip`/`limit` for pagination. |
| `QFilterCriteria` | `query.ts` | A single field-level predicate: a `fieldName`, a `QCriteriaOperator`, and a `values` array that may contain literals or dynamic expressions (`NowExpression`, `NowWithOffsetExpression`, etc.). |
| `QFilterOrderBy` | `query.ts` | A sort clause: `fieldName` and `isAscending`. Multiple entries form a compound sort. |
| `QueryJoin` | `query.ts` | A join instruction sent alongside a `QQueryFilter` when fields from a related table are needed in query results. Specifies `joinTable`, join type (`INNER`/`LEFT`/etc.), and optional alias. |
| `NowWithOffsetExpression` | `query.ts` | A dynamic filter value that resolves to "now minus/plus N days/weeks/months/years" at query time, enabling "last 7 days" style saved filters. |
### QQueryFilter example
```typescript
const filter: QQueryFilter = {
criteria: [
{ fieldName: 'status', operator: 'EQUALS', values: ['ACTIVE'] },
{ fieldName: 'createdDate', operator: 'GREATER_THAN', values: [{ type: 'NOW_WITH_OFFSET', offsetValue: 30, offsetUnit: 'DAY', isNegativeOffset: true }] },
],
booleanOperator: 'AND',
orderBys: [{ fieldName: 'lastName', isAscending: true }],
skip: 0,
limit: 25,
}
```
---
## 4. Processes
| Type | Source file | Purpose |
|------|-------------|---------|
| `QJobStarted` | `processes.ts` | Initial response after submitting a process step; provides `processUUID` and `jobUUID` for subsequent status polling. |
| `QJobRunning` | `processes.ts` | Intermediate polling response while a job executes asynchronously; provides a `message` and optional `current`/`total` progress counts for the progress bar. |
| `QJobComplete` | `processes.ts` | Terminal success response; carries output `values`, optional `nextStep`/`backStep` navigation hints, and any `processMetaDataAdjustment` that modifies remaining wizard steps. |
| `QJobError` | `processes.ts` | Terminal failure response; provides a technical `error` string and an optional `userFacingError` message for display in the wizard UI. |
| `QJobResponse` | `processes.ts` | Discriminated union of `QJobStarted \| QJobRunning \| QJobComplete \| QJobError`. Narrow by checking for the discriminating property (`jobUUID`, `message`, `values`, or `error`). |
| `ProcessMetaDataAdjustment` | `processes.ts` | Runtime modifications to the process wizard applied after a step completes: `addedSteps`, `removedSteps`, and partial `modifiedFields` overrides. |
### QJobResponse narrowing example
```typescript
function handleJobResponse(response: QJobResponse) {
if ('error' in response) {
// QJobError
showToast(response.userFacingError ?? response.error)
} else if ('values' in response) {
// QJobComplete
advanceToStep(response.nextStep)
} else if ('message' in response) {
// QJobRunning — update progress bar
setProgress({ message: response.message, current: response.current, total: response.total })
} else {
// QJobStarted — store jobUUID and start polling
setJobUUID(response.jobUUID)
}
}
```
---
## 5. Widgets
| Type | Source file | Purpose |
|------|-------------|---------|
| `WidgetData` | `widgets.ts` | Base shape for all widget data payloads; the `type` string is the discriminant used to select the correct renderer. |
| `ChartWidgetData` | `widgets.ts` | Recharts widget payload: `chartType` (`line`/`bar`/`pie`/`area`), `labels` array, and `datasets` array of `ChartDataset` series. |
| `ChartDataset` | `widgets.ts` | A single Recharts data series: human-readable `label`, ordered `data` values aligned with the parent chart's `labels`, and optional `color`. |
| `StatisticsWidgetData` | `widgets.ts` | KPI tile payload: headline `title`, `value`, optional `unit`, and optional `trend` object with direction (`up`/`down`/`flat`), numeric magnitude, and period label. |
| `HtmlWidgetData` | `widgets.ts` | Raw HTML widget payload; the `html` string **must** be sanitized with DOMPurify before use in `dangerouslySetInnerHTML`. |
| `RecordGridWidgetData` | `widgets.ts` | Tabular record list widget: `tableName`, optional `columns` (field name strings) or `fields` (full `QFieldMetaData[]`), `records` array, and optional `totalCount`. |
| `BlockData` | `widgets.ts` | Discriminated union of all block element types renderable inside a `BlockWidgetData`: `text`, `big_number`, `up_or_down`, `progress`, `button`, `icon`, `image`, `audio`, `divider`, `input`, `html`. |
| `BlockWidgetData` | `widgets.ts` | Ordered heterogeneous block widget: a `blocks` array of `BlockData` variants and an optional `layout` (`vertical`/`horizontal`/`grid`). |
---
## 6. Enums
| Type | Source file | Purpose |
|------|-------------|---------|
| `QFieldType` | `enums.ts` | String-union of all primitive field data types: `STRING`, `INTEGER`, `LONG`, `DECIMAL`, `BOOLEAN`, `DATE`, `TIME`, `DATE_TIME`, `TEXT`, `HTML`, `PASSWORD`, `BLOB`. Determines which renderer, input component, and Zod validator to use. |
| `QCriteriaOperator` | `enums.ts` | String-union of all filter comparison operators used in `QFilterCriteria.operator`: `EQUALS`, `NOT_EQUALS`, `IN`, `NOT_IN`, `CONTAINS`, `STARTS_WITH`, `LESS_THAN`, `GREATER_THAN`, `IS_BLANK`, `IS_NOT_BLANK`, `BETWEEN`, and more. |
| `Capability` | `enums.ts` | String-union of backend-declared table capabilities (`TABLE_QUERY`, `TABLE_GET`, `TABLE_COUNT`, `TABLE_INSERT`, `TABLE_UPDATE`, `TABLE_DELETE`) that gate UI elements such as Create, Edit, and Delete buttons. |
| `QAppNodeType` | `enums.ts` | String-union identifying the entity kind of a sidebar navigation node: `TABLE`, `PROCESS`, `REPORT`, `APP`. Drives route prefix selection in `QAppTreeNode`. |
| `QComponentType` | `enums.ts` | String-union of all UI component types renderable inside a process step: `EDIT_FORM`, `VIEW_FORM`, `RECORD_LIST`, `VALIDATION_REVIEW_SCREEN`, `BULK_LOAD_FILE_MAPPING_FORM`, `PROCESS_SUMMARY_RESULTS`, `HTML`, `WIDGET`, and more. |
| `AdornmentType` | `enums.ts` | String-union of all field adornment identifiers (`LINK`, `CHIP`, `SIZE`, `ERROR`, `RENDER_HTML`, `REVEAL`, `CODE_EDITOR`, `FILE_DOWNLOAD`, `FILE_UPLOAD`, `TOOLTIP`). Mirrors the discriminant property of the `FieldAdornment` union for use in switch statements and type-guard utilities. |
---
## Quick-Reference Import Cheat Sheet
```typescript
// App & auth
import type { QInstance, QAuthenticationMetaData, QBrandingMetaData, Banner } from '@/types'
// Metadata
import type {
QTableMetaData, QFieldMetaData, QProcessMetaData,
QFrontendStepMetaData, QWidgetMetaData, FieldAdornment,
} from '@/types'
// Records & queries
import type { QRecord, QPossibleValue, QQueryFilter, QFilterCriteria } from '@/types'
// Processes
import type { QJobResponse, QJobComplete, QJobError, ProcessMetaDataAdjustment } from '@/types'
// Widgets
import type { WidgetData, ChartWidgetData, StatisticsWidgetData, BlockData } from '@/types'
// Enums
import type { QFieldType, QCriteriaOperator, Capability } from '@/types'
```
+125
View File
@@ -0,0 +1,125 @@
# Browser matrix — real-backend acceptance (QRun-IO/qqq#649)
The acceptance suite runs against the owned sample backend and the production static
export (javalin mode). The default gate runs Chromium only; the documented matrix is the
four configured Playwright projects below, in one run and one gate.
```bash
export QQQ_SAMPLE_JAR=/path/to/qqq-sample-project-<version>-jar-with-dependencies.jar
# full gate, all four projects, fresh static export
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile node scripts/acceptance.mjs
# the same with explicit ports (use distinct ports for concurrent runs)
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile \
QQQ_ACCEPTANCE_BACKEND_PORT=18793 QQQ_ACCEPTANCE_FRONTEND_PORT=13793 \
node scripts/acceptance.mjs
# one project, reusing out/ (a filtered run is a partial gate)
QQQ_ACCEPTANCE_BROWSERS=mobile node scripts/acceptance.mjs --skip-build
```
| Project | Playwright device | Engine (Playwright 1.58.2) | Runs |
|----------|-------------------|----------------------------|------|
| chromium | Desktop Chrome | Chrome for Testing 145 | every spec |
| firefox | Desktop Firefox | Firefox 146 | every spec |
| webkit | Desktop Safari | WebKit 26.0 | every spec |
| mobile | Pixel 7 (412 px, touch) | Chromium | tests tagged `@mobile` |
## Phone scope
Below 768 px the UI is a different layout by design: lists are card lists, record actions
live in an action sheet, navigation is a drawer. Specs that assert the desktop layout
(grid columns and cells, column resizing, grid keyboard navigation, desktop menus) do not
describe phone behavior, and skipping them is not allowed (skips fail the gate). The
`mobile` project therefore runs only tests whose title carries `@mobile`
(`grep: /@mobile/` in `tests/acceptance/playwright.config.ts`). Those tests run on the
desktop projects as well, either layout-neutral or pinned to a 412 px touch viewport, so
every phone scenario is also checked in Firefox and WebKit at phone width.
The phone-relevant rows and their `@mobile` tests:
| Phone behavior | Tests |
|----------------|-------|
| Navigation drawer | NAV-026, INT-008 |
| List / card view, loading state | INT-008, INT-009 (busy placeholder cards, QRun-IO/qqq#694) |
| Record view | INT-008, INT-003 |
| Forms and validation | INT-008, INT-007, PRC-009 |
| Process run | PRC-001, PRC-004, PRC-009 |
| Dialogs and focus (action sheet, delete dialog) | INT-003 (QRun-IO/qqq#694) |
| Sign-in, logout, session expiry | SEC-020, SEC-021, SEC-022 (both) |
| Accessibility scan at phone width | INT-005 |
The gate also fails any configured project that ran no tests.
## Results
Recorded 2026-09-25 on macOS (Darwin 27), sample `4.1.0-SNAPSHOT` with the #649 backend
changes including QRun-IO/qqq#674 and #675, Next static export of
`feature/GH-649-next-acceptance` with all six areas integrated (navigation, records, query,
processes, widgets, security), one run of the full gate with the four projects:
Tests passed / run, per spec area and project (the mobile project runs the `@mobile` tests only):
| Area | chromium | firefox | webkit | mobile |
|---|---|---|---|---|
| navigation | 41/41 | 41/41 | 41/41 | 1/1 |
| processes | 50/50 | 50/50 | 50/50 | 3/3 |
| query | 50/50 | 50/50 | 50/50 | — |
| records | 48/48 | 48/48 | 48/48 | — |
| security | 57/57 | 57/57 | 57/57 | 9/9 |
| widgets | 80/80 | 80/80 | 80/80 | — |
| **total** | **326/326** | **326/326** | **326/326** | **13/13** |
`—`: the area has no `@mobile` test; the phone list, record and form behavior is proven by
the interaction tests (INT-003, INT-007, INT-008, INT-009) in the security area.
Gate (`test-results/acceptance/gate.json`, 991 tests, 18.1 min, retries 0, commit `2c5eb2d`):
```json
{
"summary": { "passed": 289, "failed": 0, "missing": 0, "excluded": 3 },
"byProject": {
"chromium": { "passed": 326, "failed": 0, "skipped": 0, "flaky": 0 },
"firefox": { "passed": 326, "failed": 0, "skipped": 0, "flaky": 0 },
"webkit": { "passed": 326, "failed": 0, "skipped": 0, "flaky": 0 },
"mobile": { "passed": 13, "failed": 0, "skipped": 0, "flaky": 0 }
},
"problems": []
}
```
Every required row passes in every project that runs it. PRC-039, WID-033 and SEC-030 are
`required: false` pending approval and are reported as excluded.
## Differences found between engines
Fixed:
- **WebKit, aborted prefetches.** WebKit raises "<url> due to access control checks." as
a page error for same-origin Next.js route prefetches and RSC payload fetches that a
document navigation cancels; the server answered 200 and Playwright never sees the
request. Traces confirmed every report named a `/app/...` route, `__next.*.txt` or
`?_rsc=` payload within a millisecond of a main-frame navigation. The shared diagnostics
fixture ignores exactly these (same origin, Next route or payload URL, within one second
of a navigation) and lists them under `interruptedFetches`; API, cross-origin and
unrelated access-control failures still fail the test.
- **WebKit, document title.** Next.js re-applies the static metadata title ("QQQ Admin")
after client navigations, in WebKit after the layout set the metadata title (NAV-023,
NAV-006). `useDocumentTitle` keeps the breadcrumb title in place.
- **Firefox, rich text.** Firefox stores a typed space before a formatting change as
`&nbsp;`; the editor now stores engine-neutral HTML (REC-020).
- **Firefox/WebKit, computed styles.** A `CSSStyleDeclaration` returned from `evaluate()`
only serializes in Chromium; WID-057 reads the properties inside the page.
- **WebKit keyboard order.** WebKit, like Safari, moves Tab only between form fields;
keyboard specs use Option+Tab there (`tabKey` in `specs/security/support/ui.ts`).
- **Aborted requests.** A request aborted by the test is reported as `net::ERR_FAILED`
(Chromium), `NS_ERROR_FAILURE` (Firefox) or "Blocked by Web Inspector" (WebKit).
- **Provider logout in Firefox.** Starting an in-app navigation just before leaving for
the identity provider made Firefox log "Failed to fetch RSC payload". Provider logout and
sign-in retries now leave the page without a client navigation first.
- **Set-Cookie visibility in WebKit.** WebKit hides `Set-Cookie` from page responses, so
SEC-033 checks the logout response from Node.
- **Phone defects (QRun-IO/qqq#694).** The card list showed "No records found" while
loading; it now shows busy placeholder cards. The record action sheet moves focus in on
open, closes on Escape, and returns focus to its trigger, also when a dialog opened from
one of its items closes.
Open: none.
+331
View File
@@ -0,0 +1,331 @@
# Next UI feature acceptance matrix
Generated by `node scripts/acceptance-matrix-md.mjs` from `tests/acceptance/matrix/*.json` and the
acceptance gate results. Each row is a supported QQQ UI feature or use case with executable
real-backend acceptance coverage (QRun-IO/qqq#649). See `tests/acceptance/README.md` to run it.
Rows: 292. Required rows passing in the recorded runs: 289.
Recorded runs: `test-results/acceptance/gate.json` (2026-09-25T20:52:12.358Z).
## Navigation, application structure, branding and icons
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| NAV-001 | Direct link to a table list renders seeded rows | success | | prime-test-database.sql | pass (chromium, firefox, webkit) | |
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under their parent | admin sees Miscellaneous, Nav Empty App, Nav Level One, People App, Sample Widgets Dashboard, Sharing Demo in backend order; Greetings App appears only under People App (regression: it was duplicated at top level); group children use metadata labels | noPets persona: Pet and Pet Note are absent from the sidebar; backend returns 404 for their metadata and 403 for a query | stock sample apps + NavigationFixtures (Nav Level One, Nav Empty App); persona noPets | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | expand three nested groups and open Nav Deep Item; grid rows match nav_deep_item; deep link to /app/navDeepItem expands all three groups and marks the item current | | NavigationFixtures: navLevelOne > navLevelTwo > navLevelThree > navDeepItem | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | Dashboard is current on /app; click app, table, process; URL, content and aria-current follow; collapse hides children, expand shows them | | stock sample (Miscellaneous app) | pass (chromium, firefox, webkit) | |
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | sidebar glyph per declared Material name, color applied, image path loads; app home entries and section headings show declared icons; command palette entries show declared icons | no declared icon renders the type fallback glyph | sample icons (stars, person, widgets, share, pets, local_shipping, badge) + NavigationFixtures (path icon, colored icon, inventory_2, inbox, layers) | pass (chromium, firefox, webkit) | [#538](https://github.com/QRun-IO/qqq/issues/538) |
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access show not-found and the backend denies them | sidebar, app home, landing page and command palette omit City, Greet People, Simple Sleep and Nav Hidden Note | /app/city and /app/greet show the not-found state; backend returns 404 and 403 | sample city (hidden table), greet and simpleSleep (hidden processes); NavigationFixtures navHiddenNote (hidden, NOT_PROTECTED) | pass (chromium, firefox, webkit) | |
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | /app/navHiddenNote lists nav_hidden_note rows; breadcrumbs show its enclosing apps | | NavigationFixtures navHiddenNote (isHidden, PermissionLevel.NOT_PROTECTED) | pass (chromium, firefox, webkit) | |
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each entry navigates | Miscellaneous: Actions Sleep Interactive, Simple Throw; Data Carrier, Field Lab, Pet Species (labels, not names); section heading shows its label and declared icon; clicking an entry opens the table or process | hidden or unpermitted section entries are omitted (navHiddenNote, noPets persona) | sample greetingsApp and miscellaneous sections | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-009 | App home table entries show the backend record count | each Data entry reads 'N total records' equal to the SQL count | | sample carrier, fieldLab, petSpecies, person, pet, petNote rows | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | People App shows Clone People and the Greetings App child app (regression: crashed on missing widgets); Nav Level One lists its child app and navigates to it; Nav Empty App shows the empty state | viewer persona: People App shows only the Greetings App child app (no denied process) | sample peopleApp; NavigationFixtures navLevelOne, navEmptyApp; persona viewer | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared widget | sidebar click opens the dashboard; one widget region per declared widget label, in order | | sample SampleWidgetsDashboard (11 widgets) | pass (chromium, firefox, webkit) | |
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding accepted without warnings | sidebar shows /samples-logo.png with alt QQQ Sample, loaded, linking to the dashboard; favicon link points to /kr-icon.png; primary color variables equal the branding accent color; no 'schema validation' console warning for branding without companyName/companyUrl | | sample branding (appName, logo /samples-logo.png, icon /kr-icon.png) + NavigationFixtures accentColor #1d4ed8 | pass (chromium, firefox, webkit) | [#539](https://github.com/QRun-IO/qqq/issues/539) |
| NAV-013 | Branding banners render in their slots (top of site, top of body, side nav under logo) with text or sanitized HTML, severity and colors | each slot shows its message in the right place with severity and colors from metadata | unknown slots are not rendered | NavigationFixtures banners: QFMD_TOP_OF_SITE (INFO text), QFMD_TOP_OF_BODY (WARNING HTML), QFMD_SIDE_NAV_UNDER_LOGO (SUCCESS, colors, additional styles) | pass (chromium, firefox, webkit) | [#539](https://github.com/QRun-IO/qqq/issues/539) |
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb navigates | /app/person: People App / Greetings App / Person; /app/navDeepItem/2/edit: Nav Level One / Nav Level Two / Nav Level Three / Nav Deep Item / 2 / Edit; process and create pages; clicking a crumb opens that app home | no breadcrumbs on the dashboard root | sample person/greetInteractive; NavigationFixtures navDeepItem | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-015 | Document title names the page, its enclosing apps and the application (Material parity) | Dashboard \| QQQ Sample; Person \| Greetings App \| People App \| QQQ Sample; record label title on the record page | unknown route title is Not Found \| QQQ Sample | stock sample | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-016 | The site root redirects to the dashboard | / replaces itself with /app (back does not return to /) | | stock sample | pass (chromium, firefox, webkit) | |
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata by label | counts equal the navigable tables, processes, apps and their widgets from backend metadata; quick actions create only navigable insertable tables and run only navigable processes, by label (regression: listed internal and table-bulk processes, crashed on apps without widgets); application cards open app homes | viewer persona: no create or process quick actions | stock sample + NavigationFixtures; persona viewer | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) |
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | app home, nested app home, table, process, record view, record edit, create, saved view | | stock sample + NavigationFixtures | pass (chromium, firefox, webkit) | |
| NAV-019 | Browser refresh keeps the route, its query string and the rendered content | refresh on a table with a query string, a record view, and an app home | | stock sample | pass (chromium, firefox, webkit) | |
| NAV-020 | Browser back and forward traverse navigation history with the right content | dashboard > app home > table > record, then back three times and forward twice | | stock sample | pass (chromium, firefox, webkit) | |
| NAV-021 | An unknown /app/{name} shows an in-shell not-found state with a link to the dashboard | /app/noSuchThing shows 'Page not found' naming the segment; the link opens the dashboard; no failed requests | | stock sample | pass (chromium, firefox, webkit) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | /app/person/1/no/such and /no-such-page respond 404 and render the 404 page; Go Home reaches the dashboard | only the document 404 is reported | stock sample | pass (chromium, firefox, webkit) | |
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | ?code=NAV-B2 opens record 2 (Tide Chart); ?email=... opens that person | no match, several matches (?shelf=North), unknown field, no parameters: error message, no navigation | NavigationFixtures navDeepItem (unique key code); sample person emails | pass (chromium, firefox, webkit) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context, filters, and navigates by keyboard | filter 'pet' shows Pet, Pet Note, Pet Species with type and app; Enter opens the first; '.' opens the palette; Escape closes it | hidden objects are not listed | stock sample + NavigationFixtures | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records without a backend search endpoint | typing a label lists matching pages with app context; choosing one navigates; a viewed record is offered as a recent record; Enter without a selection opens /app/search?q= with the matches, surviving refresh | no POST /qqq/v1/search request is made (regression: every keystroke produced a 404) | stock sample | pass (chromium, firefox, webkit) | [#653](https://github.com/QRun-IO/qqq/issues/653) |
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | hamburger opens the drawer; choosing a table navigates and closes it; close button closes it | | stock sample, 390x844 viewport | pass (chromium, firefox, mobile, webkit) | |
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | metadata counts equal the backend's; framework shows the bundled Next.js version (regression: hardcoded 'Next.js 15') | | stock sample | pass (chromium, firefox, webkit) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report page | sidebar entry with its icon opens /app/navDeepItemReport with the report heading, title and breadcrumbs; app home Reports group lists and opens it | | NavigationFixtures navDeepItemReport (child of Nav Level Three, icon assessment) | pass (chromium, firefox, webkit) | [#654](https://github.com/QRun-IO/qqq/issues/654) |
| NAV-029 | A user who may access no apps sees the Dashboard entry and the no-apps message (Material NoApps parity) | sidebar shows only Dashboard; landing shows You do not have permission to access any apps. | app home links show the not-found state | harness persona noApps (every permission except App HAS_ACCESS) - requested: not yet in AcceptanceSampleServer | pass (chromium, firefox, webkit) | [#652](https://github.com/QRun-IO/qqq/issues/652) |
## Processes: steps, components, lifecycle, bulk load/edit/delete
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list results render | recordIds 1,2 -> setup prefix/suffix -> results view values and both greeted rows | | sample greetInteractive | pass (chromium, firefox, mobile, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) [#656](https://github.com/QRun-IO/qqq/issues/656) |
| PRC-002 | All-records run from a filter selection with no criteria | filterJSON {criteria:[]} greets every seeded person (count checked against SQL) | | sample greetInteractive | pass (chromium, firefox, webkit) | |
| PRC-003 | Zero-record runs from a filter that matches nothing | clonePeople input 0, validation on 0 with no lines, 0 processed | greetInteractive record-list step with zero records is refused ("Missing input records.") | sample greetInteractive, clonePeople | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-004 | Processes launched from an app (no records) | app shortcut opens Component Lab without records | greetInteractive without records is refused at init ("Missing input records.") | prcLab app, sample greetInteractive | pass (chromium, firefox, mobile, webkit) | |
| PRC-005 | Process launched from selected rows of a table query | select Avery and Casey, Actions -> Greet Interactive -> both greeted | | sample person + greetInteractive | pass (chromium, firefox, webkit) | |
| PRC-006 | Minimum and maximum input records | 1-2 selected records run and are listed | 0 or 3 explicit records show a message without init; filter selecting 5 shows the backend refusal; API filter selecting 0 is refused | prcBounds (min 1, max 2) | pass (chromium, firefox, webkit) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | HELP_TEXT, HELP_TEXT, VIEW_FORM, EDIT_FORM, HTML, EDIT_FORM in order | | prcComponents mixed screen | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | plain lines; Show/Hide preview text | | prcComponents | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | Lab Inputs section holds only its fields; values saved (SQL prc_lab_run) | | prcComponents | pass (chromium, firefox, mobile, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-010 | Required input validation on screens and in the backend | filled required field proceeds | empty required field blocks without a request; API step without the required input is refused | prcComponents | pass (chromium, firefox, webkit) | |
| PRC-011 | Process possible-value fields | options from the process route; chosen id saved, label shown on review; search filters options | | prcComponents labColor (prcColor enum) | pass (chromium, firefox, webkit) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
| PRC-012 | VIEW_FORM labels and backend values | Introduction and Status values | | prcComponents | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-013 | HTML component renders the step html value, sanitized | markup rendered | script and onerror removed, never executed | prcComponents mixed.html | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-014 | DOWNLOAD_FORM delivers the generated file | download bytes equal the generated file | unregistered file path refused (403) | prcComponents compute step | pass (chromium, firefox, webkit) | |
| PRC-015 | Step help content for process screens | help text shown | | prcComponents helpContent (PROCESS_SCREEN) | pass (chromium, firefox, webkit) | |
| PRC-016 | Back to the backend back step with entered values | Back restarts at the back step with values (possible-value label shown); resubmitting saves again | no Back without a backend backStep | prcComponents review, prcWizard longDetails, greetInteractive | pass (chromium, firefox, webkit) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#667](https://github.com/QRun-IO/qqq/issues/667) |
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | short route drops a screen from the stepper; values logged (SQL) | | prcWizard | pass (chromium, firefox, webkit) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | field relabelled and required on the later screen | empty relabelled required field blocks | prcWizard long route | pass (chromium, firefox, webkit) | [#662](https://github.com/QRun-IO/qqq/issues/662) |
| PRC-019 | Async job progress (message, N of M, progress bar, updated time) then continue | RUNNING progress shown while polling; finished screen and SQL log | | prcProgress | pass (chromium, firefox, webkit) | [#657](https://github.com/QRun-IO/qqq/issues/657) |
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | confirm dialog; cancel step row (SQL) from a screen and while working | Stay on Page keeps the run | prcProgress cancelStep | pass (chromium, firefox, webkit) | |
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | user-facing message; internal detail toggle; simpleThrow "I always throw."; Close returns to the app | | prcFailures, prcComponents, sample simpleThrow | pass (chromium, firefox, webkit) | |
| PRC-022 | Retry after an error restarts with the original record selection | retry loads the selected records 2 and 4 | | prcFlaky (fails once) | pass (chromium, firefox, webkit) | [#658](https://github.com/QRun-IO/qqq/issues/658) |
| PRC-023 | Completion: Next/Submit labels, Return on the last screen or when noMoreSteps | Submit on the step before the last screen; Return only on the last screen; noMoreSteps ends early | | prcComponents, prcWizard, prcEarlyFinish | pass (chromium, firefox, webkit) | |
| PRC-024 | Linear stepper and step position | all step labels; current step marked; Step i of n | | prcComponents | pass (chromium, firefox, webkit) | |
| PRC-025 | RECORD_LIST paging and page size over process records | 1-10, 11-20, 21-23; page size 25 | next disabled on the last page | prcManyRows (23 generated records) | pass (chromium, firefox, webkit) | |
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | Input: 2 Person records.; radio switches the button label; preview 1 of 2 / 2 of 2 | | sample clonePeople | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | four lines with counts and messages; link filters to the refused record | nothing persisted by validation | sample clonePeople + inserted clone persons | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-028 | Process summary results after execution and persisted effects | result lines and processed count; clones persisted (SQL); skip validation executes immediately | refused and nested records not cloned | sample clonePeople | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-029 | Bulk edit: switched-on fields only, review and result | edited fields persisted for selected records only (SQL); result lines | no switched-on field blocks without a request | enricher person.bulkEdit | pass (chromium, firefox, webkit) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-030 | Bulk delete: review, validation and result | selected records removed (SQL) | validation deletes nothing | enricher person.bulkDelete | pass (chromium, firefox, webkit) | [#659](https://github.com/QRun-IO/qqq/issues/659) |
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | uploaded CSV mapped and inserted (SQL) | missing required file blocks | enricher person.bulkInsert + prcUploadArchive | pass (chromium, firefox, webkit) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#660](https://github.com/QRun-IO/qqq/issues/660) [#543](https://github.com/QRun-IO/qqq/issues/543) |
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | Back from review keeps the mapping; Back from mapping to upload | unmapped required column blocks without a request | enricher person.bulkInsert | pass (chromium, firefox, webkit) | [#655](https://github.com/QRun-IO/qqq/issues/655) [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-033 | Bulk edit with a file matched by key fields | rows matched by Id updated (SQL) | | enricher person.bulkEditWithFile | pass (chromium, firefox, webkit) | [#656](https://github.com/QRun-IO/qqq/issues/656) [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-034 | Upload instructions HTML with downloadable template | template CSV downloads with the header row | | enricher person.bulkInsert upload.html | pass (chromium, firefox, webkit) | |
| PRC-035 | Named WIDGET fetched with the process UUID and values | widget text built from process values | | prcWidgets prcHtmlWidget | pass (chromium, firefox, webkit) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-036 | Named composite WIDGET seeded from process values | seeded blocks rendered, no fetch | | prcWidgets prcCompositeWidget | pass (chromium, firefox, webkit) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-037 | Ad hoc composite WIDGET: interpolation, conditional blocks, action buttons, submit-on-enter inputs | Approve submits actionCode (SQL); Enter submits the scan code (SQL) | conditional block hidden | prcWidgets ad hoc blocks | pass (chromium, firefox, webkit) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-038 | GOOGLE_DRIVE_SELECT_FOLDER screen keeps its other components working | help text and edit form submit (SQL); picker explains it is not configured | required export note blocks | prcDrive | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) |
| PRC-039 | Google Drive folder selection through the Google Picker | pick a folder and submit its id | | needs GOOGLE_APP_CLIENT_ID, GOOGLE_APP_API_KEY and a Google account | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): Google Drive folder picker needs a real Google OAuth client and account; the rest of the step is covered by PRC-038; real-service testing is follow-up work.) | |
| PRC-040 | Reload mid-process starts a fresh run | new process UUID, first screen, empty inputs; earlier effects kept (SQL) | | prcComponents | pass (chromium, firefox, webkit) | |
| PRC-041 | Expired session mid-process returns to sign-in | 401 redirects to login | nothing saved | prcComponents + expired persona | pass (chromium, firefox, webkit) | [#668](https://github.com/QRun-IO/qqq/issues/668) |
| PRC-042 | Process permission enforcement | revoked permission shows the permission message | step refused, nothing saved; direct link offers no process; API init refused (403) | prcComponents + noProcesses persona | pass (chromium, firefox, webkit) | |
| PRC-043 | Default process values from the link (defaultProcessValues) | sleepMillis preset drives an async sleep to screen1; labName prefilled | | sample sleepInteractive, prcComponents | pass (chromium, firefox, webkit) | |
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | auto-focused input submits on Enter (SQL) | no Next/Cancel buttons rendered | prcScanner (format scanner) | pass (chromium, firefox, webkit) | [#661](https://github.com/QRun-IO/qqq/issues/661) |
| PRC-045 | State-machine step flow: repeated screens, no linear stepper | screen repeats with fresh inputs and counted rounds; noMoreSteps ends with Return | no stepper or Step i of n | prcLoop (STATE_MACHINE) | pass (chromium, firefox, webkit) | |
| PRC-046 | Saved bulk load profiles: save, choose for a later load, delete | Save As stores the mapping (SQL); choosing the profile restores the mapping and is named on review; Delete removes it (SQL) | a duplicate name is refused | SavedBulkLoadProfileMetaDataProvider on owned saved_bulk_load_profile tables | pass (chromium, firefox, webkit) | [#660](https://github.com/QRun-IO/qqq/issues/660) |
| PRC-047 | A run with no frontend screens completes with a completion screen | completion heading and message; backend effect persisted (SQL) | | prcQuickTask (backend step only) | pass (chromium, firefox, webkit) | |
## Querying, relationships, saved views, export and bulk selection
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | Headers use field labels; Rows equal SQL select ordered by id desc; Possible-value cells show labels, booleans Yes/No, money/decimals formatted | Null boolean renders empty, not No; Hidden and heavy (TEXT) fields are not columns | QueryFixtures.java qryItem (8 rows, every field type) | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-002 | Pagination with Material page sizes and URL state | Page 2 shows the remaining rows; Page sizes 10/25/50/100/250; Page and size survive reload via URL | Next is disabled on the last page | sample (person, pet, petNote, carrier, petSpecies) carrier (11 rows) | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | Ascending and descending match SQL order by; Sort persists across reload | Third click returns to the PK-descending default | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-004 | Column visibility and order persist per table | Hide/show and reorder columns; Order and visibility survive reload; Show/hide all per table group | Another table keeps its own columns | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | |
| QRY-005 | Column widths, density and card view | Resized width persists; Density changes row height; Card view shows the same records | Keyboard resize separator exposes its value | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | |
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | Filter, sort and page are restored after Back; Refresh shows a row changed through the API | No stale cached rows after refresh | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | |
| QRY-007 | A failed query reports the backend error with a retry | Backend error text shown; Retry reloads after the failure clears | No rows from the failed request are shown | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | |
| QRY-010 | Text operators match Material labels and backend semantics | equals, does not equal, contains, starts/ends with, is any of, is like, is empty/not empty; results equal SQL | does not equal includes empty values (NOT_EQUALS_OR_IS_NULL); Incomplete criteria are not sent | QueryFixtures.java qryItem.name/code | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-011 | Number operators including ranges and lists | equals, greater/less (or equal), is between, is any of; results equal SQL; Values sent as numbers | not between and none of exclude the bounds/list | QueryFixtures.java qryItem.quantity/price | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-012 | Date operators and relative date expressions | is/after/before/between with dates; today, N days ago, start of this/last month send backend expression JSON and match SQL | Clearing an expression restores a plain value input | QueryFixtures.java qryItem.receivedDate (relative to today) | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-013 | Date-time operators convert local input to UTC and support relative values | Local input sent as UTC instant; now and N hours ago expressions match SQL | Grid shows formatted date-times, not raw ISO | QueryFixtures.java qryItem.checkedAt | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) [#679](https://github.com/QRun-IO/qqq/issues/679) |
| QRY-014 | Boolean, blob and long-text operators | equals yes / equals no / is empty for booleans; blob is empty / not empty; long text contains | Blob offers no value operators | QueryFixtures.java qryItem.isActive/photo/notes | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | is / is any of / is none of with label chips; Link-loaded ids render labels; Results equal SQL | Search with no match shows no options | QueryFixtures.java qryItem.ownerId (person), speciesId (petSpecies enum) | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) [#667](https://github.com/QRun-IO/qqq/issues/667) |
| QRY-016 | AND/OR and nested groups combine as declared | AND group, OR group and nested subfilter match SQL | Removing the last criterion clears the filter | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | Reload keeps the filter; Back/forward switches filters; Plain JSON filter link opens filtered | Malformed filter param falls back to no filter | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-018 | Backend-only operators from links render and filter correctly | Each operator from a link renders its label and returns the SQL rows | FALSE matches no records; builder does not crash | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-019 | Quick search combines with the advanced filter | Search term ANDs with Is Active = yes | Clearing search restores the filtered rows | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | Join column 'Owner: First Name' hidden by default, shown on demand; Join filter and sort match SQL LEFT JOIN | Joins only sent when used; rows without a match are kept | QueryFixtures.java qryItem -> person (MANY_TO_ONE) | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) [#684](https://github.com/QRun-IO/qqq/issues/684) |
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | Repeated rows per note; Count shows (N distinct) | Row selection dedupes repeated ids | QueryFixtures.java qryItem -> qryItemNote (ONE_TO_MANY) | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| QRY-022 | Column statistics aggregate the filtered rows | Value distribution and count/distinct/sum/avg/min/max equal SQL under the filter | Sort by value/count | QueryFixtures.java qryItem (QUERY_STATS) | pass (chromium, firefox, webkit) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-023 | Tables without QUERY_STATS offer no column statistics | No statistics button on headers | Process not launched | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| QRY-030 | Selection menu: this page, full query result, first N, clear | Banners and counts for page, all and subset | Subset dialog rejects invalid N | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-031 | Bulk Edit launches with the checked record ids | recordIds param equals checked rows | No selection shows 'No records were selected to Bulk Edit.' | sample (person, pet, petNote, carrier, petSpecies) carrier | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-032 | Bulk Delete of the full query result sends the whole filter | filterJSON has no skip/limit; backend recordCount equals SQL count | Current page paging is not sent | sample (person, pet, petNote, carrier, petSpecies) carrier (page 2) | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-033 | First-N selection under a filter launches with that limit | filterJSON limit N with the filter and sort | N larger than the result is capped | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) |
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | Bulk Load/Edit/Edit With File/Delete then table processes; Launch carries the selection | Process min/max record checks block launch with a message | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) [#662](https://github.com/QRun-IO/qqq/issues/662) |
| QRY-035 | Viewers get no create, bulk or process actions and the backend refuses them | No Create, bulk or write process entries | Direct insert/delete API calls return 403 | viewer persona on sample (person, pet, petNote, carrier, petSpecies) | pass (chromium, firefox, webkit) | [#689](https://github.com/QRun-IO/qqq/issues/689) [#671](https://github.com/QRun-IO/qqq/issues/671) |
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | File name, headers and rows equal SQL; Join columns exported | Hidden columns excluded | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-041 | XLSX and JSON exports carry the same rows | XLSX sheet and JSON array equal SQL rows | JSON keys limited to exported fields | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-042 | Nothing to export disables the formats | Empty filter result disables CSV/XLSX/JSON | No export request sent | QueryFixtures.java qryItem | pass (chromium, firefox, webkit) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-043 | Backend refuses exports of unreadable tables | Export of a readable table succeeds | Export of pet returns 403 without data | noPets persona | pass (chromium, firefox, webkit) | [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-050 | Your saved views are listed and open on their own route | 'Your Saved Views' lists backend views; /savedView/{id} applies filter/columns | Empty group message | sample sharing demo (Alice People View) | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-051 | Saving a new view stores filter, sort, columns and page size | saved_view row viewJson matches Material RecordQueryView | Unsaved-changes indicator clears after save | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-052 | Update, rename and delete your view | Update keeps unrelated view JSON; Rename and delete change the saved_view row | Reset All Changes restores the stored view | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-053 | Duplicate names and unknown views are reported without changes | Duplicate name error shown in the dialog | Unknown view id shows an error; saved_view unchanged | sample (person, pet, petNote, carrier, petSpecies) person | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | Listed under 'Views Shared with you' for bob; Opens with its filter | Owner-only actions disabled; storeSavedView by bob refused | QueryFixtures.java (Alice People View shared with sample:bob READ_ONLY) | pass (chromium, firefox, webkit) | [#687](https://github.com/QRun-IO/qqq/issues/687) |
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | Picker lists /variants; Rows equal the chosen store; Choice remembered and changeable; export uses it | Filter narrows options | QueryFixtures.java qryStock on qryVariantMemory (qryStore) | pass (chromium, firefox, webkit) | [#690](https://github.com/QRun-IO/qqq/issues/690) |
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | Prompt shown with a choose button | No query sent; API query without a variant fails | QueryFixtures.java qryStock | pass (chromium, firefox, webkit) | [#690](https://github.com/QRun-IO/qqq/issues/690) |
| QRY-062 | A table without count, export or write capabilities offers none of them | Rows listed without a total; Export disabled with explanation; No create or bulk actions | Backend insert refused | QueryFixtures.java qryLedger (no TABLE_INSERT/UPDATE/DELETE/EXPORT/COUNT) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) [#688](https://github.com/QRun-IO/qqq/issues/688) |
| QRY-063 | Enum-backed Pet Species table is read-only | Metadata omits write capabilities; no Create or bulk writes | Insert returns 403, not 500 | sample (person, pet, petNote, carrier, petSpecies) petSpecies | pass (chromium, firefox, webkit) | [#692](https://github.com/QRun-IO/qqq/issues/692) |
| QRY-064 | Backend refuses count and export for a table without those capabilities | Query still succeeds | v1 count, legacy count and export return 403 | QueryFixtures.java qryLedger | pass (chromium, firefox, webkit) | [#693](https://github.com/QRun-IO/qqq/issues/693) |
| REL-001 | Possible-value cells show the related record label and link to it | Owner cell shows the person label; Link opens /app/person/{id} | Enum values are not links | sample (person, pet, petNote, carrier, petSpecies) pet.personId | pass (chromium, firefox, webkit) | [#691](https://github.com/QRun-IO/qqq/issues/691) |
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | Panel rows equal SQL children; View All filters /app/pet and Back returns | Parent without children shows an empty panel | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium, firefox, webkit) | [#686](https://github.com/QRun-IO/qqq/issues/686) |
| REL-003 | Adding a child from its parent links it and shows it immediately | New pet stored with person_id of the parent; Panel refreshes | Join field not editable; no other rows changed | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium, firefox, webkit) | |
| REL-004 | Three-level associations: each pet shows only its own notes | Notes equal SQL per pet | Other pets' notes absent | sample (person, pet, petNote, carrier, petSpecies) person -> pet -> petNote | pass (chromium, firefox, webkit) | |
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | Renamed pet shown in parent panel and list; Deleting a pet removes its notes | Other rows unchanged | sample (person, pet, petNote, carrier, petSpecies) pet, petNote | pass (chromium, firefox, webkit) | |
| REL-006 | Denied child reads show an unavailable panel without data or actions | Panel says related records are unavailable | No data, View All or Create; API query 403 | noPets persona; qryShelter whose associated qryShelterPet is denied with DenyBehavior.DISABLED | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | childRecordList and rowBuilder show SQL-matching members; Composite join on code + review date | Household without members shows empty | QueryFixtures.java qryHousehold/qryMember (members, care group / primary, scheduled reviews) | pass (chromium, firefox, webkit) | |
| REL-008 | Adding through a composite association stores every join value | New member stored with household_code and review_date from the parent | Aliased association unaffected; other rows unchanged | QueryFixtures.java qryHousehold/qryMember | pass (chromium, firefox, webkit) | |
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | Person and its pets removed | Other pets and notes unchanged; list reflects SQL | sample (person, pet, petNote, carrier, petSpecies) person -> pets | pass (chromium, firefox, webkit) | |
## Records, fields, editors, adornments, validation and persistence
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | label heading; T1 header fields; T2 section cards; currency, grouped integer, Yes/No, date | values match independent SQL readback | prime-test-database.sql (person 1) | pass (chromium, firefox, webkit) | |
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | NULL date shows an em dash; hidden field absent from view and from the GET payload; hidden section not rendered | hidden value stays stored (SQL) | person 4 (NULL birth date); recordLab hiddenCode + hidden legacy section | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-003 | Record view of a missing record shows Record Not Found and a way back | not-found panel; back to the table list | 404 read is the only failed request; no generic error toast | person 999 (absent) | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-004 | Record view tabs and list view expose every visible section | Overview shows all T2 sections; a section tab shows only that section; list view shows every section | | fieldLab record created in the test | pass (chromium, firefox, webkit) | |
| REC-005 | Create form validates required fields in the browser before any request | required markers and messages for each required field | no insert request, no row | person | pass (chromium, firefox, webkit) | |
| REC-006 | Create persists the entered values and opens the new record | success toast; navigates to /app/person/<new id>; view shows the entered values | non-editable fields are not offered on create; row matches SQL readback | person | pass (chromium, firefox, webkit) | |
| REC-007 | Backend enforces required fields when the UI is bypassed | direct insert without Name is rejected with the backend message | no row written | fieldLab | pass (chromium, firefox, webkit) | |
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | clean cancel returns immediately; dirty cancel asks Stay/Leave; Stay keeps typed values; Leave discards | no insert or update request; no row created or changed | person | pass (chromium, firefox, webkit) | |
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | fields prefilled from the record; Save disabled until a change; view shows the new value without reload | only the changed field is submitted; other columns unchanged in SQL | person 1 | pass (chromium, firefox, webkit) | [#672](https://github.com/QRun-IO/qqq/issues/672) [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-010 | Edit shows non-editable fields read-only and never submits them | read-only controls with the stored values | controls are disabled; create date unchanged in SQL after save | person 1 (id, createDate, modifyDate) | pass (chromium, firefox, webkit) | [#680](https://github.com/QRun-IO/qqq/issues/680) [#537](https://github.com/QRun-IO/qqq/issues/537) |
| REC-011 | Edit of a missing record shows the load failure instead of a form | error message, no form fields | 404 is the only failed request | person 999 | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | dialog names the record; Cancel keeps it; Delete removes it and returns to the list; success toast | no Resource not found toast; no 404 read of the deleted record; row gone in SQL | person 5 | pass (chromium, firefox, webkit) | [#541](https://github.com/QRun-IO/qqq/issues/541) |
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | source values prefilled without the key; save creates a new row and opens it; source unchanged | unique-key conflict on copy shows the backend message and keeps the form; download-URL files are not copied | person 1; recordLab 1 | pass (chromium, firefox, webkit) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | single-line input; multi-line textarea keeps line breaks in view and SQL | | fieldLab | pass (chromium, firefox, webkit) | |
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | LONG beyond 2^53 saved and shown exactly; DECIMAL with 4 places | fractional LONG rejected in the browser; backend rejects a fractional LONG sent directly | fieldLab | pass (chromium, firefox, webkit) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | create form starts at the default (true); switching to false persists false; view shows Yes/No | unchanged boolean is not rewritten on edit | fieldLab (booleanValue default true) | pass (chromium, firefox, webkit) | [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-017 | DATE and TIME editors round-trip values and clearing | date input value and view; time input value and backend 12-hour display; clearing a date stores NULL | | fieldLab | pass (chromium, firefox, webkit) | |
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | typed local time stored as the UTC instant; view shows yyyy-MM-dd hh:mm:ss AM EDT; edit form shows the local time | unchanged date-times are not rewritten; non-editable date-times read-only | fieldLab; browser zone America/New_York | pass (chromium, firefox, webkit) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | fixed America/Chicago display; display in the record's own timeZone; daylight-saving boundary | invalid zone falls back to UTC | fieldLab | pass (chromium, firefox, webkit) | [#679](https://github.com/QRun-IO/qqq/issues/679) |
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | formatted text rendered on the view | script content is not executed | fieldLab htmlValue | pass (chromium, firefox, webkit) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | view shows the mask; typing a new password saves it | editing another field keeps the stored password (SQL) | fieldLab passwordValue | pass (chromium, firefox, webkit) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| REC-022 | BLOB upload and download without a download adornment | file chosen on create is stored byte-for-byte; view offers the bytes as a download | | fieldLab blobValue | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-023 | Dynamic default values (user id, create day, modify day, explicit NONE) | user id and dates filled on create; modify day updated on edit, create day kept | NONE leaves the value empty | fieldLab | pass (chromium, firefox, webkit) | |
| REC-024 | Max-length policies: truncate, ellipsis, pass-through and reject | truncate, ellipsis and pass-through stored as declared | reject shows the backend message, keeps the form and writes nothing | fieldLab (maxLength 8) | pass (chromium, firefox, webkit) | [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-025 | Case and whitespace normalization on save | upper, lower, trim variants, remove-all-whitespace and unchanged stored as declared | | fieldLab | pass (chromium, firefox, webkit) | |
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | clip to 0/100 and 0.01/99.99 | out-of-range and exclusive-boundary values show the backend message and write nothing | fieldLab | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-027 | Unique keys are enforced with the backend message on create and edit | distinct values save | duplicate Name on create; duplicate normalized key on edit; form keeps values, row unchanged | fieldLab (name, normalizedKey) | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| REC-028 | LINK adornment: record links and URL links | toRecordFromTable opens the referenced record; URL opens in a new tab (target _blank) | a non-http value is not turned into a link | recordLab (ownerId, website) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-029 | CHIP adornment colors and icons by value | label is the display value; color from color.<value>; icon from icon.<value> | undeclared value uses the default color | recordLab status | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-030 | SIZE adornment sets the query grid column width | column is 100px wide | undeclared columns keep the default width | recordLab shortCode (width small) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | view shows the code and its language; Format JSON / Reset Format; editor saves edited code | invalid JSON reports a formatting error | recordLab config (json) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-032 | RENDER_HTML adornment renders sanitized HTML | markup rendered | script removed and not executed | recordLab htmlNote | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-033 | REVEAL adornment masks, reveals and copies the value | masked by default; reveal shows the exact stored value; hide masks again | | recordLab apiToken (PASSWORD + REVEAL) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | file name from the companion field; file name from the format; open and download return the stored bytes | record without a file shows no link | recordLab attachment (heavy, fileNameField) and notesFile (fileNameFormat) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | drag-and-drop zone and button formats; current file shown on edit; new file replaces bytes and file name | removing the file clears bytes and name | recordLab attachment (dragAndDrop), notesFile (button) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#680](https://github.com/QRun-IO/qqq/issues/680) |
| REC-036 | TOOLTIP adornment shows its text on the value | tooltip on hover and keyboard focus | | recordLab hint (staticText) | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) [#685](https://github.com/QRun-IO/qqq/issues/685) |
| REC-037 | WIDGET adornment renders the field value as the named widget | widget content from the record | the value is not offered as an editable field | recordLab summaryWidget | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-038 | ERROR adornment highlights the value | value shown with error styling | | recordLab problem | pass (chromium, firefox, webkit) | [#677](https://github.com/QRun-IO/qqq/issues/677) |
| REC-039 | Field help content follows screen roles and formats | view: READ_SCREENS text and VIEW_SCREEN HTML; edit: WRITE_SCREENS markdown rendered and EDIT_SCREEN text; create: INSERT_SCREEN text | help for other screens is not shown | recordLab title/website/ownerId help | pass (chromium, firefox, webkit) | [#681](https://github.com/QRun-IO/qqq/issues/681) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| REC-040 | Section help content on the record view | HTML help under the section heading | | recordLab presentation section | pass (chromium, firefox, webkit) | [#681](https://github.com/QRun-IO/qqq/issues/681) |
| REC-041 | Table help content slots are delivered in table metadata | v1 table metadata carries the slot help | | recordLab summary slot | pass (chromium, firefox, webkit) | |
| REC-042 | Audit history lists who changed what, newest first | edit then open Audit: message, user and per-field sentences; audit rows independently verified in SQL | unaudited record shows no audits | recordLab (FIELD audits) | pass (chromium, firefox, webkit) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-043 | Audit history without process permission reads the audit table | same entries via the audit table query | no process request is made | recordLab; persona noProcesses | pass (chromium, firefox, webkit) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-044 | Record label format is used for the record's title and dialogs | heading; delete dialog; audit title | | recordLab (Lab: %s) | pass (chromium, firefox, webkit) | [#678](https://github.com/QRun-IO/qqq/issues/678) |
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | edit shows the stored label, not the id; create default shows its label; keyboard search and select saves the id | | recordLab status/ownerId | pass (chromium, firefox, webkit) | [#667](https://github.com/QRun-IO/qqq/issues/667) |
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | no Edit, Copy or Delete; create and edit URLs show the permission message | direct insert, update and delete are 403 and change nothing | persona viewer | pass (chromium, firefox, webkit) | |
| REC-047 | Forms are labelled and keyboard operable | every control has a label; keyboard-only create saves the record | invalid fields expose aria-invalid and a described error | person | pass (chromium, firefox, webkit) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| REC-048 | Server validation errors show the backend message once and keep the form | alert and toast show the backend message; typed values remain | no generic Something went wrong toast | fieldLab | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
## Permissions, sessions, authentication and interaction quality
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend refuses | noPets sees Person but no Pet / Pet Note in navigation; person record neither shows nor requests the hidden pet association | direct link /app/pet shows unknown resource with no data request; API query/get of pet returns 403, table metadata 404 | tests/acceptance/fixture/AcceptanceSampleServer.java personas (noPets) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denial without loading data | admin lists the ledger rows; noPets sees the Security Ledger link | noPets list, record, create and edit links show the permission message and request no records; API query/get/insert/update return 403 and rows are unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityLedger, base name pet, noPets) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | viewer list has no Create button and the dashboard has no Create quick action | /app/person/create shows the permission message with no form; POST /data/person returns 403 and the person count is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium, firefox, webkit) | |
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | viewer record view has no Edit action | /app/person/1/edit shows the permission message without loading the record; PUT /data/person/1 returns 403 and the name is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium, firefox, webkit) | |
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothing | record view and bulk action bar have no Delete | /app/person.bulkDelete is unknown to the viewer; DELETE /data/person/1 and bulkDelete init return 403; the row remains | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium, firefox, webkit) | |
| SEC-006 | Copy requires insert permission: copy not offered and copy link refused | viewer record view has no Copy action | /app/person/1/copy shows the permission message; nothing is inserted | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer) | pass (chromium, firefox, webkit) | |
| SEC-007 | Process permission denied (hidden): processes not offered in navigation or launcher, links do not start them, backend refuses | viewer has no Actions menu; noProcesses' Actions menu offers only the table's bulk actions (table rights) and no standalone process; no process links; noProcesses keeps table rights (Create offered) | /app/clonePeople and /app/greetInteractive are unknown and make no process request; legacy and v1 process init return 403; no person is cloned | tests/acceptance/fixture/AcceptanceSampleServer.java personas (viewer, noProcesses) | pass (chromium, firefox, webkit) | |
| SEC-008 | Process disabled for a user (DenyBehavior.DISABLED): shown disabled, link explains the denial, backend refuses with no side effect | admin runs Security Audit and one audit row is written; viewer sees the audit tile disabled and no dashboard quick action for it | viewer direct link shows the permission message and never calls init; process init returns 403 and the audit log stays empty | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityAudit process) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-009 | Widget restrictions: hidden widgets absent, disabled widgets show a permission state without loading data, backend refuses | admin sees both pet widgets with their content; noPets sees the open bulletin widget | noPets: hidden widget absent, disabled widget shows "You do not have permission to view this data." and no /widget request; GET /widget/<denied> returns 403 | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityPetWidget, securityPetDisabledWidget) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-010 | App restrictions: hidden apps absent from navigation and unknown by link; disabled apps keep their children gated | admin sees both pet apps | noPets: Pet Vault App absent and /app/securityPetApp unknown; Pet Disabled App children stay permission-gated | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityPetApp hidden, securityPetDisabledApp disabled) | pass (chromium, firefox, webkit) | [#651](https://github.com/QRun-IO/qqq/issues/651) [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-011 | Record-level security (sharing demo): users see only their own or shared saved views/reports; direct links and writes to others are refused | alice lists and opens her own saved view | bob's list omits alice's view; bob's direct link shows not found; bob's GET/PUT/DELETE of alice's view are refused and the row is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (alice, bob) | pass (chromium, firefox, webkit) | |
| SEC-012 | Record security lock on a data table: list filtered to the key, other users' records unreachable, forged owner refused | alice sees and edits only her notes (persisted) | bob's direct link to alice's note shows not found; bob's update/delete of alice's note and insert with alice as owner are refused; SQL unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityNote locked by sample user) | pass (chromium, firefox, webkit) | |
| SEC-013 | Field protection: hidden fields never shown or returned, password fields masked and never overwritten by the mask, heavy fields not loaded in lists | REVEAL password can be revealed on request; editing another field keeps the stored password | hidden value absent from list, record, edit form and API responses; masked password shows only the mask; the list query does not carry the heavy BLOB | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityVault) | pass (chromium, firefox, webkit) | [#672](https://github.com/QRun-IO/qqq/issues/672) |
| SEC-014 | Capability restrictions: tables without insert/update/delete capabilities offer none of them and refuse direct links | admin can list and view the archive | no Create/Edit/Delete offered; create and edit links explain the table cannot be changed; backend insert/update/delete are refused; SQL unchanged | tests/acceptance/fixture/SecurityFixtures.java via SecurityAcceptanceServer (security variant) (securityArchive) | pass (chromium, firefox, webkit) | [#671](https://github.com/QRun-IO/qqq/issues/671) |
| SEC-015 | Permission revoked mid-session: stale UI actions are refused by the backend with a clear message, nothing is written, reload reflects the new rights | after reload the viewer rights are shown | saving an open edit form after revocation shows a permission error and the row is unchanged | tests/acceptance/fixture/AcceptanceSampleServer.java personas (admin then viewer) | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | lands on the requested page with rows; user menu shows Alice (sample) / sample:alice | | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium, firefox, mobile, webkit) | [#332](https://github.com/QRun-IO/qqq/issues/332) [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per-user client data is cleared | Sign in returns to the original page | after logout a protected page (full load) stays on login and reads no data; a different user signing in on the same browser never sees the previous user's cached pages or recent records | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium, firefox, mobile, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-022 | Session expiry: 401 redirects to login with returnTo and re-authenticates back to the page (initial load and mid-use) | expired on first load returns to the page; expired mid-use returns to the clicked record | no hang on "Setting up anonymous session" | tests/acceptance/fixture/AcceptanceSampleServer.java personas (expired) | pass (chromium, firefox, mobile, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-023 | Denied sign-in: a 401 from manageSession keeps the user out with an explanation (no silent anonymous fallback) | | login shows "Sign-in was denied", no navigation or data is rendered | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-024 | Post-login returnTo is limited to in-app paths (open-redirect guard) | in-app returnTo with query string is honoured | absolute, protocol-relative, backslash and javascript: targets land on /app | tests/acceptance/fixture/AcceptanceSampleServer.java personas | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend token exchange, session, reload resume) | authorize request carries S256 challenge, state, client id, scopes and /token redirect; backend exchanges code + verifier with client secret; user_session row stored; provider identity is displayed; reload resumes without the provider | | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) [#406](https://github.com/QRun-IO/qqq/issues/406) |
| SEC-026 | OAUTH2 sign-in failures: provider denial, tampered state and failed code exchange are reported and create no session | | access_denied shows the provider denial; a state mismatch is rejected before any token exchange; a rejected code exchange shows the failure; no user_session row | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-027 | OAUTH2 logout: backend session deleted, provider end-session called, next visit requires provider sign-in | user_session row deleted and provider session ended | protected page after logout returns to the provider login | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) [#674](https://github.com/QRun-IO/qqq/issues/674) |
| SEC-028 | OAUTH2 session revoked server-side mid-use: 401 sends the user through the provider and back to the page | returns to the same page after provider sign-in | revoked session gets 401, not stale data | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-029 | AUTH_0 authorization code + PKCE with an owned Auth0-compatible provider (browser token exchange, backend JWT validation via JWKS) | sign-in and data access through the Auth0 module; logout returns through the provider | provider denial is reported | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=AUTH_0 | pass (chromium, firefox, webkit) | [#670](https://github.com/QRun-IO/qqq/issues/670) |
| SEC-030 | AUTH_0 against a real Auth0 tenant | real tenant sign-in/logout | | Requires an Auth0 tenant QRun does not own in CI | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): real Auth0 tenant; the AUTH_0 flow is covered against an owned Auth0-compatible provider by SEC-029/SEC-031; real-service testing is follow-up work.) | |
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in again | deep link loads data as Anonymous | after logout the page stays signed out until Sign in | SecurityAcceptanceServer -Dqqq.security.auth=FULLY_ANONYMOUS | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-032 | Unsupported authentication type (TABLE_BASED, also unsupported by Material) is reported, never silently treated as another type | | login shows "Unsupported authentication type: TABLE_BASED" and no data is requested | SecurityAcceptanceServer -Dqqq.security.auth=TABLE_BASED | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
| SEC-033 | Server-side logout invalidation: v1 logout expires every session cookie it issued and an ended Auth0 session cannot be replayed | logout response expires sessionUUID and sessionId | a logged-out Auth0 sessionUUID replayed against the API gets 401 | tests/acceptance/support/fake-oidc.ts + SecurityAcceptanceServer -Dqqq.security.auth=OAUTH2 | pass (chromium, firefox, webkit) | [#674](https://github.com/QRun-IO/qqq/issues/674) [#675](https://github.com/QRun-IO/qqq/issues/675) |
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | edit saved by keyboard persists (SQL) | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#676](https://github.com/QRun-IO/qqq/issues/676) [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-002 | Keyboard: run a process through its steps without a pointer | greetInteractive completes and shows results | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | |
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | keyboard delete confirmation deletes the row (SQL) | Escape cancels without deleting; focus returns to Delete | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | [#682](https://github.com/QRun-IO/qqq/issues/682) [#683](https://github.com/QRun-IO/qqq/issues/683) [#694](https://github.com/QRun-IO/qqq/issues/694) |
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | command menu navigates to a table | Escape closes and returns focus | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-005 | Automated accessibility scan (axe WCAG 2.1 AA) of login, dashboard, list, record, edit form and process step | no serious or critical violations on each page | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | [#682](https://github.com/QRun-IO/qqq/issues/682) |
| INT-006 | Form labels and ARIA: every field has an accessible label, required fields are marked, invalid fields announce their error | labels match metadata labels | submitting empty required fields sets aria-invalid and a described error | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | |
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | | missing required value is named and focused; backend rejection is shown verbatim; SQL unchanged | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | |
| INT-008 | Responsive layout at phone width (Pixel 7): navigation drawer, list, record and form usable without horizontal scrolling | drawer navigation to a list, open and edit a record on a phone viewport | no horizontal page overflow | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | [#676](https://github.com/QRun-IO/qqq/issues/676) |
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | busy indicator while the real request is held, then rows | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, mobile, webkit) | [#694](https://github.com/QRun-IO/qqq/issues/694) |
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | retry succeeds | the failure is explained, not a blank page | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | |
| INT-011 | Delayed save: the submit control is disabled while saving and double submission creates one record | exactly one row inserted (SQL) | second click while pending is ignored | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | record edited through the API shows the new value on revisit | record deleted through the API shows not found | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#683](https://github.com/QRun-IO/qqq/issues/683) |
| INT-013 | Empty state: a query with no results explains it and offers the create action | no-match filter shows the empty message and Create | | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | |
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | Try again after recovery signs in | network failure shows "Sign-in failed" | qqq-sample-project (stock sample, mock auth, sharing demo) | pass (chromium, firefox, webkit) | [#669](https://github.com/QRun-IO/qqq/issues/669) |
## Dashboards, widgets, reports, saved and scheduled reports, and sharing
| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |
|---|---|---|---|---|---|---|
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | WARNING alert renders strong HTML and each bullet as sanitized HTML | hideWidget and empty alerts render nothing (WID-063) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accAlert | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-002 | barChart widget: one bar per label in backend colors | bars carry per-point backgroundColors; accessible data table has exact values | | sample SampleWidgetsDashboard/SampleBarChartWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-003 | chart output from an untyped widget renders as a bar chart | metadata type absent, payload type chart, values rendered | zero-value month has no drawn bar | sample greetingsApp/PersonsByCreateDateBarChart | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-004 | divider widget renders a rule without card chrome | hr rendered, no label or card | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accDivider | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-005 | fieldValueList widget: labels, display values, prefix icons, indentation | display value wins, zero shown, icon color, indent | empty field list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accFieldValueList | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-006 | generic widget renders its common payload fields (sublabel, footer) | sublabel and sanitized footer HTML shown | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accGeneric | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-007 | horizontalBarChart widget: horizontal bars with negative values left of zero | negative bar extends left of the zero line; height from payload | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accHorizontalBarChart | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-008 | html widget (no-code) renders sanitized user HTML | velocity output with i/b/u and centered wrapper | empty html renders an empty body (WID-052) | sample SampleWidgetsDashboard/SampleHTMLWidget | pass (chromium, firefox, webkit) | |
| WID-009 | lineChart widget: line with a point per label and description | points carry exact values | | sample SampleWidgetsDashboard/SampleLineChartWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-010 | smallLineChart widget: compact line with title and HTML description | title, strong description text, 5 points | | sample SampleWidgetsDashboard/SampleSmallLineChartWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-011 | location widget: image, title, description, address and footer | owned image loads from the owned service | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accLocation | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | every group and value matches the payload | empty group list shows empty state (WID-052) | sample SampleWidgetsDashboard/SampleMultiStatisticsWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-013 | multiTable widget renders each table with its label and rows | two labelled tables with their rows | empty table list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accMultiTable | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-014 | pieChart widget: colored slices, legend values and subheader | slice fills, legend label: value, main number, up/good change | | sample SampleWidgetsDashboard/SamplePieChartWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-015 | quickSightChart widget embeds the provider URL (real renderer, owned local provider endpoint) | iframe src is the owned embed URL and its content renders | metadata never exposes the secret key | tests/acceptance/fixture/WidgetsFixtures.java repointSampleQuickSight (greetingsApp/QuickSightChartRenderer against the owned loopback endpoint) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-016 | statistics widget: count, context, colored percentage change and links | decrease with increaseIsGood is bad; increase with increaseIsGood=false is bad; count links to countURL | | sample SampleStatisticsWidget; tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accStatisticsGood | pass (chromium, firefox, webkit) | |
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | bars of one label share x; dataset colors | | sample SampleStackedBarChartWidget; tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accStackedBars | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | states and current link | empty steps show empty state (WID-052) | sample SampleWidgetsDashboard/SampleStepperWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-019 | table widget: headers, rows, alignment and fixed totals row | rows and sticky last row match payload | no rows shows noRowsFoundHTML or default (WID-052) | sample SampleWidgetsDashboard/SampleTableWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-020 | usaMap widget plots each marker with name and coordinates | 3 markers placed west to east by longitude; marker list | empty marker list shows empty state (WID-052) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accUsaMap | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-021 | process widget runs the sample interactive process inline | form fields render, submit, result greeting shows | viewer persona: process denied contained (WID-061) | tests/acceptance/fixture/WidgetsFixtures.java widgetProcess/accProcess (greetInteractive) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-022 | parentWidget renders its children in a 12-column grid | children render with their own data | tabs (WID-055), denied/missing children (WID-056) | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentGrid | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | every block value, direction, link and tooltip | no blocks renders an empty composite (WID-052) | sample SampleWidgetsDashboard/SampleBigNumberBlocksWidget | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-024 | childRecordList widget without association binding lists joined child records | rows link to child records, Showing 2 of 3, View All filter link | host with no children shows empty state | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accWidgetHostJoinChild (maxRows 2) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#663](https://github.com/QRun-IO/qqq/issues/663) |
| WID-025 | customComponent widget loads its bundle and renders the named component | component receives widget metadata and data | missing bundle shows contained error (WID-062) | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accCustomComponent (owned bundle) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-026 | cronUI widget shows the record's schedule expression, description and time zone | expression, backend description and time zone | record without expression shows No schedule set | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostCron | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-027 | dynamicForm widget shows labeled values from the payload | labels and values incl. 0 | | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostDynamicForm | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-028 | dataBagViewer widget lists versions and shows the selected version contents | newest version current and shown; selecting older version shows its JSON | bag without versions shows empty message | tests/acceptance/fixture/WidgetsFixtures.java dataBag/1 section accDataBagViewer (owned dataBag tables) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-029 | pivotTableSetup widget shows the saved report's pivot definition | rows/values labels from the source table | report without pivot shows message | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned pivot saved report | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-030 | filterAndColumnsSetup widget shows the saved report's filter, sort and columns | criteria with field labels and operator phrases, columns by label | report without filter shows No filters | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned filtered saved report | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-031 | rowBuilder widget without association binding shows its rows read-only | column labels from widget fields, row values incl. 0 | | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost section accHostRows | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#663](https://github.com/QRun-IO/qqq/issues/663) |
| WID-032 | scriptViewer widget lists script revisions and shows the selected revision's files | current revision marked and its file contents shown; selecting older revision | | tests/acceptance/fixture/WidgetsFixtures.java script/1 (owned memory script with 2 revisions) | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-033 | quickSightChart against a real AWS QuickSight account and dashboard | real embed URL | | requires AWS credentials and a QuickSight dashboard (external service) | excluded (Approved by James 2026-09-25 (QRun-IO/qqq#649): real AWS QuickSight dashboard; the widget and renderer are covered against an owned loopback provider by WID-015; real-service testing is follow-up work.) | |
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | order matches metadata; 12 spans the grid; 4 is a third | | sample SampleWidgetsDashboard | pass (chromium, firefox, webkit) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-041 | widget chrome: card vs plain (isCard=false), sublabel, metadata and payload footer HTML | plain widget has no card border; footers render | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accPlain, widgetGallery/accGeneric | pass (chromium, firefox, webkit) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-042 | header icons (topLeftInsideCard/topRightInsideCard) with metadata colors | icon names, colors and placement | | sample SampleWidgetsDashboard | pass (chromium, firefox, webkit) | [#538](https://github.com/QRun-IO/qqq/issues/538) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-043 | widget label tooltip from metadata on hover and keyboard focus | hover and focus show, Escape hides | | sample SampleWidgetsDashboard/SampleBarChartWidget | pass (chromium, firefox, webkit) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-044 | widget help content (HTML) on the label | help icon shows sanitized HTML help | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accHelp | pass (chromium, firefox, webkit) | [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-045 | reload button re-renders the widget from the backend | render count increments per reload | no reload button without showReloadButton | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accReload | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-046 | export button downloads the widget's csvData as CSV | file bytes match Material quoting rules | no csvData: no-data message and no download | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accExport, accExportEmpty, accControls | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-047 | PVS dropdown from the widget payload: selection is sent as a parameter and re-renders | options from payload, choice reaches renderer, clearing removes it | non-stored selection is not persisted | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accDropdownHtml | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-048 | DATE_PICKER dropdown sends the chosen date | date reaches the child renderer | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-049 | required dropdowns: the parent waits with the backend message until selections are made | message then children render with selections | | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-050 | storeDropdownSelections persists selections across reload; stale stored options are dropped | localStorage qqq.widgets.dropdownData.<widget>.<param>; reload keeps selections | removed option in storage is not selected | tests/acceptance/fixture/WidgetsFixtures.java widgetControls/accControls | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | error message, retry re-requests, neighbor content | no error toast, no console error | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accError + accHealthy | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-052 | empty payloads show descriptive empty states for every display type | table, multiStatistics, charts, stepper, statistics 0, composite, multiTable, field values, map, html | | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accEmpty* | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-053 | malformed payloads are contained to their own widget | chart, table, composite, stepper, multiStatistics show a format notice | no crash, no console error, neighbors render | tests/acceptance/fixture/WidgetsFixtures.java widgetStates/accMalformed* | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-054 | widget permissions: a denied widget is absent from metadata, not rendered and not requested; the API denies it | admin sees it | noPets: no card, no request, GET /widget 403, renderer not called | tests/acceptance/fixture/WidgetsFixtures.java widgetPermissions/accDenied (pet.accDenied.hasAccess) with the noPets persona | pass (chromium, firefox, webkit) | |
| WID-055 | parent widget TABS layout switches tabs and remembers the selected tab | tab click, arrow keys, stored tab restored after reload | | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentTabs | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| WID-056 | parent widget skips denied and unknown children without requests | allowed children render | noPets: denied child absent and not requested; unknown child skipped | tests/acceptance/fixture/WidgetsFixtures.java widgetParents/accParentDenied, accParentMissing | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-057 | composite renders every block type with its values and styles | TEXT, BIG_NUMBER, UP_OR_DOWN_NUMBER, NUMBER_ICON_BADGE, ICON, TABLE_SUB_ROW_DETAIL_ROW, PROGRESS_BAR, DIVIDER, IMAGE, AUDIO, INPUT_FIELD, BUTTON | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-058 | composite layouts and slot links/tooltips | FLEX_COLUMN, FLEX_ROW, BADGES_WRAPPER, TABLE_SUB_ROW_DETAILS, FLEX_ROW_SPACE_BETWEEN, FLEX_ROW_CENTER, FLEX_ROW_WRAPPED; link to /app/person with tooltip | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocks | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-059 | unsupported block type shows a contained warning | Unsupported block type: OWNED_UNKNOWN; neighbor healthy | | tests/acceptance/fixture/WidgetsFixtures.java widgetBlocks/accBlocksUnknown | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-060 | record-view section widgets receive the record context (id, tableName) | record 1 and 2 show their own values | renderer enforces the user's table read | tests/acceptance/fixture/WidgetsFixtures.java accWidgetHost sections accHostHtml, accHostFieldValues | pass (chromium, firefox, webkit) | |
| WID-061 | process widget for a persona without process permission is contained | error shown in the widget; neighbor healthy | process metadata 403, no process run | tests/acceptance/fixture/WidgetsFixtures.java widgetProcess/accProcess with the noProcesses persona | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-062 | custom component whose bundle cannot load shows a contained error | Error loading MissingOwnedComponent | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accCustomComponentMissing | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| WID-063 | alerts with hideWidget or without HTML render nothing | no card rendered | | tests/acceptance/fixture/WidgetsFixtures.java widgetGallery/accAlertHidden, widgetStates/accEmptyAlert | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) [#666](https://github.com/QRun-IO/qqq/issues/666) |
| RPT-001 | TABLE report runs through its process and downloads CSV matching the database | CSV header labels and every person row from SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-002 | TABLE report downloads XLSX with the same rows | sheet rows match SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-003 | TABLE report downloads JSON with the same rows | JSON objects match SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-004 | SUMMARY report groups rows with counts and a totals row | pets per owner match SQL group by; Totals row | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPetSummaryReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-005 | PIVOT report produces a native pivot table in XLSX | xlsx contains the pivot table and cache over the rows sheet | CSV omits the pivot view (no native pivots) | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPetPivotReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) [#484](https://github.com/QRun-IO/qqq/issues/484) |
| RPT-006 | report input fields: required validation and the value filters the rows | minimumId filters rows >= value | empty required input blocks submit | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accPersonInputReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-007 | report permissions: restricted report denied in UI and by the server | admin can run it | noPets: no-permission message, legacy route 403 | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accRestrictedReport with the noPets persona | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-008 | reports appear in app navigation and open their run page | sidebar REPORT nodes navigate to the report page | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports app | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) [#651](https://github.com/QRun-IO/qqq/issues/651) |
| RPT-009 | saved report view shows its filter/columns and pivot setup | filter criteria, columns, pivot rows/values labels | | prime-sharing savedReport 1; tests/acceptance/fixture/WidgetsFixtures.java owned saved reports | pass (chromium, firefox, webkit) | [#550](https://github.com/QRun-IO/qqq/issues/550) |
| RPT-010 | saved report renders through renderSavedReport and downloads the saved columns and rows | format chosen, CSV contains the saved columns and every species | | prime-sharing savedReport 1 (Pet Species Report) | pass (chromium, firefox, webkit) | [#645](https://github.com/QRun-IO/qqq/issues/645) [#661](https://github.com/QRun-IO/qqq/issues/661) |
| RPT-011 | streaming report route for reports without a process | CSV streamed from /reports/{name}?format=csv matches SQL | | tests/acceptance/fixture/WidgetsFixtures.java acceptanceReports/accStreamedReport | pass (chromium, firefox, webkit) | [#664](https://github.com/QRun-IO/qqq/issues/664) |
| RPT-012 | scheduled report: create a schedule for a saved report and view its cron schedule | schedule persisted with cron description; record view shows the schedule widget | invalid cron expression rejected with message | scheduledReport (memory) for savedReport 1 | pass (chromium, firefox, webkit) | [#673](https://github.com/QRun-IO/qqq/issues/673) |
| RPT-013 | sharing: the owner shares a saved report with a user (Read-Only) | dialog lists Bob; shared_saved_report row READ_ONLY | | prime-sharing savedReport 1 owned by alice | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) [#444](https://github.com/QRun-IO/qqq/issues/444) |
| RPT-014 | sharing: change a share's scope to Read and Edit | row scope READ_WRITE | | prime-sharing savedReport 1 | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-015 | sharing: remove a share | row deleted, list empty | | prime-sharing savedReport 1 | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-016 | sharing record security: another user cannot see an unshared report until it is shared | bob sees it after sharing (UI list + API) | before sharing: not in bob's list, GET 404 | prime-sharing savedReport 1 (alice), user bob | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
| RPT-017 | sharing: only the owner may share | Share disabled with reason for the non-owner | server rejects a non-owner share; no row | tests/acceptance/fixture/WidgetsFixtures.java savedReport owned by casey shared with alice | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) [#371](https://github.com/QRun-IO/qqq/issues/371) |
| RPT-018 | sharing a saved view through the share dialog | shared_saved_view row for Casey | | prime-sharing savedView 1 owned by alice | pass (chromium, firefox, webkit) | [#665](https://github.com/QRun-IO/qqq/issues/665) |
+6
View File
@@ -0,0 +1,6 @@
# qqq-frontend-next
This Maven artifact contains the static export of the QQQ Next dashboard under
`next-dashboard/`. It has no Java sources or API documentation; the source code is at
https://github.com/QRun-IO/qqq-frontend-next. Add the jar to a QQQ application and
`QApplicationJavalinServer` serves the dashboard at `/`.
+409
View File
@@ -0,0 +1,409 @@
# Prompt: Add `node_app_*` jobs to kingsrook/qqq-orb
## Context
You are working in the `kingsrook/qqq-orb` CircleCI orb repo at:
`/Users/james.maes/Git.Local/QRun-IO/qqq-orb`
The orb currently supports:
- `mvn_*` — Maven Java libraries
- `mvn_frontend_*` — Maven + bundled frontend
- `node_*` — npm-published TypeScript libraries
You are adding a new project type: **`node_app_*`** — pnpm-based Node.js
applications (Next.js, Vite, etc.) that are _deployed_, not published to npm.
---
## Step 1: Read these files before writing anything
### qqq-orb existing patterns (understand before touching)
```
src/@orb.yml ← orb-level imports (browser-tools, node orbs already imported)
src/executors/default.yml ← Docker executor (cimg/node)
src/jobs/node_test_only.yml ← existing node job structure
src/jobs/node_publish.yml ← existing node publish job
src/commands/node_install_dependencies.yml
src/commands/node_run_tests.yml
src/commands/node_build_package.yml
src/scripts/node_npm_auth.sh ← existing script style/conventions
src/scripts/node_version_commit.sh ← existing script style/conventions
CLAUDE.md ← orb architecture, make commands, linting rules
```
### Munitor reference orb (copy engineering patterns from here)
These files live at `/Users/james.maes/Git.Local/Kof22/Munitor/`. Read them
to understand the patterns you must replicate — **not to copy content**, but
to adopt the same engineering discipline.
```
src/scripts/munitor_helpers.sh ← shared helper library pattern
src/scripts/install_node.sh ← nvm-based node install with BASH_ENV export
src/scripts/npm_install.sh ← lockfile validation before install
src/scripts/npm_test.sh ← JSON test_commands array override pattern
src/scripts/npm_lint.sh ← lint with JSON artifact output
src/scripts/run_e2e_playwright.sh ← Playwright install + JUnit reporter
src/commands/restore_npm_cache.yml ← cache key pattern
src/commands/save_npm_cache.yml
src/jobs/npm_build_and_test.yml ← two-job pipeline with workspace handoff
src/jobs/npm_e2e_test.yml ← E2E job that attaches workspace
```
---
## Step 2: Create a shared helper script
### `src/scripts/qqq_helpers.sh`
Model this on Munitor's `munitor_helpers.sh`. It must provide:
- `qqq_header <step_name>` — prints a banner with step name, date (UTC),
hostname, and current directory. Format to match qqq style (not Munitor branding).
- `qqq_check_tool <command> [version_flag]` — verifies tool is in PATH,
prints its version. Exits 1 with clear error if missing.
- Guard against double-sourcing (`[[ -n "${_QQQ_HELPERS_LOADED:-}" ]] && return 0`)
Sourcing pattern every script must use at the top:
```bash
QQQ_HELPERS="${QQQ_HELPERS:-$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/qqq_helpers.sh}"
[[ -f "${QQQ_HELPERS}" ]] && source "${QQQ_HELPERS}"
```
With a fallback stub if helpers are missing (same pattern as Munitor):
```bash
elif ! type qqq_header &>/dev/null; then
qqq_header() { echo "=== QQQ: ${1:-unknown} ==="; }
qqq_check_tool() { command -v "$1" &>/dev/null || { echo "ERROR: $1 not found"; exit 1; }; }
fi
```
---
## Step 3: Create shell scripts
All scripts: `#!/usr/bin/env bash`, `set -euo pipefail`, source `qqq_helpers.sh`
at top. Clear section headers. Descriptive error messages. Print summaries.
### `src/scripts/node_app_install.sh`
Purpose: Enable pnpm via corepack, validate lockfile exists, install deps.
Environment variables consumed:
- `NODE_PKG_MANAGER` — `pnpm` (default), `npm`, or `yarn`
Logic:
1. Call `qqq_header "node_app_install"`
2. Check tool: node, npm (for corepack)
3. If `NODE_PKG_MANAGER=pnpm`:
- Run `corepack enable`
- Run `corepack prepare pnpm@latest --activate`
- Verify `pnpm --version`
- Validate `pnpm-lock.yaml` exists; exit 1 with clear message if not
- Run `pnpm install --frozen-lockfile`
4. If `NODE_PKG_MANAGER=npm`:
- Validate `package-lock.json` exists
- Print lockfile version + npm version compatibility warning (copy logic from
Munitor's `npm_install.sh`)
- Run `npm ci`
5. If `NODE_PKG_MANAGER=yarn`:
- Validate `yarn.lock` exists
- Run `yarn install --frozen-lockfile`
6. Print count of installed packages
### `src/scripts/node_app_test.sh`
Purpose: Run type check, lint, and unit tests. Each step is individually
skippable. Supports custom test commands via JSON array (replaces default test
runner when provided).
Environment variables consumed:
- `NODE_PKG_MANAGER` — pnpm | npm | yarn (default: pnpm)
- `TYPE_CHECK_SCRIPT` — npm script name, or empty to skip (default: `tsc`)
- `TYPE_CHECK_ARGS` — extra args (default: `--noEmit`)
- `LINT_SCRIPT` — npm script name, or empty to skip (default: `lint`)
- `TEST_SCRIPT` — npm script name (default: `test`)
- `QQQ_TEST_COMMANDS_JSON` — JSON array of shell commands; if set and non-empty,
runs these instead of the default test command (same pattern as Munitor's
`MUNITOR_TEST_COMMANDS_JSON`)
Logic:
1. `qqq_header "node_app_test"`
2. If `TYPE_CHECK_SCRIPT` is non-empty: run `<pkg_manager> run <TYPE_CHECK_SCRIPT> <TYPE_CHECK_ARGS>`
3. If `LINT_SCRIPT` is non-empty: run `<pkg_manager> run <LINT_SCRIPT>`
4. If `QQQ_TEST_COMMANDS_JSON` is set and not `[]`/`null`:
- Parse JSON array using `jq` and run each command in sequence
5. Else: run `<pkg_manager> run <TEST_SCRIPT>`
6. Print pass/fail summary
### `src/scripts/node_app_build.sh`
Purpose: Run the production build. For Next.js this is `pnpm build`. Exits
non-zero on failure with clear output.
Environment variables consumed:
- `NODE_PKG_MANAGER` — default: pnpm
- `BUILD_SCRIPT` — npm script name (default: `build`)
Logic:
1. `qqq_header "node_app_build"`
2. Check pkg manager is available
3. Run `<pkg_manager> run <BUILD_SCRIPT>`
4. Print success with timing
### `src/scripts/node_app_e2e.sh`
Purpose: Install Playwright browsers and run E2E tests. Reports results in
JUnit format for CircleCI test analytics.
Environment variables consumed:
- `NODE_PKG_MANAGER` — default: pnpm
- `E2E_TEST_DIR` — default: `e2e`
- `PLAYWRIGHT_BROWSERS` — comma-separated browsers (default: `chromium`)
Logic:
1. `qqq_header "node_app_e2e"`
2. Install Playwright browsers: `npx playwright install --with-deps <browsers>`
3. Run: `npx playwright test --reporter=junit --output=e2e-results`
- If `E2E_TEST_DIR/playwright.config.ts` exists, pass `--config=<path>`
4. On failure: print "E2E tests failed. See playwright-report artifact." and
re-exit with the original exit code
5. Print pass summary
---
## Step 4: Create commands
### `src/commands/node_app_install.yml`
Parameters:
- `pkg_manager` — string, default: `"pnpm"` — package manager (pnpm | npm | yarn)
Steps:
1. Restore cache with keys:
- `node-app-v1-{{ checksum "pnpm-lock.yaml" }}-{{ checksum "package-lock.json" }}-{{ checksum "yarn.lock" }}`
- `node-app-v1-`
2. Run `node_app_install.sh` with environment `NODE_PKG_MANAGER: << parameters.pkg_manager >>`
3. Save cache key (same composite key as restore), paths: `node_modules`
### `src/commands/node_app_test.yml`
Parameters:
- `pkg_manager` — string, default: `"pnpm"`
- `type_check_script` — string, default: `"tsc"` — set empty to skip
- `type_check_args` — string, default: `"--noEmit"`
- `lint_script` — string, default: `"lint"` — set empty to skip
- `test_script` — string, default: `"test"`
- `test_commands` — string, default: `""` — JSON array of custom test commands
Steps:
1. Run `node_app_test.sh` with environment:
```
NODE_PKG_MANAGER: << parameters.pkg_manager >>
TYPE_CHECK_SCRIPT: << parameters.type_check_script >>
TYPE_CHECK_ARGS: << parameters.type_check_args >>
LINT_SCRIPT: << parameters.lint_script >>
TEST_SCRIPT: << parameters.test_script >>
QQQ_TEST_COMMANDS_JSON: << parameters.test_commands >>
```
2. store_artifacts: path: `test-results`, destination: `test-results` (if exists)
### `src/commands/node_app_build_step.yml`
Parameters:
- `pkg_manager` — string, default: `"pnpm"`
- `build_script` — string, default: `"build"`
Steps:
1. Run `node_app_build.sh` with environment:
```
NODE_PKG_MANAGER: << parameters.pkg_manager >>
BUILD_SCRIPT: << parameters.build_script >>
```
### `src/commands/node_app_e2e.yml`
Parameters:
- `pkg_manager` — string, default: `"pnpm"`
- `test_dir` — string, default: `"e2e"`
- `browsers` — string, default: `"chromium"`
Steps:
1. Run `node_app_e2e.sh` with environment:
```
NODE_PKG_MANAGER: << parameters.pkg_manager >>
E2E_TEST_DIR: << parameters.test_dir >>
PLAYWRIGHT_BROWSERS: << parameters.browsers >>
```
2. store_test_results: path: `e2e-results`
3. store_artifacts: path: `e2e-results`, destination: `e2e-results`
4. store_artifacts: path: `test-results/traces`, destination: `playwright-traces`
(use `when: always` so traces are captured even on failure)
---
## Step 5: Create jobs
### `src/jobs/node_app_test_only.yml`
For feature/non-protected branches. Run install + test only (no build).
Executor: `default` (existing `cimg/node` Docker executor)
- Use `default` executor, tag parameter: `<< parameters.node_version >>`
Parameters:
- `node_version` — string, default: `"lts"`
- `pkg_manager` — string, default: `"pnpm"`
- `type_check_script` — string, default: `"tsc"`
- `type_check_args` — string, default: `"--noEmit"`
- `lint_script` — string, default: `"lint"`
- `test_script` — string, default: `"test"`
- `test_commands` — string, default: `""`
Steps:
1. `git_full_checkout`
2. `node_app_install` (pass `pkg_manager`)
3. `node_app_test` (pass all test params)
### `src/jobs/node_app_build.yml`
For protected branches (develop, release/*, main, hotfix/*). Run install +
test + production build. Persists workspace for a downstream E2E job.
Executor: `default` (cimg/node Docker)
Parameters: (same as node_app_test_only PLUS:)
- `branch_type` — string, default: `"snapshot"` — reserved for future deploy hooks
- `build_script` — string, default: `"build"`
Steps:
1. `git_full_checkout`
2. `node_app_install` (pass `pkg_manager`)
3. `node_app_test` (pass all test params)
4. `node_app_build_step` (pass `pkg_manager`, `build_script`)
5. `persist_to_workspace` — root: `.`, paths: `["."]`
(full workspace so E2E job gets built artifacts)
### `src/jobs/node_app_e2e.yml`
Separate E2E job. Attaches workspace from `node_app_build` and runs Playwright.
Executor: `default` (cimg/node Docker) — browsers are installed by the script
using `npx playwright install --with-deps`, which works in Docker with root.
Parameters:
- `node_version` — string, default: `"lts"`
- `pkg_manager` — string, default: `"pnpm"`
- `test_dir` — string, default: `"e2e"`
- `browsers` — string, default: `"chromium"`
Steps:
1. `attach_workspace` — at: `.`
2. `node_app_e2e` (pass all E2E params)
---
## Step 6: Do NOT modify any existing files
These must be completely unchanged:
- All `mvn_*` jobs, commands, and scripts
- All `mvn_frontend_*` jobs, commands, and scripts
- All `node_*` jobs (node_test_only, node_publish), commands, and scripts
- `src/@orb.yml` — do NOT add new orb imports; existing `browser-tools`,
`node`, and `slack` orbs are already available
- `src/executors/default.yml`
---
## Step 7: Validate your work
After creating all files, run these commands from the orb root:
```bash
make lint # yamllint + shellcheck + circleci validate
make pack # pack to target/qqq-orb-packed.yml
make validate # pack + validate
```
Fix any yamllint, shellcheck, or orb validation errors before finishing.
---
## Target usage (what consuming repos will write)
After your changes, a pnpm Next.js app at `QRun-IO/qqq-frontend-next` writes:
```yaml
version: 2.1
orbs:
qqq-orb: qrun-io/qqq-orb@<new-version>
workflows:
test_only:
jobs:
- qqq-orb/node_app_test_only:
context: [ qqq-maven-registry-credentials ]
filters:
branches:
ignore: /(develop|main|release\/.*|hotfix\/.*|integration.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/node_app_build:
branch_type: snapshot
context: [ qqq-maven-registry-credentials ]
filters:
branches:
only: [ develop ]
publish_release_candidate:
jobs:
- qqq-orb/node_app_build:
branch_type: release_candidate
context: [ qqq-maven-registry-credentials ]
filters:
branches:
only: [ /release\/.*/ ]
publish_release:
jobs:
- qqq-orb/node_app_build:
branch_type: release
context: [ qqq-maven-registry-credentials ]
filters:
branches:
only: [ main ]
tags:
only: [ /v.*/ ]
publish_hotfix_release:
jobs:
- qqq-orb/node_app_build:
branch_type: hotfix
context: [ qqq-maven-registry-credentials ]
filters:
branches:
only: [ /hotfix\/.*/ ]
```
All parameters use defaults that work for any standard pnpm Next.js project
with `tsc`, `lint`, `test`, and `build` scripts — zero configuration needed
unless the project deviates from those conventions.
---
## Engineering quality bar
Every script must meet the standard set by Munitor's scripts:
- `#!/usr/bin/env bash` + `set -euo pipefail`
- `qqq_helpers.sh` sourced at top with fallback stub
- `qqq_header` called as first real step
- `qqq_check_tool` used to validate required tools before using them
- Clear, actionable error messages with fix instructions
- Summary output at the end (what ran, pass/fail, counts)
- shellcheck-clean (no SC warnings)
+73
View File
@@ -0,0 +1,73 @@
# RecordQuery Feature Gap Analysis
_Generated: 2026-02-28_
## Summary
The new RecordQuery implementation covers the core query-page surface area well: quick search, advanced filter builder with nested AND/OR groups and possible-value comboboxes, server-side pagination, single-column sort, column show/hide/reorder with drag-and-drop, column resize, saved views, CSV export, bulk process launch, row selection, density picker, grid/card view toggle, variant picker, and mobile filter bottom-sheet. The foundation is solid and the hook architecture (`useRecordQuery`) is clean.
The primary gaps fall into three categories: (1) **multi-column sort** — the DataGrid sort handler replaces the entire sort array on every click instead of appending to it, so only one column can be sorted at a time; (2) **join-aware filtering** — the FilterBuilder only lists columns from the primary table; joined-table fields exposed via `exposedJoins` cannot be added as filter criteria even though the backend type (`QFilterCriteria`) has no `joinName` field and uses dot-notation `fieldName` instead; (3) **select-all-across-pages / bulk delete** — the selection affordance is page-scoped (only rows visible on the current page can be checked) and the BulkActionBar's delete button is wired up to an `onDeleteSelected` prop that `RecordQuery` never passes, so bulk delete is dead code.
Several lower-impact features from the legacy implementation are also absent: per-row action buttons (edit/delete inline on each row), inline cell editing, and distinct-count display.
---
## Feature Inventory
| Feature | Status | Notes |
|---------|--------|-------|
| Multi-column sort | ⚠️ Partial | State and API types support multiple `orderBys`, but `handleSortColumn` in `DataGrid.tsx` calls `onSortChange([{ fieldName, isAscending: true }])` — a single-element array — on every click, replacing any prior sort. Shift-click to append a secondary sort is not implemented. |
| Join filters | ⚠️ Partial | `use-record-query.ts` derives `QueryJoin[]` from `exposedJoins` and sends them to the API. However `FilterBuilder.tsx` builds its field list solely from `tableMetaData.fields` and has no awareness of joined-table fields. Users cannot add filter criteria on joined columns. The `QFilterCriteria` type uses dot-notation `fieldName` (e.g. `"joinedTable.fieldName"`) rather than a separate `joinName` property, so the backend contract is compatible — the UI just never produces such criteria. |
| Bulk select across pages | ❌ Not implemented | The select-all checkbox (`grid-select-all`) selects only rows on the current page (TanStack Table `getToggleAllRowsSelectedHandler`). There is no "Select all N matching records" affordance analogous to the legacy "select all" banner. The `BulkActionBar` shows `"{selected} of {total} selected"` but has no way to extend selection beyond the page. |
| Export | ✅ Implemented | `ExportButton.tsx` supports three scopes: all records (up to 10,000), current page, and selected records. CSV is built client-side from visible/ordered fields and downloaded via a temporary `<a>` element. No server-side export endpoint is called; this may diverge from the legacy approach for very large datasets or Excel format support. |
| Inline edit | ❌ Not implemented | `DataCell.tsx` is read-only. There is no double-click-to-edit or pencil-icon affordance on any cell. The legacy implementation offered inline editing on editable fields. |
| Saved views | ✅ Implemented | `SavedViewsMenu.tsx` and the `views` namespace in `use-record-query.ts` fully implement save / load / delete of named views persisted to `localStorage`. Views capture filter, column visibility, column order, and sort order. |
| Quick search | ✅ Implemented | Debounced input in `RecordQueryToolbar.tsx` drives an OR-joined `CONTAINS` search across all visible `STRING`/`TEXT` columns. Mutually exclusive with the advanced filter panel. |
| Column resizing | ✅ Implemented | `DataGrid.tsx` implements mousedown/mousemove/mouseup column resize with a minimum width of 60 px. Widths are persisted to `localStorage` via `useRecordQuery`. Resize listeners are cleaned up on unmount. |
| Column reordering | ✅ Implemented | `ColumnConfig.tsx` supports HTML5 drag-and-drop reordering as well as keyboard Up/Down arrow keys on the grip button. Order is persisted to `localStorage`. Drag-reorder directly in the grid header is not implemented (only via the side panel). |
| Pinned columns | ❌ Not implemented | No sticky/pinned column support. TanStack Table supports column pinning but it is not wired up. |
| Bulk actions | ⚠️ Partial | Process launch from the bulk bar works (`ProcessLauncherMenu` inside `BulkActionBar`). Bulk delete is dead code — `BulkActionBar` renders a Delete button only when `onDeleteSelected` is provided, but `RecordQueryBulkBar.tsx` never passes that prop. There is no "delete selected" handler anywhere in `RecordQuery.tsx`. |
| Row-level actions | ❌ Not implemented | Each row navigates to the record detail view on click but has no per-row action buttons (edit pencil, delete trash, custom process). The legacy implementation rendered per-row action icon buttons in a trailing "actions" column. |
| Pagination | ✅ Implemented | Standard offset-based pagination with first/prev/next/last buttons, a page-size selector (10/25/50/100/250), and a "Go to page" input when `totalPages > 5`. Controlled by `use-record-query.ts`. |
| Distinct count | ⚠️ Partial | `countRecords` in `tables.ts` accepts `includeDistinct = false` as a third parameter and the `CountRecordsResponse` type includes `distinctCount?`. However `use-record-query.ts` always calls `countRecords(tableName, { filter: countFilter, joins }, false)` (or omits the flag, defaulting to `false`) and the UI never reads or displays `distinctCount`. The infrastructure is there; it is just never invoked or surfaced. |
---
## Priority Gaps (implement these next)
Ranked by user-visible impact on day-to-day admin usage:
1. **Multi-column sort** — High impact, low effort. Change `handleSortColumn` in `DataGrid.tsx` to detect a Shift-click and append/toggle the clicked column in the existing `sortOrder` array rather than replacing it. Update the column header button to accept a `shiftKey` event attribute. The `QFilterOrderBy[]` structure and API calls already support multiple entries.
2. **Join filters** — High impact, medium effort. Extend `FilterBuilder.tsx` to accept a `joinedFields` prop (or derive it from `tableMetaData.exposedJoins`). Prefix each joined field's display label with the join table label (e.g. "Order → Customer Name"). Use dot-notation fieldName (e.g. `"customer.name"`) in the emitted `QFilterCriteria`. The backend already expects this format.
3. **Bulk select across pages** — Medium impact, medium effort. Add a "Select all {N} records" banner that appears below the bulk-action bar when the page-scoped selection equals `pageSize` and `totalCount > pageSize`. Store a `selectAllMode: boolean` flag in state. When `true`, submit the current filter as the selection scope to process launches rather than the explicit ID list.
4. **Bulk delete** — Medium impact, low effort. Wire up `onDeleteSelected` in `RecordQuery.tsx` (and pass it through `RecordQueryBulkBar`). Implement a confirmation dialog, call `deleteRecord` in a loop (or a future bulk-delete endpoint), then invalidate the records query.
5. **Row-level actions** — Medium impact, medium effort. Add an "actions" column to `DataGrid.tsx` as the last column. Render icon buttons for Edit (navigate to `/{tableName}/{id}/edit`), Delete (single-record confirmation modal), and optionally a per-row process dropdown if the table's `processes` array contains single-record processes. Guard each button on the respective permission flag from `tableMetaData`.
6. **Pinned columns** — Low-to-medium impact, medium effort. Enable TanStack Table's `columnPinning` feature. Add a "Pin left" option to the `ColumnConfig` panel or a column header context menu. Apply `position: sticky; left: 0` styling to pinned headers and cells. Persist the pinned set to `localStorage` alongside visibility and order.
7. **Distinct count display** — Low impact, low effort. Pass `includeDistinct = true` in `countRecords` when `tableMetaData.usesVariants` is true or when the query includes a join with `type: 'LEFT'`. Read `countQuery.data?.distinctCount` and display it alongside or instead of the plain count in the `Pagination` component.
8. **Inline edit** — Low impact (admin use case), high effort. On double-click of an editable cell, swap `DataCell` for a `DataCellEditor` component that renders an appropriate input (text, number, date, possible-value combobox). On blur or Enter, call `updateRecord` and invalidate the query cache. Requires a new `DataCellEditor` component, field-type-aware input selection, and optimistic update handling.
---
## Reference Implementation Notes
The following patterns from the legacy `RecordQuery.tsx` (MUI + React) are worth preserving or adapting as the new implementation matures:
- **Multi-column sort via Shift-click:** The legacy grid accumulates `orderBys` entries when the user holds Shift while clicking a column header. This is the industry-standard UX (matching MUI DataGrid Pro) and should be replicated exactly.
- **Join filter field grouping:** The legacy FilterBuilder groups fields under their parent table heading in the field selector dropdown (e.g. a separator "— Order fields —" followed by "— Customer fields —"). This makes joined fields discoverable rather than mixed into a flat list.
- **Select-all banner:** When the user selects all rows on the current page, a blue banner appears: "All 25 records on this page are selected. Select all 1,247 records matching your filter." Clicking the link sets a `filterOnlyMode` flag that replaces the ID list with the filter in process invocations.
- **Per-row action icons:** The legacy grid renders three small icon buttons at the end of every row (view, edit, delete) that are hidden until hover. They are absolutely positioned so they do not affect column widths. The new DataGrid's trailing-column approach is acceptable but should use `opacity-0 group-hover:opacity-100` CSS to avoid visual noise.
- **Export via server endpoint (Excel):** The legacy implementation calls a dedicated server-side export endpoint (`POST /table/{name}/export`) that returns an Excel file stream. The current implementation builds a CSV client-side from the raw query results. This has a hard limit of 10,000 rows and produces only CSV. For parity, a server-side export endpoint call should be added; the client-side fallback can remain for smaller datasets.
- **Distinct count in count query:** The legacy implementation passes `includeDistinctCount=true` for tables that use joins, and displays "N (M distinct)" in the pagination footer when `distinctCount < count`. This helps users understand that a LEFT join is inflating the row count.
- **Filter variable expressions (NOW, NOW\_WITH\_OFFSET):** The legacy FilterBuilder renders dedicated UI controls for dynamic date expressions so users can save filters like "created in the last 7 days" that remain accurate over time. The types (`NowExpression`, `NowWithOffsetExpression`, `ThisOrLastPeriodExpression`) are already defined in `src/types/query.ts` and `filter-utils.ts` includes type guards and display formatting for them, but the `FilterBuilder` UI never offers these expressions as selectable value options — only static scalar inputs are rendered.
+73 -11
View File
@@ -1,16 +1,78 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
import nextConfig from 'eslint-config-next'
import prettierConfig from 'eslint-config-prettier'
import jsdoc from 'eslint-plugin-jsdoc'
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
...nextConfig,
prettierConfig,
{
rules: {
// React Compiler rules — project does not use React Compiler; disable to avoid false positives
'react-hooks/purity': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/refs': 'off',
'react-hooks/incompatible-library': 'off',
'react-hooks/globals': 'off',
},
},
// ---------------------------------------------------------------------------
// JSDoc enforcement — all TypeScript source files except tests and stories
// ---------------------------------------------------------------------------
{
files: ['src/**/*.{ts,tsx}'],
ignores: [
'src/**/*.test.{ts,tsx}',
'src/**/*.spec.{ts,tsx}',
'src/**/*.stories.{ts,tsx}',
],
plugins: { jsdoc },
settings: {
jsdoc: {
mode: 'typescript',
},
},
rules: {
// Require JSDoc on all exported functions, classes, and methods
'jsdoc/require-jsdoc': [
'error',
{
publicOnly: false,
require: {
FunctionDeclaration: true,
MethodDefinition: true,
ClassDeclaration: true,
ArrowFunctionExpression: false, // too noisy for inline callbacks
FunctionExpression: false, // too noisy for inline expressions
},
checkConstructors: false, // constructors documented on the class
contexts: [
// Exported arrow functions assigned to a const
'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > ArrowFunctionExpression',
'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > FunctionExpression',
],
enableFixer: false,
},
],
// Every JSDoc block must have a non-empty description
'jsdoc/require-description': ['error', { descriptionStyle: 'any' }],
// Params and returns
'jsdoc/require-param': ['error', { checkDestructured: false }],
'jsdoc/require-param-description': 'error',
'jsdoc/require-returns': ['error', { forceReturnsWithAsync: false }],
'jsdoc/require-returns-description': 'error',
// Correctness checks
'jsdoc/check-param-names': ['error', { checkDestructured: false }],
'jsdoc/check-tag-names': ['error', { typed: true }],
'jsdoc/no-undefined-types': 'off', // too many false positives with complex TS generics
// File-level overview
'jsdoc/require-file-overview': 'error',
},
},
]
export default eslintConfig
+28 -3
View File
@@ -1,13 +1,38 @@
import type { NextConfig } from 'next'
import packageJson from './package.json'
/**
* Two build outputs:
* - `QQQ_NEXT_OUTPUT=export`: static files for the QQQ Javalin server to host from its
* classpath (the default dashboard). Same origin as the API, so no rewrites.
* - otherwise `standalone`: a Node server; `QQQ_BACKEND_URL` bakes same-origin API rewrites
* into the build (the quickstart container image).
*/
const exportBuild = process.env.QQQ_NEXT_OUTPUT === 'export'
/** Backend route prefixes the frontend calls; forwarded by the standalone server. */
const BACKEND_PREFIXES = ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession']
const nextConfig: NextConfig = {
reactStrictMode: true,
// Reported to the backend as frontendVersion and shown on the developer page.
env: { NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version },
...(exportBuild
? { output: 'export', trailingSlash: true, images: { unoptimized: true } }
: {
output: 'standalone',
async rewrites() {
const backend = process.env.QQQ_BACKEND_URL
if (!backend) return []
return BACKEND_PREFIXES.map((prefix) => ({
source: `/${prefix}/:path*`,
destination: `${backend}/${prefix}/:path*`,
}))
},
}),
typescript: {
tsconfigPath: './tsconfig.json',
},
eslint: {
ignoreDuringBuilds: false,
},
}
export default nextConfig
+69 -42
View File
@@ -1,81 +1,108 @@
{
"name": "qqq-frontend-next",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"build:export": "QQQ_NEXT_OUTPUT=export NEXT_PUBLIC_MOCK_API=false next build",
"build:jar": "pnpm build:export && mvn -B -q package",
"start": "next start",
"lint": "next lint",
"lint": "eslint src && node scripts/check-license-headers.mjs",
"lint:jsdoc": "eslint src --ext .ts,.tsx",
"lint:license": "node scripts/check-license-headers.mjs",
"lint:license:fix": "node scripts/check-license-headers.mjs --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"tsc": "tsc"
"test:acceptance": "node scripts/acceptance.mjs",
"tsc": "tsc",
"typecheck": "tsc --noEmit",
"prepare": "git config core.hooksPath .githooks || true",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@auth0/auth0-react": "^2.2.4",
"@auth0/auth0-react": "^2.15.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.69.0",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.8.4",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"dompurify": "^3.4.16",
"lodash": "^4.18.1",
"lucide-react": "^0.485.0",
"next": "15.2.3",
"oidc-client-ts": "^3.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"next": "16.3.6",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwind-merge": "^3.5.0",
"ts-md5": "^1.3.1",
"universal-cookie": "^7.2.2",
"zod": "^3.24.2"
"zod": "^3.25.76"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@eslint/eslintrc": "^3.3.4",
"@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@playwright/test": "^1.58.2",
"@storybook/addon-docs": "10.6.0",
"@storybook/nextjs": "10.6.0",
"@storybook/react": "10.6.0",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/jest-axe": "^3.5.9",
"@types/lodash": "^4.17.24",
"@types/node": "^22.19.11",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^3.0.9",
"@vitest/ui": "^3.0.9",
"autoprefixer": "^10.4.21",
"eslint": "^9.22.0",
"eslint-config-next": "15.2.3",
"eslint-config-prettier": "^10.1.1",
"jsdom": "^26.0.0",
"@vitest/coverage-v8": "^3.2.7",
"@vitest/ui": "^3.2.7",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-config-next": "16.3.6",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsdoc": "^62.7.1",
"jest-axe": "^10.0.0",
"jsdom": "^26.1.0",
"msw": "^2.12.10",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2",
"vitest": "^3.0.9"
"postcss": "^8.5.28",
"prettier": "^3.8.1",
"storybook": "10.6.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vitest": "^3.2.7"
},
"msw": {
"workerDirectory": [
"public"
]
},
"pnpm": {
"overrides": {
"serialize-javascript": ">=7.0.3",
"@humanfs/node": ">=0.16.8",
"@babel/core": ">=7.29.6 <8"
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html style='scrollbar-gutter: stable both-edges;'>
<head>
<meta charset='UTF-8'>
<meta name='color-scheme' content='dark light'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Playwright Test Report</title>
<script type="module">var NA=Object.defineProperty;var BA=(l,u,c)=>u in l?NA(l,u,{enumerable:!0,configurable:!0,writable:!0,value:c}):l[u]=c;var yn=(l,u,c)=>BA(l,typeof u!="symbol"?u+"":u,c);(function(){const u=document.createElement("link").relList;if(u&&u.supports&&u.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))f(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const h of o.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&f(h)}).observe(document,{childList:!0,subtree:!0});function c(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function f(r){if(r.ep)return;r.ep=!0;const o=c(r);fetch(r.href,o)}})();function UA(l){return l&&l.__esModule&&Object.prototype.hasOwnProperty.call(l,"default")?l.default:l}var Mf={exports:{}},Ei={};/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var D1;function QA(){if(D1)return Ei;D1=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.fragment");function c(f,r,o){var h=null;if(o!==void 0&&(h=""+o),r.key!==void 0&&(h=""+r.key),"key"in r){o={};for(var v in r)v!=="key"&&(o[v]=r[v])}else o=r;return r=o.ref,{$$typeof:l,type:f,key:h,ref:r!==void 0?r:null,props:o}}return Ei.Fragment=u,Ei.jsx=c,Ei.jsxs=c,Ei}var M1;function zA(){return M1||(M1=1,Mf.exports=QA()),Mf.exports}var m=zA();const YA=15,xt=0,bn=1,LA=2,ye=-2,Ut=-3,j1=-4,xn=-5,Me=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],L2=1440,GA=0,XA=4,VA=9,ZA=5,qA=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],IA=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],KA=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],kA=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],JA=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,Line truncated
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var T2;function B5(){if(T2)return dt;T2=1;var l=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),o=Symbol.for("react.consumer"),h=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),A=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),S=Symbol.for("react.activity"),O=Symbol.iterator;function X(C){return C===null||typeof C!="object"?null:(C=O&&C[O]||C["@@iterator"],typeof C=="function"?C:null)}var B={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},b=Object.assign,p={};function x(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}x.prototype.isReactComponent={},x.prototype.setState=function(C,L){if(typeof C!="object"&&typeof C!="function"&&C!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,C,L,"setState")},x.prototype.forceUpdate=function(C){this.updater.enqueueForceUpdate(this,C,"forceUpdate")};function R(){}R.prototype=x.prototype;function U(C,L,W){this.props=C,this.context=L,this.refs=p,this.updater=W||B}var Z=U.prototype=new R;Z.constructor=U,b(Z,x.prototype),Z.isPureReactComponent=!0;var F=Array.isArray;function j(){}var D={H:null,A:null,T:null,S:null},N=Object.prototype.hasOwnProperty;function K(C,L,W){var et=W.ref;return{$$typeof:l,type:C,key:L,ref:et!==void 0?et:null,props:W}}function J(C,L){return K(C.type,L,C.props)}function k(C){return typeof C=="object"&&C!==null&&C.$$typeof===l}function nt(C){var L={"=":"=0",":":"=2"};return"$"+C.replace(/[=:]/g,function(W){return L[W]})}var P=/\/+/g;function st(C,L){return typeof C=="object"&&C!==null&&C.key!=null?nt(""+C.key):L.toString(36)}function it(C){switch(C.status){case"fulfilled":return C.value;case"rejected":throw C.reason;default:switch(typeof C.status=="string"?C.then(j,j):(C.status="pending",C.then(function(L){C.status==="pending"&&(C.status="fulfilled",C.value=L)},function(L){C.status==="pending"&&(C.status="rejected",C.reason=L)})),C.status){case"fulfilled":return C.value;case"rejected":throw C.reason}}throw C}function H(C,L,W,et,rt){var ot=typeof C;(ot==="undefined"||ot==="boolean")&&(C=null);var gt=!1;if(C===null)gt=!0;else switch(ot){case"bigint":case"string":case"number":gt=!0;break;case"object":switch(C.$$typeof){case l:case u:gt=!0;break;case E:return gt=C._init,H(gt(C._payload),L,W,et,rt)}}if(gt)return rt=rt(C),gt=et===""?"."+st(C,0):et,F(rt)?(W="",gt!=null&&(W=gt.replace(P,"$&/")+"/"),H(rt,L,W,"",function(On){return On})):rt!=null&&(k(rt)&&(rt=J(rt,W+(rt.key==null||C&&C.key===rt.key?"":(""+rt.key).replace(P,"$&/")+"/")+gt)),L.push(rt)),1;gt=0;var Ft=et===""?".":et+":";if(F(C))for(var Qt=0;Qt<C.length;Qt++)et=C[Qt],ot=Ft+st(et,Qt),gt+=H(et,L,W,ot,rt);else if(Qt=X(C),typeof Qt=="function")for(C=Qt.call(C),Qt=0;!(et=C.next()).done;)et=et.value,ot=Ft+st(et,Qt++),gt+=H(et,L,W,ot,rt);else if(ot==="object"){if(typeof C.then=="function")return H(it(C),L,W,et,rt);throw L=String(C),Error("Objects are not valid as a React child (found: "+(L==="[object Object]"?"object with keys {"+Object.keys(C).join(", ")+"}":L)+"). If you meant to render a collection of children, use an array instead.")}return gt}function _(C,L,W){if(C==null)return C;var et=[],rt=0;return H(C,et,"","",function(ot){return L.call(W,ot,rt++)}),et}function $(C){if(C._status===-1){var L=C._result;L=L(),L.then(function(W){(C._status===0||C._status===-1)&&(C._status=1,C._result=W)},function(W){(C._status===0||C._status===-1)&&(C._status=2,C._result=W)}),C._status===-1&&(C._status=0,C._result=L)}if(C._status===1)return C._result.default;throw C._result}var ht=typeof reportError=="function"?reportError:function(C){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var L=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof C=="object"&&C!==null&&typeof C.message=="string"?String(C.message):String(C),error:C});if(!window.dispatchEvent(L))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",C);return}console.error(C)},tt={map:_,forEach:function(C,L,W){_(C,function(){L.apply(this,arguments)},W)},count:function(C){var L=0;return _(C,function(){L++}),L},toArray:function(C){return _(C,function(L){return L})||[]},only:function(C){if(!k(C))throw Error("React.Children.only expected to receive a single React element child.");return C}};return dt.Activity=S,dt.Children=tt,dt.Component=x,dt.Fragment=c,dt.Profiler=r,dt.PureComponent=U,dt.StrictMode=f,dt.Suspense=y,dt.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=D,dt.__COMPILER_RUNTIME={__proto__:null,c:function(C){return D.H.useMemoCache(C)}},dt.cache=function(C){return function(){return C.apply(null,arguments)}},dt.cacheSignal=function(){return null},dt.cloneElement=function(C,L,W){if(C==nullLine truncated
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var O2;function U5(){return O2||(O2=1,(function(l){function u(H,_){var $=H.length;H.push(_);t:for(;0<$;){var ht=$-1>>>1,tt=H[ht];if(0<r(tt,_))H[ht]=_,H[$]=tt,$=ht;else break t}}function c(H){return H.length===0?null:H[0]}function f(H){if(H.length===0)return null;var _=H[0],$=H.pop();if($!==_){H[0]=$;t:for(var ht=0,tt=H.length,C=tt>>>1;ht<C;){var L=2*(ht+1)-1,W=H[L],et=L+1,rt=H[et];if(0>r(W,$))et<tt&&0>r(rt,W)?(H[ht]=rt,H[et]=$,ht=et):(H[ht]=W,H[L]=$,ht=L);else if(et<tt&&0>r(rt,$))H[ht]=rt,H[et]=$,ht=et;else break t}}return _}function r(H,_){var $=H.sortIndex-_.sortIndex;return $!==0?$:H.id-_.id}if(l.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var o=performance;l.unstable_now=function(){return o.now()}}else{var h=Date,v=h.now();l.unstable_now=function(){return h.now()-v}}var y=[],A=[],E=1,S=null,O=3,X=!1,B=!1,b=!1,p=!1,x=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,U=typeof setImmediate<"u"?setImmediate:null;function Z(H){for(var _=c(A);_!==null;){if(_.callback===null)f(A);else if(_.startTime<=H)f(A),_.sortIndex=_.expirationTime,u(y,_);else break;_=c(A)}}function F(H){if(b=!1,Z(H),!B)if(c(y)!==null)B=!0,j||(j=!0,nt());else{var _=c(A);_!==null&&it(F,_.startTime-H)}}var j=!1,D=-1,N=5,K=-1;function J(){return p?!0:!(l.unstable_now()-K<N)}function k(){if(p=!1,j){var H=l.unstable_now();K=H;var _=!0;try{t:{B=!1,b&&(b=!1,R(D),D=-1),X=!0;var $=O;try{e:{for(Z(H),S=c(y);S!==null&&!(S.expirationTime>H&&J());){var ht=S.callback;if(typeof ht=="function"){S.callback=null,O=S.priorityLevel;var tt=ht(S.expirationTime<=H);if(H=l.unstable_now(),typeof tt=="function"){S.callback=tt,Z(H),_=!0;break e}S===c(y)&&f(y),Z(H)}else f(y);S=c(y)}if(S!==null)_=!0;else{var C=c(A);C!==null&&it(F,C.startTime-H),_=!1}}break t}finally{S=null,O=$,X=!1}_=void 0}}finally{_?nt():j=!1}}}var nt;if(typeof U=="function")nt=function(){U(k)};else if(typeof MessageChannel<"u"){var P=new MessageChannel,st=P.port2;P.port1.onmessage=k,nt=function(){st.postMessage(null)}}else nt=function(){x(k,0)};function it(H,_){D=x(function(){H(l.unstable_now())},_)}l.unstable_IdlePriority=5,l.unstable_ImmediatePriority=1,l.unstable_LowPriority=4,l.unstable_NormalPriority=3,l.unstable_Profiling=null,l.unstable_UserBlockingPriority=2,l.unstable_cancelCallback=function(H){H.callback=null},l.unstable_forceFrameRate=function(H){0>H||125<H?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):N=0<H?Math.floor(1e3/H):5},l.unstable_getCurrentPriorityLevel=function(){return O},l.unstable_next=function(H){switch(O){case 1:case 2:case 3:var _=3;break;default:_=O}var $=O;O=_;try{return H()}finally{O=$}},l.unstable_requestPaint=function(){p=!0},l.unstable_runWithPriority=function(H,_){switch(H){case 1:case 2:case 3:case 4:case 5:break;default:H=3}var $=O;O=H;try{return _()}finally{O=$}},l.unstable_scheduleCallback=function(H,_,$){var ht=l.unstable_now();switch(typeof $=="object"&&$!==null?($=$.delay,$=typeof $=="number"&&0<$?ht+$:ht):$=ht,H){case 1:var tt=-1;break;case 2:tt=250;break;case 5:tt=1073741823;break;case 4:tt=1e4;break;default:tt=5e3}return tt=$+tt,H={id:E++,callback:_,priorityLevel:H,startTime:$,expirationTime:tt,sortIndex:-1},$>ht?(H.sortIndex=$,u(A,H),c(y)===null&&H===c(A)&&(b?(R(D),D=-1):b=!0,it(F,$-ht))):(H.sortIndex=tt,u(y,H),B||X||(B=!0,j||(j=!0,nt()))),H},l.unstable_shouldYield=J,l.unstable_wrapCallback=function(H){var _=O;return function(){var $=O;O=_;try{return H.apply(this,arguments)}finally{O=$}}}})(qf)),qf}var w2;function Q5(){return w2||(w2=1,Zf.exports=U5()),Zf.exports}var If={exports:{}},ce={};/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var R2;function z5(){if(R2)return ce;R2=1;var l=xr();function u(y){var A="https://react.dev/errors/"+y;if(1<arguments.length){A+="?args[]="+encodeURIComponent(arguments[1]);for(var E=2;E<arguments.length;E++)A+="&args[]="+encodeURIComponent(arguments[E])}return"Minified React error #"+y+"; visit "+A+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function c(){}var f={d:{f:c,r:function(){throw Error(u(522))},D:c,C:c,L:c,m:c,X:c,S:c,M:c},p:0,findDOMNode:null},r=Symbol.for("react.portal");function o(y,A,E){var S=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:r,key:S==null?null:""+S,children:y,containerInfo:A,implementation:E}}var h=l.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function v(y,A){if(y==="font")return"";if(typeof A=="string")return A==="use-credentials"?A:""}return ce.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ce.createPortal=function(y,A){var E=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!A||A.nodeType!==1&&A.nodeType!==9&&A.nodeType!==11)throw Error(u(299));return o(y,A,null,E)},ce.flushSync=function(y){var A=h.T,E=f.p;try{if(h.T=null,f.p=2,y)return y()}finally{h.T=A,f.p=E,f.d.f()}},ce.preconnect=function(y,A){typeof y=="string"&&(A?(A=A.crossOrigin,A=typeof A=="string"?A==="use-credentials"?A:"":void 0):A=null,f.d.C(y,A))},ce.prefetchDNS=function(y){typeof y=="string"&&f.d.D(y)},ce.preinit=function(y,A){if(typeof y=="string"&&A&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin),O=typeof A.integrity=="string"?A.integrity:void 0,X=typeof A.fetchPriority=="string"?A.fetchPriority:void 0;E==="style"?f.d.S(y,typeof A.precedence=="string"?A.precedence:void 0,{crossOrigin:S,integrity:O,fetchPriority:X}):E==="script"&&f.d.X(y,{crossOrigin:S,integrity:O,fetchPriority:X,nonce:typeof A.nonce=="string"?A.nonce:void 0})}},ce.preinitModule=function(y,A){if(typeof y=="string")if(typeof A=="object"&&A!==null){if(A.as==null||A.as==="script"){var E=v(A.as,A.crossOrigin);f.d.M(y,{crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0})}}else A==null&&f.d.M(y)},ce.preload=function(y,A){if(typeof y=="string"&&typeof A=="object"&&A!==null&&typeof A.as=="string"){var E=A.as,S=v(E,A.crossOrigin);f.d.L(y,E,{crossOrigin:S,integrity:typeof A.integrity=="string"?A.integrity:void 0,nonce:typeof A.nonce=="string"?A.nonce:void 0,type:typeof A.type=="string"?A.type:void 0,fetchPriority:typeof A.fetchPriority=="string"?A.fetchPriority:void 0,referrerPolicy:typeof A.referrerPolicy=="string"?A.referrerPolicy:void 0,imageSrcSet:typeof A.imageSrcSet=="string"?A.imageSrcSet:void 0,imageSizes:typeof A.imageSizes=="string"?A.imageSizes:void 0,media:typeof A.media=="string"?A.media:void 0})}},ce.preloadModule=function(y,A){if(typeof y=="string")if(A){var E=v(A.as,A.crossOrigin);f.d.m(y,{as:typeof A.as=="string"&&A.as!=="script"?A.as:void 0,crossOrigin:E,integrity:typeof A.integrity=="string"?A.integrity:void 0})}else f.d.m(y)},ce.requestFormReset=function(y){f.d.r(y)},ce.unstable_batchedUpdates=function(y,A){return y(A)},ce.useFormState=function(y,A,E){return h.H.useFormState(y,A,E)},ce.useFormStatus=function(){return h.H.useHostTransitionStatus()},ce.version="19.2.1",ce}var D2;function Y5(){if(D2)return If.exports;D2=1;function l(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(l)}catch(u){console.error(u)}}return l(),If.exports=z5(),If.exports}/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var M2;function L5(){if(M2)return xi;M2=1;var l=Q5(),u=xr(),c=Y5();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)e+="&args[]="+encodeURIComponent(arguments[n])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function r(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function o(t){var e=t,n=t;if(t.alternate)for(;e.return;)e=e.return;else{t=e;do e=t,(e.flags&4098)!==0&&(n=e.return),t=e.return;while(t)}return e.tag===3?n:null}function h(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function v(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function y(t){if(o(t)!==t)throw Error(f(188))}function A(t){var e=t.alternate;if(!e){if(e=o(t),e===null)throw Error(f(188));return e!==t?null:t}for(var n=t,a=e;;){var i=n.return;if(i===null)break;var s=i.alternate;if(s===null){if(a=i.return,a!==null){n=a;continue}break}if(i.child===s.child){for(s=i.child;s;){if(s===n)return y(i),t;if(s===a)return y(i),e;s=s.sibling}throw Error(f(188))}if(n.return!==a.return)n=i,a=s;else{for(var d=!1,g=i.child;g;){if(g===n){d=!0,n=i,a=s;break}if(g===a){d=!0,a=i,n=s;break}g=g.sibling}if(!d){for(g=s.child;g;){if(g===n){d=!0,n=s,a=i;break}if(g===a){d=!0,a=s,n=i;break}g=g.sibling}if(!d)throw Error(f(189))}}if(n.alternate!==a)throw Error(f(190))}if(n.tag!==3)throw Error(f(188));return n.stateNode.current===n?t:e}function E(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=E(t),e!==null)return e;t=t.sibling}return null}var S=Object.assign,O=Symbol.for("react.element"),X=Symbol.for("react.transitional.element"),B=Symbol.for("react.portal"),b=Symbol.for("react.fragment"),p=Symbol.for("react.strict_mode"),x=Symbol.for("react.profiler"),R=Symbol.for("react.consumer"),U=Symbol.for("react.context"),Z=Symbol.for("react.forward_ref"),F=Symbol.for("react.suspense"),j=Symbol.for("react.suspense_list"),D=Symbol.for("react.memo"),N=Symbol.for("react.lazy"),K=Symbol.for("react.activity"),J=Symbol.for("react.memo_cache_sentinel"),k=Symbol.iterator;function nt(t){return t===null||typeof t!="object"?null:(t=k&&t[k]||t["@@iterator"],typeof t=="function"?t:null)}var P=Symbol.for("react.client.reference");function st(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===P?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case b:return"Fragment";case x:return"Profiler";case p:return"StrictMode";case F:return"Suspense";case j:return"SuspenseList";case K:return"Activity"}if(typeof t=="object")switch(t.$$typeof){case B:return"Portal";case U:return t.displayName||"Context";case R:return(t._context.displayName||"Context")+".Consumer";case Z:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case D:return e=t.displayName||null,e!==null?e:st(t.type)||"Memo";case N:e=t._payload,t=t._init;try{return st(t(e))}catch{}}return null}var it=Array.isArray,H=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,_=c.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,$={pending:!1,data:null,method:null,action:null},ht=[],tt=-1;function C(t){return{current:t}}function L(t){0>tt||(t.current=ht[tt],ht[tt]=null,tt--)}function W(t,e){tt++,ht[tt]=t.current,t.current=e}var et=C(null),rt=C(null),ot=C(null),gt=C(null);function Ft(t,e){switch(W(ot,e),W(rt,t),W(et,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?Pd(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=Pd(e),t=$d(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}L(et),W(et,t)}function Qt(){L(et),L(rt),L(ot)}function On(t){t.memoizedState!==null&&W(gt,t);var e=et.current,n=$d(e,t.type);e!==n&&(W(rt,t),W(et,n))}function ra(t){rt.current===t&&(L(et),L(rt)),gt.current===t&&(L(gt),gi._currentValue=$)}var wn,Ba;function Ee(t){if(wn===void 0)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);wn=e&&e[1]||"",Ba=-1<n.stack.indexOf(`
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
`+wn+t+Ba}var Ua=!1;function Rn(t,e){if(!t||Ua)return"";Ua=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var I=function(){throw Error()};if(Object.defineProperty(I.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(I,[])}catch(G){var Y=G}Reflect.construct(t,[],I)}else{try{I.call()}catch(G){Y=G}t.call(I.prototype)}}else{try{throw Error()}catch(G){Y=G}(I=t())&&typeof I.catch=="function"&&I.catch(function(){})}}catch(G){if(G&&Y&&typeof G.stack=="string")return[G.stack,Y.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var i=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");i&&i.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var s=a.DetermineComponentFrameRoot(),d=s[0],g=s[1];if(d&&g){var T=d.split(`
`),z=g.split(`
`);for(i=a=0;a<T.length&&!T[a].includes("DetermineComponentFrameRoot");)a++;for(;i<z.length&&!z[i].includes("DetermineComponentFrameRoot");)i++;if(a===T.length||i===z.length)for(a=T.length-1,i=z.length-1;1<=a&&0<=i&&T[a]!==z[i];)i--;for(;1<=a&&0<=i;a--,i--)if(T[a]!==z[i]){if(a!==1||i!==1)do if(a--,i--,0>i||T[a]!==z[i]){var V=`
`+T[a].replace(" at new "," at ");return t.displayName&&V.includes("<anonymous>")&&(V=V.replace("<anonymous>",t.displayName)),V}while(1<=a&&0<=i);break}}}finally{Ua=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?Ee(n):""}function Qa(t,e){switch(t.tag){case 26:case 27:case 5:return Ee(t.type);case 16:return Ee("Lazy");case 13:return t.child!==e&&e!==null?Ee("Suspense Fallback"):Ee("Suspense");case 19:return Ee("SuspenseList");case 0:case 15:return Rn(t.type,!1);case 11:return Rn(t.type.render,!1);case 1:return Rn(t.type,!0);case 31:return Ee("Activity");default:return""}}function Bi(t){try{var e="",n=null;do e+=Qa(t,n),n=t,t=t.return;while(t);return e}catch(a){return`
Error generating stack: `+a.message+`
`+a.stack}}var mc=Object.prototype.hasOwnProperty,gc=l.unstable_scheduleCallback,Ac=l.unstable_cancelCallback,om=l.unstable_shouldYield,dm=l.unstable_requestPaint,pe=l.unstable_now,hm=l.unstable_getCurrentPriorityLevel,Dr=l.unstable_ImmediatePriority,Mr=l.unstable_UserBlockingPriority,Ui=l.unstable_NormalPriority,mm=l.unstable_LowPriority,jr=l.unstable_IdlePriority,gm=l.log,Am=l.unstable_setDisableYieldValue,wl=null,be=null;function Dn(t){if(typeof gm=="function"&&Am(t),be&&typeof be.setStrictMode=="function")try{be.setStrictMode(wl,t)}catch{}}var xe=Math.clz32?Math.clz32:Em,vm=Math.log,ym=Math.LN2;function Em(t){return t>>>=0,t===0?32:31-(vm(t)/ym|0)|0}var Qi=256,zi=262144,Yi=4194304;function oa(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Li(t,e,n){var a=t.pendingLanes;if(a===0)return 0;var i=0,s=t.suspendedLanes,d=t.pingedLanes;t=t.warmLanes;var g=a&134217727;return g!==0?(a=g&~s,a!==0?i=oa(a):(d&=g,d!==0?i=oa(d):n||(n=g&~t,n!==0&&(i=oa(n))))):(g=a&~s,g!==0?i=oa(g):d!==0?i=oa(d):n||(n=a&~t,n!==0&&(i=oa(n)))),i===0?0:e!==0&&e!==i&&(e&s)===0&&(s=i&-i,n=e&-e,s>=n||s===32&&(n&4194048)!==0)?e:i}function Rl(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function pm(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hr(){var t=Yi;return Yi<<=1,(Yi&62914560)===0&&(Yi=4194304),t}function vc(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function Dl(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function bm(t,e,n,a,i,s){var d=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var g=t.entanglements,T=t.expirationTimes,z=t.hiddenUpdates;for(n=d&~n;0<n;){var V=31-xe(n),I=1<<V;g[V]=0,T[V]=-1;var Y=z[V];if(Y!==null)for(z[V]=null,V=0;V<Y.length;V++){var G=Y[V];G!==null&&(G.lane&=-536870913)}n&=~I}a!==0&&Nr(t,a,0),s!==0&&i===0&&t.tag!==0&&(t.suspendedLanes|=s&~(d&~e))}function Nr(t,e,n){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-xe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|n&261930}function Br(t,e){var n=t.entangledLanes|=e;for(t=t.entanglements;n;){var a=31-xe(n),i=1<<a;i&e|t[a]&e&&(t[a]|=e),n&=~i}}function Ur(t,e){var n=e&-e;return n=(n&42)!==0?1:yc(n),(n&(t.suspendedLanes|e))!==0?0:n}function yc(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Ec(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function Qr(){var t=_.p;return t!==0?t:(t=window.event,t===void 0?32:x1(t.type))}function zr(t,e){var n=_.p;try{return _.p=t,e()}finally{_.p=n}}var Mn=Math.random().toString(36).slice(2),te="__reactFiber$"+Mn,oe="__reactProps$"+Mn,za="__reactContainer$"+Mn,pc="__reactEvents$"+Mn,xm="__reactListeners$"+Mn,Sm="__reactHandles$"+Mn,Yr="__reactResources$"+Mn,Ml="__reactMarker$"+Mn;function bc(t){delete t[te],delete t[oe],delete t[pc],delete t[xm],delete t[Sm]}function Ya(t){var e=t[te];if(e)return e;for(var n=t.parentNode;n;){if(e=n[za]||n[te]){if(n=e.alternate,e.child!==null||n!==null&&n.child!==null)for(t=u1(t);t!==null;){if(n=t[te])return n;t=u1(t)}return e}t=n,n=t.parentNode}return null}function La(t){if(t=t[te]||t[za]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function jl(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function Ga(t){var e=t[Yr];return e||(e=t[Yr]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function Wt(t){t[Ml]=!0}var Lr=new Set,Gr={};function da(t,e){Xa(t,e),Xa(t+"Capture",e)}function Xa(t,e){for(Gr[t]=e,t=0;t<e.length;t++)Lr.add(e[t])}var Tm=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21Line truncated
`).replace(eA,"")}function Wd(t,e){return e=Fd(e),Fd(t)===e}function Dt(t,e,n,a,i,s){switch(n){case"children":typeof a=="string"?e==="body"||e==="textarea"&&a===""||Za(t,a):(typeof a=="number"||typeof a=="bigint")&&e!=="body"&&Za(t,""+a);break;case"className":Xi(t,"class",a);break;case"tabIndex":Xi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Xi(t,n,a);break;case"style":Fr(t,a,s);break;case"data":if(e!=="object"){Xi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||n!=="href")){t.removeAttribute(n);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof s=="function"&&(n==="formAction"?(e!=="input"&&Dt(t,e,"name",i.name,i,null),Dt(t,e,"formEncType",i.formEncType,i,null),Dt(t,e,"formMethod",i.formMethod,i,null),Dt(t,e,"formTarget",i.formTarget,i,null)):(Dt(t,e,"encType",i.encType,i,null),Dt(t,e,"method",i.method,i,null),Dt(t,e,"target",i.target,i,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(n);break}a=Zi(""+a),t.setAttribute(n,a);break;case"onClick":a!=null&&(t.onclick=tn);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}n=Zi(""+a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""+a):t.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,""):t.removeAttribute(n);break;case"capture":case"download":a===!0?t.setAttribute(n,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(n,a):t.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(n,a):t.removeAttribute(n);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(n):t.setAttribute(n,a);break;case"popover":yt("beforetoggle",t),yt("toggle",t),Gi(t,"popover",a);break;case"xlinkActuate":$e(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":$e(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":$e(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":$e(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":$e(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":$e(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":$e(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":$e(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":$e(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Gi(t,"is",a);break;case"innerText":case"textContent":break;default:(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")&&(n=Dm.get(n)||n,Gi(t,n,a))}}function df(t,e,n,a,i,s){switch(n){case"style":Fr(t,a,s);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(n=a.__html,n!=null){if(i.children!=null)throw Error(f(60));t.innerHTML=n}}break;case"children":typeof a=="string"?Za(t,a):(typeof a=="number"||typeof a=="bigint")&&Za(t,""+a);break;case"onScroll":a!=null&&yt("scroll",t);break;case"onScrollEnd":a!=null&&yt("scrollend",t);break;case"onClick":a!=null&&(t.onclick=tn);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inneLine truncated
linear-gradient(-45deg, #80808020 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #80808020 75%),
linear-gradient(-45deg, transparent 75%, #80808020 75%)`,backgroundSize:"20px 20px",backgroundPosition:"0 0, 0 10px, 10px -10px, -10px 0px",boxShadow:`rgb(0 0 0 / 10%) 0px 1.8px 1.9px,
rgb(0 0 0 / 15%) 0px 6.1px 6.3px,
rgb(0 0 0 / 10%) 0px -2px 4px,
rgb(0 0 0 / 15%) 0px -6.1px 12px,
rgb(0 0 0 / 25%) 0px 6px 12px`},um=({diff:l,noTargetBlank:u,hideDetails:c})=>{const[f,r]=ct.useState(l.diff?"diff":"actual"),[o,h]=ct.useState(!1),[v,y]=ct.useState(null),[A,E]=ct.useState("Expected"),[S,O]=ct.useState(null),[X,B]=ct.useState(null),[b,p]=Wh();ct.useEffect(()=>{(async()=>{var K,J,k,nt;y(await kf((K=l.expected)==null?void 0:K.attachment.path)),E(((J=l.expected)==null?void 0:J.title)||"Expected"),O(await kf((k=l.actual)==null?void 0:k.attachment.path)),B(await kf((nt=l.diff)==null?void 0:nt.attachment.path))})()},[l]);const x=v&&S&&X,R=x?Math.max(v.naturalWidth,S.naturalWidth,200):500,U=x?Math.max(v.naturalHeight,S.naturalHeight,200):500,Z=Math.min(1,(b.width-30)/R),F=Math.min(1,(b.width-50)/R/2),j=R*Z,D=U*Z,N={flex:"none",margin:"0 10px",cursor:"pointer",userSelect:"none"};return m.jsx("div",{"data-testid":"test-result-image-mismatch",style:{display:"flex",flexDirection:"column",alignItems:"center",flex:"auto"},ref:p,children:x&&m.jsxs(m.Fragment,{children:[m.jsxs("div",{"data-testid":"test-result-image-mismatch-tabs",style:{display:"flex",margin:"10px 0 20px"},children:[l.diff&&m.jsx("div",{style:{...N,fontWeight:f==="diff"?600:"initial"},onClick:()=>r("diff"),children:"Diff"}),m.jsx("div",{style:{...N,fontWeight:f==="actual"?600:"initial"},onClick:()=>r("actual"),children:"Actual"}),m.jsx("div",{style:{...N,fontWeight:f==="expected"?600:"initial"},onClick:()=>r("expected"),children:A}),m.jsx("div",{style:{...N,fontWeight:f==="sxs"?600:"initial"},onClick:()=>r("sxs"),children:"Side by side"}),m.jsx("div",{style:{...N,fontWeight:f==="slider"?600:"initial"},onClick:()=>r("slider"),children:"Slider"})]}),m.jsxs("div",{style:{display:"flex",justifyContent:"center",flex:"auto",minHeight:D+60},children:[l.diff&&f==="diff"&&m.jsx(En,{image:X,alt:"Diff",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="actual"&&m.jsx(En,{image:S,alt:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="expected"&&m.jsx(En,{image:v,alt:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),l.diff&&f==="slider"&&m.jsx(Dv,{expectedImage:v,actualImage:S,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z,expectedTitle:A}),l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:o?X:S,title:o?"Diff":"Actual",onClick:()=>h(!o),hideSize:c,canvasWidth:F*R,canvasHeight:F*U,scale:F})]}),!l.diff&&f==="actual"&&m.jsx(En,{image:S,title:"Actual",hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="expected"&&m.jsx(En,{image:v,title:A,hideSize:c,canvasWidth:j,canvasHeight:D,scale:Z}),!l.diff&&f==="sxs"&&m.jsxs("div",{style:{display:"flex"},children:[m.jsx(En,{image:v,title:A,canvasWidth:F*R,canvasHeight:F*U,scale:F}),m.jsx(En,{image:S,title:"Actual",canvasWidth:F*R,canvasHeight:F*U,scale:F})]})]}),!c&&m.jsxs("div",{style:{alignSelf:"start",lineHeight:"18px",marginLeft:"15px"},children:[m.jsx("div",{children:l.diff&&m.jsx("a",{target:"_blank",href:l.diff.attachment.path,rel:"noreferrer",children:l.diff.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.actual.attachment.path,rel:"noreferrer",children:l.actual.attachment.name})}),m.jsx("div",{children:m.jsx("a",{target:u?"":"_blank",href:l.expected.attachment.path,rel:"noreferrer",children:l.expected.attachment.name})})]})]})})},Dv=({expectedImage:l,actualImage:u,canvasWidth:c,canvasHeight:f,scale:r,expectedTitle:o,hideSize:h})=>{const v={position:"absolute",top:0,left:0},[y,A]=ct.useState(c/2),E=l.naturalWidth===u.naturalWidth&&l.naturalHeight===u.naturalHeight;return m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column",userSelect:"none"},children:[!h&&m.jsxs("div",{style:{margin:5},children:[!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"Expected "}),m.jsx("span",{children:l.naturalWidth}),m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),m.jsx("span",{children:l.naturalHeight}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px 0 15px"},children:"Actual "}),!E&&m.jsx("span",{children:u.naturalWidth}),!E&&m.jsx("span",{style:{flex:"none",margin:"0 5px"},children:"x"}),!E&&m.jsx("span",{children:u.naturalHeight})]}),m.jsxs("div",{style:{position:"relative",width:c,height:f,margin:15,...fr},children:[m.jsx(Rv,{orientation:"horizontal",offsets:[y],setOffsets:S=>A(S[0]),resizerColor:"#57606a80",resizerWidth:6}),m.jsx("img",{alt:o,style:{width:l.naturalWidth*r,height:l.naturalHeight*r},draggable:"false",src:l.src}),m.jsx("div",{style:{...v,bottom:0,overflow:"hidden",width:y,...fr},children:m.jsx("img",{alt:"Actual",style:{width:u.naturalWidth*r,height:u.naturalHeight*r},draggable:"false",src:u.src})})]})]})},En=({image:l,title:u,alt:c,hideSize:f,canvasWidth:r,canvasHeight:o,scale:h,onClick:v})=>m.jsxs("div",{style:{flex:"none",display:"flex",alignItems:"center",flexDirection:"column"},children:[!f&&m.jsxs("div",{style:{margin:5},childrenLine truncated
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
`.trimStart();async function zv({testInfo:l,metadata:u,errorContext:c,errors:f,buildCodeFrame:r,stdout:o,stderr:h}){var S;const v=new Set(f.filter(O=>O.message&&!O.message.includes(`
`)).map(O=>O.message));for(const O of f)for(const X of v.keys())(S=O.message)!=null&&S.includes(X)&&v.delete(X);const y=f.filter(O=>!(!O.message||!O.message.includes(`
`)&&!v.has(O.message)));if(!y.length)return;const A=[Qv,"# Test info","",l];o&&A.push("","# Stdout","","```",Jf(o),"```"),h&&A.push("","# Stderr","","```",Jf(h),"```"),A.push("","# Error details");for(const O of y)A.push("","```",Jf(O.message||""),"```");c&&A.push(c);const E=await r(y[y.length-1]);return E&&A.push("","# Test source","","```ts",E,"```"),u!=null&&u.gitDiff&&A.push("","# Local changes","","```diff",u.gitDiff,"```"),A.join(`
`)}const Yv=new RegExp("([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))","g");function Jf(l){return l.replace(Yv,"")}function Lv(l,u){var f;const c=new Map;for(const r of l){const o=r.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);if(!o)continue;const[,h,v,y=""]=o,A=h+y;let E=c.get(A);E||(E={name:A,anchors:[`attachment-${h}`]},c.set(A,E)),E.anchors.push(`attachment-${u.attachments.indexOf(r)}`),v==="actual"&&(E.actual={attachment:r}),v==="expected"&&(E.expected={attachment:r,title:"Expected"}),v==="previous"&&(E.expected={attachment:r,title:"Previous"}),v==="diff"&&(E.diff={attachment:r})}for(const[r,o]of c)!o.actual||!o.expected?c.delete(r):(l.delete(o.actual.attachment),l.delete(o.expected.attachment),l.delete((f=o.diff)==null?void 0:f.attachment));return[...c.values()]}const Gv=({test:l,result:u,testRunMetadata:c,options:f})=>{const{screenshots:r,videos:o,traces:h,otherAttachments:v,diffs:y,errors:A,otherAttachmentAnchors:E,screenshotAnchors:S,errorContext:O}=ct.useMemo(()=>{const B=u.attachments.filter(N=>!N.name.startsWith("_")),b=new Set(B.filter(N=>N.contentType.startsWith("image/"))),p=[...b].map(N=>`attachment-${B.indexOf(N)}`),x=B.filter(N=>N.contentType.startsWith("video/")),R=B.filter(N=>N.name==="trace"),U=B.find(N=>N.name==="error-context"),Z=new Set(B);[...b,...x,...R].forEach(N=>Z.delete(N));const F=[...Z].map(N=>`attachment-${B.indexOf(N)}`),j=Lv(b,u),D=u.errors.map(N=>N.message);return{screenshots:[...b],videos:x,traces:R,otherAttachments:Z,diffs:j,errors:D,otherAttachmentAnchors:F,screenshotAnchors:p,errorContext:U}},[u]),X=P5(async()=>{if(f!=null&&f.noCopyPrompt)return;const B=u.attachments.find(R=>R.name==="stdout"),b=u.attachments.find(R=>R.name==="stderr"),p=B!=null&&B.body&&B.contentType==="text/plain"?B.body:void 0,x=b!=null&&b.body&&b.contentType==="text/plain"?b.body:void 0;return await zv({testInfo:[`- Name: ${l.path.join(" >> ")} >> ${l.title}`,`- Location: ${l.location.file}:${l.location.line}:${l.location.column}`].join(`
`),metadata:c,errorContext:O!=null&&O.path?await fetch(O.path).then(R=>R.text()):O==null?void 0:O.body,errors:u.errors,buildCodeFrame:async R=>R.codeframe,stdout:p,stderr:x})},[l,O,c,u],void 0);return m.jsxs("div",{className:"test-result",children:[!!A.length&&m.jsxs(ke,{header:"Errors",children:[X&&m.jsx("div",{style:{position:"absolute",right:"16px",padding:"10px",zIndex:1},children:m.jsx(Nv,{prompt:X})}),A.map((B,b)=>{const p=Xv(B,y);return m.jsxs(m.Fragment,{children:[m.jsx(wr,{code:B},"test-result-error-message-"+b),p&&m.jsx(Bv,{diff:p})]})})]}),!!u.steps.length&&m.jsx(ke,{header:"Test Steps",children:u.steps.map((B,b)=>m.jsx(cm,{step:B,result:u,test:l,depth:0},`step-${b}`))}),y.map((B,b)=>m.jsx(Si,{id:B.anchors,children:m.jsx(ke,{dataTestId:"test-results-image-diff",header:`Image mismatch: ${B.name}`,revealOnAnchorId:B.anchors,children:m.jsx(um,{diff:B})})},`diff-${b}`)),!!r.length&&m.jsx(ke,{header:"Screenshots",revealOnAnchorId:S,children:r.map((B,b)=>m.jsxs(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:[m.jsx("a",{href:Ve(B.path),children:m.jsx("img",{className:"screenshot",src:Ve(B.path)})}),m.jsx(nc,{attachment:B,result:u})]},`screenshot-${b}`))}),!!h.length&&m.jsx(Si,{id:"attachment-trace",children:m.jsx(ke,{header:"Traces",revealOnAnchorId:"attachment-trace",children:m.jsxs("div",{children:[m.jsx("a",{href:Ve(nm(h)),children:m.jsx("img",{className:"screenshot",src:Cv,style:{width:192,height:117,marginLeft:20}})}),h.map((B,b)=>m.jsx(nc,{attachment:B,result:u,linkName:h.length===1?"trace":`trace-${b+1}`},`trace-${b}`))]})})}),!!o.length&&m.jsx(Si,{id:"attachment-video",children:m.jsx(ke,{header:"Videos",revealOnAnchorId:"attachment-video",children:o.map(B=>m.jsxs("div",{children:[m.jsx("video",{controls:!0,children:m.jsx("source",{src:Ve(B.path),type:B.contentType})}),m.jsx(nc,{attachment:B,result:u})]},B.path))})}),!!v.size&&m.jsx(ke,{header:"Attachments",revealOnAnchorId:E,dataTestId:"attachments",children:[...v].map((B,b)=>m.jsx(Si,{id:`attachment-${u.attachments.indexOf(B)}`,children:m.jsx(nc,{attachment:B,result:u,openInNewTab:B.contentType.startsWith("text/html")})},`attachment-link-${b}`))})]})};function Xv(l,u){const c=l.split(`
`)[0];if(!(!c.includes("toHaveScreenshot")&&!c.includes("toMatchSnapshot")))return u.find(f=>l.includes(f.name))}const cm=({test:l,step:u,result:c,depth:f})=>{const r=se();return m.jsx(Tv,{title:m.jsxs("div",{"aria-label":u.title,className:"step-title-container",children:[hc(u.error||u.duration===-1?"failed":u.skipped?"skipped":"passed"),m.jsxs("span",{className:"step-title-text",children:[m.jsx("span",{children:u.title}),u.count>1&&m.jsxs(m.Fragment,{children:[" ✕ ",m.jsx("span",{className:"test-result-counter",children:u.count})]}),u.location&&m.jsxs("span",{className:"test-result-path",children:["— ",u.location.file,":",u.location.line]})]}),m.jsx("span",{className:"step-spacer"}),u.attachments.length>0&&m.jsx("a",{className:"step-attachment-link",title:"reveal attachment",href:Ve(Cn({test:l,result:c,anchor:`attachment-${u.attachments[0]}`},r)),onClick:o=>{o.stopPropagation()},children:Ih()}),m.jsx("span",{className:"step-duration",children:Ol(u.duration)})]}),loadChildren:u.steps.length||u.snippet?()=>{const o=u.snippet?[m.jsx(wr,{testId:"test-snippet",code:u.snippet},"line")]:[],h=u.steps.map((v,y)=>m.jsx(cm,{step:v,depth:f+1,result:c,test:l},y));return o.concat(h)}:void 0,depth:f})},Vv=({projectNames:l,test:u,testRunMetadata:c,run:f,next:r,prev:o,options:h})=>{const[v,y]=ct.useState(f),A=se(),E=u.annotations.filter(S=>!S.type.startsWith("_"))??[];return m.jsxs(m.Fragment,{children:[m.jsx(Or,{title:u.title,leftSuperHeader:m.jsx("div",{className:"test-case-path",children:u.path.join(" › ")}),rightSuperHeader:m.jsxs(m.Fragment,{children:[m.jsx("div",{className:Ze(!o&&"hidden"),children:m.jsx(Tn,{href:Cn({test:o},A),children:"« previous"})}),m.jsx("div",{style:{width:10}}),m.jsx("div",{className:Ze(!r&&"hidden"),children:m.jsx(Tn,{href:Cn({test:r},A),children:"next »"})})]})}),m.jsxs("div",{className:"hbox",style:{lineHeight:"24px"},children:[m.jsx("div",{className:"test-case-location",children:m.jsxs(Sr,{value:`${u.location.file}:${u.location.line}`,children:[u.location.file,":",u.location.line]})}),m.jsx("div",{style:{flex:"auto"}}),m.jsx(tm,{test:u,trailingSeparator:!0}),m.jsx("div",{className:"test-case-duration",children:Ol(u.duration)})]}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:l,activeProjectName:u.projectName,otherLabels:u.tags}),u.results.length===0&&E.length!==0&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:E.map((S,O)=>m.jsx(z2,{annotation:S},O))}),m.jsx(Sv,{tabs:u.results.map((S,O)=>({id:String(O),title:m.jsxs("div",{style:{display:"flex",alignItems:"center"},children:[hc(S.status)," ",Zv(O),u.results.length>1&&m.jsx("span",{className:"test-case-run-duration",children:Ol(S.duration)})]}),render:()=>{const X=S.annotations.filter(B=>!B.type.startsWith("_"));return m.jsxs(m.Fragment,{children:[!!X.length&&m.jsx(ke,{header:"Annotations",dataTestId:"test-case-annotations",children:X.map((B,b)=>m.jsx(z2,{annotation:B},b))}),m.jsx(Gv,{test:u,result:S,testRunMetadata:c,options:h})]})}}))||[],selectedTab:String(v),setSelectedTab:S=>y(+S)})]})};function z2({annotation:{type:l,description:u}}){return m.jsxs("div",{className:"test-case-annotation",children:[m.jsx("span",{style:{fontWeight:"bold"},children:l}),u&&m.jsxs(Sr,{value:u,children:[": ",Di(u)]})]})}function Zv(l){return l?`Retry #${l}`:"Run"}const sm=({file:l,projectNames:u,isFileExpanded:c,setFileExpanded:f,footer:r})=>{const o=se();return m.jsx(im,{expanded:c?c(l.fileId):void 0,noInsets:!0,setExpanded:f?(h=>f(l.fileId,h)):void 0,header:m.jsx("span",{className:"chip-header-allow-selection",children:l.fileName}),footer:r,children:l.tests.map(h=>m.jsxs("div",{className:Ze("test-file-test","test-file-test-outcome-"+h.outcome),children:[m.jsxs("div",{className:"hbox",style:{alignItems:"flex-start"},children:[m.jsxs("div",{className:"hbox",children:[m.jsx("span",{className:"test-file-test-status-icon",children:hc(h.outcome)}),m.jsxs("span",{children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),children:m.jsx("span",{className:"test-file-title",children:[...h.path,h.title].join(" › ")})}),m.jsx($h,{style:{marginLeft:"6px"},projectNames:u,activeProjectName:h.projectName,otherLabels:h.tags})]})]}),m.jsx("span",{"data-testid":"test-duration",style:{minWidth:"50px",textAlign:"right"},children:Ol(h.duration)})]}),m.jsx("div",{className:"test-file-details-row",children:m.jsxs("div",{className:"test-file-details-row-items",children:[m.jsx(Tn,{href:Cn({test:h},o),title:[...h.path,h.title].join(" › "),className:"test-file-path-link",children:m.jsxs("span",{className:"test-file-path",children:[h.location.file,":",h.location.line]})}),m.jsx(qv,{test:h}),m.jsx(Iv,{test:h}),m.jsx(tm,{test:h,dim:!0})]})})]},`test-${h.testId}`))})};function qv({test:l}){const u=se();for(const c of l.results)for(const f of c.attachments)if(f.contentType.startsWith("image/")&&f.name.match(/-(expected|actual|diff)/))return m.jsx(Tr,{href:Cn({test:l,result:c,anchor:`attachment-${c.attachments.indexOf(f)}`},u),Line truncated
</script>
<style type='text/css'>:root{--color-canvas-default-transparent: rgba(255,255,255,0);--color-marketing-icon-primary: #218bff;--color-marketing-icon-secondary: #54aeff;--color-diff-blob-addition-num-text: #24292f;--color-diff-blob-addition-fg: #24292f;--color-diff-blob-addition-num-bg: #CCFFD8;--color-diff-blob-addition-line-bg: #E6FFEC;--color-diff-blob-addition-word-bg: #ABF2BC;--color-diff-blob-deletion-num-text: #24292f;--color-diff-blob-deletion-fg: #24292f;--color-diff-blob-deletion-num-bg: #FFD7D5;--color-diff-blob-deletion-line-bg: #FFEBE9;--color-diff-blob-deletion-word-bg: rgba(255,129,130,.4);--color-diff-blob-hunk-num-bg: rgba(84,174,255,.4);--color-diff-blob-expander-icon: #57606a;--color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;--color-diffstat-deletion-border: rgba(27,31,36,.15);--color-diffstat-addition-border: rgba(27,31,36,.15);--color-diffstat-addition-bg: #2da44e;--color-search-keyword-hl: #fff8c5;--color-prettylights-syntax-comment: #6e7781;--color-prettylights-syntax-constant: #0550ae;--color-prettylights-syntax-entity: #8250df;--color-prettylights-syntax-storage-modifier-import: #24292f;--color-prettylights-syntax-entity-tag: #116329;--color-prettylights-syntax-keyword: #cf222e;--color-prettylights-syntax-string: #0a3069;--color-prettylights-syntax-variable: #953800;--color-prettylights-syntax-brackethighlighter-unmatched: #82071e;--color-prettylights-syntax-invalid-illegal-text: #f6f8fa;--color-prettylights-syntax-invalid-illegal-bg: #82071e;--color-prettylights-syntax-carriage-return-text: #f6f8fa;--color-prettylights-syntax-carriage-return-bg: #cf222e;--color-prettylights-syntax-string-regexp: #116329;--color-prettylights-syntax-markup-list: #3b2300;--color-prettylights-syntax-markup-heading: #0550ae;--color-prettylights-syntax-markup-italic: #24292f;--color-prettylights-syntax-markup-bold: #24292f;--color-prettylights-syntax-markup-deleted-text: #82071e;--color-prettylights-syntax-markup-deleted-bg: #FFEBE9;--color-prettylights-syntax-markup-inserted-text: #116329;--color-prettylights-syntax-markup-inserted-bg: #dafbe1;--color-prettylights-syntax-markup-changed-text: #953800;--color-prettylights-syntax-markup-changed-bg: #ffd8b5;--color-prettylights-syntax-markup-ignored-text: #eaeef2;--color-prettylights-syntax-markup-ignored-bg: #0550ae;--color-prettylights-syntax-meta-diff-range: #8250df;--color-prettylights-syntax-brackethighlighter-angle: #57606a;--color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;--color-prettylights-syntax-constant-other-reference-link: #0a3069;--color-codemirror-text: #24292f;--color-codemirror-bg: #ffffff;--color-codemirror-gutters-bg: #ffffff;--color-codemirror-guttermarker-text: #ffffff;--color-codemirror-guttermarker-subtle-text: #6e7781;--color-codemirror-linenumber-text: #57606a;--color-codemirror-cursor: #24292f;--color-codemirror-selection-bg: rgba(84,174,255,.4);--color-codemirror-activeline-bg: rgba(234,238,242,.5);--color-codemirror-matchingbracket-text: #24292f;--color-codemirror-lines-bg: #ffffff;--color-codemirror-syntax-comment: #24292f;--color-codemirror-syntax-constant: #0550ae;--color-codemirror-syntax-entity: #8250df;--color-codemirror-syntax-keyword: #cf222e;--color-codemirror-syntax-storage: #cf222e;--color-codemirror-syntax-string: #0a3069;--color-codemirror-syntax-support: #0550ae;--color-codemirror-syntax-variable: #953800;--color-checks-bg: #24292f;--color-checks-run-border-width: 0px;--color-checks-container-border-width: 0px;--color-checks-text-primary: #f6f8fa;--color-checks-text-secondary: #8c959f;--color-checks-text-link: #54aeff;--color-checks-btn-icon: #afb8c1;--color-checks-btn-hover-icon: #f6f8fa;--color-checks-btn-hover-bg: rgba(255,255,255,.125);--color-checks-input-text: #eaeef2;--color-checks-input-placeholder-text: #8c959f;--color-checks-input-focus-text: #8c959f;--color-checks-input-bg: #32383f;--color-checks-input-shadow: none;--color-checks-donut-error: #fa4549;--color-checks-donut-pending: #bf8700;--color-checks-donut-success: #2da44e;--color-checks-donut-neutral: #afb8c1;--color-checks-dropdown-text: #afb8c1;--color-checks-dropdown-bg: #32383f;--color-checks-dropdown-border: #424a53;--color-checks-dropdown-shadow: rgba(27,31,36,.3);--color-checks-dropdown-hover-text: #f6f8fa;--color-checks-dropdown-hover-bg: #424a53;--color-checks-dropdown-btn-hover-text: #f6f8fa;--color-checks-dropdown-btn-hover-bg: #32383f;--color-checks-scrollbar-thumb-bg: #57606a;--color-checks-header-label-text: #d0d7de;--color-checks-header-label-open-text: #f6f8fa;--color-checks-header-border: #32383f;--color-checks-header-icon: #8c959f;--color-checks-line-text: #d0d7de;--color-checks-line-num-text: rgba(140,149,159,.75);--color-checks-line-timestamp-text: #8c959f;--color-checks-line-hover-bg: #32383f;--color-checks-line-selected-bg: rgba(33,139,255,.15);--color-checks-line-selected-num-text: #54aeff;--color-checks-line-dt-fm-text: #24292f;--color-checks-line-dt-fm-bg: #9a6700;--color-checks-gate-bg: rgba(1Line truncated
</style>
</head>
<body>
<div id='root'></div>
</body>
</html>
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIAFeOXFyF7ksyOyAAAF5iAgAZAAAAMjRmZGUwMzNlZDRlZmUzZDhmODkuanNvbu2d7W7jOJaGb0XIn0oDFZVISpRUQC/Q3ZgvYHaAmWnsADvdA8i2nHjLsdO20t2F3gL2IvYK90qWpOREOpItUT6KFOfkx0x17NAyX5E65HnOy9+ulqt1+qfF1ccr7i8XqSdEuvDTZSoW0TKKr96b1/+S3KfqHfPt+vF+czPfbparW3f/kM7dbK/ekqV79f8f//mb+dfRxm5iP5rPWBrO4rmczWY88oNQ//kqWz837+TNO7PHLNtunNXeedil+3STOauNk92lTrbdrmfJTv3hw277X+k8O1zd3W57v3q8Vy+st/MkW203Vx9/M9d/4trXq4162Y/eF++4+hh+eX+1eNwVDfA4Vq8lm802M7/R3/NHdc3JbfGv7WM235oLSH9VrWbpQl9Zkt2pl6++M21+Zz70Sr1ZfZPHddFX8DP2WbLLvl+ZprjH5Y3Hb3j0PRcf/fhjIFzP8//zSjeR7T5fffT0H6QPRbcXPfhtutzuUueP2+0n/d3aW5S6xecLCYQQrKnhmWn4d8n8zrlTjfdtu/Gif7/6NXtUV/3D1Wy3/WWf7n646tI+46B9TwSiqf0/J48bdd1F452a9utNl7pF6ZhkmeqLe3VXFr+Ybx832dVH/a5Pq4cHdRN8XCbrffrF6s3vm3pF3bJZ+mvW0iuB99EXrpCg1+OmHvlulyZZ6hQNd2o2rDYbjdYbD8lt2q0rwgCo2HhnF32hm+3UKLyrvRfuib8kP69u9RVnW9UbH5KHhw8P6W6/3XTqFD8Q1etnQsanv4H9XOo9z6VMfjn+tdR/b/R/q9euHMcRsfPfjv5JfklWmbNPs8eHbx5W/76df9pfa32++mGjXvW9ytv0C+7tNttevyt1xrv8veonf2/p5195K6zSiv6f3293f9itFn9Lk8Xn/POuyh3/D/0+NQuqC1uraV79w/TMdnf97p+77Tr9+gfd+9nj/oerH/+Z7FbJzTqZpWv16z9vk8Vqc6t+/+6rVo18N+LgHuMcWaHSw85Cn9D02HOvm36Mim50ix78e9E5Np3y3vkNqJVrFD+Jp9tIPzrv7laLRbp5974izO/MY1cNhmz7bfofq/1qtlYzREmdRZIlNz/99NPNaqE++D5ZmY7JVAd2liQADxvmtzwPrDVhoiQK764K4wcF1AfO766vv3K+/jdn87he50OAiZJseYRibm63c/985ZY69vo3J1P9pIKej47w1I/z5fhQs/vJRWc+uM06D8Km28uxFDqO/aPPTxyZg15jr9YreV8Fx0dfW2+oQVcbVs/SsqBR2kIiefSW6yxW9Z67VfPuzdpmmgwO4fCzVFIga6Xn3R5acdakFefHtTrdGWcoxUU3pWym0H8dLvEQdvz43jn9DZpfT+8fss+dxZbRsOOSy2etAwutDyPw+JSK31+5tPkgPDo588bJubgt9KP8S/UO+DlZP6p7rIsWIVgMYIcnvugXQPqHIfbhg/PdOk3y+Wb9dyWJUkcHzOlmr1cRc/Wi3mcobzeYEZaHhqIeYKZF9xQjqNyuq1vbXX/VGnH69WZ3qVb9ujoY/2Z+2UmIGEQlnh9ia+H31OKcTjzVVW29HEw0rpcu41CuUFJgP2ZgL13hgbksoLD+4sJ6JTMD2x7YoSKF9ThhvZYqhA81CusvM6xXYgsPrOEorD8/rEfpOVGKJSKLTVx9DToL6C7S/Xy3mqXX78pJMHWP53drHhmIPMwwf/CcY7pO9p83c0d9b93/6ts+/0H5eZR/6/Zt4+Ye6bLZbhmB5CnLm0rHdh8KIDpEj+TjUorTIhLxnyW6fmeXolViH1fSr+rWJYY52r/V4YIVuTz/5HdakGcevlQn2W+WWbrrkHItZPbB5n5bTrRjvku3DLZI+mamzkxCNlwJC5u+43fr7b57ElI1GxzPbQ6XerN9c7rbbXfF+/IVkvo6D8l+b7iAGkcA2tYtbD9dfcx2j7kQJ3kKNpulQeIHYTqTC89Ll0JEZZ5ivZp/yuPDhlG7fUg3ezNaH5JNukbFKYJSiFXFKWK95h2WpojzZXUbnhCgwxQgcBVBGDY23AemiGpty8bUfl+YAuylqyEcY8EUcMJTTb8CmIKHbgx7PWicxqxgCtMseNL7E4cp9DXDBbR/LkxhGgV3nf+KYAp9/RIIyf0WLoZYipfcc+WRyxncc8Xe4KMtV5u9OC2JCCCChL3UoU3X0TddldAiFMNu7tCmK8qmq5YqAvtwIW26QqUuY9NVie0HbNj8/ZvcdO3BUhgtQAiMjRUSS9GBpTBCgGUqk9hTIKEUaGG9hElCgc2DUVhvGe3JAEpChPQlRvUygpse2HseFNZjhfUhgxuYhEhDpS4mrA9DRiwFsRRvnqUwQyEadoUrStMeZxaK5vOeanWfObNs43x9YlSc6oRcLV4PXFSjR+931ikaKe6EclCkGnVNMhmsrr7TvxtGQbghFbckyewlLC+MhYWE53T6sV5tVqRQwrcMT9vwmNLFFQ/Gc9YKveSNYG029lpBlNba0kbdUwK9gBJY5FJx6wT1vakXUXfgAqGgrK5FKBJUd1I0AVN+npYSo0F12wRDbEQqrcDRgjNxNCMVWM2VFnNnAAMNDY9DozVdSTPFYQOj6VYjbnWPXz6MtpQLzoVkYeynEU8jPxD+1TFzH4OcOXfJ3kmcuen6PFrApdDCYxSaXhEOjaHlnzEBDs2PAjkUh6baDlA5NLAL74dMYnFo8NJV06+AQ/M9l0VgZc8a5bR09dHtiqOT/yRBNH3NMeiLoJkttnD10Y1CL6mWvZMpgWjq+jkMuHz0fAiBaOcU/3puyOEYptrfUWt/lSQ+2GNgHN3thVJWY6eslNAR3BnnRKLVB+YEUlZaqxiWwVHKCip1GSkrJXYcQqNNSlmNQqJpLSJ/2E07ItG6uPpoIWB5kEck2kTjeuaKGCaqiEQbNa5nrg8lQY/2KKofParXMg9tFENRPU5Uz9ygZrOFHulRVD+NqF6JLWGtMEX1BKK9PRDNDAXo20Ag2isC0bSCIUgNobtME4g2Foim5JUCJpwJRJs+iHZjkJHOGsM8j0Dn0crWahazcBBVnnKneDR7s7RjPTawV1ouuDzbKk3pBqdeVhqcZ0AEumUxBTit6UoQnNJ0q3az2uXDaX7EUpmEzJ+H/mLJlzLwK3BaCUFzFqv9/UpdxVDmaPKoORqLZDg4lmY+ox30irGxNHhem6/WiY13ew8sreZOpdqWpycLOywNbsmLuNkAyx5Lg1yIaroN3pkCliakG8sBsDTTbmC1fzs6lqavGSIGfiNIbYGlmUbhkGkxSpoSliZC14OBVxCTP9qE0ldKolpszLC9uCh9ZZPX0JIE0LJOoFfYUwJr7ASWEjqEqBP62SOUv8LIXympotphxujnj1H+ahL5Ky02PEMZe/p9k/mrHlSa1gISgti770SldaDSjBDQSibC3ionKg0prI9cH6bg0UNICuutor3IDTy7gmEK6q1/xg/qtcxg86qtlJCi+pGieqWVBMFFhO6ES1H9NKL6yJW1g5coqicq7e1RaWYoxESlvV4qTSsYQDSCY8PvhKWNhaUJbWA4MEJNWNoRLO30mO1LoRlJa2V9yJrKnprKzq5osmGzxLarTg/pvkrK6l5M35tvs816TQXq75wBp4MI8ifY6xRZXlPGFrcO9g3xYjriYo/FTSjPhB611DWfmJLWZyAqDS2PAz2aK4FM/dnQo2lVWKVTLx96jNVvgsUymvM0nXHflzKOS9Bj7sG3Xu2z/SE/scrS+72z3G3vnSxRA8e5T7NEDzdcALK03qsCkELoHZNhAcj8M05zedyN8yJiNABStwjvT8HDRu88WwDSpu0+AKRuH6IKnhc3doo9AOlBQzfPi8YzousOb6tLr1UgnzoUtSu+rdsFvT0eD9oRqRcug86QjDf6NtoY8+lWIQvFX8RnFcmZT7gcLuSYQCfsiIE8x8LDdyWcgdDtwShZauXtoCSpnRHrU7r04tKlSujargi6LSalS3FMPJRWMcighegnDFG6dBLpUiV2BGtPyJpvJGs+v36eBkGQY1jzKSFqRuzMR9eCKEicwF66Hq/ZolNgP2ZgrySBPDe6JBTXjx/XS5dx6LlBxU31cTmFuF5pJWENqEee21CqCwnspctr0CsF9sRBvj0OUg+F2uY89tY8gZCD2vMpCWHik3noZ/0SCDmWP590RQxPbSMQ8mVAyL8+prvPjumN49FLlenS9MhNV2V9TwzLtIWlcStCi9X5IbzMZ9+8iT8ZMObULHyqO0wfhwK0q3v26+ImKX2Ka16xBSRzHcPDPk5x05mm8lvpDybxvvv+LtlceycGcul9HTLcWseBj3IOyxtiFmxir/7u3IGn1Cm0OPd4XtO5IOXCGx3TrHkN3XI4BRqw6UqOUDRWHoiqWegJ9+ZxwPl8ufBmfLkQS9VfwuOxtyzhgKnmzUocYHE6b7a9vV0PcjxvePx43oj7w/sgcr8LnTb88byB5I1mcRjH86q2W2YMOx/E+jmjfiOf3ON43gaLxb5z0kvizOSDeNoHsZntPtMH8UXmcjwfRGgyLj06nndCqULjgwiDEPJBHNsHUUJJBLYmlCscPVdozPWCYfd4KFWI5oPI4NF62EWslCmcRqbQWL5xyhROAQHUWkSQpyAEcBwfRHhMcoBuGkUEIKIPIiRnyQdxdB9E6MNFBODlRfVKZx/m99BXbxTWYxkhQhAmwNaKwvqJhPWRKwXcoqWwngDAtwcAmqEwsCco8X9DGyHC84skdoBP+N+YPojw0EDsKhLC/xDwvxwR6Ub+GVGhmRD2DkpUyoYKm2X54bGWT7z59/o223Qm/8o9ka/pvUqLVQjtuf1ezF+uLine truncated
+6742 -1282
View File
File diff suppressed because it is too large. Load diff
+176
View File
@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2026 QRun.IO, Inc.
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
-->
<!--
Packages the static export (QQQ_NEXT_OUTPUT=export pnpm build, written to out/) as a
resource-only jar with the files under next-dashboard/. QQQ's QApplicationJavalinServer
serves that folder at "/" when the jar is on the classpath. Build with: pnpm build:jar
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QQQ Frontend Next</name>
<description>Metadata-driven Next.js admin dashboard for QQQ, packaged for the QQQ Javalin server</description>
<url>https://github.com/QRun-IO/qqq-frontend-next</url>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qqq-frontend-next.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qqq-frontend-next.git</developerConnection>
<url>https://github.com/QRun-IO/qqq-frontend-next</url>
</scm>
<developers>
<developer>
<name>QRun.IO Development Team</name>
<organization>QRun.IO, Inc.</organization>
<organizationUrl>https://qrun.io</organizationUrl>
</developer>
</developers>
<properties>
<revision>0.2.0</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
<build>
<resources>
<resource>
<directory>out</directory>
<targetPath>next-dashboard</targetPath>
<excludes>
<!-- development-only browser mock worker -->
<exclude>mockServiceWorker.js</exclude>
</excludes>
</resource>
<resource>
<directory>.</directory>
<targetPath>META-INF</targetPath>
<includes>
<include>LICENSE</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>require-static-export</id>
<phase>validate</phase>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireFilesExist>
<files>
<file>${project.basedir}/out/index.html</file>
<file>${project.basedir}/out/app/_/_/index.html</file>
</files>
<message>Run "QQQ_NEXT_OUTPUT=export pnpm build" (or pnpm build:jar) before packaging.</message>
</requireFilesExist>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<!-- Maven Central requires sources and javadoc jars; this artifact has no Java code. -->
<execution>
<id>empty-sources</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<classifier>sources</classifier>
<classesDirectory>${project.basedir}/docs/maven-artifact</classesDirectory>
</configuration>
</execution>
<execution>
<id>empty-javadoc</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
<configuration>
<classifier>javadoc</classifier>
<classesDirectory>${project.basedir}/docs/maven-artifact</classesDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<!-- Release profile for Maven Central: sign and publish (used by qqq-orb mvn_jar_deploy) -->
<id>release</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.2.4</version>
<configuration>
<keyname>${gpg.keyname}</keyname>
<gpgArguments>
<arg>--pinentry-mode</arg>
<arg>loopback</arg>
</gpgArguments>
</configuration>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals>
<goal>sign</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.8.0</version>
<extensions>true</extensions>
<configuration>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>uploaded</waitUntil>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
+39
View File
@@ -0,0 +1,39 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Compiles the owned acceptance fixture against the sample JAR and runs it on loopback.
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import { ACCEPTANCE_MODE, EXPORT_CLASSPATH, resolveSampleJar } from './acceptance-paths.mjs'
const port = process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'
const jar = resolveSampleJar()
const classes = path.resolve('test-results/acceptance/fixture-classes')
rmSync(classes, { recursive: true, force: true })
mkdirSync(classes, { recursive: true })
const fixtureDirectory = path.resolve('tests/acceptance/fixture')
const sources = readdirSync(fixtureDirectory).filter((name) => name.endsWith('.java')).map((name) => path.join(fixtureDirectory, name))
const javac = spawnSync('javac', ['-proc:none', '-encoding', 'UTF-8', '-cp', jar, '-d', classes, ...sources], { stdio: 'inherit' })
if (javac.status !== 0) {
console.error('Acceptance fixture compilation failed.')
process.exit(1)
}
if (!existsSync(path.join(classes, 'AcceptanceSampleServer.class'))) process.exit(1)
const server = spawn('java', [
'-Dqqq.sample.mockAuthentication=true',
'-Dqqq.sample.sharing=true',
`-Dqqq.sample.port=${port}`,
'-Duser.timezone=UTC',
// javalin mode: the fresh export shadows any dashboard bundled in the sample jar
'-cp', [...(ACCEPTANCE_MODE === 'javalin' ? [EXPORT_CLASSPATH] : []), classes, jar].join(path.delimiter),
...(ACCEPTANCE_MODE === 'javalin' ? ['-Dqqq.javalin.frontend=next'] : ['-Dqqq.javalin.frontend=none']),
'AcceptanceSampleServer',
], { stdio: 'inherit' })
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
server.on('exit', (code) => process.exit(code ?? 1))
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Serves the production standalone build exactly as the published image does.
import { spawn } from 'node:child_process'
import { cpSync, existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { BUILD_MARKER } from './acceptance-paths.mjs'
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
const standalone = path.resolve('.next/standalone')
if (!existsSync(path.join(standalone, 'server.js')) || !existsSync(BUILD_MARKER)
|| readFileSync(BUILD_MARKER, 'utf8').trim() !== backend) {
console.error(`No production build for ${backend}; run node scripts/acceptance.mjs.`)
process.exit(1)
}
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true })
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true })
const server = spawn('node', ['server.js'], {
cwd: standalone,
stdio: 'inherit',
env: { ...process.env, NODE_ENV: 'production', HOSTNAME: '127.0.0.1', PORT: process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? '13765' },
})
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.kill('SIGTERM'))
server.on('exit', (code) => process.exit(code ?? 1))
+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 https://www.apache.org/licenses/LICENSE-2.0
*/
// Fails unless every required matrix row has at least one acceptance test and every test for it
// passed on the first attempt in every configured browser. Unknown IDs, skips and flakes fail.
// --partial checks only the rows exercised by a filtered run and never reports acceptance.
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
const partial = process.argv.includes('--partial')
const matrixFiles = readdirSync('tests/acceptance/matrix').filter((name) => name.endsWith('.json')).sort()
const matrix = { rows: matrixFiles.flatMap((name) => {
const area = JSON.parse(readFileSync(`tests/acceptance/matrix/${name}`, 'utf8'))
return area.rows.map((row) => {
if (!area.prefixes.some((prefix) => row.id.startsWith(prefix + '-'))) throw new Error(`${row.id} does not belong to ${name}`)
return { ...row, area: area.area }
})
}) }
const duplicates = matrix.rows.map((row) => row.id).filter((id, index, ids) => ids.indexOf(id) !== index)
if (duplicates.length) throw new Error(`Duplicate matrix IDs: ${duplicates.join(', ')}`)
const report = JSON.parse(readFileSync('test-results/acceptance/report.json', 'utf8'))
const rows = new Map(matrix.rows.map((row) => [row.id, row]))
const results = new Map()
const problems = []
const byProject = Object.fromEntries((report.config?.projects ?? []).map((project) => [project.name, { passed: 0, failed: 0, skipped: 0, flaky: 0 }]))
function walk(suite, titles = []) {
for (const child of suite.suites ?? []) walk(child, [...titles, child.title])
for (const spec of suite.specs ?? []) {
const ids = [...`${titles.join(' ')} ${spec.title}`.matchAll(/\[([A-Z]{3}-\d{3})\]/g)].map((match) => match[1])
if (ids.length === 0) problems.push(`Test without a matrix ID: ${spec.file} ${spec.title}`)
for (const test of spec.tests) {
const attempts = test.results ?? []
const outcome = test.status === 'skipped' ? 'skipped'
: attempts.length === 1 && attempts[0].status === 'passed' ? 'passed'
: attempts.some((attempt) => attempt.status === 'passed') ? 'flaky' : 'failed'
byProject[test.projectName] ??= { passed: 0, failed: 0, skipped: 0, flaky: 0 }
byProject[test.projectName][outcome]++
for (const id of ids) {
if (!rows.has(id)) problems.push(`Unknown matrix ID ${id} in ${spec.file}`)
const list = results.get(id) ?? []
list.push({ title: spec.title, project: test.projectName, outcome })
results.set(id, list)
}
}
}
}
for (const suite of report.suites ?? []) walk(suite)
const summary = { passed: 0, failed: 0, missing: 0, excluded: 0 }
for (const row of matrix.rows) {
if (row.required === false) {
summary.excluded++
if (!row.approval) problems.push(`${row.id} is excluded without a recorded approval`)
continue
}
const list = results.get(row.id)
if (!list) {
if (partial) continue
summary.missing++
problems.push(`${row.id} ${row.feature}: no acceptance test`)
continue
}
const bad = list.filter(({ outcome }) => outcome !== 'passed')
if (bad.length) {
summary.failed++
for (const { title, project, outcome } of bad) problems.push(`${row.id} ${outcome} [${project}] ${title}`)
} else summary.passed++
}
// Every configured project must run something (the phone project runs only @mobile tests).
if (!partial) for (const [project, counts] of Object.entries(byProject)) {
if (Object.values(counts).every((count) => count === 0)) problems.push(`Project ${project} ran no tests`)
}
const status = { partial, generatedAt: new Date().toISOString(), summary, byProject, problems,
rows: matrix.rows.map((row) => ({ id: row.id, results: results.get(row.id) ?? [] })) }
writeFileSync('test-results/acceptance/gate.json', JSON.stringify(status, null, 2) + '\n')
console.log(`Acceptance gate${partial ? ' (partial run, not an acceptance result)' : ''}:`, JSON.stringify(summary), JSON.stringify(byProject))
for (const problem of problems) console.log(` FAIL ${problem}`)
process.exit(problems.length ? 1 : 0)
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Renders docs/acceptance/feature-matrix.md from tests/acceptance/matrix/*.json and, when
// present, the gate results of one or more runs (test-results/acceptance/gate.json or paths
// given as arguments, e.g. one per browser). Usage:
// node scripts/acceptance-matrix-md.mjs [gate.json ...] [--evidence "<versions and commands>"]
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
const args = process.argv.slice(2)
const evidenceIndex = args.indexOf('--evidence')
const evidence = evidenceIndex >= 0 ? args[evidenceIndex + 1] : ''
const gateFiles = args.filter((arg, index) => arg.endsWith('.json') && index !== evidenceIndex + 1)
if (gateFiles.length === 0 && existsSync('test-results/acceptance/gate.json')) gateFiles.push('test-results/acceptance/gate.json')
const runs = gateFiles.map((file) => ({ file, gate: JSON.parse(readFileSync(file, 'utf8')) }))
const outcomes = new Map()
for (const { gate } of runs) {
for (const row of gate.rows) {
const list = outcomes.get(row.id) ?? []
list.push(...row.results)
outcomes.set(row.id, list)
}
}
const escape = (text) => String(text ?? '').replaceAll('|', '\\|').replaceAll('\n', ' ')
const areas = readdirSync('tests/acceptance/matrix').filter((name) => name.endsWith('.json')).sort()
.map((name) => JSON.parse(readFileSync(`tests/acceptance/matrix/${name}`, 'utf8')))
let total = 0
let passing = 0
const sections = areas.map((area) => {
const lines = [`## ${area.description}`, '', '| ID | Feature | Scenarios | Negative | Fixture | Result | Issues |', '|---|---|---|---|---|---|---|']
for (const row of area.rows) {
total++
const results = outcomes.get(row.id) ?? []
const projects = [...new Set(results.map((result) => result.project))].sort()
const ok = results.length > 0 && results.every((result) => result.outcome === 'passed')
if (ok && row.required !== false) passing++
const result = row.required === false ? `excluded (${escape(row.approval)})`
: results.length === 0 ? 'not run'
: ok ? `pass (${projects.join(', ')})`
: `FAIL: ${results.filter((r) => r.outcome !== 'passed').map((r) => `${r.project} ${r.outcome}`).join(', ')}`
const issues = (row.issues ?? []).map((issue) => {
const match = String(issue).match(/^(?:([\w.-]+\/[\w.-]+))?#?(\d+)$/)
if (!match) return escape(issue)
const repo = match[1] ?? 'QRun-IO/qqq'
return `[${repo === 'QRun-IO/qqq' ? '' : repo}#${match[2]}](https://github.com/${repo}/issues/${match[2]})`
}).join(' ')
lines.push(`| ${row.id} | ${escape(row.feature)} | ${escape((row.scenarios ?? []).join('; '))} | ${escape((row.negative ?? []).join('; '))} | ${escape(row.fixture)} | ${result} | ${issues} |`)
}
return lines.join('\n')
})
const header = [
'# Next UI feature acceptance matrix',
'',
'Generated by `node scripts/acceptance-matrix-md.mjs` from `tests/acceptance/matrix/*.json` and the',
'acceptance gate results. Each row is a supported QQQ UI feature or use case with executable',
'real-backend acceptance coverage (QRun-IO/qqq#649). See `tests/acceptance/README.md` to run it.',
'',
`Rows: ${total}. Required rows passing in the recorded runs: ${passing}.`,
'',
runs.length ? `Recorded runs: ${runs.map(({ file, gate }) => `\`${file}\` (${gate.generatedAt}${gate.partial ? ', partial' : ''})`).join(', ')}.` : 'No recorded run.',
...(evidence ? ['', evidence] : []),
'',
]
mkdirSync('docs/acceptance', { recursive: true })
writeFileSync('docs/acceptance/feature-matrix.md', header.join('\n') + '\n' + sections.join('\n\n') + '\n')
console.log(`Wrote docs/acceptance/feature-matrix.md (${total} rows, ${passing} passing required rows)`)
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { existsSync, readdirSync } from 'node:fs'
import path from 'node:path'
/** Sample JAR from QQQ_SAMPLE_JAR, else the newest jar-with-dependencies in a sibling qqq checkout. */
export function resolveSampleJar() {
const explicit = process.env.QQQ_SAMPLE_JAR
if (explicit) {
if (!existsSync(explicit)) throw new Error(`QQQ_SAMPLE_JAR does not exist: ${explicit}`)
return path.resolve(explicit)
}
const target = path.resolve('../qqq/qqq-sample-project/target')
const jars = existsSync(target) ? readdirSync(target).filter((name) => name.endsWith('-jar-with-dependencies.jar')) : []
if (jars.length !== 1) {
throw new Error('Set QQQ_SAMPLE_JAR to the qqq-sample-project jar-with-dependencies (see tests/acceptance/README.md).')
}
return path.join(target, jars[0])
}
export const BUILD_MARKER = path.resolve('.next/acceptance-backend.txt')
/** Classpath folder holding a copy of out/ as next-dashboard/, ahead of the sample jar. */
export const EXPORT_CLASSPATH = path.resolve('test-results/acceptance/export-classpath')
export const ACCEPTANCE_MODE = process.env.QQQ_ACCEPTANCE_MODE === 'standalone' ? 'standalone' : 'javalin'
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Full real-backend acceptance: production build, Playwright suite, then the matrix gate.
// Usage: QQQ_SAMPLE_JAR=<jar> node scripts/acceptance.mjs [--skip-build] [playwright args...]
import { spawnSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar } from './acceptance-paths.mjs'
const args = process.argv.slice(2)
const skipBuild = args.includes('--skip-build')
const passthrough = args.filter((arg) => arg !== '--skip-build')
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
resolveSampleJar()
function run(command, commandArgs, env = {}) {
const result = spawnSync(command, commandArgs, { stdio: 'inherit', env: { ...process.env, ...env } })
return result.status ?? 1
}
if (!skipBuild && ACCEPTANCE_MODE === 'javalin') {
const status = run('pnpm', ['build:export'], { NEXT_TELEMETRY_DISABLED: '1' })
if (status !== 0) process.exit(status)
}
if (ACCEPTANCE_MODE === 'javalin') {
if (!existsSync('out/index.html')) throw new Error('No static export in out/; run without --skip-build.')
rmSync(EXPORT_CLASSPATH, { recursive: true, force: true })
mkdirSync(EXPORT_CLASSPATH, { recursive: true })
cpSync('out', path.join(EXPORT_CLASSPATH, 'next-dashboard'), { recursive: true, filter: (source) => !source.endsWith('mockServiceWorker.js') })
}
if (!skipBuild && ACCEPTANCE_MODE === 'standalone') {
const status = run('pnpm', ['build'], { QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
if (status !== 0) process.exit(status)
writeFileSync(BUILD_MARKER, backend + '\n')
}
const tests = run('pnpm', ['exec', 'playwright', 'test', '-c', 'tests/acceptance/playwright.config.ts', ...passthrough])
const filtered = passthrough.some((arg) => !arg.startsWith('--project'))
const gate = run('node', ['scripts/acceptance-gate.mjs', ...(filtered ? ['--partial'] : [])])
process.exit(tests || gate)
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* check-license-headers.mjs
*
* Validates that every TypeScript/TSX source file in src/ begins with the
* Apache 2.0 license header required by this project.
*
* Usage:
* node scripts/check-license-headers.mjs # check only (exits 1 on failure)
* node scripts/check-license-headers.mjs --fix # auto-prepend missing headers
*
* Integrated via:
* pnpm lint:license → check mode
* pnpm lint:license:fix → fix mode
* pnpm lint → check mode (enforced in CI)
*/
import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const ROOT = join(__dirname, '..')
const SRC = join(ROOT, 'src')
const FIX = process.argv.includes('--fix')
/** Full Apache 2.0 header prepended to files missing it (fix mode). */
const HEADER = `/*
* 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.
*/`
/**
* Canonical identifier used when scanning for the header.
* Matching a substring instead of the full block makes the check robust to
* minor formatting differences (trailing spaces, year updates, etc.).
*/
const HEADER_MARKER = 'Licensed under the Apache License, Version 2.0'
/** Directories inside src/ that are generated or third-party — skip entirely. */
const SKIP_DIRS = new Set(['node_modules', '.next', 'coverage', '__generated__', 'out'])
/**
* Recursively yields every .ts / .tsx file under `dir`, skipping directories
* listed in SKIP_DIRS.
*
* @param {string} dir - Absolute path of the directory to walk.
* @yields {string} Absolute path of each matching file.
*/
function* walkTs(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue
yield* walkTs(full)
} else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) {
yield full
}
}
}
const missing = []
for (const file of walkTs(SRC)) {
const content = readFileSync(file, 'utf8')
if (!content.includes(HEADER_MARKER)) {
missing.push(file)
if (FIX) {
writeFileSync(file, HEADER + '\n\n' + content, 'utf8')
console.log(` fixed ${relative(ROOT, file)}`)
}
}
}
if (missing.length === 0) {
console.log('✓ All source files carry the Apache 2.0 license header.')
process.exit(0)
}
if (FIX) {
console.log(`\n✓ Added license header to ${missing.length} file(s).`)
process.exit(0)
}
// Check-only mode: report and fail.
console.error(`\n❌ ${missing.length} file(s) are missing the Apache 2.0 license header:\n`)
for (const f of missing) {
console.error(` ${relative(ROOT, f)}`)
}
console.error('\nRun pnpm lint:license:fix to add the headers automatically.\n')
process.exit(1)
@@ -0,0 +1,98 @@
/*
* 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 CallbackContent — completes the OAuth2 / Auth0 PKCE redirect (`/token`, `/callback`).
*/
'use client'
import React, { useEffect, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/use-auth'
import { PKCE_STORAGE } from '@/lib/auth/oidc'
import { safeReturnTo } from '@/lib/auth/return-to'
/**
* Inner callback handler — reads `code`, `state`, and `error` from search params,
* retrieves the PKCE verifier from sessionStorage, and delegates to
* `handleOAuthCallback`. Redirects on success or failure.
*
* @returns A loading card while the callback is being processed.
*/
export default function CallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { handleOAuthCallback } = useAuth()
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const returnTo = safeReturnTo(sessionStorage.getItem(PKCE_STORAGE.returnTo))
const toLogin = (reason: string) => {
sessionStorage.removeItem(PKCE_STORAGE.verifier)
router.replace(`/login?error=${encodeURIComponent(reason)}&returnTo=${encodeURIComponent(returnTo)}`)
}
if (error) {
console.warn('[Callback] Identity provider error:', error, searchParams.get('error_description') ?? '')
toLogin(error)
return
}
if (!code || !state) {
console.warn('[Callback] Missing code or state')
toLogin('callback_failed')
return
}
// The verifier the login page stored before redirecting to the provider.
const codeVerifier = sessionStorage.getItem(PKCE_STORAGE.verifier) ?? undefined
sessionStorage.removeItem(PKCE_STORAGE.verifier)
handleOAuthCallback(code, state, codeVerifier)
.then(() => {
sessionStorage.removeItem(PKCE_STORAGE.returnTo)
router.replace(returnTo)
})
.catch((err: unknown) => {
console.warn('[Callback] Sign-in could not be completed:', err instanceof Error ? err.message : err)
toLogin('callback_failed')
})
}, [searchParams, handleOAuthCallback, router])
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">
Completing sign-in...
</h1>
<p className="text-sm text-muted-foreground" role="status">
Please wait while we complete authentication.
</p>
</div>
</div>
)
}
+49 -65
View File
@@ -1,74 +1,58 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 OAuth2 / Auth0 callback page — handles the PKCE redirect from the authorization server.
*/
'use client'
// OAuth2 callback page — handles the redirect from authorization server
// Wrapped in Suspense because it uses useSearchParams()
// OAuth2 / Auth0 callback page — handles the redirect from the authorization server.
//
// After a successful PKCE redirect from the login page the IdP returns the user
// here with `?code=...&state=...` query parameters. This page:
//
// 1. Validates that `state` matches the nonce stored in sessionStorage (CSRF guard).
// 2. Retrieves the `pkce_code_verifier` stored by the login page.
// 3. Calls `handleOAuthCallback(code, state, codeVerifier)` which:
// a. Re-validates the state nonce.
// b. OAUTH2: posts code + verifier + redirect URI to the backend, which
// exchanges them with the IdP and issues a session cookie.
// AUTH_0: exchanges the code with Auth0 (public PKCE client) and posts
// the access token to the backend.
// 4. Redirects to `oauth2ReturnTo` (set by the login page) or '/'.
//
// `/token` (the registered redirect URI) renders the same handler.
// Wrapped in Suspense because it uses useSearchParams().
import React, { Suspense, useEffect, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import React, { Suspense } from 'react'
import { useAuth } from '@/lib/auth/use-auth'
function CallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { handleOAuthCallback } = useAuth()
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
if (error) {
console.error('[Callback] Auth error:', error, errorDescription)
router.replace(`/login?error=${encodeURIComponent(error)}`)
return
}
if (!code || !state) {
console.error('[Callback] Missing code or state params')
router.replace('/login')
return
}
handleOAuthCallback(code, state)
.then(() => {
// Get stored returnTo from session storage or default to home
const returnTo = sessionStorage.getItem('oauth2ReturnTo') ?? '/'
sessionStorage.removeItem('oauth2ReturnTo')
router.replace(returnTo)
})
.catch((err) => {
console.error('[Callback] Failed to handle callback:', err)
router.replace('/login?error=callback_failed')
})
}, [searchParams, handleOAuthCallback, router])
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">
Completing login...
</h1>
<p className="text-sm text-muted-foreground">
Please wait while we complete authentication.
</p>
</div>
</div>
)
}
import CallbackContent from './CallbackContent'
/**
* OAuth2 / Auth0 callback page exported as the Next.js default for `/callback`.
*
* Wraps `CallbackContent` in a `<Suspense>` boundary because `useSearchParams()`
* requires Suspense in the App Router.
*
* @returns The callback page wrapped in a full-screen centered container.
*/
export default function CallbackPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
<Suspense
fallback={
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
@@ -80,6 +64,6 @@ export default function CallbackPage() {
>
<CallbackContent />
</Suspense>
</div>
</main>
)
}
+151 -74
View File
@@ -1,97 +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 Login page — signs the user in and returns them to the page they asked for.
*/
'use client'
// Login page — authenticates user and redirects to dashboard
// Wrapped in Suspense because it uses useSearchParams()
// States:
// signing in — first visit or an expired session: sign in automatically (anonymous
// types create a session; OAUTH2 / AUTH_0 redirect to the provider).
// signed out — after an explicit logout: stay here until the user chooses Sign in.
// failed — sign-in was denied or the provider callback failed: explain why and
// offer Try again; never fall back to an anonymous session.
// Wrapped in Suspense because it uses useSearchParams().
import React, { Suspense, useEffect } from 'react'
import React, { Suspense, useEffect, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/use-auth'
import { recordReauthAttempt, resetReauthAttempts } from '@/lib/auth/auth-storage'
import { safeReturnTo } from '@/lib/auth/return-to'
function LoginContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { isAuthenticated, isLoading, authMetadata } = useAuth()
/** Messages for provider callback errors passed back as `?error=`. */
const CALLBACK_ERRORS: Record<string, string> = {
access_denied: 'Sign-in was denied by the identity provider.',
callback_failed: 'Sign-in could not be completed.',
login_required: 'The identity provider requires you to sign in again.',
}
const returnTo = searchParams.get('returnTo') ?? '/'
useEffect(() => {
if (!isLoading && isAuthenticated) {
// Decode and validate the returnTo URL
try {
const decoded = decodeURIComponent(returnTo)
// Only redirect to relative paths (security: prevent open redirect)
if (decoded.startsWith('/')) {
router.replace(decoded)
} else {
router.replace('/')
}
} catch {
router.replace('/')
}
}
}, [isAuthenticated, isLoading, router, returnTo])
if (isLoading) {
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">
Authenticating...
</h1>
<p className="text-sm text-muted-foreground">
{authMetadata
? `Setting up ${authMetadata.type} session...`
: 'Checking authentication...'}
</p>
</div>
</div>
)
}
// If not loading and not authenticated, show login state
/**
* Card wrapper shared by every login state.
*
* @param props - The card contents.
* @param props.children - Card body.
* @returns The card.
*/
function LoginCard({ children }: { children: React.ReactNode }) {
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground"
aria-hidden="true"
>
Q
</div>
<h1 className="text-xl font-semibold text-foreground">QQQ Admin</h1>
<p className="text-sm text-muted-foreground">
{authMetadata?.type === 'FULLY_ANONYMOUS' || authMetadata?.type === 'MOCK'
? 'Setting up anonymous session...'
: 'Redirecting to authentication provider...'}
</p>
<div
className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"
aria-hidden="true"
/>
</div>
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm" data-qqq-id="login-card">
<div className="flex flex-col items-center gap-4 text-center">{children}</div>
</div>
)
}
/**
* Inner login content — reacts to the auth state and the `returnTo` / `error` params.
*
* @returns The login card for the current state.
*/
function LoginContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { isAuthenticated, isLoading, authMetadata, authError, isSignedOut, signIn } = useAuth()
const autoSignInStarted = useRef(false)
const [loopStopped, setLoopStopped] = React.useState(false)
const returnTo = safeReturnTo(searchParams.get('returnTo'))
const callbackErrorCode = searchParams.get('error')
const callbackError = callbackErrorCode
? CALLBACK_ERRORS[callbackErrorCode] ?? `Sign-in failed (${callbackErrorCode}).`
: null
const errorMessage = authError ?? callbackError ?? (loopStopped ? 'Your session could not be re-established. Sign in again to continue.' : null)
// Signed in: go where the user was headed.
useEffect(() => {
if (!isLoading && isAuthenticated) router.replace(returnTo)
}, [isAuthenticated, isLoading, router, returnTo])
// Sign in automatically unless the user signed out or a failure needs attention.
useEffect(() => {
if (isLoading || isAuthenticated || isSignedOut || errorMessage || !authMetadata) return
if (autoSignInStarted.current) return
autoSignInStarted.current = true
if (recordReauthAttempt()) {
setLoopStopped(true)
return
}
void signIn(returnTo)
}, [authMetadata, errorMessage, isAuthenticated, isLoading, isSignedOut, returnTo, signIn])
const signInAgain = () => {
resetReauthAttempts()
setLoopStopped(false)
void signIn(returnTo)
}
if (!isLoading && !isAuthenticated && isSignedOut) {
return (
<LoginCard>
<h1 className="text-xl font-semibold text-foreground">You have signed out</h1>
<p className="text-sm text-muted-foreground">Sign in again to continue.</p>
<button
type="button"
onClick={signInAgain}
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-qqq-id="button-sign-in"
>
Sign in
</button>
</LoginCard>
)
}
if (!isLoading && !isAuthenticated && errorMessage) {
return (
<LoginCard>
<h1 className="text-xl font-semibold text-foreground">Unable to sign in</h1>
<p role="alert" className="text-sm text-destructive" data-qqq-id="login-error">{errorMessage}</p>
<button
type="button"
onClick={signInAgain}
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-qqq-id="button-sign-in-retry"
>
Try again
</button>
</LoginCard>
)
}
const redirecting = authMetadata?.type === 'OAUTH2' || authMetadata?.type === 'AUTH_0'
return (
<LoginCard>
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">Signing in...</h1>
<p className="text-sm text-muted-foreground" role="status">
{redirecting ? 'Redirecting to your identity provider...' : 'Setting up your session...'}
</p>
</LoginCard>
)
}
/**
* Login page exported as the Next.js default for `/login`.
*
* Wraps `LoginContent` in a `<Suspense>` boundary because `useSearchParams()`
* requires Suspense in the App Router.
*
* @returns The login page wrapped in a full-screen centered container.
*/
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
<Suspense
fallback={
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
</div>
<LoginCard>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" aria-hidden="true" />
<p className="text-sm text-muted-foreground" role="status">Loading...</p>
</LoginCard>
}
>
<LoginContent />
</Suspense>
</div>
</main>
)
}
+41
View File
@@ -0,0 +1,41 @@
/*
* 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 Token page — the OAuth2 / Auth0 redirect URI (`{origin}/token`), matching the
* Material dashboard so existing identity-provider registrations keep working.
*/
'use client'
import React, { Suspense } from 'react'
import CallbackContent from '../callback/CallbackContent'
/**
* `/token` — completes the provider redirect.
*
* @returns The callback handler in a full-screen container.
*/
export default function TokenPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-muted p-4">
<Suspense fallback={<p role="status" className="text-sm text-muted-foreground">Loading...</p>}>
<CallbackContent />
</Suspense>
</main>
)
}
@@ -1,37 +1,120 @@
/*
* 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 EntityCopy page — copies a record by pre-populating the create form with existing values.
*/
'use client'
// EntityCopy — copy a record (pre-populates form with existing values)
// Package 3: full implementation using EntityForm with isCopy=true
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import React, { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { EntityForm } from '@/components/forms/EntityForm'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import type { QTableMetaData } from '@/types'
import type { CopyNode } from '@/lib/utils/copy-tree'
import { copyTableNames, prepareCopyTree } from '@/lib/utils/copy-tree'
import { getErrorStatusCode, recordLoadFailure } from '@/lib/utils/error-utils'
import { canInsertRecords, canReadRecords, hasCapability } from '@/lib/auth/permissions'
import { EntityForm, type EntityFormProps } from '@/components/forms/EntityForm'
import { FullCopyDraft } from '@/components/forms/FullCopyDraft'
/**
* Renders the entity copy form for the record identified by `slug` and `recordId`.
*
* Fetches full table metadata and the base source record, then renders `<EntityForm>`
* in copy mode (`isCopy=true`) so field values are pre-populated but the form
* will create a new record on submit. Shows a loading spinner while data is
* pending, a permission error when the user lacks `insertPermission`, and a
* fetch-error panel when the source record cannot be loaded.
*
* @returns A composed page that renders one of:
* - A full-screen spinner while metadata or record data is loading
* - A permission-error banner when the user lacks `insertPermission`
* - A destructive error panel when the source record cannot be fetched
* - `<EntityForm>` in copy mode (`isCopy=true`, pre-populated with source values)
* wrapped in a centered `max-w-4xl` container; submitting creates a new record
*/
export default function EntityCopyPage() {
const params = useParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const params = useRouteParams<{ slug: string; recordId: string }>()
return <CopyPageContent key={JSON.stringify(params)} slug={params.slug} recordId={params.recordId} />
}
const { data: metaData } = useQuery({
/**
* Own metadata/source loading while rendering components receive ready metadata.
* @param props - Exact route identifiers.
* @returns Base form and explicit full-copy draft flow.
*/
function CopyPageContent({ slug, recordId }: { slug: string; recordId: string }) {
const { setPageHeader, setTableMetaData } = useQContext()
const [mode, setMode] = useState<'base' | 'full'>('base')
const [tree, setTree] = useState<CopyNode>()
const [copyState, setCopyState] = useState<NonNullable<EntityFormProps['copyAssociations']>>()
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
const { record, isLoading, isError, error } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
enabled: canInsertRecords(tableMetaData) && canReadRecords(tableMetaData),
includeAssociations: false,
})
const expanded = useRecord({ tableName: slug, primaryKey: recordId, includeAssociations: true,
enabled: mode === 'full' && !tree && canInsertRecords(tableMetaData) && canReadRecords(tableMetaData),
})
const sourceTables = useMemo(() => {
if (!expanded.record) return { names: [] as string[] }
try { return { names: copyTableNames(expanded.record).filter(name => name !== slug) } }
catch (failure) { return { names: [] as string[], error: failure instanceof Error ? failure.message : 'Full copy source is invalid.' } }
}, [expanded.record, slug])
const targetQueries = useQueries({ queries: sourceTables.names.map(name => ({
queryKey: queryKeys.tableMetadata(name), queryFn: () => loadTableMetaData(name), staleTime: 1000 * 60 * 30,
enabled: mode === 'full' && !tree,
})) })
let fullError = sourceTables.error
if (expanded.isError || targetQueries.some(query => query.isError)) {
const accessDenied = (expanded.isError && getErrorStatusCode(expanded.error) === 403) ||
targetQueries.some(query => query.isError && getErrorStatusCode(query.error) === 403)
fullError = accessDenied
? 'You do not have access to all the information needed for Full Copy. Choose Base Copy to copy this record only.'
: 'Full Copy could not load all required information. Reload to try again, or choose Base Copy.'
}
let prepared: CopyNode | undefined
if (!tree && !fullError && expanded.record && record && tableMetaData && targetQueries.every(query => Boolean(query.data))) {
const tables: Record<string, QTableMetaData> = { [slug]: tableMetaData }
targetQueries.forEach((query, index) => { if (query.data) tables[sourceTables.names[index]] = query.data })
try { prepared = prepareCopyTree(tableMetaData, { ...expanded.record, values: record.values }, tables) }
catch (failure) { fullError = failure instanceof Error ? failure.message : 'Full copy source is invalid.' }
}
useEffect(() => { if (prepared && !tree) setTree(prepared) }, [prepared, tree])
const unavailable = fullError ?? (!tree || !copyState ? 'Loading full copy source and metadata…' : undefined)
const fullState = unavailable ? { getRecords: () => { throw new Error(unavailable) }, error: unavailable } : copyState
useEffect(() => {
setPageHeader(`Copy ${tableMetaData?.label ?? slug} #${recordId}`)
if (tableMetaData) {
@@ -39,6 +122,10 @@ export default function EntityCopyPage() {
}
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
}
if (!tableMetaData || isLoading) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
@@ -47,14 +134,21 @@ export default function EntityCopyPage() {
)
}
if (!tableMetaData.insertPermission) {
if (!tableMetaData.readPermission) {
return <div role="alert" data-qqq-id="permission-denied" className="py-12 text-center text-destructive">You do not have permission to read the source {tableMetaData.label} record.</div>
}
if (!canInsertRecords(tableMetaData)) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
data-qqq-id="permission-denied"
>
<p className="text-sm text-yellow-700">
You do not have permission to create {tableMetaData.label} records.
{!hasCapability(tableMetaData, 'TABLE_INSERT')
? `${tableMetaData.label} records cannot be created.`
: `You do not have permission to create ${tableMetaData.label} records.`}
</p>
</div>
)
@@ -67,7 +161,7 @@ export default function EntityCopyPage() {
role="alert"
>
<p className="text-sm text-destructive">
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
{recordLoadFailure(tableMetaData.label, recordId, error)}
</p>
</div>
)
@@ -75,11 +169,21 @@ export default function EntityCopyPage() {
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-copy-${slug}-${recordId}`}>
<fieldset className="mb-4 flex flex-wrap gap-4">
<legend className="mb-2 font-medium">Copy scope</legend>
<label className="flex items-center gap-2"><input type="radio" name="copy-mode" checked={mode === 'base'} onChange={() => setMode('base')} data-qqq-id="copy-mode-base" />Base copy</label>
<label className="flex items-center gap-2"><input type="radio" name="copy-mode" checked={mode === 'full'} onChange={() => setMode('full')} data-qqq-id="copy-mode-full" />Full copy</label>
</fieldset>
<p className="mb-4 text-sm text-muted-foreground">{mode === 'base' ? 'Copy this record’s editable fields. Associated records are not copied.' : 'Copy editable fields and every loaded named association. A record reached through two named paths is copied twice. Normal insert defaults and validation apply. Limited to 64 association levels and 1000 associated records in this form.'}</p>
<EntityForm
tableMetaData={tableMetaData}
widgets={metaData?.widgets}
record={record}
isCopy={true}
/>
copyAssociations={mode === 'full' ? fullState : undefined}
>
{tree && <fieldset hidden={mode !== 'full'} disabled={mode !== 'full'} className="min-w-0"><FullCopyDraft tree={tree} onChange={setCopyState} /></fieldset>}
</EntityForm>
</div>
)
}
@@ -1,20 +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 RecordDeveloperView page — shows raw record data and table metadata as formatted JSON for debugging.
*/
'use client'
// RecordDeveloperView — shows raw record data and table metadata as formatted JSON
// Useful for debugging field values, types, and backend responses
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { getRecord } from '@/lib/api/tables'
import { loadTableMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
/**
* 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.
*
* @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 (expanded by default)
* - 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
*/
export default function RecordDeveloperViewPage() {
const params = useParams<{ slug: string; recordId: string }>()
const params = useRouteParams<{ slug: string; recordId: string }>()
const { setPageHeader } = useQContext()
const { slug, recordId } = params
@@ -122,6 +149,15 @@ export default function RecordDeveloperViewPage() {
)
}
/**
* 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,
@@ -1,35 +1,81 @@
/*
* 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 EntityEdit page — serves the route `/app/[slug]/[recordId]/edit` and renders the edit form.
*/
'use client'
// EntityEdit — edit an existing record
// Package 3: full implementation using EntityForm + useRecord
/**
* EntityEdit page — serves the route `/app/[slug]/[recordId]/edit` and renders the
* edit form for an existing QQQ record.
*
* Route params:
* - `slug` — the QQQ table name.
* - `recordId` — the primary-key value of the record to edit.
*/
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { recordLoadFailure } from '@/lib/utils/error-utils'
import { EntityForm } from '@/components/forms/EntityForm'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import { canEditRecords, hasCapability } from '@/lib/auth/permissions'
/**
* Renders the record-edit form for the record identified by `slug` and `recordId`.
*
* Fetches both application metadata and the existing record data via `useRecord`.
* Shows a loading spinner while either is pending. Renders a permission error
* when the user lacks `editPermission` on the table, and a fetch-error panel
* when the record cannot be loaded. On success renders `<EntityForm>` in edit
* mode (pre-populated with the existing record values).
*
* @returns A composed page that renders one of:
* - A full-screen spinner while metadata or record data is loading
* - A permission-error banner when the user lacks `editPermission`
* - A destructive error panel when the record cannot be fetched
* - `<EntityForm>` in edit mode (pre-populated with the existing record values)
* wrapped in a centered `max-w-4xl` container
*/
export default function EntityEditPage() {
const params = useParams<{ slug: string; recordId: string }>()
const params = useRouteParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const { data: metaData } = useQuery({
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
const { record, isLoading, isError, error } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
enabled: canEditRecords(tableMetaData),
includeAssociations: false,
})
useEffect(() => {
@@ -39,6 +85,10 @@ export default function EntityEditPage() {
}
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
}
if (!tableMetaData || isLoading) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
@@ -47,14 +97,17 @@ export default function EntityEditPage() {
)
}
if (!tableMetaData.editPermission) {
if (!canEditRecords(tableMetaData)) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
data-qqq-id="permission-denied"
>
<p className="text-sm text-yellow-700">
You do not have permission to edit {tableMetaData.label} records.
{!hasCapability(tableMetaData, 'TABLE_UPDATE')
? `${tableMetaData.label} records cannot be edited.`
: `You do not have permission to edit ${tableMetaData.label} records.`}
</p>
</div>
)
@@ -67,7 +120,7 @@ export default function EntityEditPage() {
role="alert"
>
<p className="text-sm text-destructive">
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
{recordLoadFailure(tableMetaData.label, recordId, error)}
</p>
</div>
)
@@ -75,7 +128,7 @@ export default function EntityEditPage() {
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
<EntityForm tableMetaData={tableMetaData} record={record} />
<EntityForm tableMetaData={tableMetaData} record={record} widgets={metaData?.widgets} />
</div>
)
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Prerenders this dynamic segment once for the Javalin-served static export.
* The server returns that page for every concrete value; see use-route-params.
*/
import type { ReactNode } from 'react'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/**
* Static export placeholder params for this segment.
*
* @returns One placeholder entry.
*/
export function generateStaticParams() {
return [{ recordId: EXPORT_PLACEHOLDER }]
}
/**
* Pass-through layout that exists to carry generateStaticParams.
*
* @param props - Layout props.
* @param props.children - Route content.
* @returns The children unchanged.
*/
export default function Layout({ children }: { children: ReactNode }) {
return children
}
@@ -0,0 +1,295 @@
/*
* 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 { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { deserializeFilter } from '@/lib/utils/filter-utils'
import apiClient from '@/lib/api/client'
import { QContextProvider } from '@/lib/context/q-context'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import RecordViewPage from './page'
const navigation = vi.hoisted(() => ({ search: 'tab=related' }))
vi.mock('next/navigation', () => ({
useParams: () => ({ slug: 'person', recordId: '1' }),
usePathname: () => '/app/person/1',
useSearchParams: () => new URLSearchParams(navigation.search),
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
}))
const baseRecord = { tableName: 'person', values: { id: 1, firstName: 'Avery' }, recordLabel: 'Avery Sample' }
function fixture(childRead = false) {
const person = structuredClone(qInstance.tables.person)
const pet = { ...structuredClone(qInstance.tables.company), name: 'pet', label: 'Pet', readPermission: childRead, insertPermission: true }
pet.fields.personId = { ...person.fields.id, name: 'personId', label: 'Person', isEditable: true, isHidden: false }
person.exposedJoins = []
person.associations = [{ name: 'pets', associatedTableName: 'pet', join: {
name: 'personJoinPet', type: 'ONE_TO_MANY', leftTable: 'person', rightTable: 'pet',
joinOns: [{ leftField: 'id', rightField: 'personId' }],
} }]
const registry = { ...qInstance, widgets: { ...qInstance.widgets }, tables: { person: { name: 'person', label: 'Person', readPermission: true }, pet: { name: 'pet', readPermission: childRead, insertPermission: true } } }
server.use(
http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)),
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(person)),
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json(pet)),
)
return { person, pet, registry }
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}><QContextProvider><RecordViewPage /></QContextProvider></QueryClientProvider>)
}
describe('RecordViewPage independent association loading', () => {
beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); navigation.search = 'tab=related' })
it('keeps the base and child Add, and requests no expansion, when the child table is listed without read permission (QRun-IO/qqq#671)', async () => {
fixture()
const requests: string[] = []
server.use(http.get('/data/person/1', ({ request }) => {
requests.push(new URL(request.url).searchParams.get('includeAssociations') ?? '')
return HttpResponse.json(baseRecord)
}))
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
expect(await screen.findByText('Related records are unavailable.')).toBeVisible()
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
expect(requests).toEqual(['false'])
})
it('keeps the readable base and child Add when whole association expansion is denied', async () => {
const { person: metadata, pet } = fixture(true)
pet.readPermission = false
const before = structuredClone(metadata)
const requests: string[] = []
server.use(http.get('/data/person/1', ({ request }) => {
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
requests.push(mode)
return mode === 'true'
? HttpResponse.json({ error: 'Pet read denied' }, { status: 403 })
: HttpResponse.json(baseRecord)
}))
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
expect(await screen.findByText('Related records could not be loaded.')).toBeVisible()
const add = screen.getByRole('button', { name: '+ Add Pet' })
expect(add).toBeEnabled()
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
fireEvent.click(add)
expect(await screen.findByRole('dialog', { name: 'Add Pet' })).toBeVisible()
expect(requests).toEqual(['false', 'true'])
expect(metadata).toEqual(before)
})
it('neither requests nor shows an association whose table is hidden from the user (QRun-IO/qqq#671)', async () => {
const { registry } = fixture()
const { pet: _hidden, ...visibleTables } = registry.tables
void _hidden
const requests: string[] = []
server.use(
http.get('/qqq/v1/metaData', () => HttpResponse.json({ ...registry, tables: visibleTables })),
http.get('/qqq/v1/metaData/table/pet', () => { requests.push('pet metadata'); return HttpResponse.json({ error: 'not found' }, { status: 404 }) }),
http.get('/data/person/1', ({ request }) => {
requests.push(`includeAssociations=${new URL(request.url).searchParams.get('includeAssociations')}`)
return HttpResponse.json(baseRecord)
}),
)
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
await waitFor(() => expect(requests).toContain('includeAssociations=false'))
expect(requests).toEqual(['includeAssociations=false'])
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
expect(screen.queryByText('Related records could not be loaded.')).not.toBeInTheDocument()
})
it('does not rename association keys or present an unmatched association as empty', async () => {
fixture(true)
server.use(http.get('/data/person/1', ({ request }) => HttpResponse.json({
...baseRecord,
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
associatedRecords: { pet: [{ tableName: 'pet', values: { id: 2, name: 'Actual pet' }, recordLabel: 'Actual pet' }] },
} : {}),
})))
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
await waitFor(() => expect(screen.queryByText('Loading related records...')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
expect(screen.queryByText('No Pets records')).not.toBeInTheDocument()
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
})
it('retains explicitly keyed related records when the response binding is available', async () => {
fixture(true)
server.use(http.get('/data/person/1', ({ request }) => HttpResponse.json({
...baseRecord,
...(new URL(request.url).searchParams.get('includeAssociations') === 'true' ? {
associatedRecords: { pets: [{ tableName: 'pet', values: { id: 2, name: 'Mapped pet' }, recordLabel: 'Mapped pet' }] },
} : {}),
})))
renderPage()
expect(await screen.findByText('Mapped pet')).toBeVisible()
expect(screen.queryByText('Related records are unavailable.')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
})
it('keeps arbitrary aliases distinct and deduplicates target metadata loads', async () => {
const { person, pet } = fixture(true)
person.associations = ['care / primary', 'scheduled reviews'].map((name) => ({ ...person.associations![0], name }))
const metadataCalls: string[] = []
let releaseMetadata!: () => void
const ready = new Promise<void>((resolve) => { releaseMetadata = resolve })
server.use(
http.get('/qqq/v1/metaData/table/pet', async () => { metadataCalls.push('pet'); await ready; return HttpResponse.json(pet) }),
http.get('/data/person/1', ({ request }) => HttpResponse.json({ ...baseRecord,
...(new URL(request.url).searchParams.get('includeAssociations') === 'true'
? { associatedRecords: { 'care / primary': [], 'scheduled reviews': [{ tableName: 'pet', values: { id: 4, name: 'Review pet' } }] } } : {}),
})),
)
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
await waitFor(() => expect(screen.getAllByText('Loading related metadata...')).toHaveLength(2))
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
releaseMetadata()
expect(await screen.findByText('No care / primary records')).toBeVisible()
expect(await screen.findByText('Review pet')).toBeVisible()
expect(metadataCalls).toEqual(['pet'])
expect(screen.getAllByRole('button', { name: '+ Add Pet' })).toHaveLength(2)
})
it.each(['tab=overview', 'view=list'])('places explicitly bound groups in their sections (%s)', async (view) => {
navigation.search = view
const { person, registry } = fixture(true)
person.associations = [{ ...person.associations![0], name: 'care / primary' }, { ...person.associations![0], name: 'scheduled reviews' }]
person.sections = [
{ name: 'fieldGuide', label: 'Companions', tier: 'T2', isHidden: false, fieldNames: [], widgetName: 'companionPanel' },
{ name: 'reviewSchedule', label: 'Reviews', tier: 'T2', isHidden: false, fieldNames: [], widgetName: 'reviewEditor' },
]
registry.widgets.companionPanel = { name: 'companionPanel', label: 'Different widget title', type: 'childRecordList', hasPermission: true, defaultValues: { manageAssociationName: 'care / primary' } }
registry.widgets.reviewEditor = { name: 'reviewEditor', label: 'Another title', type: 'rowBuilder', hasPermission: true, defaultValues: { associationName: 'scheduled reviews' } }
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { 'care / primary': [], 'scheduled reviews': [] } })))
const { container } = renderPage()
const byId = (id: string) => Array.from(container.querySelectorAll('[data-qqq-id]')).find((element) => element.getAttribute('data-qqq-id') === id)
await waitFor(() => expect(byId('button-create-association-care%20%2F%20primary')).toBeEnabled())
expect(byId('associated-records-care%20%2F%20primary')).toHaveTextContent('Companions')
expect(byId('associated-records-scheduled%20reviews')).toHaveTextContent('Reviews')
expect(screen.queryByRole('tab', { name: 'Related' })).not.toBeInTheDocument()
})
it('localizes target metadata failure without hiding the base or claiming an empty group', async () => {
fixture(true)
server.use(
http.get('/qqq/v1/metaData/table/pet', () => HttpResponse.json({ error: 'Unavailable' }, { status: 500 })),
http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })),
)
renderPage()
expect(await screen.findByText('Avery Sample')).toBeVisible()
expect(await screen.findByText('Related table metadata is unavailable.')).toBeVisible()
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
})
it('submits every composite non-PK relationship field despite their exclusion from editable inputs', async () => {
const { person, pet } = fixture(false)
person.fields.schedule = { ...person.fields.id, name: 'schedule' }
person.fields.visitDate = { ...person.fields.firstName, name: 'visitDate', type: 'DATE' }
pet.fields.birthDate = { ...person.fields.visitDate, name: 'birthDate', isEditable: true }
pet.fields.personId.possibleValueSourceName = undefined
person.associations![0].join.joinOns = [{ leftField: 'schedule', rightField: 'personId' }, { leftField: 'visitDate', rightField: 'birthDate' }]
server.use(http.get('/data/person/1', ({ request }) => new URL(request.url).searchParams.get('includeAssociations') === 'true'
? HttpResponse.json({ error: 'Denied' }, { status: 403 })
: HttpResponse.json({ ...baseRecord, values: { ...baseRecord.values, schedule: 2, visitDate: '1990-01-15' } })))
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'pet', values: { id: 8, personId: 2, birthDate: '1990-01-15' } }] })
const before = structuredClone({ person, pet })
renderPage()
fireEvent.click(await screen.findByRole('button', { name: '+ Add Pet' }))
const dialog = await screen.findByRole('dialog', { name: 'Add Pet' })
expect(within(dialog).getAllByRole('heading', { name: 'Add Pet' })).toHaveLength(1)
expect(within(dialog).queryByLabelText('Person')).not.toBeInTheDocument()
fireEvent.change(within(dialog).getByLabelText(/Name/), { target: { value: 'Created child' } })
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
await waitFor(() => expect(post).toHaveBeenCalled())
expect(post.mock.calls[0][0]).toBe('/data/pet')
const values = post.mock.calls[0][1] as FormData
expect(values.get('personId')).toBe('2')
expect(values.get('birthDate')).toBe('1990-01-15')
expect(values.get('name')).toBe('Created child')
expect(values.has('associations')).toBe(false)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect({ person, pet }).toEqual(before)
})
it('round-trips Unicode and plus-bearing base64 View All filters through URLSearchParams', async () => {
const { person, pet } = fixture(true)
person.associations![0].join.joinOns = [{ leftField: 'firstName', rightField: 'personId' }]
pet.fields.personId.type = 'STRING'
const value = '>>>漢'
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, values: { id: 1, firstName: value }, associatedRecords: { pets: [] } })))
renderPage()
const link = await screen.findByRole('link', { name: 'View All' })
const url = new URL(link.getAttribute('href')!, 'http://localhost')
expect(url.search).toContain('%2B')
expect(deserializeFilter(url.searchParams.get('filter')!)).toMatchObject({ criteria: [{ fieldName: 'personId', operator: 'EQUALS', values: [value] }], booleanOperator: 'AND' })
})
it('allows correctly oriented direct Add for reverse joins without claiming expanded absence', async () => {
const { person } = fixture(true)
person.associations![0].join = { ...person.associations![0].join, leftTable: 'pet', rightTable: 'person', type: 'MANY_TO_ONE', joinOns: [{ leftField: 'personId', rightField: 'id' }] }
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [] } })))
renderPage()
expect(await screen.findByText('Related record loading for reverse associations is not supported.')).toBeVisible()
expect(screen.queryByText('No pets records')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '+ Add Pet' })).toBeEnabled()
})
it('retains a panel with no visible columns while denying an unavailable relationship write', async () => {
const { person, pet } = fixture(false)
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
delete person.fields.id
server.use(http.get('/data/person/1', () => HttpResponse.json(baseRecord)))
renderPage()
expect(await screen.findByText('Relationship values are unavailable; this record cannot be linked.')).toBeVisible()
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
})
it('keeps permitted Add available when there are no visible child columns', async () => {
const { pet } = fixture(false)
Object.values(pet.fields).forEach((field) => { field.isHidden = true })
server.use(http.get('/data/person/1', () => HttpResponse.json(baseRecord)))
renderPage()
expect(await screen.findByRole('button', { name: '+ Add Pet' })).toBeEnabled()
expect(screen.getByText('Related records are unavailable.')).toBeVisible()
})
it('keeps readable data and View All when INSERT alone is denied', async () => {
const { pet } = fixture(true)
pet.insertPermission = false
server.use(http.get('/data/person/1', () => HttpResponse.json({ ...baseRecord, associatedRecords: { pets: [{ tableName: 'pet', values: { id: 'one/two?', name: 'Readable pet' } }] } })))
renderPage()
const recordLinks = await screen.findAllByRole('link', { name: 'View Pet record one/two?' })
for (const link of recordLinks) expect(link.getAttribute('href')).toContain('/app/pet/one%2Ftwo%3F?')
expect(screen.getByRole('link', { name: 'View All' })).toBeVisible()
expect(screen.queryByRole('button', { name: '+ Add Pet' })).not.toBeInTheDocument()
})
})
@@ -1,39 +1,116 @@
/*
* 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 RecordView page — serves the route `/app/[slug]/[recordId]` and renders the record detail view.
*/
'use client'
// RecordView page — view a single record
// Package 3: full implementation using RecordView component + useRecord hook
/**
* RecordView page — serves the route `/app/[slug]/[recordId]` and renders a
* detailed view of a single QQQ record.
*
* Route params:
* - `slug` — the QQQ table name.
* - `recordId` — the primary-key value of the record to display.
*/
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useQueries, useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { addRecentRecord } from '@/lib/utils/recent-records'
import { getProcessesForTable } from '@/lib/utils/process-utils'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
import { auditSource } from '@/lib/api/audits'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import type { AssociationTableState } from '@/lib/utils/association-utils'
import { canReadRecords } from '@/lib/auth/permissions'
import { RecordView } from '@/components/records/RecordView'
/**
* Renders the detail view for a single record identified by `slug` (table name)
* and `recordId` (primary key).
*
* Loads the base record and optional whole-association expansion independently. Once
* the record loads it is registered in the recently-viewed history (via
* `addRecentRecord`) so it surfaces in the GlobalSearch and SearchDialog.
* The page header in QContext is updated to the record label.
*
* @returns A composed view that assembles:
* - A full-screen spinner while table metadata resolves
* - `<RecordView>` wired with the fetched record, loading state, error state,
* a refetch callback, the list of available processes, and all table definitions
* (needed to render association sub-tables)
*/
export default function RecordViewPage() {
const params = useParams<{ slug: string; recordId: string }>()
const params = useRouteParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const { data: metaData } = useQuery({
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
const { record, isLoading, isError, error, refetch } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
enabled: canReadRecords(tableMetaData),
includeAssociations: false,
})
const targetNames = [...new Set(tableMetaData?.associations?.map((association) => association.associatedTableName) ?? [])]
// Associated tables hidden from the user (absent from their metadata) are neither requested nor shown;
// disabled ones (listed, readPermission false) keep their panel so permitted actions such as Add remain.
const deniedTargets = new Set(targetNames.filter((name) => metaData?.tables && !metaData.tables[name]))
const readableTargets = targetNames.filter((name) => !deniedTargets.has(name))
const targetQueries = useQueries({ queries: readableTargets.map((name) => ({
queryKey: queryKeys.tableMetadata(name),
queryFn: () => loadTableMetaData(name),
staleTime: 1000 * 60 * 30,
})) })
const associationTables: Record<string, AssociationTableState> = Object.fromEntries(targetNames.map((name) => {
if (deniedTargets.has(name)) return [name, { isLoading: false, isError: false, isDenied: true }]
const target = targetQueries[readableTargets.indexOf(name)]
return [name, { table: target.data, isLoading: target.isLoading, isError: target.isError }]
}))
// The legacy endpoint expands every association (all or nothing), so it is not requested
// when an associated table is hidden from the user or listed without read permission
// (DenyBehavior.DISABLED; its panel says the records are unavailable). A denial must not
// hide the base record.
const unreadableTargets = readableTargets.filter((name) => metaData?.tables?.[name]?.readPermission === false)
const associations = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(record) && Boolean(tableMetaData?.associations?.length) && Boolean(metaData)
&& deniedTargets.size === 0 && unreadableTargets.length === 0,
includeAssociations: true,
})
const displayRecord = record && associations.record && !associations.isError
? { ...record, associatedRecords: associations.record.associatedRecords }
: record
useEffect(() => {
if (record?.recordLabel) {
@@ -59,6 +136,10 @@ export default function RecordViewPage() {
}
}, [record, tableMetaData])
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
}
if (!tableMetaData) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
@@ -67,18 +148,38 @@ export default function RecordViewPage() {
)
}
if (!canReadRecords(tableMetaData)) {
return (
<div className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center" role="alert" data-qqq-id="permission-denied">
<p className="text-sm text-yellow-700">You do not have permission to view {tableMetaData.label} records.</p>
</div>
)
}
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
return (
<RecordView
tableMetaData={tableMetaData}
record={record}
isLoading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
processes={tableProcesses}
allTables={metaData?.tables}
/>
<>
{record && associations.isLoading && <p role="status">Loading related records...</p>}
{record && associations.isError && (
<p role="alert" className="mb-4 text-destructive">Related records could not be loaded.</p>
)}
<RecordView
tableMetaData={tableMetaData}
record={displayRecord}
isLoading={isLoading}
isError={isError}
error={error}
onRefetch={() => {
refetch()
if (tableMetaData.associations?.length) associations.refetch()
}}
processes={tableProcesses}
allTables={metaData?.tables}
widgetMetaDataMap={metaData?.widgets}
associationTables={associationTables}
auditSource={auditSource(metaData)}
/>
</>
)
}
+56 -9
View File
@@ -1,29 +1,69 @@
/*
* 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 EntityCreate page — serves the route `/app/[slug]/create` and renders the record-creation form.
*/
'use client'
// EntityCreate — create a new record
// Package 3: full implementation using EntityForm component
/**
* EntityCreate page — serves the route `/app/[slug]/create` and renders the
* record-creation form for the table identified by `slug`.
*
* Route params:
* - `slug` — the QQQ table name for which a new record should be created.
*/
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { EntityForm } from '@/components/forms/EntityForm'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import { canInsertRecords, hasCapability } from '@/lib/auth/permissions'
/**
* Renders the record-creation form for the table identified by `slug`.
*
* Fetches application metadata to resolve the table definition, then renders
* `<EntityForm>` with no initial record (create mode). Shows a permission
* error banner when the user lacks `insertPermission` on the table, and a
* loading spinner while metadata is being fetched.
*
* @returns A composed page that renders one of:
* - A full-screen spinner while metadata resolves
* - A permission-error banner when the user lacks `insertPermission`
* - `<EntityForm>` in create mode (no `record` prop) wrapped in a centered `max-w-4xl` container
*/
export default function EntityCreatePage() {
const params = useParams<{ slug: string }>()
const params = useRouteParams<{ slug: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const slug = params.slug
const { data: metaData } = useQuery({
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { data: tableMetaData, isError: tableError } = useTableMetaData(metaData?.tables?.[slug] ? slug : undefined)
useEffect(() => {
setPageHeader(`Create ${tableMetaData?.label ?? slug}`)
@@ -32,6 +72,10 @@ export default function EntityCreatePage() {
}
}, [tableMetaData?.label, slug, tableMetaData, setPageHeader, setTableMetaData])
if (metadataError || tableError || (metaData && !metaData.tables?.[slug])) {
return <div role="alert" className="py-12 text-center text-destructive">Table metadata is unavailable.</div>
}
if (!tableMetaData) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
@@ -40,14 +84,17 @@ export default function EntityCreatePage() {
)
}
if (!tableMetaData.insertPermission) {
if (!canInsertRecords(tableMetaData)) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
data-qqq-id="permission-denied"
>
<p className="text-sm text-yellow-700">
You do not have permission to create {tableMetaData.label} records.
{!hasCapability(tableMetaData, 'TABLE_INSERT')
? `${tableMetaData.label} records cannot be created.`
: `You do not have permission to create ${tableMetaData.label} records.`}
</p>
</div>
)
@@ -55,7 +102,7 @@ export default function EntityCreatePage() {
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-create-${slug}`}>
<EntityForm tableMetaData={tableMetaData} />
<EntityForm tableMetaData={tableMetaData} widgets={metaData?.widgets} />
</div>
)
}
+48 -5
View File
@@ -1,19 +1,45 @@
/*
* 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 TableDeveloperView page — shows raw table metadata as formatted JSON for debugging field definitions and permissions.
*/
'use client'
// TableDeveloperView — shows raw table metadata as formatted JSON
// Useful for debugging field definitions, sections, and permissions
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadTableMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
/**
* Renders a developer debug view for a QQQ table, showing summary statistics
* and the full table metadata as a collapsible JSON block.
*
* @returns A composed page that assembles:
* - A 4-column stats grid (`<MetaStat>` cards: field count, section count, permissions, primary key)
* - A collapsible `<JsonBlock>` panel rendering the full table metadata as formatted JSON
* - A loading spinner while metadata is fetching, and a destructive error panel on failure
*/
export default function TableDeveloperViewPage() {
const params = useParams<{ slug: string }>()
const params = useRouteParams<{ slug: string }>()
const { setPageHeader } = useQContext()
const slug = params.slug
@@ -83,6 +109,14 @@ export default function TableDeveloperViewPage() {
)
}
/**
* Renders a single labeled statistic card.
*
* @param props - Component props.
* @param props.label - The stat label displayed above the value.
* @param props.value - The numeric or string value to display prominently.
* @returns A bordered card showing the label and value.
*/
function MetaStat({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-xl border border-border bg-card p-4">
@@ -96,6 +130,15 @@ function MetaStat({ label, value }: { label: string; value: string | number }) {
)
}
/**
* 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,
+87 -12
View File
@@ -1,26 +1,101 @@
/*
* 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-by-key page — `/app/{table}/key?{field}={value}...` opens the one record matching the given field values.
*/
'use client'
// RecordViewByUniqueKey — view a record by unique key
// Full implementation in Package 3
import React, { useEffect, useMemo } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import { useQContext } from '@/lib/context/q-context'
import { queryRecords } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
/**
* Looks up a record by field values from the query string, as Material
* Dashboard's `RecordViewByUniqueKey` does (typically a unique key, e.g.
* `/app/person/key?email=avery@example.invalid`).
*
* Each parameter becomes an `EQUALS` criterion. Exactly one match replaces the
* URL with the record's view page; no match, several matches, an unknown field
* or no parameters show an error.
*
* @returns A loading state, or an alert explaining why no single record matched.
*/
export default function RecordViewByKeyPage() {
const params = useParams<{ slug: string }>()
const { slug } = useRouteParams<{ slug: string }>()
const searchParams = useSearchParams()
const router = useRouter()
const { setPageHeader } = useQContext()
const slug = params.slug
const { data: table, isError: tableError } = useTableMetaData(slug)
const criteria = useMemo(() => [...searchParams.entries()], [searchParams])
const unknownField = table ? criteria.find(([fieldName]) => !table.fields?.[fieldName])?.[0] : undefined
const lookup = useQuery({
queryKey: [...queryKeys.tableRecords(slug), 'byKey', searchParams.toString()],
queryFn: () => queryRecords(slug, {
filter: {
criteria: criteria.map(([fieldName, value]) => ({ fieldName, operator: 'EQUALS' as const, values: [value] })),
booleanOperator: 'AND',
limit: 2,
},
}),
enabled: Boolean(table) && criteria.length > 0 && !unknownField,
})
useEffect(() => {
setPageHeader(`View ${slug} by Key`)
}, [slug, setPageHeader])
setPageHeader(`View ${table?.label ?? slug} by Key`)
}, [slug, table?.label, setPageHeader])
const records = lookup.data?.records
const match = records?.length === 1 ? records[0] : undefined
const primaryKey = match && table ? match.values[table.primaryKeyField] : undefined
useEffect(() => {
if (primaryKey !== undefined && primaryKey !== null) {
router.replace(`/app/${encodeURIComponent(slug)}/${encodeURIComponent(String(primaryKey))}`)
}
}, [primaryKey, router, slug])
let error: string | undefined
if (tableError) error = `Could not load the ${slug} table.`
else if (table && criteria.length === 0) error = `Add field values to the address to look up a ${table.label} record, for example ?${table.primaryKeyField}=1.`
else if (table && unknownField) error = `Query-string parameter [${unknownField}] is not a defined field on the ${table.label} table.`
else if (lookup.isError) error = lookup.error instanceof Error ? lookup.error.message : 'Unexpected error running query'
else if (table && records?.length === 0) error = `No ${table.label} record was found matching the given values.`
else if (table && records && records.length > 1) error = `More than one ${table.label} record was found matching the given values.`
if (error) {
return (
<div role="alert" className="rounded-xl border border-destructive/40 bg-destructive/10 p-6 text-sm text-destructive" data-qqq-id={`record-view-key-error-${slug}`}>
{error}
</div>
)
}
return (
<div data-qqq-id={`record-view-key-${slug}`}>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="text-muted-foreground">Record view by key -- implemented in Package 3</p>
</div>
<div role="status" aria-label="Looking up record" className="flex items-center justify-center py-12" data-qqq-id={`record-view-key-${slug}`}>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Prerenders this dynamic segment once for the Javalin-served static export.
* The server returns that page for every concrete value; see use-route-params.
*/
import type { ReactNode } from 'react'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/**
* Static export placeholder params for this segment.
*
* @returns One placeholder entry.
*/
export function generateStaticParams() {
return [{ slug: EXPORT_PLACEHOLDER }]
}
/**
* Pass-through layout that exists to carry generateStaticParams.
*
* @param props - Layout props.
* @param props.children - Route content.
* @returns The children unchanged.
*/
export default function Layout({ children }: { children: ReactNode }) {
return children
}
@@ -0,0 +1,193 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { QContextProvider } from '@/lib/context/q-context'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import * as processesApi from '@/lib/api/processes'
import SlugPage from './page'
const { recordQuery, params, location } = vi.hoisted(() => ({
recordQuery: vi.fn(() => <div data-testid="record-query" />),
params: { slug: 'person' },
location: { search: '' },
}))
vi.mock('next/navigation', () => ({
useParams: () => params,
usePathname: () => '/app/' + params.slug,
useSearchParams: () => new URLSearchParams(location.search),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/components/query', () => ({ RecordQuery: recordQuery }))
vi.mock('@/components/widgets', () => ({ AppHome: () => <div>App home</div> }))
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={client}>
<QContextProvider><SlugPage /></QContextProvider>
</QueryClientProvider>
)
}
// The V1 registry deliberately omits fields, sections, primary keys and joins.
const registry = {
...qInstance,
tables: Object.fromEntries(Object.entries(qInstance.tables).map(([name, table]) => [name, {
name, label: table.label, readPermission: table.readPermission,
insertPermission: table.insertPermission,
}])),
}
describe('SlugPage table metadata readiness', () => {
beforeEach(() => {
params.slug = 'person'
location.search = ''
recordQuery.mockClear()
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)))
})
it('waits for full active metadata and passes the light permission registry separately', async () => {
let releaseTable!: () => void
const tableReady = new Promise<void>((resolve) => { releaseTable = resolve })
let requested = false
server.use(http.get('/qqq/v1/metaData/table/person', async () => {
requested = true
await tableReady
return HttpResponse.json(qInstance.tables.person)
}))
renderPage()
try {
await waitFor(() => expect(requested).toBe(true))
expect(screen.queryByTestId('record-query')).not.toBeInTheDocument()
expect(screen.getByRole('status', { name: 'Loading table metadata' })).toBeInTheDocument()
} finally {
await act(async () => { releaseTable() })
}
await screen.findByTestId('record-query')
expect(recordQuery).toHaveBeenLastCalledWith(expect.objectContaining({
tableMetaData: qInstance.tables.person, allTables: registry.tables,
}), undefined)
expect(registry.tables.person).not.toHaveProperty('fields')
})
it('shows full-table failure without rendering a query from registry metadata', async () => {
server.use(http.get('/qqq/v1/metaData/table/person', () =>
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })))
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load table metadata')
expect(recordQuery).not.toHaveBeenCalled()
})
it('does not fetch full table metadata when an app takes priority over a same-name table', async () => {
const metadata = { ...registry, apps: { person: { name: 'person', label: 'People app' } } }
let requested = false
server.use(
http.get('/qqq/v1/metaData', () => HttpResponse.json(metadata)),
http.get('/qqq/v1/metaData/table/person', () => {
requested = true
return HttpResponse.json(qInstance.tables.person)
}),
)
renderPage()
await screen.findByText('App home')
expect(requested).toBe(false)
expect(recordQuery).not.toHaveBeenCalled()
})
})
const greeting = {
name: 'greetInteractive', label: 'Greet Interactive', hasPermission: true,
tableName: 'person', minInputRecords: 1, maxInputRecords: 2,
frontendSteps: [{
name: 'setup', label: 'Setup', components: [{ type: 'EDIT_FORM' }],
formFields: [{ name: 'greetingPrefix', label: 'Greeting Prefix', type: 'STRING', isEditable: true, isHidden: false, isRequired: false }],
}],
}
describe('SlugPage process initialization', () => {
beforeEach(() => {
vi.restoreAllMocks()
params.slug = greeting.name
location.search = 'recordsParam=recordIds&recordIds=1,3'
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json({
...registry, processes: { [greeting.name]: { name: greeting.name, label: greeting.label } },
})))
})
it.each([
['recordIds', 'recordIds', '1,3', 'recordIds'],
['filterJSON', 'filterJSON', '{"criteria":[]}', 'filterJSON'],
['queryFilter', 'filterJSON', '{"criteria":[]}', 'filterJSON'],
])('loads full metadata before initializing with %s selection', async (selector, field, value, expectedSelector) => {
location.search = new URLSearchParams({ recordsParam: selector, [field]: value }).toString()
let releaseMetadata!: () => void
const metadataReady = new Promise<void>((resolve) => { releaseMetadata = resolve })
const initialize = vi.spyOn(processesApi, 'processInit').mockResolvedValue({
type: 'COMPLETE', processUUID: 'selected-run', nextStep: 'setup', values: {},
})
server.use(
http.get('/metaData/process/greetInteractive', async () => {
await metadataReady
return HttpResponse.json({ process: greeting })
}),
)
renderPage()
await screen.findByRole('status', { name: 'Loading process metadata' })
expect(initialize).not.toHaveBeenCalled()
await act(async () => { releaseMetadata() })
await screen.findByRole('textbox', { name: 'Greeting Prefix' })
expect(initialize).toHaveBeenCalledWith(greeting.name, {
recordsParam: expectedSelector, [field]: value, tableName: 'person',
})
})
it('does not initialize when full process metadata is denied', async () => {
let initialized = false
server.use(
http.get('/metaData/process/greetInteractive', () =>
HttpResponse.json({ error: 'Permission denied' }, { status: 403 })),
http.post('/processes/greetInteractive/init', () => {
initialized = true
return HttpResponse.json({ processUUID: 'unexpected', values: {} })
}),
)
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load process metadata')
expect(initialized).toBe(false)
})
})
describe('SlugPage unknown names', () => {
beforeEach(() => {
location.search = ''
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)))
})
it('shows the not-found state with a link to the dashboard (regression: bare "Unknown resource")', async () => {
params.slug = 'noSuchThing'
renderPage()
expect(await screen.findByRole('heading', { level: 1, name: 'Page not found' })).toBeInTheDocument()
expect(screen.getByText('noSuchThing')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Go to the dashboard' })).toHaveAttribute('href', '/app')
expect(screen.queryByText(/Unknown resource/)).not.toBeInTheDocument()
})
})
+162 -46
View File
@@ -1,27 +1,113 @@
/*
* 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 Unified slug page — dispatches to AppHome, RecordQuery, ProcessRun, or ReportRun based on the slug.
*/
'use client'
// Unified slug page — routes to AppHome, RecordQuery, or ProcessRun
// based on what the slug resolves to in the app metadata
/**
* Unified slug page — serves the route `/app/[slug]` and dispatches to
* `AppHome`, `RecordQuery`, or `ProcessRun` based on how the slug resolves
* against the QQQ application metadata.
*
* Route params:
* - `slug` — a QQQ app name, table name, process name, or report name.
*/
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useSearchParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { FileBarChart } from 'lucide-react'
import type { QInstance } from '@/types'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { useProcessMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
import type { ProcessInitRequest } from '@/lib/api/processes'
import { queryKeys } from '@/lib/query-client'
import { getProcessesForTable } from '@/lib/utils/process-utils'
import { canAccessProcess, canReadRecords } from '@/lib/auth/permissions'
import { RecordQuery } from '@/components/query'
import { ProcessRun } from '@/components/process'
import { AppHome } from '@/components/widgets'
import { ReportRun } from '@/components/reports'
import { NotFoundState } from '@/components/layout/NotFoundState'
/**
* Resolves a URL slug to its QQQ resource type and name.
*
* Checks the slug against apps, tables, processes, and reports in priority order.
* Returns a discriminated union describing the match, or `null` when the slug is
* not found in any category.
*
* Extracted as a named function for testability — the caller (`SlugPage`) passes
* the already-fetched metadata so this function remains a pure mapping with no
* side effects or data fetching.
*
* @param slug - The URL path segment to resolve (e.g. `"orders"` or `"importData"`).
* @param metaData - The full QQQ instance metadata fetched from the backend.
* @returns A `{ type, name }` object describing the resolved resource, or `null`.
*/
export function resolveSlugTarget(
slug: string,
metaData: QInstance
): { type: 'app' | 'table' | 'process' | 'report'; name: string } | null {
if (metaData.apps?.[slug]) {
return { type: 'app', name: slug }
}
if (metaData.tables?.[slug]) {
return { type: 'table', name: slug }
}
if (metaData.processes?.[slug]) {
return { type: 'process', name: slug }
}
if (metaData.reports?.[slug]) {
return { type: 'report', name: slug }
}
return null
}
/**
* Renders the appropriate page component for a given `slug` URL segment.
*
* Resolution priority:
* 1. If the slug matches a QQQ **app** → renders `<AppHome>` (dashboard widgets).
* 2. If the slug matches a **table** → renders `<RecordQuery>` (data grid + filters).
* 3. If the slug matches a **process** → renders `<ProcessRun>` (step wizard).
* 4. If the slug matches a **report** → renders `<ReportRun>` (format selector + download).
* 5. Otherwise → renders a not-found state (the backend omits objects the user may not access).
*
* The page header in QContext is updated whenever the resolution changes.
*
* @returns A composed page selected by slug resolution:
* - `<AppHome>` (dashboard widgets) when the slug matches a QQQ app
* - `<RecordQuery>` (data grid + filters + pagination) when the slug matches a table
* - `<ProcessRun>` (step wizard) when the slug matches a process
* - `<ReportRun>` (format selector + download) when the slug matches a report
* - A full-screen spinner while metadata is loading or the resource is resolving
* - A not-found state when the slug matches nothing the user can access
*/
export default function SlugPage() {
const params = useParams<{ slug: string }>()
const params = useRouteParams<{ slug: string }>()
const searchParams = useSearchParams()
const { setPageHeader, setTableMetaData } = useQContext()
const slug = params.slug
const { data: metaData } = useQuery({
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
@@ -34,8 +120,12 @@ export default function SlugPage() {
const isReport = Boolean(metaData?.reports?.[slug])
const app = metaData?.apps?.[slug]
const table = metaData?.tables?.[slug]
const process = metaData?.processes?.[slug]
// Objects the user may not use (DenyBehavior.DISABLED) are listed in metadata with
// their permission flag false: show why, and do not load or start them.
const tableDenied = isTable && !isApp && !canReadRecords(metaData?.tables?.[slug])
const processDenied = isProcess && !isApp && !isTable && !canAccessProcess(metaData?.processes?.[slug])
const { data: table, isError: tableError } = useTableMetaData(isTable && !isApp && !tableDenied ? slug : undefined)
const { data: process, isError: processError } = useProcessMetaData(isProcess && !isApp && !isTable && !processDenied ? slug : undefined)
const report = metaData?.reports?.[slug]
useEffect(() => {
@@ -47,11 +137,30 @@ export default function SlugPage() {
} else if (isProcess) {
setPageHeader(process?.label ?? slug)
} else if (isReport) {
setPageHeader((report as { label?: string })?.label ?? slug)
} else {
setPageHeader(slug)
setPageHeader(report?.label ?? slug)
} else if (metaData) {
setPageHeader('Not Found')
}
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, setPageHeader, setTableMetaData])
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, metaData, setPageHeader, setTableMetaData])
if (tableDenied || processDenied) {
const label = (tableDenied ? metaData?.tables?.[slug]?.label : metaData?.processes?.[slug]?.label) ?? slug
return (
<div className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center" role="alert" data-qqq-id="permission-denied">
<p className="text-sm text-yellow-700">
{tableDenied ? `You do not have permission to view ${label} records.` : `You do not have permission to run ${label}.`}
</p>
</div>
)
}
if (metadataError || (isTable && !isApp && tableError) || (isProcess && !isApp && !isTable && processError)) {
return (
<div role="alert" className="py-12 text-center text-destructive">
Failed to load {metadataError ? 'application' : isTable ? 'table' : 'process'} metadata.
</div>
)
}
if (!metaData) {
return (
@@ -66,6 +175,7 @@ export default function SlugPage() {
return (
<AppHome
appMetaData={app}
instance={metaData}
widgetRegistry={metaData.widgets ?? {}}
/>
)
@@ -74,13 +184,13 @@ export default function SlugPage() {
// Table record query — Package 2 implementation
if (isTable && table) {
const tableProcesses = getProcessesForTable(metaData, slug)
return <RecordQuery tableName={slug} tableMetaData={table} processes={tableProcesses} />
return <RecordQuery key={slug} tableName={slug} tableMetaData={table} allTables={metaData.tables} processes={tableProcesses} metaData={metaData} />
}
// Table loading state (table found but metadata not yet available)
if (isTable && !table) {
return (
<div className="flex items-center justify-center py-12">
<div role="status" aria-label="Loading table metadata" className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
@@ -88,11 +198,45 @@ export default function SlugPage() {
// Process run — Package 4 implementation
if (isProcess && process) {
return <ProcessRun processName={slug} processMetaData={process} />
const initialRequest: ProcessInitRequest = {}
const selection = searchParams.get('recordsParam')
if (selection === 'recordIds' || (!selection && searchParams.get('recordIds'))) {
initialRequest.recordsParam = 'recordIds'
initialRequest.recordIds = searchParams.get('recordIds') ?? ''
} else if (selection === 'filterJSON' || selection === 'queryFilter' || (!selection && searchParams.get('filterJSON'))) {
initialRequest.recordsParam = 'filterJSON'
initialRequest.filterJSON = searchParams.get('filterJSON') ?? ''
}
////////////////////////////////////////////////////////////////////////
// links may preset process inputs, as in the Material dashboard: //
// ?defaultProcessValues={"name":"value"} //
////////////////////////////////////////////////////////////////////////
let initialValues: Record<string, unknown> | undefined
try {
const parsed: unknown = JSON.parse(searchParams.get('defaultProcessValues') ?? 'null')
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) initialValues = parsed as Record<string, unknown>
} catch {
initialValues = undefined
}
return <ProcessRun key={`${slug}?${searchParams}`} processName={slug} processMetaData={process} initialRequest={initialRequest} initialValues={initialValues} />
}
// Process loading state (process found but metadata not yet available)
if (isProcess && !process) {
return (
<div role="status" aria-label="Loading process metadata" className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
// Report run
if (isReport && report) {
return <ReportRun reportName={slug} reportMetaData={report} />
}
// Report loading state (report found but metadata not yet available)
if (isReport && !report) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
@@ -100,34 +244,6 @@ export default function SlugPage() {
)
}
// Report run
if (isReport) {
return (
<div className="space-y-6" data-qqq-id={`report-run-${slug}`}>
<div className="flex items-center gap-3">
<FileBarChart className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<h2 className="text-2xl font-semibold text-foreground">
{(report as { label?: string })?.label ?? slug}
</h2>
</div>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="mt-4 text-muted-foreground">
Report — implemented in a future package
</p>
</div>
</div>
)
}
// Unknown slug
return (
<div className="space-y-4" data-qqq-id={`unknown-slug-${slug}`}>
<h2 className="text-2xl font-semibold text-foreground">{slug}</h2>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="text-muted-foreground">
Unknown resource: <code className="font-mono">{slug}</code>
</p>
</div>
</div>
)
// Unknown, hidden-and-denied, or unpermitted slug: the backend exposes nothing by this name
return <NotFoundState name={slug} />
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Prerenders this dynamic segment once for the Javalin-served static export.
* The server returns that page for every concrete value; see use-route-params.
*/
import type { ReactNode } from 'react'
import { EXPORT_PLACEHOLDER } from '@/lib/utils/export-placeholder'
/**
* Static export placeholder params for this segment.
*
* @returns One placeholder entry.
*/
export function generateStaticParams() {
return [{ viewId: EXPORT_PLACEHOLDER }]
}
/**
* Pass-through layout that exists to carry generateStaticParams.
*
* @param props - Layout props.
* @param props.children - Route content.
* @returns The children unchanged.
*/
export default function Layout({ children }: { children: ReactNode }) {
return children
}
@@ -1,31 +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.
*/
/**
* @file SavedView page — the record query screen opened with a backend saved view
* (`/app/{table}/savedView/{id}`), as in the Material dashboard.
*/
'use client'
// RecordQuery with Saved View — loads a saved filter/view configuration
// Full implementation in Package 2
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQContext } from '@/lib/context/q-context'
import { useQuery } from '@tanstack/react-query'
import { useRouteParams } from '@/lib/hooks/use-route-params'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import { queryKeys } from '@/lib/query-client'
import { getProcessesForTable } from '@/lib/utils/process-utils'
import { RecordQuery } from '@/components/query'
/**
* Saved view route: resolves the table and renders its query screen with the view applied.
*
* @returns The query screen, a loading indicator, or an error for unknown tables or ids.
*/
export default function SavedViewPage() {
const params = useParams<{ slug: string; viewId: string }>()
const { setPageHeader } = useQContext()
const { slug, viewId } = params
const { slug, viewId } = useRouteParams<{ slug: string; viewId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { data: metaData, isError: metadataError } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const isTable = Boolean(metaData?.tables?.[slug])
const { data: table, isError: tableError } = useTableMetaData(isTable ? slug : undefined)
const id = /^\d+$/.test(viewId) ? Number(viewId) : NaN
useEffect(() => {
setPageHeader(`${slug} — Saved View`)
}, [slug, setPageHeader])
setPageHeader(table?.label ?? slug)
if (table) setTableMetaData(table)
}, [slug, table, setPageHeader, setTableMetaData])
if (metadataError || tableError || (metaData && !isTable) || !Number.isFinite(id)) {
return (
<div role="alert" className="py-12 text-center text-destructive" data-qqq-id={`saved-view-${slug}-${viewId}`}>
{metaData && !isTable ? <>Unknown table: <code className="font-mono">{slug}</code></> : !Number.isFinite(id) ? 'The requested view was not found.' : 'Failed to load table metadata.'}
</div>
)
}
if (!metaData || !table) {
return (
<div role="status" aria-label="Loading saved view" className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
return (
<div data-qqq-id={`saved-view-${slug}-${viewId}`}>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="text-muted-foreground">
Saved view -- implemented in Package 2
</p>
<p className="mt-1 text-sm text-muted-foreground">
View ID: <code className="font-mono">{viewId}</code>
</p>
</div>
</div>
<RecordQuery
key={`${slug}-${id}`}
tableName={slug}
tableMetaData={table}
allTables={metaData.tables}
processes={getProcessesForTable(metaData, slug)}
metaData={metaData}
savedViewId={id}
/>
)
}
@@ -0,0 +1,397 @@
/*
* 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 { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import apiClient from '@/lib/api/client'
import { QContextProvider } from '@/lib/context/q-context'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import EntityCreatePage from './create/page'
import EntityEditPage from './[recordId]/edit/page'
import EntityCopyPage from './[recordId]/copy/page'
const { push } = vi.hoisted(() => ({ push: vi.fn() }))
vi.mock('next/navigation', () => ({
useParams: () => ({ slug: 'person', recordId: '1' }),
usePathname: () => '/app/person/1',
useRouter: () => ({ push, replace: vi.fn(), back: vi.fn() }),
}))
function fixture() {
const person = structuredClone(qInstance.tables.person)
person.fields = Object.fromEntries(['id', 'firstName', 'lastName', 'email'].map((name) => [name, person.fields[name]]))
person.fields.firstName.label = 'Preferred given name'
person.sections = [{ name: 'basic', label: 'Person', isHidden: false, fieldNames: ['firstName', 'lastName', 'email'] }]
const registry = { ...qInstance, tables: { person: { name: 'person', label: 'Person', readPermission: true, insertPermission: true, editPermission: true } } }
server.use(
http.get('/qqq/v1/metaData', () => HttpResponse.json(registry)),
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(person)),
)
return person
}
function renderPage(page: React.ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(<QueryClientProvider client={client}><QContextProvider>{page}</QContextProvider></QueryClientProvider>)
}
describe('Create and base edit use full metadata and actual write contracts', () => {
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
it('edits the base without requesting denied children or replacing associations', async () => {
const reads: string[] = []
server.use(
http.get('/data/person/1', ({ request }) => {
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
reads.push(mode)
return mode === 'false' ? HttpResponse.json({ tableName: 'person', values: {
id: 1, firstName: 'Avery', lastName: 'Sample', email: 'avery@example.invalid', unknown: 'must not echo',
} }) : HttpResponse.json({ error: 'Children denied' }, { status: 403 })
}),
)
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 1, firstName: 'Updated' } }] })
renderPage(<EntityEditPage />)
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Updated' } })
fireEvent.change(screen.getByRole('textbox', { name: /Email/ }), { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/1'))
expect(reads.length).toBeGreaterThan(0)
expect(new Set(reads)).toEqual(new Set(['false']))
expect(put.mock.calls[0][0]).toBe('/data/person/1')
const submitted = put.mock.calls[0][1] as FormData
expect(submitted?.get('firstName')).toBe('Updated')
expect(submitted?.get('email')).toBe('')
expect(submitted?.has('associations')).toBe(false)
expect(submitted?.has('unknown')).toBe(false)
})
it('creates from full fields and unwraps the legacy envelope before navigating', async () => {
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 7 } }] })
renderPage(<EntityCreatePage />)
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Created' } })
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/7'))
expect(post.mock.calls[0][0]).toBe('/data/person')
const submitted = post.mock.calls[0][1] as FormData
expect(submitted?.get('firstName')).toBe('Created')
expect(submitted?.has('associations')).toBe(false)
})
it('copies only base fields after full metadata is ready, without requiring child READ', async () => {
const person = fixture()
const source = { tableName: 'person', values: {
id: 1, firstName: 'Avery', lastName: 'Sample', email: 'avery@example.invalid', unknown: 'must not echo',
} }
const sourceSnapshot = structuredClone(source)
let releaseMetadata: () => void = () => {}
const pendingMetadata = new Promise<void>((resolve) => { releaseMetadata = resolve })
let requestedMetadata = false
const reads: string[] = []
server.use(
http.get('/qqq/v1/metaData/table/person', async () => {
requestedMetadata = true
await pendingMetadata
return HttpResponse.json(person)
}),
http.get('/data/person/1', ({ request }) => {
const mode = new URL(request.url).searchParams.get('includeAssociations') ?? ''
reads.push(mode)
return mode === 'false' ? HttpResponse.json(source) : HttpResponse.json({ error: 'Children denied' }, { status: 403 })
}),
)
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 8 } }] })
renderPage(<EntityCopyPage />)
await waitFor(() => expect(requestedMetadata).toBe(true))
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
expect(reads).toEqual([])
await act(async () => { releaseMetadata() })
expect(await screen.findByLabelText(/Preferred given name/)).toHaveValue('Avery')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/8'))
expect(new Set(reads)).toEqual(new Set(['false']))
const submitted = post.mock.calls[0][1] as FormData
expect(submitted.get('firstName')).toBe('Avery')
expect(submitted.has('id')).toBe(false)
expect(submitted.has('associations')).toBe(false)
expect(submitted.has('unknown')).toBe(false)
expect(source).toEqual(sourceSnapshot)
})
it('retains the populated form after a failed save without an unhandled rejection', async () => {
vi.spyOn(apiClient, 'post').mockRejectedValue(new Error('Write denied'))
renderPage(<EntityCreatePage />)
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Keep input' } })
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Write denied')
expect(screen.getByLabelText(/Preferred given name/)).toHaveValue('Keep input')
expect(push).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
})
it('submits untouched optional numeric inputs as blanks and preserves typed zero', async () => {
const person = fixture()
for (const [name, type] of [['quantity', 'INTEGER'], ['price', 'DECIMAL'], ['total', 'LONG']] as const) {
person.fields[name] = { ...person.fields.firstName, name, label: name, type, isRequired: false }
person.sections[0].fieldNames.push(name)
}
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
renderPage(<EntityCreatePage />)
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Numeric' } })
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
fireEvent.change(screen.getByLabelText('total'), { target: { value: '0' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/9'))
const submitted = post.mock.calls[0][1] as FormData
expect(submitted.get('quantity')).toBe('')
expect(submitted.get('price')).toBe('')
expect(submitted.get('total')).toBe('0')
})
it('keeps the form disabled for the complete pending mutation', async () => {
let finish: (response: unknown) => void = () => {}
const pending = new Promise((resolve) => { finish = resolve })
const post = vi.spyOn(apiClient, 'post').mockReturnValue(pending)
renderPage(<EntityCreatePage />)
const firstName = await screen.findByLabelText(/Preferred given name/)
fireEvent.change(firstName, { target: { value: 'Pending' } })
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Save' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
const saving = await screen.findByRole('button', { name: 'Saving...' })
expect(saving).toBeDisabled()
expect(screen.getByLabelText(/Preferred given name/)).toBeDisabled()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
fireEvent.click(saving)
expect(post).toHaveBeenCalledTimes(1)
await act(async () => { finish({ records: [{ tableName: 'person', values: { id: 8 } }] }) })
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/8'))
})
it('navigates with the metadata-defined string primary key as one encoded segment', async () => {
const person = fixture()
person.primaryKeyField = 'recordCode'
person.fields.recordCode = { ...person.fields.firstName, name: 'recordCode', label: 'Code', isEditable: false }
vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { recordCode: 'A/B#1' } }] })
renderPage(<EntityCreatePage />)
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Natural' } })
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Key' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(push).toHaveBeenCalledWith('/app/person/A%2FB%231'))
})
it('does not announce a saved record when the response has no usable primary key', async () => {
vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: {} }] })
renderPage(<EntityCreatePage />)
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Keep input' } })
fireEvent.change(screen.getByLabelText(/Last Name/), { target: { value: 'Sample' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('alert')).toHaveTextContent('The server did not return a valid record identifier.')
expect(push).not.toHaveBeenCalled()
})
it.each(['create', 'edit', 'copy'])('shows a full-metadata failure for %s without rendering an unusable form', async (mode) => {
server.use(http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json({ error: 'Metadata failed' }, { status: 500 })))
renderPage(mode === 'create' ? <EntityCreatePage /> : mode === 'edit' ? <EntityEditPage /> : <EntityCopyPage />)
expect(await screen.findByRole('alert')).toHaveTextContent('Table metadata is unavailable.')
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
})
it.each(['missing', 'error'])('shows unavailable copy registry metadata for %s', async (mode) => {
server.use(http.get('/qqq/v1/metaData', () => mode === 'missing'
? HttpResponse.json({ ...qInstance, tables: {} }) : HttpResponse.json({ error: 'Unavailable' }, { status: 500 })))
renderPage(<EntityCopyPage />)
expect(await screen.findByRole('alert')).toHaveTextContent('Table metadata is unavailable.')
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
})
it.each(['readPermission', 'insertPermission'] as const)('does not load a copy source without %s', async (permission) => {
const person = fixture()
person[permission] = false
const read = vi.fn()
server.use(http.get('/data/person/1', () => {
read()
return HttpResponse.json({ tableName: 'person', values: { id: 1, firstName: 'Must not load' } })
}))
renderPage(<EntityCopyPage />)
expect(await screen.findByRole('alert')).toHaveTextContent(permission === 'readPermission'
? 'You do not have permission to read the source People record.' : 'You do not have permission to create People records.')
expect(read).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
})
it('shows a failed base-copy source read without offering a blank create form', async () => {
server.use(http.get('/data/person/1', () => HttpResponse.json({ error: 'Source denied' }, { status: 403 })))
renderPage(<EntityCopyPage />)
expect(await screen.findByRole('alert')).toHaveTextContent('You do not have permission to view People records')
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
})
})
describe('Explicit full copy', () => {
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
it('keeps child edits in one root submission with exact names and fresh manual keys', async () => {
const parent = fixture()
parent.associations = [{ name: 'care / primary', associatedTableName: 'child', join: {
name: 'personToChild', leftTable: 'person', rightTable: 'child', type: 'ONE_TO_MANY', joinOns: [{ leftField: 'id', rightField: 'owner' }],
} }]
const child = { ...structuredClone(parent), name: 'child', label: 'Child', primaryKeyField: 'manualKey', associations: [], fields: {
manualKey: { ...parent.fields.firstName, name: 'manualKey', label: 'New child key', isRequired: true },
owner: { ...parent.fields.id, name: 'owner', isEditable: true, isRequired: true },
firstName: { ...parent.fields.firstName, label: 'Child name' },
}, sections: [] }
const values = { id: 1, firstName: 'Parent', lastName: 'Source', email: 'copy@example.invalid' }
const originalChild = { tableName: 'child', values: { manualKey: 'old-child', owner: 1, firstName: 'Original child' } }
const reads: string[] = []
server.use(
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(parent)),
http.get('/qqq/v1/metaData/table/child', () => HttpResponse.json(child)),
http.get('/data/person/1', ({ request }) => {
const expanded = new URL(request.url).searchParams.get('includeAssociations') === 'true'; reads.push(String(expanded))
return HttpResponse.json({ tableName: 'person', values, ...(expanded ? { associatedRecords: { 'care / primary': [originalChild] } } : {}) })
}),
)
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 }, associatedRecords: { 'care / primary': [{ tableName: 'child', values: { manualKey: 'fresh-child', owner: 9 } }] } }] })
const view = renderPage(<EntityCopyPage />)
fireEvent.change(await screen.findByLabelText(/Preferred given name/), { target: { value: 'Edited parent' } })
expect(reads).toEqual(['false'])
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
const key = await screen.findByLabelText(/New child key/)
expect(key).toHaveValue('')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.change(key, { target: { value: 'fresh-child' } })
fireEvent.change(screen.getByLabelText(/Child name/), { target: { value: 'Edited child' } })
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
expect(screen.getByLabelText(/New child key/)).toHaveValue('fresh-child')
expect(screen.getByLabelText(/Preferred given name/)).toHaveValue('Edited parent')
expect(view.container.querySelectorAll('form')).toHaveLength(1)
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
const body = post.mock.calls[0][1] as FormData
expect(body.get('firstName')).toBe('Edited parent')
expect(body.has('id')).toBe(false)
expect(JSON.parse(body.get('associations') as string)).toEqual({ 'care / primary': [{ values: { manualKey: 'fresh-child', firstName: 'Edited child' } }] })
expect(originalChild.values).toEqual({ manualKey: 'old-child', owner: 1, firstName: 'Original child' })
})
})
describe('Full copy failures stay visible and cannot save a base-only fallback', () => {
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
function sources(problem: string) {
const parent = fixture()
parent.associations = [{ name: 'children', associatedTableName: 'child', join: { name: 'join', leftTable: 'person', rightTable: 'child', type: 'ONE_TO_MANY', joinOns: [{ leftField: 'id', rightField: 'owner' }] } }]
const child = { ...structuredClone(parent), name: 'child', label: 'Child', associations: [], fields: { id: parent.fields.id, firstName: problem === 'numeric' ? { ...parent.fields.firstName, type: 'INTEGER' as const, label: 'Child amount' } : parent.fields.firstName, owner: { ...parent.fields.id, name: 'owner' } }, sections: [] }
const values = { id: 1, firstName: 'Parent', lastName: 'Source', email: 'copy@example.invalid' }
server.use(
http.get('/qqq/v1/metaData/table/person', () => HttpResponse.json(parent)),
http.get('/qqq/v1/metaData/table/child', () => problem.startsWith('metadata') ? HttpResponse.json({ error: 'Metadata unavailable' }, { status: problem === 'metadata-server' ? 500 : 403 }) : HttpResponse.json(child)),
http.get('/data/person/1', ({ request }) => {
if (new URL(request.url).searchParams.get('includeAssociations') !== 'true') return HttpResponse.json({ tableName: 'person', values })
if (problem.startsWith('read')) return HttpResponse.json({ error: 'Read unavailable' }, { status: problem === 'read-server' ? 500 : 403 })
return HttpResponse.json({ tableName: 'person', values, ...(problem === 'group' ? {} : { associatedRecords: { children: [{ tableName: 'child', values: { id: 2, owner: 1, ...(problem === 'value' ? {} : { firstName: problem === 'numeric' ? 5 : 'Child' }) } }] } }) })
}),
)
}
it.each(['read', 'metadata', 'group', 'value'])('blocks %s failure even on forced form submit', async problem => {
sources(problem)
const post = vi.spyOn(apiClient, 'post')
const view = renderPage(<EntityCopyPage />)
await screen.findByLabelText(/Preferred given name/)
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
await waitFor(() => expect(screen.getByRole('alert')).not.toHaveTextContent('Loading full copy'))
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.submit(view.container.querySelector('form')!)
expect(post).not.toHaveBeenCalled()
expect(push).not.toHaveBeenCalled()
})
it('does not announce success when a saved response omits a nonempty requested group', async () => {
sources('none')
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
renderPage(<EntityCopyPage />)
await screen.findByLabelText(/Preferred given name/)
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
expect(await screen.findByRole('alert')).toHaveTextContent('did not confirm every copied association')
expect(push).not.toHaveBeenCalled()
})
it('ignores a disabled hidden numeric child draft in Base mode while Full still checks it', async () => {
sources('numeric')
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
const view = renderPage(<EntityCopyPage />)
await screen.findByLabelText(/Preferred given name/)
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
const amount = await screen.findByRole('spinbutton', { name: /Child amount/ }) as HTMLInputElement
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
// jsdom does not implement partial numeric entry; supply the browser's validity result.
Object.defineProperty(amount, 'validity', { configurable: true, value: { badInput: true } })
const report = vi.spyOn(amount, 'reportValidity')
fireEvent.submit(view.container.querySelector('form')!)
expect(report).toHaveBeenCalledTimes(1)
expect(post).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
expect(amount.disabled).toBe(false)
expect(amount.matches(':disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
expect((post.mock.calls[0][1] as FormData).has('associations')).toBe(false)
expect(report).toHaveBeenCalledTimes(1)
})
it.each(['read', 'metadata'])('explains %s denial and lets the user explicitly save a Base Copy', async problem => {
sources(problem)
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'person', values: { id: 9 } }] })
const view = renderPage(<EntityCopyPage />)
await screen.findByLabelText(/Preferred given name/)
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('You do not have access to all the information needed for Full Copy. Choose Base Copy to copy this record only.'))
expect(screen.getByRole('alert')).not.toHaveTextContent('status code')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.submit(view.container.querySelector('form')!)
expect(post).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('radio', { name: 'Base copy' }))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
const body = post.mock.calls[0][1] as FormData
expect(body.has('associations')).toBe(false)
expect(body.get('firstName')).toBe('Parent')
})
it.each(['read-server', 'metadata-server'])('offers a retry for %s failure without claiming permission denial or silently saving', async problem => {
sources(problem)
const post = vi.spyOn(apiClient, 'post')
const view = renderPage(<EntityCopyPage />)
await screen.findByLabelText(/Preferred given name/)
fireEvent.click(screen.getByRole('radio', { name: 'Full copy' }))
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Full Copy could not load all required information. Reload to try again, or choose Base Copy.'))
expect(screen.getByRole('alert')).not.toHaveTextContent('do not have access')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.submit(view.container.querySelector('form')!)
expect(post).not.toHaveBeenCalled()
expect(screen.getByRole('radio', { name: 'Base copy' })).toBeEnabled()
})
})
+388
View File
@@ -0,0 +1,388 @@
/*
* 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 Developer page — shows debug information including session info, metadata stats, and API health.
*/
'use client'
/**
* Developer page — shows debug information useful during development and support.
*
* Fixed route at `/app/developer` — takes priority over the dynamic `[slug]` route.
*
* Displays:
* - Session info (auth type, user ID)
* - Environment values from backend metadata
* - Metadata stats (table / process / report / widget / app counts)
* - API health check with response time
* - Build info placeholder
*/
import React, { useState, useCallback, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Copy, Check, Activity } from 'lucide-react'
import { useQContext } from '@/lib/context/q-context'
import { useAuth } from '@/lib/auth/use-auth'
import { loadMetaData } from '@/lib/api/metadata'
import apiClient from '@/lib/api/client'
import { queryKeys } from '@/lib/query-client'
import { cn } from '@/lib/utils/cn'
/** Duration in milliseconds before the "copied!" feedback resets to the copy icon. */
const COPY_RESET_MS = 2000
/**
* A section card with a heading, optional copy button, and arbitrary content.
*/
interface SectionCardProps {
/** Section heading text. */
title: string
/** Content to render inside the card. */
children: React.ReactNode
/** When provided, the "Copy JSON" button copies this value to the clipboard. */
copyData?: unknown
/** data-qqq-id value for the card wrapper. */
dataQqqId?: string
}
/**
* Renders a card section with a header and optional "Copy JSON" button.
*
* @param props - {@link SectionCardProps}
* @returns A bordered card containing the section heading and children.
*/
function SectionCard({ title, children, copyData, dataQqqId }: SectionCardProps) {
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(async () => {
if (copyData === undefined) return
try {
await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2))
setCopied(true)
setTimeout(() => setCopied(false), COPY_RESET_MS)
} catch {
// Clipboard API unavailable — silent fail
}
}, [copyData])
return (
<div
className="rounded-xl border border-border bg-card p-6 space-y-4"
data-qqq-id={dataQqqId}
>
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
{copyData !== undefined && (
<button
type="button"
onClick={handleCopy}
aria-label={`Copy ${title} as JSON`}
data-qqq-id={`button-copy-${title.toLowerCase().replace(/\s+/g, '-')}`}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1 text-xs font-medium',
'text-muted-foreground bg-background hover:bg-accent',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'transition-colors duration-150'
)}
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-green-600" aria-hidden="true" />
Copied!
</>
) : (
<>
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
Copy JSON
</>
)}
</button>
)}
</div>
{children}
</div>
)
}
/**
* Renders a simple key/value table.
*
* @param props - Component props.
* @param props.rows - Array of `[key, value]` tuples to display.
* @returns A definition list styled as a bordered table, or a "No data" message when empty.
*/
function KeyValueTable({ rows }: { rows: Array<[string, React.ReactNode]> }) {
if (rows.length === 0) {
return (
<p className="text-sm text-muted-foreground">No data available.</p>
)
}
return (
<dl className="divide-y divide-border rounded-lg border border-border text-sm">
{rows.map(([key, val]) => (
<div
key={key}
className="flex flex-col gap-1 px-4 py-2.5 sm:flex-row sm:items-baseline sm:gap-4"
>
<dt className="font-medium text-muted-foreground sm:w-1/3 sm:flex-shrink-0 font-mono">
{key}
</dt>
<dd className="text-foreground sm:flex-1 break-all">{val}</dd>
</div>
))}
</dl>
)
}
/**
* Developer debug page rendered at the fixed route `/app/developer`.
*
* Uses `useQContext()` for auth-related state and `useQuery` for metadata.
* Provides an API health check that measures round-trip time to the metadata endpoint.
*
* @returns The developer page content wrapped in a container.
*/
export default function DeveloperPage() {
const { userId, setPageHeader } = useQContext()
useEffect(() => {
setPageHeader('Developer')
}, [setPageHeader])
const { authMetadata } = useAuth()
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
// API health check state
const [healthStatus, setHealthStatus] = useState<
'idle' | 'loading' | 'ok' | 'error'
>('idle')
const [healthMs, setHealthMs] = useState<number | null>(null)
const [healthError, setHealthError] = useState<string | null>(null)
/**
* Fires a GET request to `/metaData` and measures the round-trip time.
* Updates `healthStatus`, `healthMs`, and `healthError` accordingly.
*/
const handleHealthCheck = useCallback(async () => {
setHealthStatus('loading')
setHealthMs(null)
setHealthError(null)
const start = performance.now()
try {
await apiClient.get('/metaData')
const elapsed = Math.round(performance.now() - start)
setHealthMs(elapsed)
setHealthStatus('ok')
} catch (err) {
const elapsed = Math.round(performance.now() - start)
setHealthMs(elapsed)
setHealthStatus('error')
setHealthError(err instanceof Error ? err.message : String(err))
}
}, [])
// Versions reported by the running bundle (Next.js publishes its version on window.next)
const [nextVersion, setNextVersion] = useState<string | undefined>(undefined)
useEffect(() => {
setNextVersion((window as Window & { next?: { version?: string } }).next?.version)
}, [])
// Counts derived from metadata
const tableCount = Object.keys(metaData?.tables ?? {}).length
const processCount = Object.keys(metaData?.processes ?? {}).length
const reportCount = Object.keys(metaData?.reports ?? {}).length
const widgetCount = Object.keys(metaData?.widgets ?? {}).length
const appCount = Object.keys(metaData?.apps ?? {}).length
const metaStatsRows: Array<[string, React.ReactNode]> = [
['apps', appCount],
['tables', tableCount],
['processes', processCount],
['reports', reportCount],
['widgets', widgetCount],
]
const sessionRows: Array<[string, React.ReactNode]> = [
['userId', userId ?? '(not set)'],
['authType', authMetadata?.type ?? '(unknown)'],
['authName', authMetadata?.name ?? '(unknown)'],
]
const envRows: Array<[string, React.ReactNode]> = Object.entries(
metaData?.environmentValues ?? {}
)
const buildRows: Array<[string, React.ReactNode]> = [
['framework', nextVersion ? `Next.js ${nextVersion}` : 'Next.js'],
['react', React.version],
['appVersion', process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown'],
['apiBaseUrl', process.env.NEXT_PUBLIC_API_BASE_URL ?? '/qqq/v1'],
]
return (
<div
className="space-y-6"
data-qqq-id="developer-page"
>
{/* Page heading */}
<div>
<h1 className="text-2xl font-semibold text-foreground">Developer</h1>
<p className="mt-1 text-sm text-muted-foreground">
Debug information for developers and support staff.
</p>
</div>
{/* Session Info */}
<SectionCard
title="Session Info"
copyData={{ userId, authType: authMetadata?.type, authName: authMetadata?.name }}
dataQqqId="developer-section-session"
>
<KeyValueTable rows={sessionRows} />
</SectionCard>
{/* Environment Values */}
<SectionCard
title="Environment Values"
copyData={metaData?.environmentValues}
dataQqqId="developer-section-environment"
>
{envRows.length > 0 ? (
<KeyValueTable rows={envRows} />
) : (
<p className="text-sm text-muted-foreground">
No environment values returned by the backend.
</p>
)}
</SectionCard>
{/* Metadata Stats */}
<SectionCard
title="Metadata Stats"
copyData={{ apps: appCount, tables: tableCount, processes: processCount, reports: reportCount, widgets: widgetCount }}
dataQqqId="developer-section-metadata"
>
<KeyValueTable rows={metaStatsRows} />
</SectionCard>
{/* API Health */}
<SectionCard
title="API Health"
dataQqqId="developer-section-health"
>
<div className="space-y-3">
<button
type="button"
onClick={handleHealthCheck}
disabled={healthStatus === 'loading'}
aria-label="Run API health check"
data-qqq-id="button-api-health-check"
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'text-primary-foreground bg-primary hover:bg-primary/90',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-150'
)}
>
{healthStatus === 'loading' ? (
<>
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
aria-hidden="true"
/>
Checking&hellip;
</>
) : (
<>
<Activity className="h-4 w-4" aria-hidden="true" />
Check API Health
</>
)}
</button>
{healthStatus === 'ok' && healthMs !== null && (
<p
className="text-sm text-green-700 dark:text-green-400"
aria-live="polite"
data-qqq-id="developer-health-ok"
>
API responded in <strong>{healthMs} ms</strong>
</p>
)}
{healthStatus === 'error' && (
<div
className="text-sm text-destructive"
aria-live="assertive"
data-qqq-id="developer-health-error"
>
<p>
Health check failed
{healthMs !== null ? ` after ${healthMs} ms` : ''}.
</p>
{healthError && (
<p className="mt-1 font-mono text-xs">{healthError}</p>
)}
</div>
)}
</div>
</SectionCard>
{/* Build Info */}
<SectionCard
title="Build Info"
copyData={Object.fromEntries(buildRows.map(([k, v]) => [k, v]))}
dataQqqId="developer-section-build"
>
<KeyValueTable rows={buildRows} />
</SectionCard>
{/* Raw metadata JSON */}
{metaData && (
<SectionCard
title="Raw Metadata (JSON)"
copyData={metaData}
dataQqqId="developer-section-raw-metadata"
>
<pre
className="overflow-auto rounded-lg bg-muted p-4 text-xs text-muted-foreground max-h-64"
aria-label="Raw metadata JSON"
>
{JSON.stringify(
{
apps: Object.keys(metaData.apps ?? {}),
tables: Object.keys(metaData.tables ?? {}),
processes: Object.keys(metaData.processes ?? {}),
reports: Object.keys(metaData.reports ?? {}),
widgets: Object.keys(metaData.widgets ?? {}),
},
null,
2
)}
</pre>
</SectionCard>
)}
</div>
)
}
+170 -165
View File
@@ -1,14 +1,29 @@
'use client'
/*
* 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.
*/
// Dashboard — top-level home page showing system overview
// Demonstrates: stats, recent records, quick actions, app navigation, widget rendering
/**
* @file Dashboard home page — navigation overview: counts, recently viewed records, quick actions and applications.
*/
'use client'
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import {
LayoutDashboard,
Table2,
Workflow,
Clock,
ArrowRight,
@@ -25,12 +40,57 @@ import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useQContext } from '@/lib/context/q-context'
import { useAuth } from '@/lib/auth/use-auth'
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 type { RecentRecord } from '@/lib/utils/recent-records'
import type { QWidgetMetaData } from '@/types'
import { ConnectedWidget } from '@/components/widgets/ConnectedWidget'
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' }
/**
* Flattens an app route's descendants into leaf (non-app) routes.
*
* @param route - App route.
* @returns Every table, process and report under the app, in tree order.
*/
function leafRoutes(route: SidebarRoute): SidebarRoute[] {
return (route.children ?? []).flatMap((child) => (child.nodeType === 'APP' ? leafRoutes(child) : [child]))
}
/**
* Formats a Unix millisecond timestamp as a human-readable relative time string.
*
* @param timestamp - Milliseconds since epoch (e.g. from `Date.now()`).
* @returns A string such as "Just now", "5m ago", "3h ago", or "2d ago".
*/
function timeAgo(timestamp: number): string {
const diff = Date.now() - timestamp
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'Just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
/**
* Top-level dashboard home page rendered at `/app`.
*
* Everything shown is derived from the navigable app tree, so hidden and
* unpermitted objects never appear and all text uses metadata labels.
* App dashboards (widgets) live on each app's own home page.
*
* @returns A composed page that assembles:
* - A time-based greeting header with the authenticated user's first name
* - Counts of navigable tables, processes and apps, and of widgets on those apps
* - A recently-viewed records list (up to 8, sourced from localStorage)
* - Quick actions: create links for up to 6 insertable tables; run links for up to 4 processes
* - An applications overview linking to each top-level app's home
*/
export default function DashboardPage() {
const { setPageHeader } = useQContext()
const { user } = useAuth()
@@ -42,8 +102,8 @@ export default function DashboardPage() {
staleTime: 1000 * 60 * 30,
})
const { sidebarRoutes } = useAppTreeRoutes(metaData)
const appRoutes = sidebarRoutes.filter((r) => r.path !== '/app')
const { sidebarRoutes, navTargets } = useAppTreeRoutes(metaData)
const appRoutes = sidebarRoutes.filter((route) => route.nodeType === 'APP')
const appName = metaData?.branding?.appName || 'QQQ Admin'
@@ -56,56 +116,34 @@ export default function DashboardPage() {
setRecentRecords(getRecentRecords().slice(0, 8))
}, [])
// Compute system stats from metadata
const tableCount = metaData ? Object.keys(metaData.tables).length : 0
const processCount = metaData ? Object.keys(metaData.processes).length : 0
const appCount = metaData ? Object.keys(metaData.apps).length : 0
const widgetCount = metaData ? Object.keys(metaData.widgets).length : 0
const tableTargets = navTargets.filter((target) => target.nodeType === 'TABLE')
const processTargets = navTargets.filter((target) => target.nodeType === 'PROCESS')
const appTargets = navTargets.filter((target) => target.nodeType === 'APP')
const widgetNames = new Set(appTargets.flatMap((target) => (metaData?.apps?.[target.key]?.widgets ?? [])
.filter((widgetName) => metaData?.widgets?.[widgetName]?.hasPermission)))
// Gather tables that support create for quick actions
const creatableTables = metaData
? Object.values(metaData.tables)
.filter((t) => !t.isHidden && t.insertPermission && t.capabilities?.includes('TABLE_INSERT'))
.slice(0, 6)
: []
// Tables in navigation the user may insert into (permission and TABLE_INSERT capability)
const creatableTables = tableTargets
.filter((target) => canInsertRecords(metaData?.tables?.[target.key]))
.slice(0, 6)
// Gather runnable processes for quick actions
const runnableProcesses = metaData
? Object.values(metaData.processes)
.filter((p) => !p.isHidden)
.slice(0, 4)
: []
// Gather home-level widgets (widgets that are on any app's home)
const homeWidgets: QWidgetMetaData[] = []
if (metaData) {
const seen = new Set<string>()
for (const app of Object.values(metaData.apps)) {
for (const wName of app.widgets) {
if (!seen.has(wName) && metaData.widgets[wName]?.hasPermission) {
seen.add(wName)
homeWidgets.push(metaData.widgets[wName])
}
}
}
}
// Processes in navigation the user may run: DenyBehavior.DISABLED processes are
// listed with hasPermission false and must not be offered (QRun-IO/qqq#671)
const runnableProcesses = processTargets
.filter((target) => canRunProcess(metaData?.processes?.[target.key]))
.slice(0, 4)
// Time-based greeting
const hour = new Date().getHours()
const greeting = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening'
const firstName = user?.name?.split(' ')[0] || ''
// Format relative time
function timeAgo(timestamp: number): string {
const diff = Date.now() - timestamp
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'Just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
const stats: Array<{ label: string; value: number; hint: string; icon: React.ReactNode; id: string }> = [
{ id: 'tables', label: 'Tables', value: tableTargets.length, hint: 'Data tables available', icon: <Database className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
{ id: 'processes', label: 'Processes', value: processTargets.length, hint: 'Automated workflows', icon: <Workflow className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
{ id: 'apps', label: 'Applications', value: appTargets.length, hint: 'App modules', icon: <Layers className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
{ id: 'widgets', label: 'Widgets', value: widgetNames.size, hint: 'Dashboard components', icon: <TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
]
return (
<div className="space-y-8" data-qqq-id="dashboard-home">
@@ -121,48 +159,26 @@ export default function DashboardPage() {
{/* Stats cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Tables</span>
<Database className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{stats.map((stat) => (
<div key={stat.id} className="rounded-xl border border-border bg-card p-5 shadow-sm" data-qqq-id={`dashboard-stat-${stat.id}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">{stat.label}</span>
{stat.icon}
</div>
<p className="mt-2 text-3xl font-bold text-foreground" data-qqq-id={`dashboard-stat-${stat.id}-value`}>{stat.value}</p>
<p className="mt-1 text-xs text-muted-foreground">{stat.hint}</p>
</div>
<p className="mt-2 text-3xl font-bold text-foreground">{tableCount}</p>
<p className="mt-1 text-xs text-muted-foreground">Data tables available</p>
</div>
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Processes</span>
<Workflow className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<p className="mt-2 text-3xl font-bold text-foreground">{processCount}</p>
<p className="mt-1 text-xs text-muted-foreground">Automated workflows</p>
</div>
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Applications</span>
<Layers className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<p className="mt-2 text-3xl font-bold text-foreground">{appCount}</p>
<p className="mt-1 text-xs text-muted-foreground">App modules</p>
</div>
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Widgets</span>
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<p className="mt-2 text-3xl font-bold text-foreground">{widgetCount}</p>
<p className="mt-1 text-xs text-muted-foreground">Dashboard components</p>
</div>
))}
</div>
{/* Two-column layout: Recent Records + Quick Actions */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Recent Records — takes 2 columns */}
<div className="lg:col-span-2 rounded-xl border border-border bg-card shadow-sm">
<section aria-labelledby="dashboard-recent-heading" className="lg:col-span-2 rounded-xl border border-border bg-card shadow-sm">
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<h2 className="text-lg font-bold text-foreground">Recently Viewed</h2>
<h2 id="dashboard-recent-heading" className="text-lg font-bold text-foreground">Recently Viewed</h2>
</div>
</div>
<div className="divide-y divide-border">
@@ -175,7 +191,7 @@ export default function DashboardPage() {
data-qqq-id={`dashboard-recent-${record.recordId}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground" aria-hidden="true">
{record.recordLabel.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
@@ -198,38 +214,38 @@ export default function DashboardPage() {
</div>
)}
</div>
</div>
</section>
{/* Quick Actions — takes 1 column */}
<div className="rounded-xl border border-border bg-card shadow-sm">
<section aria-labelledby="dashboard-actions-heading" className="rounded-xl border border-border bg-card shadow-sm">
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-bold text-foreground">Quick Actions</h2>
<h2 id="dashboard-actions-heading" className="text-lg font-bold text-foreground">Quick Actions</h2>
</div>
<div className="p-4 space-y-2">
{creatableTables.map((table) => (
{creatableTables.map((target) => (
<Link
key={table.name}
href={`/app/${table.name}/create`}
key={target.key}
href={`${target.path}/create`}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
data-qqq-id={`dashboard-create-${table.name}`}
data-qqq-id={`dashboard-create-${target.key}`}
>
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 text-primary">
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</div>
Create {table.label}
Create {target.label}
</Link>
))}
{runnableProcesses.map((process) => (
{runnableProcesses.map((target) => (
<Link
key={process.name}
href={`/app/${process.name}`}
key={target.key}
href={target.path}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
data-qqq-id={`dashboard-process-${process.name}`}
data-qqq-id={`dashboard-process-${target.key}`}
>
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-chart-2/10 text-chart-2">
<Workflow className="h-3.5 w-3.5" aria-hidden="true" />
<MetadataIcon icon={target.icon} kind="process" className="h-3.5 w-3.5" />
</div>
{process.label}
{target.label}
</Link>
))}
{creatableTables.length === 0 && runnableProcesses.length === 0 && (
@@ -238,91 +254,80 @@ export default function DashboardPage() {
</div>
)}
</div>
</div>
</section>
</div>
{/* Home Widgets — rendered from metadata */}
{homeWidgets.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-bold text-foreground">Widgets</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{homeWidgets.slice(0, 6).map((widget) => (
<ConnectedWidget key={widget.name} widgetMetaData={widget} />
))}
</div>
</div>
)}
{/* Applications overview */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">Applications</h2>
</div>
<section aria-labelledby="dashboard-apps-heading" className="space-y-4">
<h2 id="dashboard-apps-heading" className="text-lg font-bold text-foreground">Applications</h2>
{appRoutes.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{appRoutes.map((route) => (
<Link
key={route.path}
href={route.children?.[0]?.path ?? route.path}
className="group rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id={`dashboard-app-${route.name}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<LayoutDashboard className="h-5 w-5" aria-hidden="true" />
{appRoutes.map((route) => {
const leaves = leafRoutes(route)
return (
<Link
key={route.path}
href={route.path}
className="group rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id={`dashboard-app-${route.key}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<MetadataIcon icon={route.icon} kind="app" className="h-5 w-5" />
</div>
<div>
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">
{route.name}
</h3>
{leaves.length > 0 && (
<p className="text-xs text-muted-foreground">
{leaves.length} {leaves.length === 1 ? 'item' : 'items'}
</p>
)}
</div>
</div>
<div>
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">
{route.name}
</h3>
{route.children && route.children.length > 0 && (
<p className="text-xs text-muted-foreground">
{route.children.length} {route.children.length === 1 ? 'item' : 'items'}
</p>
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
</div>
{leaves.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{leaves.slice(0, 5).map((leaf) => (
<span
key={leaf.path}
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
<MetadataIcon icon={leaf.icon} kind={leaf.nodeType ? ICON_KIND[leaf.nodeType] : 'table'} className="h-3 w-3" />
{leaf.name}
</span>
))}
{leaves.length > 5 && (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
+{leaves.length - 5} more
</span>
)}
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
</div>
{/* Show child items preview */}
{route.children && route.children.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{route.children.slice(0, 5).map((child) => (
<span
key={child.path}
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
<Table2 className="h-3 w-3" aria-hidden="true" />
{child.name}
</span>
))}
{route.children.length > 5 && (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
+{route.children.length - 5} more
</span>
)}
</div>
)}
</Link>
))}
)}
</Link>
)
})}
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 py-16 text-center">
<p className="text-muted-foreground">No applications available</p>
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 py-16 text-center" data-qqq-id="dashboard-no-apps">
{/* Material Dashboard's NoApps message */}
<p className="text-muted-foreground">You do not have permission to access any apps.</p>
</div>
)}
</div>
</section>
{/* System info footer */}
<div className="flex items-center gap-6 rounded-xl border border-border bg-card px-6 py-4 shadow-sm">
<div className="flex flex-wrap items-center gap-6 rounded-xl border border-border bg-card px-6 py-4 shadow-sm">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" aria-hidden="true" />
<span>{tableCount} tables</span>
<span>{tableTargets.length} tables</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Workflow className="h-4 w-4" aria-hidden="true" />
<span>{processCount} processes</span>
<span>{processTargets.length} processes</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" aria-hidden="true" />
+114 -193
View File
@@ -1,238 +1,159 @@
/*
* 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 Search page — every page (app, table, process, report) and recently viewed record matching `?q=`.
*/
'use client'
// Search results page — displays global search results grouped by table
// Reads search query from URL params: /app/search?q=searchTerm
import React, { useState, useEffect, useMemo, useCallback } from 'react'
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 { globalSearch, type GlobalSearchResult } from '@/lib/api/tables'
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 { getRecentRecords } from '@/lib/utils/recent-records'
import type { RecentRecord } from '@/lib/utils/recent-records'
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
/** Group search results by tableLabel */
function groupByTable(
results: GlobalSearchResult[]
): Map<string, { tableName: string; records: GlobalSearchResult[] }> {
const groups = new Map<string, { tableName: string; records: GlobalSearchResult[] }>()
for (const result of results) {
const existing = groups.get(result.tableLabel)
if (existing) {
existing.records.push(result)
} else {
groups.set(result.tableLabel, {
tableName: result.tableName,
records: [result],
})
}
}
return groups
}
/** Fallback icon kind for each app-tree node type. */
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
/**
* Renders the search page at `/app/search`.
*
* Matching is local (labels of navigable app-tree nodes and recently viewed
* records); QQQ has no global record-search endpoint. The `q` parameter is kept
* in the URL so results are shareable and survive refresh.
*
* @returns The search input and matching pages and records.
*/
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 queryParam = searchParams.get('q') ?? ''
const [inputValue, setInputValue] = useState(queryParam)
const [debouncedTerm, setDebouncedTerm] = useState(queryParam)
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const { navTargets } = useAppTreeRoutes(metaData)
// Keep input value in sync when URL param changes externally
useEffect(() => {
setInputValue(queryParam)
setDebouncedTerm(queryParam)
}, [queryParam])
setInputValue(term)
}, [term])
// Debounce the input value by 300ms
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedTerm(inputValue)
}, 300)
return () => clearTimeout(timer)
}, [inputValue])
setRecentRecords(getRecentRecords())
}, [])
// Update URL when debounced term changes (without full navigation)
useEffect(() => {
if (debouncedTerm !== queryParam) {
const params = new URLSearchParams(searchParams.toString())
if (debouncedTerm) {
params.set('q', debouncedTerm)
} else {
params.delete('q')
}
router.replace(`/app/search?${params.toString()}`, { scroll: false })
}
}, [debouncedTerm, queryParam, searchParams, router])
// Set page header
useEffect(() => {
setPageHeader('Search Results')
setPageHeader('Search')
}, [setPageHeader])
const { data: results, isLoading } = useQuery({
queryKey: queryKeys.globalSearch(debouncedTerm),
queryFn: () => globalSearch(debouncedTerm),
enabled: debouncedTerm.length > 0,
})
const grouped = useMemo<
Map<string, { tableName: string; records: GlobalSearchResult[] }>
>(() => {
if (!results || results.length === 0) return new Map()
return groupByTable(results)
}, [results])
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
},
[]
const items = useMemo(
() => (term.trim() ? buildNavigationSearchItems(navTargets, recentRecords, term, 50) : []),
[navTargets, recentRecords, term]
)
const pages = items.filter((item) => item.kind === 'page')
const records = items.filter((item) => item.kind === 'record')
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
setDebouncedTerm(inputValue)
}
},
[inputValue]
)
const hasResults = results && results.length > 0
const hasSearched = debouncedTerm.length > 0
const showEmpty = hasSearched && !isLoading && !hasResults
/**
* 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">
{/* Page heading */}
{debouncedTerm ? (
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Search Results for &ldquo;{debouncedTerm}&rdquo;
</h1>
) : (
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Search
</h1>
)}
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{term ? <>Search results for &ldquo;{term}&rdquo;</> : 'Search'}
</h1>
{/* Search input */}
<div className="relative">
<Search
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<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={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Search across all tables..."
aria-label="Search across all tables"
className="rounded-xl border border-input bg-card pl-10 pr-4 py-3 text-sm w-full focus:outline-none focus:ring-2 focus:ring-ring"
onChange={(event) => setInputValue(event.target.value)}
placeholder="Find an app, table, process or recent record..."
aria-label="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"
/>
</div>
</form>
{/* Loading state */}
{isLoading && (
<div className="space-y-6" aria-busy="true" aria-live="polite">
{/* Skeleton group 1 */}
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="px-6 py-3 border-b border-border">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
<div className="divide-y divide-border">
{[1, 2, 3].map((i) => (
<div key={i} className="px-6 py-3">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
{/* Skeleton group 2 */}
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="px-6 py-3 border-b border-border">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</div>
<div className="divide-y divide-border">
{[1, 2].map((i) => (
<div key={i} className="px-6 py-3">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
</div>
{!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>
)}
{/* Empty state */}
{showEmpty && (
<div
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16 shadow-sm"
data-qqq-id="search-empty-state"
>
<Search className="h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
<p className="mt-4 text-sm text-muted-foreground">
No results found for &ldquo;{debouncedTerm}&rdquo;
</p>
<p className="mt-1 text-xs text-muted-foreground">
Try adjusting your search terms or check for typos.
</p>
</div>
{term && items.length === 0 && (
<p className="rounded-xl border border-border bg-card py-12 text-center text-sm text-muted-foreground" data-qqq-id="search-empty-state">
No pages or recent records match &ldquo;{term}&rdquo;.
</p>
)}
{/* No query state */}
{!hasSearched && !isLoading && (
<div
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16 shadow-sm"
data-qqq-id="search-prompt-state"
>
<Search className="h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
<p className="mt-4 text-sm text-muted-foreground">
Enter a search term above to find records across all tables.
</p>
</div>
{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>
)}
{/* Results grouped by table */}
{!isLoading && hasResults && (
<div className="space-y-6">
{Array.from(grouped.entries()).map(([tableLabel, group]) => (
<div
key={tableLabel}
className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"
data-qqq-id={`search-group-${group.tableName}`}
>
{/* Group header */}
<div className="flex items-center gap-2 px-6 py-3 border-b border-border">
<span className="text-sm font-semibold text-foreground">
{tableLabel}
</span>
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{group.records.length} {group.records.length === 1 ? 'result' : 'results'}
</span>
</div>
{/* Result rows */}
<div className="divide-y divide-border">
{group.records.map((result) => (
<Link
key={`${result.tableName}-${result.recordId}`}
href={`/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`}
className="block px-6 py-3 hover:bg-accent cursor-pointer transition-colors text-sm text-foreground"
data-qqq-id={`search-result-${result.tableName}-${result.recordId}`}
>
{result.recordLabel}
</Link>
))}
</div>
</div>
))}
</div>
{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">
<span className="font-medium text-foreground">{item.label}</span>
<span className="text-xs text-muted-foreground">{item.context}</span>
</Link>
</li>
))}
</ul>
</section>
)}
</div>
)
+178 -89
View File
@@ -1,30 +1,66 @@
/*
* 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 Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts.
*/
'use client'
// Dashboard layout — authenticated route group layout
// Renders sidebar, header, breadcrumbs, and banner zones
// Includes: mobile sidebar drawer, command palette (Cmd+K), skip link, customCss injection
/** Dashboard layout — authenticated route group shell providing sidebar, header, banners, command palette, and global keyboard shortcuts. */
import React, { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '@/lib/auth/use-auth'
import { useQContext, QContextProvider } from '@/lib/context/q-context'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import { useDocumentTitle } from '@/lib/hooks/use-document-title'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import Sidebar from '@/components/layout/Sidebar'
import Header from '@/components/layout/Header'
import BannerComponent from '@/components/layout/Banner'
import { buildBreadcrumbs, buildDocumentTitle } from '@/components/layout/Breadcrumbs'
import { CommandMenu } from '@/components/feedback/CommandMenu'
import { SearchDialog } from '@/components/feedback/SearchDialog'
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
// Inner layout content — needs QContextProvider to be set up first
/**
* Inner layout that renders the full dashboard chrome.
*
* Must be a child of `QContextProvider` in order to read and write QContext.
* Responsibilities:
* - Fetches full application metadata via TanStack Query (30-minute stale time).
* - Generates sidebar routes and path-to-label map from the app tree.
* - Syncs branding, accent color, favicon, and document title to the DOM.
* - Sanitizes and injects `customCss` from branding metadata.
* - Redirects unauthenticated users to `/login`.
* - Registers global keyboard shortcuts: Cmd+K (command palette), `/` (search), `?` (help).
* - Renders the desktop sidebar, mobile sidebar drawer, banners, header, and main content slot.
*
* @param children - The authenticated page content to render in the main slot.
* @returns The full dashboard layout, a loading spinner, or `null` during redirect.
*/
function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
const { isAuthenticated, isLoading: authLoading, user, logout } = useAuth()
const { setPathToLabelMap, setBranding, setAccentColor, setUserId } = useQContext()
const { setPathToLabelMap, setBranding, setAccentColor, setUserId, pageHeader } = useQContext()
// Mobile sidebar drawer state
const [sidebarOpen, setSidebarOpen] = useState(false)
@@ -51,7 +87,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
})
// Generate sidebar routes and path map from app tree
const { sidebarRoutes, pathToLabelMap, parentAppMap } = useAppTreeRoutes(metaData)
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets } = useAppTreeRoutes(metaData)
// Sync pathToLabelMap to QContext
useEffect(() => {
@@ -65,7 +101,9 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
if (metaData?.branding) {
setBranding(metaData.branding)
if (metaData.branding.accentColor) {
// MED-3: validate color format before applying to CSS custom properties
const ACCENT_COLOR_RE = /^#[0-9a-fA-F]{3,8}$|^rgb\(|^rgba\(|^hsl\(|^hsla\(/
if (metaData.branding.accentColor && ACCENT_COLOR_RE.test(metaData.branding.accentColor)) {
setAccentColor(metaData.branding.accentColor)
document.documentElement.style.setProperty(
'--qqq-accent-color',
@@ -89,32 +127,54 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
)
}
// Update favicon from branding
if (metaData.branding.icon) {
const favicon = document.querySelector("link[rel~='icon']") as HTMLLinkElement | null
if (favicon) {
favicon.href = metaData.branding.icon
}
if (metaData.branding.accentColorLight && ACCENT_COLOR_RE.test(metaData.branding.accentColorLight)) {
document.documentElement.style.setProperty('--qqq-accent-color-light', metaData.branding.accentColorLight)
}
// Update document title
if (metaData.branding.appName) {
document.title = metaData.branding.appName
// Favicon and touch icon from branding (as Material Dashboard does)
if (metaData.branding.icon) {
for (const selector of ["link[rel~='icon']", "link[rel~='apple-touch-icon']"]) {
const linkEl = document.querySelector(selector)
if (linkEl instanceof HTMLLinkElement) {
linkEl.href = metaData.branding.icon
}
}
}
}
}, [metaData?.branding, setBranding, setAccentColor])
// Document title: current page, enclosing breadcrumbs, then the app name
const documentTitle = metaData
? buildDocumentTitle(
buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap),
typeof pageHeader === 'string' ? pageHeader : undefined,
metaData.branding?.appName || 'QQQ'
)
: undefined
useDocumentTitle(documentTitle)
// Inject customCss from branding metadata
// MED-2: strip known CSS injection vectors before applying
useEffect(() => {
if (metaData?.branding?.customCss) {
const safe = metaData.branding.customCss
.replace(/<\/?\s*style[^>]*>/gi, '') // no embedded style tags
.replace(/expression\s*\(/gi, '') // no IE CSS expressions
.replace(/@import\b/gi, '') // no @import directives
.replace(/javascript\s*:/gi, '') // no javascript: scheme
.replace(/url\s*\(\s*["']?\s*data:/gi, '') // no data: URLs
const styleId = 'qqq-custom-css'
let styleTag = document.getElementById(styleId) as HTMLStyleElement | null
if (!styleTag) {
const existingStyleEl = document.getElementById(styleId)
let styleTag: HTMLStyleElement
if (existingStyleEl instanceof HTMLStyleElement) {
styleTag = existingStyleEl
} else {
styleTag = document.createElement('style')
styleTag.id = styleId
document.head.appendChild(styleTag)
}
styleTag.textContent = metaData.branding.customCss
styleTag.textContent = safe
}
}, [metaData?.branding?.customCss])
@@ -133,15 +193,32 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
}
}, [isAuthenticated, authLoading, router])
// Guard: check if focus is in a text input, textarea, select, or contentEditable
/**
* Returns `true` when focus is in a text input, textarea, select, or contentEditable element.
*
* Used to suppress single-key shortcuts (`.`, `/`, `?`) while the user is typing.
*
* @returns Whether the currently focused element is a text-entry control.
*/
const isInputFocused = useCallback(() => {
const tag = (document.activeElement?.tagName || '').toLowerCase()
const type = (document.activeElement as HTMLInputElement)?.type || ''
const isEditable = (document.activeElement as HTMLElement)?.isContentEditable
return tag === 'input' || tag === 'textarea' || tag === 'select' || type === 'search' || isEditable
const active = document.activeElement
const tag = (active?.tagName ?? '').toLowerCase()
const inputType = active instanceof HTMLInputElement ? active.type : ''
const isEditable = active instanceof HTMLElement ? active.isContentEditable : false
return tag === 'input' || tag === 'textarea' || tag === 'select' || inputType === 'search' || isEditable
}, [])
// Global keyboard shortcut: Cmd+K / Ctrl+K → command palette, "." → quick nav, "?" → help
/**
* Global `keydown` handler that drives the application-level keyboard shortcuts.
*
* - Cmd+K / Ctrl+K — toggles the command palette.
* - 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).
* - `?` — opens the keyboard shortcuts dialog (only when not in a text field).
*
* @param e - The native DOM keyboard event.
*/
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
@@ -209,76 +286,80 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
}
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Skip to main content — accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
data-qqq-id="skip-to-content"
>
Skip to main content
</a>
<div className="flex h-screen flex-col overflow-hidden bg-background">
{/* 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" />
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* Skip to main content — accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
data-qqq-id="skip-to-content"
>
Skip to main content
</a>
{/* Desktop Sidebar */}
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
logout={logout}
userName={user?.name}
userEmail={user?.email}
data-qqq-id="sidebar-container"
/>
{/* Mobile Sidebar Drawer */}
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
logout={logout}
userName={user?.name}
userEmail={user?.email}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
data-qqq-id="sidebar-mobile"
/>
{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Environment/status banners */}
{metaData?.branding?.banners &&
Object.keys(metaData.branding.banners).length > 0 && (
<BannerComponent banners={metaData.branding.banners} />
)}
{/* Header — breadcrumbs + search in one row */}
<Header
appName={metaData?.branding?.appName}
onMenuOpen={() => setSidebarOpen(true)}
pathToLabelMap={pathToLabelMap}
parentAppMap={parentAppMap}
{/* Desktop Sidebar */}
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
logout={logout}
userName={user?.name}
userEmail={user?.email}
data-qqq-id="sidebar-container"
/>
{/* Page content */}
<main className="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"
role="status"
aria-label="Loading content"
aria-live="polite"
>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : (
children
)}
</main>
{/* Mobile Sidebar Drawer */}
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
logout={logout}
userName={user?.name}
userEmail={user?.email}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
data-qqq-id="sidebar-mobile"
/>
{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header — breadcrumbs + search in one row */}
<Header
appName={metaData?.branding?.appName}
onMenuOpen={() => setSidebarOpen(true)}
onSearchOpen={() => setSearchOpen(true)}
onHelpOpen={() => setHelpOpen(true)}
pathToLabelMap={pathToLabelMap}
ancestorAppMap={ancestorAppMap}
navTargets={navTargets}
/>
{/* Page content */}
<main className="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"
role="status"
aria-label="Loading content"
aria-live="polite"
>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : (
<>
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_BODY" className="mb-4 rounded-lg" />
{children}
</>
)}
</main>
</div>
</div>
{/* Command Palette */}
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} />
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} navTargets={navTargets} />
{/* Search Dialog */}
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} navTargets={navTargets} />
{/* Keyboard Shortcuts Help Dialog */}
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
@@ -286,7 +367,15 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
)
}
// Outer layout — provides QContext to inner layout
/**
* Outer dashboard layout exported as the Next.js `(dashboard)` route group layout.
*
* Wraps the inner `DashboardLayoutContent` with `QContextProvider` so that all
* authenticated pages share a single QContext instance.
*
* @param children - The page component rendered inside the authenticated shell.
* @returns A `QContextProvider` wrapping the full dashboard layout.
*/
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<QContextProvider>
+40 -2
View File
@@ -1,12 +1,50 @@
/*
* 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 Global error boundary — Next.js `error.tsx` component that catches unhandled route-level exceptions.
*/
'use client'
// Global error boundary — catches unhandled exceptions at route level
// Detects 401 errors and provides appropriate messaging
/**
* Global error boundary — Next.js `error.tsx` component that catches unhandled
* exceptions at the route level.
*
* Detects 401 / session-expired conditions and renders a "Session Expired"
* card with a login link. All other errors render a generic error card with
* a "Try Again" button (which invokes `reset()` to re-render the segment)
* and a "Go Home" link.
*/
import React, { useEffect } from 'react'
import Link from 'next/link'
import { AlertCircle, RefreshCw } from 'lucide-react'
/**
* Renders the global route-level error UI.
*
* Next.js automatically passes `error` and `reset` when any Server or Client
* Component in the route segment throws an unhandled error.
*
* @param props - The Next.js error boundary props.
* @param props.error - The thrown error, optionally decorated with a `digest` ID from Next.js.
* @param props.reset - Next.js callback that retries rendering the failed route segment.
* @returns A full-screen card describing the error with recovery actions.
*/
export default function GlobalError({
error,
reset,
+43 -2
View File
@@ -1,16 +1,47 @@
// Root layout — wraps entire application with providers
// This is a Server Component that sets up client-side providers
/*
* 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 Root layout — Server Component that wraps the entire Next.js application.
*/
/**
* Root layout — Server Component that wraps the entire Next.js application.
*
* Sets up the Inter font variable, global CSS, Next.js metadata defaults, and
* mounts the `<Providers>` client component that supplies TanStack Query,
* authentication, and toast infrastructure to all pages.
*/
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Providers from './providers'
import '@/styles/globals.css'
/** Inter font configuration with the CSS variable `--font-inter`. */
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
/**
* Default Next.js metadata applied to every page in the application.
*
* Individual pages may override `title` via their own `export const metadata`.
*/
export const metadata: Metadata = {
title: {
default: 'QQQ Admin',
@@ -22,6 +53,16 @@ export const metadata: Metadata = {
},
}
/**
* The root HTML layout for the entire Next.js application.
*
* Renders the `<html>` and `<body>` elements, applies the Inter font variable,
* suppresses hydration warnings caused by browser extensions, and wraps all
* page content in `<Providers>`.
*
* @param children - The active page or nested layout content.
* @returns The fully-structured HTML document shell.
*/
export default function RootLayout({
children,
}: {
+32 -1
View File
@@ -1,8 +1,39 @@
// 404 Not Found page
/*
* 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 Not Found page — Next.js 404 page rendered when a route segment cannot be matched.
*/
/**
* Not Found page — Next.js `not-found.tsx` rendered automatically when
* `notFound()` is called or a route segment cannot be matched.
*
* Displays a large "404" heading, a brief explanation, and a "Go Home" button
* that returns the user to the application root.
*/
import Link from 'next/link'
import { SearchX } from 'lucide-react'
/**
* Full-screen 404 page shown for unknown routes or when `notFound()` is called.
*
* @returns A centered card containing the 404 error message and a home navigation link.
*/
export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
+37 -4
View File
@@ -1,8 +1,41 @@
// Root page — redirects to dashboard or login
/*
* 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 { redirect } from 'next/navigation'
/**
* @file Root page — redirects the application root to the dashboard.
*/
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
/**
* Root page that immediately redirects to `/app`.
*
* Client-side so the same page works in the static export hosted by the QQQ server.
* The `(dashboard)` layout handles authentication and will redirect to `/login`
* if the user is not authenticated.
*
* @returns Nothing; navigation replaces this page.
*/
export default function RootPage() {
// Redirect to the dashboard — the (dashboard) layout handles auth
redirect('/app')
const router = useRouter()
useEffect(() => {
router.replace('/app')
}, [router])
return null
}
+31
View File
@@ -1,3 +1,23 @@
/*
* 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 Client-side providers wrapper — supplies TanStack Query, authentication, theme, and toast infrastructure.
*/
'use client'
// Client-side providers wrapper
@@ -19,6 +39,17 @@ if (typeof window !== 'undefined') {
const isMockMode = process.env.NEXT_PUBLIC_MOCK_API === 'true'
/**
* Root client-side providers wrapper.
*
* In mock mode (`NEXT_PUBLIC_MOCK_API=true`) this component blocks rendering
* until the MSW service worker has fully started so that `AuthProvider` does not
* fire API calls before the mock handler is registered.
*
* @param props - Component props.
* @param props.children - The application tree to wrap with all providers.
* @returns The full provider tree, or a mock-loading spinner while MSW initializes.
*/
export default function Providers({ children }: { children: ReactNode }) {
// When mock mode is enabled, block rendering until the MSW service worker
// is fully started — otherwise AuthProvider fires its first API call before
@@ -0,0 +1,70 @@
/*
* 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 CommandMenu: typed entries from navigation targets
import React from 'react'
import { describe, it, expect, vi, beforeAll } from 'vitest'
import { render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
const pushMock = vi.fn()
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock }) }))
import { CommandMenu } from './CommandMenu'
import type { NavTarget } from '@/lib/hooks/use-routes'
const app = { label: 'People App', path: '/app/peopleApp' }
const targets: NavTarget[] = [
{ key: 'peopleApp', label: 'People App', path: '/app/peopleApp', nodeType: 'APP', icon: { name: 'person' }, ancestors: [] },
{ key: 'pet', label: 'Pet', path: '/app/pet', nodeType: 'TABLE', icon: { name: 'pets' }, ancestors: [app] },
{ key: 'clonePeople', label: 'Clone People', path: '/app/clonePeople', nodeType: 'PROCESS', ancestors: [app] },
]
describe('CommandMenu', () => {
beforeAll(() => {
// cmdk scrolls the selected item into view and observes list size
Element.prototype.scrollIntoView = vi.fn()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
})
it('renders nothing when closed', () => {
const { container } = render(<CommandMenu open={false} onClose={vi.fn()} navTargets={targets} />)
expect(container).toBeEmptyDOMElement()
})
it('lists each target with its type, app context and declared icon (regression: every entry was typed as an app)', () => {
render(<CommandMenu open onClose={vi.fn()} navTargets={targets} />)
const pet = document.querySelector('[data-qqq-id="command-item-pet"]')!
expect(pet).toHaveTextContent('Pet')
expect(pet).toHaveTextContent('Table · People App')
expect(pet).toHaveAttribute('data-node-type', 'TABLE')
expect(pet.querySelector('svg')).toHaveAttribute('data-qqq-icon', 'pets')
expect(document.querySelector('[data-qqq-id="command-item-clonePeople"]')).toHaveTextContent('Process · People App')
expect(document.querySelector('[data-qqq-id="command-item-clonePeople"] svg')).toHaveClass('lucide-workflow')
expect(document.querySelector('[data-qqq-id="command-item-peopleApp"]')).toHaveTextContent(/App$/)
})
it('filters and navigates to the chosen target, then closes', async () => {
const onClose = vi.fn()
const user = userEvent.setup()
render(<CommandMenu open onClose={onClose} navTargets={targets} />)
await user.type(screen.getByRole('combobox'), 'clone')
await user.keyboard('{Enter}')
expect(pushMock).toHaveBeenCalledWith('/app/clonePeople')
expect(onClose).toHaveBeenCalled()
})
})
+122 -89
View File
@@ -1,84 +1,106 @@
/*
* 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 CommandMenu — Cmd+K / Ctrl+K command palette that fuzzy-searches every navigable app, table, process and report.
*/
'use client'
// CommandMenu — Cmd+K / Ctrl+K command palette
// Fuzzy-searches all sidebar routes from QContext
// Navigate with arrow keys, Enter to navigate, Escape to close
import React, { useEffect, useState, useCallback } from 'react'
import React, { useEffect, useRef, useState, useCallback } from 'react'
import { Command } from 'cmdk'
import { useRouter } from 'next/navigation'
import { Search, Table2, Workflow, BarChart3, LayoutGrid, X } from 'lucide-react'
import { Search, X } from 'lucide-react'
import { useQContext } from '@/lib/context/q-context'
import type { NavTarget } from '@/lib/hooks/use-routes'
import { cn } from '@/lib/utils/cn'
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
interface CommandMenuItem {
id: string
label: string
path: string
type: 'app' | 'table' | 'process' | 'report'
breadcrumb?: string
}
/** Fallback icon kind for each app-tree node type. */
const ICON_KIND: Record<string, MetadataIconKind> = { APP: 'app', TABLE: 'table', PROCESS: 'process', REPORT: 'report' }
function buildMenuItems(pathToLabelMap: Record<string, string>): CommandMenuItem[] {
const items: CommandMenuItem[] = []
for (const [path, label] of Object.entries(pathToLabelMap)) {
// Skip parametric paths and dev/utility pages
if (path.includes(':') || path.includes('/create') || path.includes('/dev') || path.includes('/key') || path.includes('/savedView')) {
continue
}
const parts = path.split('/').filter(Boolean) // e.g. ['app', 'employees'] or ['app', 'crm', 'employees']
let type: CommandMenuItem['type'] = 'table'
let breadcrumb: string | undefined
if (parts.length === 2 && parts[0] === 'app') {
// Could be app home or table
type = 'app'
} else if (parts.length === 3) {
type = 'table'
const parentPath = `/${parts[0]}/${parts[1]}`
breadcrumb = pathToLabelMap[parentPath]
}
items.push({
id: path,
label,
path,
type,
breadcrumb,
})
}
return items
}
function TypeIcon({ type }: { type: CommandMenuItem['type'] }) {
switch (type) {
case 'app':
return <LayoutGrid className="h-4 w-4 text-blue-500" aria-hidden="true" />
case 'table':
return <Table2 className="h-4 w-4 text-green-500" aria-hidden="true" />
case 'process':
return <Workflow className="h-4 w-4 text-purple-500" aria-hidden="true" />
case 'report':
return <BarChart3 className="h-4 w-4 text-orange-500" aria-hidden="true" />
}
}
/** Human-readable type shown for each entry. */
const TYPE_LABEL: Record<string, string> = { APP: 'App', TABLE: 'Table', PROCESS: 'Process', REPORT: 'Report' }
/**
* Props for the CommandMenu component.
*/
interface CommandMenuProps {
/** Whether the command palette is currently open. */
open: boolean
/** Called when the palette should close (backdrop click, Escape, or item selected). */
onClose: () => void
/** Navigable app-tree nodes (hidden objects already excluded). */
navTargets: NavTarget[]
}
export function CommandMenu({ open, onClose }: CommandMenuProps) {
/**
* Renders the Cmd+K command palette overlay.
*
* Uses the `cmdk` Command primitive for fuzzy search and keyboard navigation
* over every navigable app, table, process and report (by metadata label, with
* its type and enclosing apps as context). Selecting an item navigates and closes.
*
* @param props - Component properties.
* @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 }: CommandMenuProps) {
const router = useRouter()
const { pathToLabelMap } = useQContext()
const [search, setSearch] = useState('')
const dialogRef = useRef<HTMLDivElement>(null)
const returnFocusRef = useRef<HTMLElement | null>(null)
const items = buildMenuItems(pathToLabelMap)
// Remember what had focus before the palette opened, and give it focus back on close
// (a modal dialog restores focus to its trigger).
useEffect(() => {
if (open) return
const returnTo = returnFocusRef.current
returnFocusRef.current = null
if (returnTo?.isConnected) returnTo.focus()
const remember = (event: FocusEvent) => {
if (event.target instanceof HTMLElement && !event.target.closest('[data-qqq-id="command-menu"]')) returnFocusRef.current = event.target
}
if (document.activeElement instanceof HTMLElement && document.activeElement !== document.body) {
returnFocusRef.current = document.activeElement
}
document.addEventListener('focusin', remember)
return () => document.removeEventListener('focusin', remember)
}, [open])
/**
* Keeps Tab and Shift+Tab inside the palette while it is open (focus trap).
*
* @param event - The keydown event from within the dialog.
*/
const trapFocus = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Tab' || !dialogRef.current) return
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('input, button, [href], [tabindex]:not([tabindex="-1"])'))
.filter((element) => !element.hasAttribute('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()
}
}
// Clear search when closed
useEffect(() => {
@@ -87,6 +109,11 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
}
}, [open])
/**
* Navigates to the selected item's path and closes the palette.
*
* @param path - The URL path of the selected command item.
*/
const handleSelect = useCallback(
(path: string) => {
router.push(path)
@@ -100,7 +127,8 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
return (
// Backdrop
<div
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
className="fixed inset-0 flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
style={{ zIndex: 'var(--qqq-z-toast)' } as React.CSSProperties}
data-qqq-id="command-menu-backdrop"
>
<div
@@ -116,6 +144,8 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
role="dialog"
aria-label="Command palette"
aria-modal="true"
ref={dialogRef}
onKeyDown={trapFocus}
>
<Command shouldFilter={true} label="Command palette">
{/* Search input */}
@@ -124,7 +154,7 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="Search pages, tables, processes..."
placeholder="Search apps, tables, processes..."
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autoFocus
data-qqq-id="command-menu-search"
@@ -132,7 +162,7 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
<button
type="button"
onClick={onClose}
className="rounded p-1 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="Close command palette"
data-qqq-id="button-command-menu-close"
>
@@ -145,12 +175,13 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
className="max-h-80 overflow-y-auto py-2"
aria-label="Navigation results"
aria-live="polite"
data-qqq-id="command-menu-results"
>
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
No results found.
</Command.Empty>
{items.length > 0 && (
{navTargets.length > 0 && (
<Command.Group
heading={
<span className="px-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
@@ -158,29 +189,31 @@ export function CommandMenu({ open, onClose }: CommandMenuProps) {
</span>
}
>
{items.map((item) => (
<Command.Item
key={item.id}
value={`${item.label} ${item.breadcrumb ?? ''}`}
onSelect={() => handleSelect(item.path)}
className={cn(
'flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm',
'text-foreground',
'hover:bg-accent',
'aria-selected:bg-primary/10 aria-selected:text-primary',
'outline-none transition-colors'
)}
data-qqq-id={`command-item-${item.id.replace(/\//g, '-')}`}
>
<TypeIcon type={item.type} />
<span className="flex-1 truncate font-medium">{item.label}</span>
{item.breadcrumb && (
{navTargets.map((target) => {
const context = target.ancestors.map((ancestor) => ancestor.label).join(' / ')
return (
<Command.Item
key={target.path}
value={`${target.label} ${context} ${target.path}`}
onSelect={() => handleSelect(target.path)}
className={cn(
'flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm',
'text-foreground',
'hover:bg-accent',
'aria-selected:bg-primary/10 aria-selected:text-primary',
'outline-none transition-colors'
)}
data-qqq-id={`command-item-${target.key}`}
data-node-type={target.nodeType}
>
<MetadataIcon icon={target.icon} kind={ICON_KIND[target.nodeType]} />
<span className="flex-1 truncate font-medium">{target.label}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{item.breadcrumb}
{context ? `${TYPE_LABEL[target.nodeType]} · ${context}` : TYPE_LABEL[target.nodeType]}
</span>
)}
</Command.Item>
))}
</Command.Item>
)
})}
</Command.Group>
)}
</Command.List>
+40 -3
View File
@@ -1,20 +1,57 @@
'use client'
/*
* 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.
*/
// EmptyState — Reusable empty state component
// Used when a query returns zero results or no data is available
/**
* @file EmptyState — reusable placeholder shown when a query returns zero results or no data is available.
*/
'use client'
import React, { type ReactNode } from 'react'
import { Inbox } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
/**
* Props for the EmptyState component.
*/
export interface EmptyStateProps {
/** The primary heading text describing the empty state (e.g. "No records found"). */
title: string
/** Optional supporting text explaining why the list is empty or what the user can do. */
description?: string
/** Optional custom icon node; defaults to the `Inbox` Lucide icon when not provided. */
icon?: ReactNode
/** Optional primary call-to-action rendered as a button below the description. */
action?: { label: string; onClick: () => void }
/** Additional Tailwind class names applied to the outermost container div. */
className?: string
}
/**
* Renders a centered empty-state placeholder with an icon, title, optional description,
* and an optional call-to-action button.
*
* Uses `role="status"` and `aria-live="polite"` so screen readers announce
* the state when it appears after a data load.
*
* @param props - Component properties.
* @returns A bordered dashed container centered in its parent, with
* `role="status"` and `aria-live="polite"` so screen readers announce the
* empty-state message without requiring focus.
*/
export function EmptyState({
title,
description,
+84 -3
View File
@@ -1,43 +1,124 @@
'use client'
/*
* 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.
*/
// ErrorBoundary — Reusable React class-based error boundary
// Catches render errors in its children subtree
/**
* @file ErrorBoundary — reusable React class-based error boundary that catches render errors in its children subtree.
*/
'use client'
import React, { type ReactNode, type ErrorInfo } from 'react'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
/**
* Props for the ErrorBoundary class component.
*/
export interface ErrorBoundaryProps {
/** The React subtree to protect against render errors. */
children: ReactNode
/** Custom fallback UI to render instead of the default error card when an error is caught. */
fallback?: ReactNode
/** Optional callback invoked with the caught error and React error info for external logging. */
onError?: (error: Error, info: ErrorInfo) => void
/** Additional Tailwind class names applied to the default error card container. */
className?: string
}
/**
* Internal state for the ErrorBoundary class component.
*/
interface ErrorBoundaryState {
/** Whether an error has been caught and the fallback UI should be shown. */
hasError: boolean
/** The most recently caught error, or `null` when none has occurred. */
error: Error | null
}
/**
* React class component that catches JavaScript errors anywhere in its child tree.
*
* When an error is caught, it renders either a custom `fallback` node or the
* built-in error card with a "Try Again" button. Calling `handleReset` clears
* the error state so children can re-render.
*
* @example
* ```tsx
* <ErrorBoundary onError={(err) => logger.error(err)}>
* <WidgetGrid />
* </ErrorBoundary>
* ```
*/
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
/**
* React lifecycle method called during the render phase when a descendant
* throws. Because it runs in the render phase (not the commit phase), React
* can use the returned state to re-render the fallback UI in the same pass —
* no additional render cycle is required. Side effects such as logging must
* NOT be placed here; use {@link componentDidCatch} instead, which runs in
* the commit phase after the fallback has been painted.
*
* @param error - The error that was thrown by a descendant component.
* @returns New state with `hasError: true` and the caught `error` object so
* `render()` can display the fallback UI.
*/
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
/**
* React lifecycle method called in the commit phase after the fallback UI
* has been painted to the DOM. This is the correct place for side effects
* such as logging because the render phase has already completed. In
* contrast, {@link getDerivedStateFromError} runs during the render phase
* and must remain pure (no side effects).
*
* @param error - The caught error.
* @param info - React error info containing the component stack trace.
*/
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, info)
this.props.onError?.(error, info)
}
/**
* Resets the error state so the child tree will attempt to re-render.
*/
handleReset = () => {
this.setState({ hasError: false, error: null })
}
/**
* Renders either the fallback UI (when an error has been caught) or the child tree.
*
* Priority order when `hasError` is true:
* 1. `fallback` prop — custom caller-supplied node rendered as-is.
* 2. Default error card — centered red card with the error message and a
* "Try Again" button that calls {@link handleReset}.
*
* When `hasError` is false, renders `children` unchanged.
*
* @returns The `fallback` prop when provided, the default error card as
* secondary fallback, or `children` when no error has occurred.
*/
render() {
if (this.state.hasError) {
if (this.props.fallback) {
@@ -1,25 +1,53 @@
'use client'
/*
* 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.
*/
// KeyboardShortcutsDialog — "?" help dialog showing available keyboard shortcuts
// Organized by context: Global, Table Query, Record View
// Uses Radix Dialog primitive with project design tokens
/**
* @file KeyboardShortcutsDialog — "?" help dialog listing available keyboard shortcuts grouped by context.
*/
'use client'
import React from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
/**
* Describes a single keyboard shortcut with its key combination and action description.
*/
interface ShortcutEntry {
/** Ordered array of key labels (e.g. `['⌘', 'K']`). */
keys: string[]
/** Human-readable description of the action triggered by the shortcut. */
description: string
}
/**
* A named group of related keyboard shortcuts shown under a section header.
*/
interface ShortcutSection {
/** Section heading (e.g. `"Global"`, `"Table Query Page"`). */
title: string
/** List of shortcuts belonging to this section. */
shortcuts: ShortcutEntry[]
}
/** Static list of all keyboard shortcut sections displayed in the dialog. */
const shortcutSections: ShortcutSection[] = [
{
title: 'Global',
@@ -49,6 +77,13 @@ const shortcutSections: ShortcutSection[] = [
},
]
/**
* Renders a styled keyboard key badge using the `<kbd>` HTML element.
*
* @param children - The key label to display (e.g. `"⌘"`, `"K"`, `"esc"`).
* @returns A `<kbd>` element with a minimum 24 px square size, monospace font,
* and muted background/border using project design tokens.
*/
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd
@@ -62,12 +97,31 @@ function Kbd({ children }: { children: React.ReactNode }) {
)
}
/**
* Props for the KeyboardShortcutsDialog component.
*/
interface KeyboardShortcutsDialogProps {
/** Whether the dialog is currently open. */
open: boolean
/** Called when the dialog should close. */
onClose: () => void
}
/**
* Modal dialog listing all keyboard shortcuts organized by contextual section.
*
* Triggered by pressing `?` anywhere in the application (when focus is not in
* a text field). Closes via the X button, the close prop, or Escape key. Uses
* the Radix `Dialog` primitive with project design tokens and the `Kbd` badge
* component to render key labels.
*
* @param props - Component properties.
* @returns A Radix Dialog portal with a sticky header, scrollable shortcut
* 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 }: KeyboardShortcutsDialogProps) {
const restoreFocus = useRestoreFocus(open)
return (
<Dialog.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<Dialog.Portal>
@@ -76,6 +130,7 @@ export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDial
data-qqq-id="keyboard-shortcuts-overlay"
/>
<Dialog.Content
onCloseAutoFocus={restoreFocus}
className={cn(
'fixed left-1/2 top-1/2 z-[2001] w-full max-w-md -translate-x-1/2 -translate-y-1/2',
'rounded-xl border border-border bg-card shadow-lg',
+90 -212
View File
@@ -1,52 +1,59 @@
/*
* 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 SearchDialog — "/" key "jump to" dialog over pages and recently viewed records.
*/
'use client'
// SearchDialog — "/" key command palette for searching records
// Shows recent records when idle, live API search results when typing
// Enter on a result navigates to record; Enter with no selection goes to full search page
import React, { useEffect, useState, useCallback, useRef } from 'react'
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { Search, Clock, ArrowRight, X } from 'lucide-react'
import { Search, ArrowRight, X } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { globalSearch } from '@/lib/api/tables'
import type { GlobalSearchResult } from '@/lib/api/tables'
import type { NavTarget } from '@/lib/hooks/use-routes'
import { getRecentRecords } from '@/lib/utils/recent-records'
import type { RecentRecord } from '@/lib/utils/recent-records'
import { queryKeys } from '@/lib/query-client'
import { buildNavigationSearchItems } from '@/lib/utils/navigation-search'
import { NavigationSearchResults } from '@/components/layout/NavigationSearchResults'
/**
* Props for the SearchDialog component.
*/
interface SearchDialogProps {
/** Whether the search dialog is currently open. */
open: boolean
/** Called when the dialog should close (backdrop click, Escape, or item navigation). */
onClose: () => void
/** Navigable targets from the app tree. */
navTargets: NavTarget[]
}
function getInitials(label: string): string {
const words = label.trim().split(/\s+/)
if (words.length === 0) return '?'
if (words.length === 1) return words[0].charAt(0).toUpperCase()
return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase()
}
function HighlightedText({ text, query }: { text: string; query: string }) {
if (!query || query.length < 2) return <>{text}</>
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${escaped})`, 'gi')
const parts = text.split(regex)
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-primary/20 text-foreground rounded-sm px-0.5">{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
)
}
export function SearchDialog({ open, onClose }: SearchDialogProps) {
/**
* Modal search dialog triggered by the `/` keyboard shortcut.
*
* Lists recently viewed records until the user types, then matching pages
* (apps, tables, processes, reports by label) and matching recent records.
* ArrowUp/Down move the selection, Enter opens it (or the search page when
* nothing is selected), Escape closes.
*
* @param props - Component properties.
* @returns The dialog overlay, or `null` when closed.
*/
export function SearchDialog({ open, onClose, navTargets }: SearchDialogProps) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
@@ -54,59 +61,30 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
const [selectedIndex, setSelectedIndex] = useState(-1)
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
// Debounced search term for API calls
const [debouncedTerm, setDebouncedTerm] = useState('')
useEffect(() => {
const timer = setTimeout(() => setDebouncedTerm(searchTerm), 300)
return () => clearTimeout(timer)
}, [searchTerm])
// TanStack Query for search results
const { data: searchResults = [], isLoading: isSearching } = useQuery<GlobalSearchResult[]>({
queryKey: queryKeys.globalSearch(debouncedTerm),
queryFn: () => globalSearch(debouncedTerm),
enabled: open && debouncedTerm.length >= 2,
staleTime: 1000 * 30,
})
// Load recent records when dialog opens; clear state when closed
// Load recent records when the dialog opens; reset state
useEffect(() => {
if (open) {
setRecentRecords(getRecentRecords().slice(0, 10))
setSearchTerm('')
setDebouncedTerm('')
setSelectedIndex(-1)
// Focus after animation frame
requestAnimationFrame(() => inputRef.current?.focus())
}
}, [open])
// Reset selected index when results change
const items = useMemo(
() => buildNavigationSearchItems(navTargets, recentRecords, searchTerm),
[navTargets, recentRecords, searchTerm]
)
useEffect(() => {
setSelectedIndex(-1)
}, [searchResults, recentRecords, searchTerm])
// Build navigable items
const navigableItems: Array<{ path: string; label: string; tableLabel?: string }> = []
if (searchTerm.length >= 2 && searchResults.length > 0) {
for (const result of searchResults) {
navigableItems.push({
path: `/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`,
label: result.recordLabel,
tableLabel: result.tableLabel || result.tableName,
})
}
} else if (searchTerm.length < 2 && recentRecords.length > 0) {
for (const record of recentRecords) {
navigableItems.push({
path: record.path,
label: record.recordLabel,
tableLabel: record.tableLabel,
})
}
}
}, [items])
/**
* Closes the dialog and navigates to the given path.
*
* @param path - The URL path to navigate to.
*/
const handleNavigate = useCallback(
(path: string) => {
onClose()
@@ -115,14 +93,17 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
[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 < navigableItems.length - 1 ? prev + 1 : prev
)
setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev))
break
case 'ArrowUp':
e.preventDefault()
@@ -130,8 +111,8 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < navigableItems.length) {
handleNavigate(navigableItems[selectedIndex].path)
if (selectedIndex >= 0 && selectedIndex < items.length) {
handleNavigate(items[selectedIndex].path)
} else if (searchTerm.trim()) {
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
}
@@ -142,26 +123,13 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
break
}
},
[selectedIndex, navigableItems, searchTerm, handleNavigate, onClose]
[selectedIndex, items, searchTerm, handleNavigate, onClose]
)
// Group search results by table
const groupedResults: Record<string, GlobalSearchResult[]> = {}
if (searchTerm.length >= 2) {
for (const result of searchResults) {
const key = result.tableLabel || result.tableName
if (!groupedResults[key]) groupedResults[key] = []
groupedResults[key].push(result)
}
}
let runningIndex = 0
const showRecent = searchTerm.length < 2 && recentRecords.length > 0
const showResults = searchTerm.length >= 2
const showEmpty = searchTerm.length >= 2 && !isSearching && searchResults.length === 0
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]"
@@ -177,7 +145,7 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg overflow-hidden"
data-qqq-id="search-dialog"
role="dialog"
aria-label="Search records"
aria-label="Search"
aria-modal="true"
>
{/* Search input */}
@@ -189,11 +157,12 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search records..."
placeholder="Jump to a page or recent record..."
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autoFocus
aria-label="Search records"
aria-label="Search pages and recent records"
aria-expanded={true}
aria-controls="search-dialog-results"
aria-haspopup="listbox"
aria-autocomplete="list"
role="combobox"
@@ -206,7 +175,7 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
<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">
@@ -215,111 +184,20 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
</div>
{/* Results */}
<div className="max-h-80 overflow-y-auto" role="listbox" aria-label="Search results">
{/* Recent records */}
{showRecent && (
<div>
<div className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-muted-foreground">
<Clock className="h-3 w-3" aria-hidden="true" />
Recently Viewed
</div>
{recentRecords.map((record, index) => (
<button
key={record.path}
onClick={() => handleNavigate(record.path)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors',
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/50'
)}
role="option"
aria-selected={selectedIndex === index}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{getInitials(record.recordLabel)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{record.recordLabel}
</div>
<div className="truncate text-xs text-muted-foreground">
{record.tableLabel}
</div>
</div>
</button>
))}
</div>
)}
{/* Searching spinner */}
{showResults && isSearching && (
<div className="flex items-center justify-center px-4 py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2 text-sm text-muted-foreground">Searching...</span>
</div>
)}
{/* Grouped search results */}
{showResults && !isSearching && !showEmpty && (
Object.entries(groupedResults).map(([tableLabel, results]) => {
const groupItems = results.map((result, resultIdx) => {
const itemIndex = runningIndex
runningIndex++
return (
<button
key={`${result.tableName}-${result.recordId}-${resultIdx}`}
onClick={() =>
handleNavigate(
`/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`
)
}
onMouseEnter={() => setSelectedIndex(itemIndex)}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors',
selectedIndex === itemIndex ? 'bg-accent' : 'hover:bg-accent/50'
)}
role="option"
aria-selected={selectedIndex === itemIndex}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{getInitials(result.recordLabel)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
<HighlightedText text={result.recordLabel} query={searchTerm} />
</div>
<div className="truncate text-xs text-muted-foreground">
{result.tableLabel || result.tableName}
</div>
</div>
</button>
)
})
return (
<div key={tableLabel}>
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
{tableLabel}
</div>
{groupItems}
</div>
)
})
)}
{/* Empty state */}
{showEmpty && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No results found for &ldquo;{searchTerm}&rdquo;
</div>
)}
{/* No recent records and no search */}
{!showRecent && !showResults && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
Start typing to search records...
</div>
)}
</div>
{hasTerm || items.length > 0 ? (
<NavigationSearchResults
id="search-dialog-results"
items={items}
term={searchTerm}
selectedIndex={selectedIndex}
onSelect={handleNavigate}
onHover={setSelectedIndex}
/>
) : (
<div id="search-dialog-results" className="px-4 py-8 text-center text-sm text-muted-foreground">
Type to find an app, table, process or recent record...
</div>
)}
{/* Footer */}
<div className="border-t border-border px-4 py-2">
@@ -329,17 +207,17 @@ export function SearchDialog({ open, onClose }: SearchDialogProps) {
<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>{' '}
{searchTerm.trim() ? 'open' : 'select'}
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}open
</span>
</div>
{searchTerm.trim() && (
{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" />
View all results
Show all matches
</button>
)}
</div>
+81 -11
View File
@@ -1,19 +1,43 @@
'use client'
/*
* 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.
*/
// Skeleton — Reusable skeleton loader variants
// Usage:
// <Skeleton.Text lines={3} />
// <Skeleton.Card />
// <Skeleton.Table rows={5} cols={4} />
/**
* @file Skeleton — collection of animated placeholder components used while content is loading.
*/
'use client'
import React from 'react'
import { cn } from '@/lib/utils/cn'
/**
* Base props shared by all Skeleton sub-components.
*/
interface SkeletonBaseProps {
/** Additional Tailwind class names applied to the outermost element. */
className?: string
}
// Single animated bar
/**
* A single animated pulse bar used as a building block for skeleton variants.
*
* @param props - Component properties.
* @returns An `aria-hidden` div with `animate-pulse rounded bg-muted` styling;
* width/height are controlled entirely by the `className` prop.
*/
function Bar({ className }: SkeletonBaseProps) {
return (
<div
@@ -26,12 +50,24 @@ function Bar({ className }: SkeletonBaseProps) {
)
}
// Text lines placeholder
/**
* Props for the Skeleton.Text sub-component.
*/
interface TextProps {
/** Number of placeholder text lines to render. Defaults to `3`. */
lines?: number
/** Additional Tailwind class names applied to the container div. */
className?: string
}
/**
* Renders a stack of animated placeholder text lines at varying widths.
*
* Uses `role="status"` and `aria-busy="true"` for accessibility.
*
* @param props - Component properties.
* @returns A stack of `Bar` elements mimicking a block of text.
*/
function Text({ lines = 3, className }: TextProps) {
const widths = ['w-full', 'w-4/5', 'w-3/5', 'w-full', 'w-2/3', 'w-1/2']
return (
@@ -48,11 +84,23 @@ function Text({ lines = 3, className }: TextProps) {
)
}
// Card placeholder
/**
* Props for the Skeleton.Card sub-component.
*/
interface CardProps {
/** Additional Tailwind class names applied to the card container. */
className?: string
}
/**
* Renders an animated placeholder that mimics a content card layout.
*
* Includes an avatar circle, two lines of meta text, body text bars, and a
* tall image placeholder. Uses `role="status"` and `aria-busy="true"`.
*
* @param props - Component properties.
* @returns A bordered rounded card filled with `Bar` placeholders.
*/
function Card({ className }: CardProps) {
return (
<div
@@ -85,13 +133,26 @@ function Card({ className }: CardProps) {
)
}
// Table rows placeholder
/**
* Props for the Skeleton.Table sub-component.
*/
interface TableProps {
/** Number of placeholder data rows to render. Defaults to `5`. */
rows?: number
/** Number of placeholder columns per row. Defaults to `4`. */
cols?: number
/** Additional Tailwind class names applied to the table container. */
className?: string
}
/**
* Renders an animated placeholder resembling a data table with a header row and data rows.
*
* Uses `role="status"` and `aria-busy="true"` for accessibility.
*
* @param props - Component properties.
* @returns A bordered container with a header row and data rows of `Bar` cells.
*/
function Table({ rows = 5, cols = 4, className }: TableProps) {
return (
<div
@@ -124,5 +185,14 @@ function Table({ rows = 5, cols = 4, className }: TableProps) {
)
}
// Namespace export — Skeleton.Text, Skeleton.Card, Skeleton.Table
/**
* Namespace object exporting all skeleton variant components.
*
* @example
* ```tsx
* <Skeleton.Text lines={3} />
* <Skeleton.Card className="my-4" />
* <Skeleton.Table rows={8} cols={5} />
* ```
*/
export const Skeleton = { Text, Card, Table }
+26 -3
View File
@@ -1,6 +1,29 @@
/*
* 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 Toast — re-exports the `Toaster` component from sonner for placement in the providers tree.
*/
'use client'
// Toast — Re-exports the Toaster from sonner for use in the providers tree
// Add <Toaster /> to the root providers to enable toast notifications globally
/**
* Re-exports `Toaster` from sonner for global toast notification support.
*
* Add `<Toaster />` once in the root providers to enable toast notifications.
* Trigger toasts with `toast()`, `toast.success()`, or `toast.error()` from `sonner`.
*/
export { Toaster } from 'sonner'
+129 -22
View File
@@ -1,51 +1,137 @@
'use client'
/*
* 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.
*/
// DynamicForm — renders form fields from metadata using React Hook Form
// This is the CORE REUSABLE FORM component used by EntityForm (Package 3)
// and will be reused by ProcessRun (Package 4).
/**
* @file DynamicForm — core metadata-driven form renderer used by EntityForm and ProcessRun.
*/
'use client'
import React from 'react'
import type { Control, UseFormRegister, FieldErrors } from 'react-hook-form'
import type { QFieldMetaData, QTableSection, QTableMetaData } from '@/types'
import type { QFieldMetaData, QRecord, QTableSection, QTableMetaData, QWidgetMetaData } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import { cn } from '@/lib/utils/cn'
import { DynamicFormField } from './DynamicFormField'
import { SectionIcon } from '@/components/layout/MetadataIcon'
/**
* Props for the {@link DynamicForm} component.
*
* Supply either `tableMetaData` (for section-grouped layout) or `fields`
* (for a flat list, typically used by processes). Both can be combined with
* `fieldNamesToInclude` for fine-grained field filtering.
*/
export interface DynamicFormProps {
// React Hook Form internals — passed in from parent
/** React Hook Form register function from the parent `useForm` instance. */
register: UseFormRegister<Record<string, unknown>>
/** React Hook Form control object from the parent `useForm` instance. */
control: Control<Record<string, unknown>>
/** React Hook Form validation error map from the parent `useForm` instance. */
errors: FieldErrors<Record<string, unknown>>
// Metadata for rendering
/** Table metadata used to derive sections and field ordering. */
tableMetaData?: QTableMetaData
/** Explicit field list; when provided, section grouping is skipped. */
fields?: QFieldMetaData[]
/** Override the sections from `tableMetaData`; ignored when `fields` is set. */
sections?: QTableSection[]
// Which fields to include (if undefined, all editable non-hidden fields)
/** Restricts rendered fields to this allow-list; when omitted all editable non-hidden fields are shown. */
fieldNamesToInclude?: string[]
// Context for possible values (table or process)
/** Context forwarded to possible-value fields to scope their fetch calls. */
possibleValueContext?: PossibleValueContext
// Whether the entire form is disabled
/** When `true`, all fields are rendered in a disabled, read-only state. */
disabled?: boolean
// Optional label shown at top
/**
* Map of field names to a boolean indicating whether that field has been
* modified from its default value. When `true` for a field, the field
* renders with a left-border accent to highlight the change.
* Typically sourced from `formState.dirtyFields` in React Hook Form.
*/
dirtyFields?: Record<string, boolean>
/**
* The record being edited, if any: supplies the display of read-only fields, the
* current file of upload fields and the labels of possible-value selections.
*/
record?: QRecord
/** Show non-editable fields as read-only controls (the edit screen does; create and copy do not). */
showReadOnlyFields?: boolean
/** Screen roles used to choose field help content, most specific first. */
helpRoles?: readonly string[]
/** Limit typing to each field's `maxLength` (default); record forms turn this off. */
enforceMaxLength?: boolean
/** Optional heading rendered above the field grid. */
formLabel?: string
/**
* Widget metadata by name. A section housing a widget shown on record edit
* screens (`includeOnRecordEditScreen`, e.g. the cron schedule) renders the
* record fields that widget edits, which usually sit in a hidden section.
*/
widgets?: Record<string, QWidgetMetaData>
/** Additional CSS classes applied to the outermost container. */
className?: string
}
/**
* The record fields a widget section edits on a record form, as Material's
* EntityForm does for `cronUI` widgets (expression and time zone fields).
*
* @param section - A table section.
* @param widgets - Widget metadata by name.
* @returns The edited field names, or `undefined` when the section is not an editable widget section.
*/
export function editScreenWidgetFieldNames(section: QTableSection, widgets: Record<string, QWidgetMetaData> | undefined): string[] | undefined {
const widget = section.widgetName ? widgets?.[section.widgetName] : undefined
if (!widget || widget.hasPermission === false || widget.type !== 'cronUI') return undefined
const defaults = widget.defaultValues ?? {}
if (defaults.includeOnRecordEditScreen !== true) return undefined
const names = [defaults.cronExpressionFieldName, defaults.timeZoneFieldName].filter((name): name is string => typeof name === 'string' && name !== '')
return names.length > 0 ? names : undefined
}
/**
* DynamicForm renders form fields from metadata.
*
* Usage patterns:
* 1. Table-based: pass tableMetaData (uses its sections for layout)
* 2. Process-based: pass fields directly (flat list, no sections)
* 3. Both: pass both tableMetaData and fieldNamesToInclude to filter
* Supports three usage patterns:
* 1. **Table-based** — pass `tableMetaData`; fields are grouped and ordered by
* the table's sections.
* 2. **Process-based** — pass `fields` directly; renders a flat list with no
* section grouping, typically used by `ProcessRun`.
* 3. **Filtered** — pass both `tableMetaData` and `fieldNamesToInclude` to show
* only a named subset of the table's fields while preserving section layout.
*
* Used by {@link EntityForm} (create/edit/copy), `ProcessRun` (step forms), and
* any other caller that needs a metadata-driven field grid.
*
* @param props - Component properties (see {@link DynamicFormProps}).
* @returns The rendered form fields grouped by section, or `null` when no
* renderable fields exist after applying visibility and include-list filters.
*/
export function DynamicForm({
register,
@@ -57,7 +143,13 @@ export function DynamicForm({
fieldNamesToInclude,
possibleValueContext,
disabled = false,
dirtyFields,
record,
showReadOnlyFields = false,
helpRoles,
enforceMaxLength = true,
formLabel,
widgets,
className,
}: DynamicFormProps) {
// Determine the set of fields to render
@@ -80,8 +172,8 @@ export function DynamicForm({
// Collect field names in section order
const resolvedSections = sections ?? tableMetaData.sections
for (const section of resolvedSections) {
if (section.isHidden) continue
for (const fn of section.fieldNames) {
if (section.isHidden || section.hidden) continue
for (const fn of section.fieldNames ?? []) {
if (!fieldOrder.includes(fn)) fieldOrder.push(fn)
}
}
@@ -108,24 +200,28 @@ export function DynamicForm({
const hasSections =
tableMetaData &&
tableMetaData.sections &&
tableMetaData.sections.filter((s) => !s.isHidden).length > 0 &&
tableMetaData.sections.filter((s) => !s.isHidden && !s.hidden).length > 0 &&
!fields
if (hasSections && tableMetaData) {
const resolvedSections = sections ?? tableMetaData.sections
const resolvedSections = (sections ?? tableMetaData.sections).map((section) => {
const widgetFieldNames = editScreenWidgetFieldNames(section, widgets)
return widgetFieldNames ? { ...section, fieldNames: widgetFieldNames } : section
})
return (
<div className={cn('space-y-6', className)} data-qqq-id="dynamic-form">
{formLabel && (
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
)}
{resolvedSections
.filter((s) => !s.isHidden)
.filter((s) => !s.isHidden && !s.hidden)
.map((section) => {
const sectionFields = section.fieldNames
const sectionFields = (section.fieldNames ?? [])
.map((fn) => tableMetaData.fields[fn])
.filter((f): f is QFieldMetaData => {
if (!f) return false
if (f.isHidden) return false
if (!f.isEditable && !showReadOnlyFields && !disabled) return false
if (fieldNamesToInclude && !fieldNamesToInclude.includes(f.name)) return false
return true
})
@@ -142,7 +238,8 @@ export function DynamicForm({
>
{section.label && (
<div className="border-b border-border pb-2">
<h4 className="text-sm font-medium text-muted-foreground">
<h4 className="flex items-center text-sm font-medium text-muted-foreground">
<SectionIcon section={section} />
{section.label}
</h4>
</div>
@@ -170,7 +267,12 @@ export function DynamicForm({
control={control}
errors={errors}
disabled={disabled}
isDirty={dirtyFields?.[f.name] === true}
possibleValueContext={possibleValueContext}
record={record}
showReadOnly={showReadOnlyFields}
helpRoles={helpRoles}
enforceMaxLength={enforceMaxLength}
/>
</div>
))}
@@ -203,7 +305,12 @@ export function DynamicForm({
control={control}
errors={errors}
disabled={disabled}
isDirty={dirtyFields?.[f.name] === true}
possibleValueContext={possibleValueContext}
record={record}
showReadOnly={showReadOnlyFields}
helpRoles={helpRoles}
enforceMaxLength={enforceMaxLength}
/>
</div>
))}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,172 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AxiosError, AxiosHeaders } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { QFieldMetaData, QRecord, QTableMetaData, QWidgetMetaData } from '@/types'
import apiClient from '@/lib/api/client'
import { EntityForm } from './EntityForm'
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), back: vi.fn() }) }))
function field(name: string, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
return { name, label: name[0].toUpperCase() + name.slice(1), type: 'STRING', isRequired: false, isEditable: true, isHeavy: false, isHidden: false, adornments: [], ...extra }
}
const table: QTableMetaData = {
name: 'lab', label: 'Lab', isHidden: false, primaryKeyField: 'id',
fields: {
id: field('id', { type: 'INTEGER', isEditable: false }),
title: field('title', { isRequired: true, maxLength: 4 }),
secret: field('secret', { type: 'PASSWORD' }),
big: field('big', { type: 'LONG' }),
stamp: field('stamp', { type: 'DATE_TIME' }),
owner: field('owner', { type: 'STRING' }),
flag: field('flag', { type: 'BOOLEAN', defaultValue: 'true' }),
createDate: field('createDate', { type: 'DATE_TIME', isEditable: false }),
},
sections: [{ name: 'main', label: 'Main', isHidden: false, fieldNames: ['id', 'title', 'secret', 'big', 'stamp', 'owner', 'flag', 'createDate'] }],
capabilities: [], exposedJoins: [], readPermission: true, insertPermission: true, editPermission: true, deletePermission: true,
usesVariants: false, variantTableLabel: '',
}
const stored: QRecord = {
tableName: 'lab',
values: { id: 7, title: 'Old', secret: '************', big: 9007199254740992, stamp: '2024-07-04T13:15:00.123456Z', owner: 'sample:bob', flag: false, createDate: '2024-01-01T00:00:00Z' },
displayValues: { id: '7', title: 'Old', secret: '************', big: '9007199254740993', stamp: '2024-07-04T13:15:00.123456Z', owner: 'sample:bob', flag: 'No', createDate: '2024-01-01T00:00:00Z' },
}
function renderForm(record?: QRecord) {
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} record={record} /></QueryClientProvider>)
}
describe('Record form regressions (#649)', () => {
beforeEach(() => vi.restoreAllMocks())
it('keeps focus while typing so a field does not remount when it becomes dirty', async () => {
const user = userEvent.setup()
renderForm(stored)
const title = screen.getByLabelText(/^Title/)
await user.clear(title)
await user.type(title, 'Newer title')
expect(title).toHaveFocus()
expect(title).toHaveValue('Newer title')
})
it('edit never re-sends a masked password, a LONG, an unchanged date-time or read-only values', async () => {
const user = userEvent.setup()
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ records: [{ tableName: 'lab', values: { id: 7 } }] })
renderForm(stored)
expect(screen.getByLabelText(/^Secret/)).toHaveValue('')
expect(screen.getByLabelText(/^Id/)).toBeDisabled()
const title = screen.getByLabelText(/^Title/)
await user.clear(title)
// Record forms leave maxLength to the server's too-long policy.
await user.type(title, 'Longer than four')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toHaveBeenCalledTimes(1))
const body = put.mock.calls[0][1] as FormData
expect(Object.fromEntries(body.entries())).toEqual({ title: 'Longer than four', owner: 'sample:bob', flag: 'false' })
})
it('sends a changed date-time as the UTC instant and a typed LONG exactly', async () => {
const user = userEvent.setup()
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ records: [{ tableName: 'lab', values: { id: 7 } }] })
renderForm(stored)
fireEvent.change(screen.getByLabelText(/^Big/), { target: { value: '9007199254740993' } })
fireEvent.change(screen.getByLabelText(/^Stamp/), { target: { value: '2024-01-15T09:15' } })
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(put).toHaveBeenCalledTimes(1))
const body = put.mock.calls[0][1] as FormData
expect(body.get('big')).toBe('9007199254740993')
expect(body.get('stamp')).toBe(new Date(2024, 0, 15, 9, 15).toISOString().replace(/\.\d{3}Z$/, 'Z'))
})
it('create applies metadata defaults and shows the backend error message once, keeping the values', async () => {
const user = userEvent.setup()
const error = new AxiosError('Request failed with status code 400', 'ERR_BAD_REQUEST', undefined, undefined,
{ status: 400, statusText: '', headers: {}, config: { headers: new AxiosHeaders() }, data: { error: 'Error inserting Lab: Another record already exists with this Title' } })
const post = vi.spyOn(apiClient, 'post').mockRejectedValue(error)
renderForm()
expect(screen.getByRole('checkbox', { name: 'Flag' })).toHaveAttribute('aria-checked', 'true')
expect(screen.queryByLabelText(/^Id/)).toBeNull()
await user.type(screen.getByLabelText(/^Title/), 'Dupe')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
expect((post.mock.calls[0][1] as FormData).get('flag')).toBe('true')
expect(await screen.findByText('Error inserting Lab: Another record already exists with this Title')).toBeVisible()
expect(screen.getByLabelText(/^Title/)).toHaveValue('Dupe')
})
it('renders the fields a cron schedule widget section edits, though they sit in a hidden section, and saves them', async () => {
const user = userEvent.setup()
const scheduled: QTableMetaData = {
...table,
name: 'schedule', label: 'Schedule',
fields: {
id: field('id', { type: 'INTEGER', isEditable: false }),
subject: field('subject'),
cronExpression: field('cronExpression', { label: 'Cron Expression', isRequired: true }),
cronTimeZoneId: field('cronTimeZoneId', { label: 'Cron Time Zone Id' }),
userId: field('userId', { label: 'User Id' }),
},
sections: [
{ name: 'email', label: 'Email', isHidden: false, fieldNames: ['subject'] },
{ name: 'schedule', label: 'Schedule', isHidden: false, fieldNames: [], widgetName: 'cronWidget' },
{ name: 'hidden', label: 'Hidden', isHidden: true, fieldNames: ['userId', 'cronExpression', 'cronTimeZoneId'] },
],
}
const widgets = {
cronWidget: { name: 'cronWidget', label: 'Schedule', type: 'cronUI', hasPermission: true,
defaultValues: { cronExpressionFieldName: 'cronExpression', timeZoneFieldName: 'cronTimeZoneId', includeOnRecordEditScreen: true } },
} as unknown as Record<string, QWidgetMetaData>
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'schedule', values: { id: 1 }, displayValues: {} }] })
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
render(<QueryClientProvider client={client}><EntityForm tableMetaData={scheduled} widgets={widgets} /></QueryClientProvider>)
expect(screen.getByRole('heading', { name: 'Schedule' })).toBeVisible()
// only the widget's fields are revealed; the rest of the hidden section stays hidden
expect(screen.queryByLabelText(/^User Id/)).toBeNull()
await user.type(screen.getByLabelText(/^Cron Expression/), '0 0 9 * * ?')
await user.type(screen.getByLabelText(/^Cron Time Zone Id/), 'UTC')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
const body = post.mock.calls[0][1] as FormData
expect(body.get('cronExpression')).toBe('0 0 9 * * ?')
expect(body.get('cronTimeZoneId')).toBe('UTC')
})
it('keeps a hidden section hidden when its widget is not shown on edit screens', () => {
const scheduled: QTableMetaData = {
...table,
fields: { ...table.fields, cronExpression: field('cronExpression', { label: 'Cron Expression' }) },
sections: [
...table.sections,
{ name: 'schedule', label: 'Schedule', isHidden: false, fieldNames: [], widgetName: 'cronWidget' },
{ name: 'hidden', label: 'Hidden', isHidden: true, fieldNames: ['cronExpression'] },
],
}
const widgets = {
cronWidget: { name: 'cronWidget', label: 'Schedule', type: 'cronUI', hasPermission: true, defaultValues: { cronExpressionFieldName: 'cronExpression' } },
} as unknown as Record<string, QWidgetMetaData>
const client = new QueryClient()
render(<QueryClientProvider client={client}><EntityForm tableMetaData={scheduled} widgets={widgets} /></QueryClientProvider>)
expect(screen.queryByLabelText(/^Cron Expression/)).toBeNull()
})
})
+224
View File
@@ -0,0 +1,224 @@
/*
* 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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { 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() }) }))
function renderForm(fixedValues: Record<string, string | number | boolean>) {
const table = structuredClone(qInstance.tables.company)
table.fields = { name: table.fields.name, owner: { ...table.fields.id, name: 'owner', label: 'Owner', isEditable: true } }
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['name', 'owner'] }]
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} fixedValues={fixedValues} /></QueryClientProvider>)
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'Child' } })
return { table }
}
describe('Explicit fixed relationship submission', () => {
beforeEach(() => vi.restoreAllMocks())
it('validates fixed values and merges them last without mutating caller data', async () => {
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 7 } }] })
const fixed = { owner: 2 }
renderForm(fixed)
fireEvent.change(screen.getByLabelText(/Owner/), { target: { value: '99' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalled())
expect((post.mock.calls[0][1] as FormData).get('owner')).toBe('2')
expect(fixed).toEqual({ owner: 2 })
})
it.each<Record<string, string | number | boolean>>([{ missing: 1 }, { owner: 'not-an-integer' }])('rejects undeclared or mistyped fixed values before HTTP: %j', async (fixed) => {
const post = vi.spyOn(apiClient, 'post')
renderForm(fixed)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('alert')).toBeVisible()
expect(post).not.toHaveBeenCalled()
})
})
describe('Base copy starts a new record identity', () => {
beforeEach(() => vi.restoreAllMocks())
it('clears inherited manual keys on initial load and source reset, then submits a fresh key', async () => {
const table = structuredClone(qInstance.tables.company)
table.primaryKeyField = 'code'
table.fields = { name: table.fields.name, code: { ...table.fields.name, name: 'code', label: 'Code', isRequired: true } }
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['code', 'name'] }]
const source: QRecord = { tableName: 'company', values: { code: 'old/key', name: 'Original' },
associatedRecords: { 'arbitrary children': [{ tableName: 'person', values: { id: 3 } }] } }
const nextSource: QRecord = { tableName: 'company', values: { code: 'other/key', name: 'Another' } }
const sourceSnapshot = structuredClone(source)
const nextSnapshot = structuredClone(nextSource)
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { code: 'new/key' } }] })
const onSuccess = vi.fn()
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
const view = (record: QRecord) => <QueryClientProvider client={client}>
<EntityForm tableMetaData={table} record={record} isCopy onSuccess={onSuccess} />
</QueryClientProvider>
const { rerender } = render(view(source))
expect(screen.getByLabelText(/Code/)).toHaveValue('')
expect(screen.getByLabelText(/Name/)).toHaveValue('Original')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Code is required')).toBeVisible()
expect(post).not.toHaveBeenCalled()
fireEvent.change(screen.getByLabelText(/Code/), { target: { value: 'draft/key' } })
rerender(view(nextSource))
expect(screen.getByLabelText(/Code/)).toHaveValue('')
expect(screen.getByLabelText(/Name/)).toHaveValue('Another')
fireEvent.change(screen.getByLabelText(/Code/), { target: { value: 'new/key' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
const submitted = post.mock.calls[0][1] as FormData
expect(submitted.get('code')).toBe('new/key')
expect(submitted.get('name')).toBe('Another')
expect(submitted.has('associations')).toBe(false)
expect(source).toEqual(sourceSnapshot)
expect(nextSource).toEqual(nextSnapshot)
})
it.each([undefined, '0'])('omits an untouched optional numeric key but preserves a supplied %s', async (newKey) => {
const table = structuredClone(qInstance.tables.company)
table.fields = { name: table.fields.name, id: { ...table.fields.id, isEditable: true, isRequired: false } }
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['id', 'name'] }]
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 99 } }] })
const onSuccess = vi.fn()
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table}
record={{ tableName: 'company', values: { id: 42, name: 'Original' } }} isCopy onSuccess={onSuccess} />
</QueryClientProvider>)
expect(screen.getByLabelText(/ID/)).toHaveValue(null)
if (newKey !== undefined) fireEvent.change(screen.getByLabelText(/ID/), { target: { value: newKey } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
const submitted = post.mock.calls[0][1] as FormData
expect(submitted.get('id')).toBe(newKey ?? null)
})
it.each([{ encoded: 'AQCA/w==', expected: [1, 0, 128, 255] }, { encoded: '', expected: [] }])('copies native BLOB $encoded bytes as a file with the declared source filename', async ({ encoded, expected }) => {
const table = structuredClone(qInstance.tables.company)
table.fields = { name: table.fields.name, attachment: { ...table.fields.name, name: 'attachment', label: 'Attachment',
type: 'BLOB', isRequired: false, adornments: [{ type: 'FILE_DOWNLOAD', values: { fileNameField: 'fileName', defaultMimeType: 'application/octet-stream' } }] } }
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['name', 'attachment'] }]
const source: QRecord = { tableName: 'company', values: { id: 1, name: 'Original', attachment: encoded, fileName: 'original.bin' } }
const snapshot = structuredClone(source)
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 99 } }] })
const onSuccess = vi.fn()
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} record={source} isCopy onSuccess={onSuccess} /></QueryClientProvider>)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
const file = (post.mock.calls[0][1] as FormData).get('attachment')
expect(file).toBeInstanceOf(File)
if (!(file instanceof File)) throw new Error('Expected binary multipart file')
expect(file.name).toBe('original.bin')
expect(file.type).toBe('application/octet-stream')
const bytes = await new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => reader.result instanceof ArrayBuffer ? resolve(reader.result) : reject(new Error('Expected binary bytes'))
reader.onerror = () => reject(reader.error)
reader.readAsArrayBuffer(file)
})
expect(Array.from(new Uint8Array(bytes))).toEqual(expected)
expect(screen.getByText('original.bin')).toBeVisible()
expect(source).toEqual(snapshot)
})
it('blocks malformed copied binary values before HTTP with an explicit source error', async () => {
const table = structuredClone(qInstance.tables.company)
table.fields = { name: table.fields.name, attachment: { ...table.fields.name, name: 'attachment', label: 'Attachment', type: 'BLOB' } }
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: ['name', 'attachment'] }]
const post = vi.spyOn(apiClient, 'post')
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table}
record={{ tableName: 'company', values: { id: 1, name: 'Original', attachment: 'not base64!' } }} isCopy />
</QueryClientProvider>)
expect(screen.getByRole('alert')).toHaveTextContent('Cannot copy Attachment: the source file data is invalid.')
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!)
expect(post).not.toHaveBeenCalled()
})
it('preserves explicit nullable source values without replacing false, zero or omitted defaults', async () => {
const table = structuredClone(qInstance.tables.company)
const optional = { ...table.fields.name, isRequired: false }
table.fields = {
name: table.fields.name,
unknownFlag: { ...optional, name: 'unknownFlag', label: 'Unknown flag', type: 'BOOLEAN' },
flag: { ...optional, name: 'flag', label: 'Known false', type: 'BOOLEAN', defaultValue: true },
note: { ...optional, name: 'note', label: 'Null note', defaultValue: 'Default note' },
missing: { ...optional, name: 'missing', label: 'Omitted note', defaultValue: 'Omitted default' },
amount: { ...optional, name: 'amount', label: 'Null amount', type: 'DECIMAL', defaultValue: 7 },
quantity: { ...optional, name: 'quantity', label: 'Known zero', type: 'INTEGER', defaultValue: 8 },
}
table.sections = [{ name: 'identity', label: 'Identity', isHidden: false, fieldNames: Object.keys(table.fields) }]
const source: QRecord = { tableName: 'company', values: { id: 1, name: 'Original', unknownFlag: null, flag: false, note: null, amount: null, quantity: 0 } }
const snapshot = structuredClone(source)
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 99 } }] })
const onSuccess = vi.fn()
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} record={source} isCopy onSuccess={onSuccess} /></QueryClientProvider>)
expect(screen.getByRole('checkbox', { name: 'Unknown flag' })).toHaveAttribute('aria-checked', 'mixed')
expect(screen.getByRole('checkbox', { name: 'Known false' })).toHaveAttribute('aria-checked', 'false')
expect(screen.getByLabelText('Null note')).toHaveValue('')
expect(screen.getByLabelText('Null amount')).toHaveValue(null)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce())
const submitted = post.mock.calls[0][1] as FormData
expect(submitted.get('unknownFlag')).toBe('')
expect(submitted.get('note')).toBe('')
expect(submitted.get('amount')).toBe('')
expect(submitted.get('flag')).toBe('false')
expect(submitted.get('quantity')).toBe('0')
expect(submitted.get('missing')).toBe('Omitted default')
expect(source).toEqual(snapshot)
})
})
describe('Copy never submits native password masks', () => {
beforeEach(() => vi.restoreAllMocks())
it('requires an explicit new password while ordinary strings and revealed passwords copy normally', async () => {
const table = structuredClone(qInstance.tables.company)
table.fields = { id: table.fields.id, name: table.fields.name,
secret: { ...table.fields.name, name: 'secret', label: 'New secret', type: 'PASSWORD', isRequired: false },
revealed: { ...table.fields.name, name: 'revealed', label: 'Revealed secret', type: 'PASSWORD', adornments: [{ type: 'REVEAL' }] },
}
table.sections = []
const source = { tableName: 'company', values: { id: 1, name: '********', secret: '********', revealed: 'available' } }
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ records: [{ tableName: 'company', values: { id: 2 } }] })
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
render(<QueryClientProvider client={client}><EntityForm tableMetaData={table} record={source} isCopy /></QueryClientProvider>)
expect(screen.getByLabelText(/New secret/)).toHaveValue('')
expect(screen.getByLabelText(/Revealed secret/)).toHaveValue('available')
expect(screen.getByLabelText(/^Name/)).toHaveValue('********')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Enter a new value for New secret')
expect(post).not.toHaveBeenCalled()
fireEvent.change(screen.getByLabelText(/New secret/), { target: { value: 'new-explicit-value' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
expect((post.mock.calls[0][1] as FormData).get('secret')).toBe('new-explicit-value')
expect(source.values.secret).toBe('********')
})
})
+255 -55
View File
@@ -1,72 +1,147 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file EntityForm — full create/edit/copy form for a QQQ record with validation, mutations, and unsaved-changes guard.
*/
'use client'
// EntityForm — full create/edit form for a record
// Wraps DynamicForm with React Hook Form + Zod validation + save/cancel actions
// Includes unsaved changes guard for both browser navigation and client-side navigation
import React, { useEffect, useMemo, useState, useCallback } from 'react'
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { Loader2, Save, X } from 'lucide-react'
import type { QTableMetaData, QRecord } from '@/types'
import type { QTableMetaData, QRecord, QRecordInput, QWidgetMetaData } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import { insertRecord, updateRecord } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { zodSchemaFromTableMetadata, defaultValuesFromRecord } from '@/lib/utils/zod-from-metadata'
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
import {
zodSchemaFromTableMetadata, defaultValuesFromRecord, defaultValuesForCopy, defaultValuesForCreate, zodFieldFromMetadata,
validateCopyPasswords, wireValuesFromForm,
} from '@/lib/utils/zod-from-metadata'
import { cn } from '@/lib/utils/cn'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { EDIT_SCREEN_HELP_ROLES, INSERT_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
import { toast } from '@/lib/hooks/use-toast'
import { DynamicForm } from './DynamicForm'
import { UnsavedChangesDialog } from './UnsavedChangesDialog'
/**
* Props for the {@link EntityForm} component.
*/
export interface EntityFormProps {
// Required
/** Table metadata that drives field rendering, schema generation, and API calls. */
tableMetaData: QTableMetaData
// For edit mode: existing record values
/** Existing record values; when provided the form operates in edit mode. */
record?: QRecord
// Control
/** When `true`, the enclosing dialog supplies the heading. */
isModal?: boolean
/** When `true`, copy base values into a new record with a blank key; an editable manual key may be supplied. */
isCopy?: boolean
/** Full-copy descendants are validated again immediately before the one insert. */
copyAssociations?: {
getRecords: (rootValues: Record<string, unknown>) => Record<string, QRecordInput[]>
validateResult?: (record: QRecord) => void
error?: string
dirty?: boolean
}
/** Draft editors rendered inside the single root form, without child form tags. */
children?: React.ReactNode
/** When `true`, all form inputs are rendered in a disabled, read-only state. */
disabled?: boolean
// Override heading
/** Overrides the auto-generated "Create / Edit / Copy {label}" heading. */
overrideHeading?: string
/** Label for the primary submit button; defaults to `"Save"`. */
saveButtonLabel?: string
// Callbacks
/**
* Called with the saved record after a successful insert or update.
* If not provided, the component auto-navigates to the record detail page
* (edit/copy mode) or the table list page (create mode) after save.
*/
onSuccess?: (record: QRecord) => void
/**
* Called when the user clicks Cancel.
* If not provided, the component auto-navigates back to the record detail page
* (edit mode) or the table list page (create/copy mode).
* When `isDirty` is `true`, an unsaved-changes confirmation dialog is shown
* before any navigation occurs (whether via this callback or the default).
*/
onCancel?: () => void
// Default values to pre-populate (overrides record values)
/** Default field values that override values derived from `record`. */
defaultValues?: Record<string, unknown>
// Specific fields to show (if not set, all editable fields shown)
/** Declared relationship fields merged after ordinary input validation on insert. */
fixedValues?: Record<string, string | number | boolean>
/** Restricts the form to only these fields; when omitted all editable non-hidden fields are shown. */
fieldNamesToInclude?: string[]
// Possible value context
/** Context used to fetch possible values (table, process, or standalone). */
possibleValueContext?: PossibleValueContext
/** Widget metadata by name, so widget sections shown on edit screens (the cron schedule) render their fields. */
widgets?: Record<string, QWidgetMetaData>
/** Additional CSS classes applied to the `<form>` element. */
className?: string
}
/**
* Full create/edit/copy form for a QQQ record.
*
* Wraps {@link DynamicForm} with React Hook Form + Zod schema validation,
* TanStack Query mutations (insert/update), a browser-level `beforeunload`
* guard, and a client-side unsaved-changes dialog.
*
* - In **create** mode (`record` is undefined and `isCopy` is false) the form
* calls `insertRecord` on submit.
* - In **edit** mode (`record` is provided and `isCopy` is false) the form
* calls `updateRecord` on submit.
* - In **copy** mode (`isCopy` is true) the form pre-fills from `record` but
* calls `insertRecord`, producing a new record.
*
* @param props - See {@link EntityFormProps}.
* @returns The rendered form with action buttons and unsaved-changes dialog.
*/
export function EntityForm({
tableMetaData,
record,
isModal = false,
isCopy = false,
copyAssociations,
children,
disabled = false,
overrideHeading,
saveButtonLabel = 'Save',
onSuccess,
onCancel,
defaultValues: propDefaultValues,
fixedValues,
fieldNamesToInclude,
possibleValueContext,
widgets,
className,
}: EntityFormProps) {
const router = useRouter()
@@ -79,63 +154,85 @@ export function EntityForm({
// Build Zod schema from metadata (memoized to avoid expensive recomputation)
const schema = useMemo(
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude),
[tableMetaData, fieldNamesToInclude]
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude, isCopy),
[tableMetaData, fieldNamesToInclude, isCopy]
)
// Build default values (memoized to keep a stable reference for useForm)
const mergedDefaults = useMemo(() => {
const computedDefaults: Record<string, unknown> = record
? defaultValuesFromRecord(tableMetaData, record.values)
: {}
return { ...computedDefaults, ...propDefaultValues }
}, [tableMetaData, record, propDefaultValues])
const { values: mergedDefaults, error: defaultsError } = useMemo(() => {
try {
const computedDefaults: Record<string, unknown> = record
? (isCopy ? defaultValuesForCopy : defaultValuesFromRecord)(tableMetaData, record.values)
: defaultValuesForCreate(tableMetaData)
return { values: { ...computedDefaults, ...propDefaultValues }, error: null }
} catch (error) {
return { values: {}, error: error instanceof Error ? error.message : 'Source values are unavailable.' }
}
}, [tableMetaData, record, propDefaultValues, isCopy])
const {
register,
control,
handleSubmit,
formState: { errors, isDirty, isSubmitting },
formState: { errors, isDirty, isSubmitting, dirtyFields },
reset,
} = useForm<Record<string, unknown>>({
resolver: zodResolver(schema),
defaultValues: mergedDefaults,
})
// Latest dirty-field map for the update mutation (values that cannot round-trip are sent only when changed).
const dirtyFieldsRef = useRef<Record<string, unknown>>({})
dirtyFieldsRef.current = dirtyFields as Record<string, unknown>
// Reset when record changes (e.g., navigating between records)
useEffect(() => {
if (record) {
reset(defaultValuesFromRecord(tableMetaData, record.values))
reset(mergedDefaults)
}
}, [record, tableMetaData, reset])
}, [record, mergedDefaults, reset])
const hasChanges = isDirty || Boolean(copyAssociations?.dirty)
// Browser-level navigation guard (tab close, URL change, refresh)
// Both e.preventDefault() and e.returnValue are required for cross-browser support:
// Chrome/Edge require returnValue to be set, Firefox/Safari rely on preventDefault().
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
if (hasChanges) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
}, [hasChanges])
// Navigation helper that checks for unsaved changes before proceeding
/**
* Runs a navigation function only after confirming no unsaved changes exist.
*
* When the form is dirty the unsaved-changes dialog is displayed and the
* navigation function is deferred until the user confirms. When the form is
* clean the function is called immediately.
*
* @param navigateFn - The navigation action to perform after the guard passes.
*/
const guardedNavigate = useCallback(
(navigateFn: () => void) => {
if (isDirty) {
if (hasChanges) {
setPendingNavigation(() => navigateFn)
setShowUnsavedDialog(true)
} else {
navigateFn()
}
},
[isDirty]
[hasChanges]
)
/**
* Confirms navigation away from the form, executing the deferred navigation
* function and closing the unsaved-changes dialog.
*/
const handleConfirmLeave = useCallback(() => {
setShowUnsavedDialog(false)
if (pendingNavigation) {
@@ -144,15 +241,51 @@ export function EntityForm({
}
}, [pendingNavigation])
/**
* Cancels the pending navigation, keeping the user on the current form and
* closing the unsaved-changes dialog.
*/
const handleCancelLeave = useCallback(() => {
setShowUnsavedDialog(false)
setPendingNavigation(null)
}, [])
/**
* Check the table's identifier before reporting success or navigating.
* @param savedRecord - The API adapter's validated record.
* @returns The record with a usable metadata-defined primary key.
*/
function validateSavedRecord(savedRecord: QRecord): QRecord {
const primaryKey = savedRecord.values[tableMetaData.primaryKeyField]
if ((typeof primaryKey !== 'string' || primaryKey.length === 0) &&
(typeof primaryKey !== 'number' || !Number.isFinite(primaryKey))) {
throw new Error('The server did not return a valid record identifier.')
}
return savedRecord
}
// --- Mutations ---
const insertMutation = useMutation({
mutationFn: (values: Record<string, unknown>) =>
insertRecord(tableMetaData.name, values),
mutationFn: async (values: Record<string, unknown>) => {
const relationshipValues: Record<string, unknown> = {}
for (const [name, value] of Object.entries(fixedValues ?? {})) {
const field = tableMetaData.fields[name]
if (!field) throw new Error(`Relationship field ${name} is unavailable`)
relationshipValues[name] = zodFieldFromMetadata(field).parse(value)
}
const insertValues = { ...values, ...relationshipValues }
const key = tableMetaData.primaryKeyField
if (isCopy && (insertValues[key] === '' || insertValues[key] == null)) {
delete insertValues[key]
}
if (isCopy) validateCopyPasswords(tableMetaData, insertValues, fieldNamesToInclude)
if (copyAssociations?.error) throw new Error(copyAssociations.error)
const associations = copyAssociations?.getRecords(insertValues)
const saved = validateSavedRecord(await insertRecord(tableMetaData.name, wireValuesFromForm(tableMetaData, insertValues), associations))
copyAssociations?.validateResult?.(saved)
return saved
},
meta: HANDLES_OWN_ERRORS,
onSuccess: (savedRecord) => {
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableMetaData.name) })
toast.success(`${tableMetaData.label} created successfully.`)
@@ -160,19 +293,26 @@ export function EntityForm({
onSuccess(savedRecord)
} else {
const pk = savedRecord.values[tableMetaData.primaryKeyField]
router.push(`/app/${tableMetaData.name}/${pk}`)
router.push(`/app/${encodeURIComponent(tableMetaData.name)}/${encodeURIComponent(String(pk))}`)
}
},
onError: (err: Error) => {
toast.error(`Failed to create ${tableMetaData.label}: ${err.message}`)
toast.error(`Failed to create ${tableMetaData.label}: ${getErrorMessage(err)}`)
},
})
const updateMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => {
mutationFn: async (values: Record<string, unknown>) => {
const pk = record!.values[tableMetaData.primaryKeyField] as string | number
return updateRecord(tableMetaData.name, pk, values)
// Values that cannot round-trip through the form are sent only when changed: a
// masked password, a LONG beyond 2^53, a date-time shown at minute/second precision
// and a file (a download URL or bytes). Other values are re-sent as stored, as the
// Material dashboard does, so omission never triggers a write default.
const submitted = Object.fromEntries(Object.entries(values).filter(([name]) =>
Boolean(dirtyFieldsRef.current[name]) || !onlyWhenChanged(tableMetaData.fields[name])))
return validateSavedRecord(await updateRecord(tableMetaData.name, pk, wireValuesFromForm(tableMetaData, submitted)))
},
meta: HANDLES_OWN_ERRORS,
onSuccess: (savedRecord) => {
const pk = savedRecord.values[tableMetaData.primaryKeyField] as string | number
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(tableMetaData.name, pk) })
@@ -181,30 +321,69 @@ export function EntityForm({
if (onSuccess) {
onSuccess(savedRecord)
} else {
router.push(`/app/${tableMetaData.name}/${pk}`)
router.push(`/app/${encodeURIComponent(tableMetaData.name)}/${encodeURIComponent(String(pk))}`)
}
},
onError: (err: Error) => {
toast.error(`Failed to save ${tableMetaData.label}: ${err.message}`)
toast.error(`Failed to save ${tableMetaData.label}: ${getErrorMessage(err)}`)
},
})
const activeMutation = isEdit ? updateMutation : insertMutation
const mutationError = activeMutation.error as Error | null
const isSaving = isSubmitting || activeMutation.isPending
const onSubmit = async (values: Record<string, unknown>) => {
await activeMutation.mutateAsync(values)
/**
* React Hook Form submit handler — delegates to the appropriate mutation
* (insert or update) based on the current form mode.
*
* @param values - The validated form field values.
*/
const onSubmit = useCallback(
(values: Record<string, unknown>) => {
activeMutation.mutate(values)
},
[activeMutation]
)
/**
* Preserve the browser's distinction between malformed numeric input and a blank.
* @param event - The form submission event.
* @returns Metadata validation when native numeric input is readable.
*/
function onFormSubmit(event: React.FormEvent<HTMLFormElement>) {
if (defaultsError || copyAssociations?.error) {
event.preventDefault()
return
}
const invalidNumber = Array.from(event.currentTarget.querySelectorAll<HTMLInputElement>('input[type="number"]'))
.find((input) => !input.matches(':disabled') && input.validity.badInput)
if (invalidNumber) {
event.preventDefault()
invalidNumber.reportValidity()
return
}
return handleSubmit(onSubmit)(event)
}
/**
* Handles the Cancel button click.
*
* Runs the navigation target through {@link guardedNavigate} so unsaved
* changes trigger a confirmation dialog. Navigation target priority:
* 1. `onCancel` prop callback.
* 2. Record detail page (edit mode).
* 3. Table list page (create/copy mode).
*/
const handleCancel = () => {
const doCancel = () => {
if (onCancel) {
onCancel()
} else if (isEdit && record) {
const pk = record.values[tableMetaData.primaryKeyField]
router.push(`/app/${tableMetaData.name}/${pk}`)
router.push(`/app/${encodeURIComponent(tableMetaData.name)}/${encodeURIComponent(String(pk))}`)
} else {
router.push(`/app/${tableMetaData.name}`)
router.push(`/app/${encodeURIComponent(tableMetaData.name)}`)
}
}
@@ -228,7 +407,7 @@ export function EntityForm({
const formContent = (
<form
onSubmit={handleSubmit(onSubmit)}
onSubmit={onFormSubmit}
noValidate
className={cn('flex flex-col gap-6', className)}
data-qqq-id={`entity-form-${tableMetaData.name}`}
@@ -241,16 +420,19 @@ export function EntityForm({
)}
{/* Mutation error alert */}
{mutationError && (
{(defaultsError || copyAssociations?.error || mutationError) && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<strong>Error: </strong>
{mutationError.message || 'An error occurred while saving.'}
{defaultsError || copyAssociations?.error || (mutationError ? getErrorMessage(mutationError, 'An error occurred while saving.') : 'An error occurred while saving.')}
</div>
)}
{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>}
{/* Fields */}
<DynamicForm
register={register}
@@ -259,9 +441,17 @@ export function EntityForm({
tableMetaData={tableMetaData}
fieldNamesToInclude={fieldNamesToInclude}
possibleValueContext={pvContext}
disabled={disabled || isSubmitting}
disabled={disabled || isSaving || Boolean(defaultsError)}
dirtyFields={dirtyFields as Record<string, boolean>}
record={record}
showReadOnlyFields={isEdit}
helpRoles={isEdit ? EDIT_SCREEN_HELP_ROLES : INSERT_SCREEN_HELP_ROLES}
enforceMaxLength={false}
widgets={widgets}
/>
{children && <fieldset disabled={disabled || isSaving} className="min-w-0">{children}</fieldset>}
{/* Actions — sticky on mobile, static on desktop */}
<div
className={cn(
@@ -274,7 +464,7 @@ export function EntityForm({
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
disabled={isSaving}
data-qqq-id="button-cancel"
className={cn(
'inline-flex items-center gap-2 rounded-md border border-input px-4 py-2 text-sm font-medium',
@@ -289,7 +479,7 @@ export function EntityForm({
</button>
<button
type="submit"
disabled={disabled || isSubmitting || (!isDirty && isEdit)}
disabled={disabled || isSaving || Boolean(defaultsError) || Boolean(copyAssociations?.error) || (!isDirty && isEdit)}
data-qqq-id="button-save"
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
@@ -299,12 +489,12 @@ export function EntityForm({
'transition-colors duration-150'
)}
>
{isSubmitting ? (
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Save className="h-4 w-4" aria-hidden="true" />
)}
{isSubmitting ? 'Saving...' : saveButtonLabel}
{isSaving ? 'Saving...' : saveButtonLabel}
</button>
</div>
</form>
@@ -314,9 +504,6 @@ export function EntityForm({
<>
{isModal ? (
<div data-qqq-id={`entity-form-modal-${tableMetaData.name}`}>
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-foreground">{heading}</h2>
</div>
<div className="p-6">{formContent}</div>
</div>
) : (
@@ -332,3 +519,16 @@ export function EntityForm({
</>
)
}
/**
* Whether an edit form submits a field only after the user changed it.
*
* @param field - Field metadata (undefined for values without metadata).
* @returns `true` for passwords without REVEAL, LONG, DATE_TIME and file fields.
*/
function onlyWhenChanged(field: QTableMetaData['fields'][string] | undefined): boolean {
if (!field) return true
if (field.type === 'LONG' || field.type === 'DATE_TIME' || field.type === 'BLOB') return true
if (field.adornments?.some((adornment) => adornment.type === 'FILE_UPLOAD')) return true
return field.type === 'PASSWORD' && !field.adornments?.some((adornment) => adornment.type === 'REVEAL')
}
+108
View File
@@ -0,0 +1,108 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Editable per-path descendants for one full-copy submission. */
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { QRecord } from '@/types'
import type { CopyNode } from '@/lib/utils/copy-tree'
import { copyTreePayload } from '@/lib/utils/copy-tree'
import { zodSchemaFromTableMetadata } from '@/lib/utils/zod-from-metadata'
import { DynamicFormField } from './DynamicFormField'
import type { EntityFormProps } from './EntityForm'
type CopyState = NonNullable<EntityFormProps['copyAssociations']>
/**
* Maintain independent descendant drafts while the enclosing EntityForm owns Save.
* @param props - Prepared immutable tree and parent readiness callback.
* @returns Editors for exact named association paths.
*/
export function FullCopyDraft({ tree, onChange }: { tree: CopyNode; onChange: (state: CopyState) => void }) {
const edits = useRef<Record<string, Record<string, unknown>>>({})
const [revision, setRevision] = useState(0)
const change = useCallback((path: string, values: Record<string, unknown>) => {
edits.current[path] = values
setRevision(value => value + 1)
}, [])
const state = useMemo<CopyState>(() => {
let error: string | undefined
try { copyTreePayload(tree, edits.current) } catch (failure) { error = failure instanceof Error ? failure.message : 'Full copy values are invalid.' }
return {
getRecords: rootValues => copyTreePayload(tree, edits.current, rootValues), error, dirty: revision > 0,
validateResult: (record: QRecord) => {
/**
* Require confirmation of every nonempty path after the single insert.
* @param node - Requested draft.
* @param result - Corresponding saved record.
*/
function check(node: CopyNode, result: QRecord) {
const key = result.values[node.table.primaryKeyField]
if (result.tableName !== node.table.name || (typeof key !== 'string' && typeof key !== 'number') || key === '') throw new Error('The server did not confirm every copied record identifier.')
for (const [name, children] of Object.entries(node.groups)) {
if (!children.length) continue
const saved = result.associatedRecords?.[name]
if (!saved || saved.length !== children.length) throw new Error('The server did not confirm every copied association.')
children.forEach((child, index) => check(child, saved[index]))
}
}
check(tree, record)
},
}
}, [tree, revision])
useEffect(() => { onChange(state) }, [onChange, state])
return <CopyGroups node={tree} onChange={change} />
}
/**
* Render exact group names, preserving known empty groups.
* @param props - Node and draft-value callback.
* @returns Named group panels.
*/
function CopyGroups({ node, onChange }: { node: CopyNode; onChange: (path: string, values: Record<string, unknown>) => void }) {
return <div className="space-y-4">{Object.entries(node.groups).map(([name, children]) => <section key={name} className="min-w-0 rounded-lg border p-4">
<h3 className="font-semibold">{name} <span className="font-normal text-muted-foreground">({children.length})</span></h3>
{children.length === 0 ? <p className="text-sm text-muted-foreground">No associated records to copy.</p> : children.map(child => <CopyChild key={child.path} node={child} onChange={onChange} />)}
</section>)}</div>
}
/**
* Edit one node locally; no nested form or independent mutation is created.
* @param props - Prepared node and shared draft-value callback.
* @returns Metadata-driven fields and nested panels.
*/
function CopyChild({ node, onChange }: { node: CopyNode; onChange: (path: string, values: Record<string, unknown>) => void }) {
const schema = useMemo(() => zodSchemaFromTableMetadata(node.table, node.fields, true), [node])
const { register, control, watch, formState: { errors } } = useForm<Record<string, unknown>>({
defaultValues: node.values, resolver: zodResolver(schema), mode: 'onChange',
})
useEffect(() => {
const subscription = watch(values => onChange(node.path, values))
return () => subscription.unsubscribe()
}, [watch, node.path, onChange])
const id = `copy-node-${encodeURIComponent(node.path)}`
return <fieldset className="my-4 min-w-0 space-y-4 rounded-lg border p-4" data-qqq-id={id}>
<legend className="px-2 font-medium">{node.table.label}</legend>
<div className="grid gap-4 sm:grid-cols-2">{node.fields.map(name => <DynamicFormField key={name} idPrefix={id}
field={node.table.fields[name]} register={register} control={control} errors={errors}
possibleValueContext={{ type: 'table', tableName: node.table.name }} />)}</div>
<CopyGroups node={node} onChange={onChange} />
</fieldset>
}
@@ -0,0 +1,472 @@
/*
* 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 PossibleValueSelect component
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, render, screen, waitFor } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { useForm } from 'react-hook-form'
import { PossibleValueSelect } from './PossibleValueSelect'
import type { QPossibleValue } from '@/types'
// Mock the API modules so we control option loading
vi.mock('@/lib/api/possible-values', () => ({
fetchTablePossibleValues: vi.fn(),
fetchProcessPossibleValues: vi.fn(),
fetchPossibleValues: vi.fn(),
}))
import {
fetchTablePossibleValues,
fetchProcessPossibleValues,
fetchPossibleValues,
} from '@/lib/api/possible-values'
const mockFetchTable = vi.mocked(fetchTablePossibleValues)
const mockFetchProcess = vi.mocked(fetchProcessPossibleValues)
const mockFetchStandalone = vi.mocked(fetchPossibleValues)
const OPTIONS: QPossibleValue[] = [
{ id: 1, label: 'Alice' },
{ id: 2, label: 'Bob' },
{ id: 3, label: 'Charlie' },
]
/**
* Test harness: wraps PossibleValueSelect in a React Hook Form context
* so the Controller can attach to a real form.
*/
function Wrapper({
context = { type: 'table' as const, tableName: 'person' },
defaultValue,
required = false,
disabled = false,
}: {
context?: { type: 'table'; tableName: string } | { type: 'process'; processName: string } | { type: 'standalone' }
defaultValue?: unknown
required?: boolean
disabled?: boolean
}) {
const { control } = useForm<Record<string, unknown>>({
defaultValues: { testField: defaultValue ?? null },
})
return (
<PossibleValueSelect
id="test-pvs"
label="Person"
name="testField"
control={control}
fieldName="person"
context={context}
required={required}
disabled={disabled}
/>
)
}
beforeEach(() => {
vi.clearAllMocks()
mockFetchTable.mockResolvedValue(OPTIONS)
mockFetchProcess.mockResolvedValue(OPTIONS)
mockFetchStandalone.mockResolvedValue(OPTIONS)
})
describe('PossibleValueSelect — initial value label', () => {
it('looks up and shows the label of a value it did not select (e.g. after process Back)', async () => {
mockFetchProcess.mockResolvedValue([{ id: 2, label: 'Bob' }])
render(<Wrapper context={{ type: 'process', processName: 'prcWizard' }} defaultValue={2} />)
expect(await screen.findByText('Bob')).toBeInTheDocument()
expect(mockFetchProcess).toHaveBeenCalledWith('prcWizard', 'person', { ids: '2' })
expect(screen.getByRole('combobox', { name: 'Person' })).not.toHaveTextContent(/^2$/)
})
})
describe('PossibleValueSelect — rendering', () => {
it('renders the label', () => {
render(<Wrapper />)
expect(screen.getByText('Person')).toBeInTheDocument()
})
it('names the combobox from its field label', () => {
render(<Wrapper />)
expect(screen.getByRole('combobox', { name: 'Person' })).toBeInTheDocument()
})
it('shows default placeholder when no value is selected', () => {
render(<Wrapper />)
expect(screen.getByText(/-- Select Person --/i)).toBeInTheDocument()
})
it('shows asterisk when required=true', () => {
render(<Wrapper required={true} />)
// The asterisk span is aria-hidden but still present in DOM
const label = screen.getByText('Person').closest('label')
expect(label?.textContent).toContain('*')
})
it('does not show asterisk when required=false', () => {
render(<Wrapper required={false} />)
const label = screen.getByText('Person').closest('label')
expect(label?.textContent).not.toContain('*')
})
it('has aria-required on the combobox when required=true', () => {
render(<Wrapper required={true} />)
expect(screen.getByRole('combobox')).toHaveAttribute('aria-required', 'true')
})
it('combobox is visually dimmed / has opacity style when disabled=true', () => {
render(<Wrapper disabled={true} />)
const combobox = screen.getByRole('combobox')
// The disabled class includes "opacity-50"
expect(combobox.className).toContain('opacity-50')
})
})
describe('PossibleValueSelect — open / close', () => {
it('starts closed (dropdown not visible)', () => {
render(<Wrapper />)
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
it('opens dropdown when combobox trigger is clicked', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
})
it('sets aria-expanded=true when open', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'true')
})
})
it('closes dropdown when clicking outside', async () => {
const user = userEvent.setup()
render(
<div>
<Wrapper />
<div data-testid="outside">Outside</div>
</div>
)
await user.click(screen.getByRole('combobox'))
await waitFor(() => expect(screen.getByRole('listbox')).toBeInTheDocument())
await user.click(screen.getByTestId('outside'))
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
})
it('does not open when disabled=true', async () => {
const user = userEvent.setup()
render(<Wrapper disabled={true} />)
await user.click(screen.getByRole('combobox'))
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
})
describe('PossibleValueSelect — loading state', () => {
it('shows loading indicator while fetching', async () => {
const user = userEvent.setup()
// Never resolves — stays loading
mockFetchTable.mockImplementation(() => new Promise(() => {}))
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
})
it('calls fetchTablePossibleValues for table context', async () => {
const user = userEvent.setup()
render(<Wrapper context={{ type: 'table', tableName: 'person' }} />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(mockFetchTable).toHaveBeenCalledWith('person', 'person', expect.any(Object))
})
})
it('calls fetchProcessPossibleValues for process context', async () => {
const user = userEvent.setup()
render(<Wrapper context={{ type: 'process', processName: 'myProcess' }} />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(mockFetchProcess).toHaveBeenCalledWith('myProcess', 'person', expect.any(Object))
})
})
it('calls fetchPossibleValues for standalone context', async () => {
const user = userEvent.setup()
render(<Wrapper context={{ type: 'standalone' }} />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(mockFetchStandalone).toHaveBeenCalledWith('person', expect.any(Object))
})
})
})
describe('PossibleValueSelect — options list', () => {
it('renders options as listbox items after loading', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getAllByRole('option')).toHaveLength(OPTIONS.length)
})
})
it('shows option labels', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
expect(screen.getByText('Charlie')).toBeInTheDocument()
})
})
it('shows "No options found" when the fetch returns empty array', async () => {
const user = userEvent.setup()
mockFetchTable.mockResolvedValue([])
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByText(/no options found/i)).toBeInTheDocument()
})
})
})
describe('PossibleValueSelect — option selection', () => {
it('closes dropdown and shows selected label after clicking an option', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
await user.click(screen.getByText('Alice'))
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(screen.getByText('Alice')).toBeInTheDocument()
})
})
it('marks selected option as aria-selected=true', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
await user.click(screen.getByText('Bob'))
// Re-open to check aria-selected
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
const bobOption = screen.getByRole('option', { name: /bob/i })
expect(bobOption).toHaveAttribute('aria-selected', 'true')
})
})
it('shows a clear button once a value is selected', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
await user.click(screen.getByText('Alice'))
await waitFor(() => {
expect(screen.getByRole('button', { name: /clear person/i })).toBeInTheDocument()
})
})
it('clears the selected value when the clear button is clicked', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))
await user.click(screen.getByText('Alice'))
await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /clear person/i }))
await waitFor(() => {
expect(screen.queryByRole('button', { name: /clear person/i })).not.toBeInTheDocument()
expect(screen.getByText(/-- Select Person --/i)).toBeInTheDocument()
})
})
})
describe('PossibleValueSelect — search', () => {
it('renders a search input when the dropdown is open', async () => {
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /search person options/i })).toBeInTheDocument()
})
})
it('triggers a fetch with the search term after typing in the search input', async () => {
// Use real timers — just wait for the 300ms debounce to fire naturally.
// This avoids the interaction issues between vi.useFakeTimers and userEvent.
const user = userEvent.setup()
render(<Wrapper />)
await user.click(screen.getByRole('combobox'))
await waitFor(() => expect(screen.getByRole('textbox', { name: /search/i })).toBeInTheDocument())
const searchInput = screen.getByRole('textbox', { name: /search/i })
await user.type(searchInput, 'Al')
await waitFor(
() => {
// Should have been called with the search term after the debounce fires
expect(mockFetchTable).toHaveBeenCalledWith('person', 'person', { searchTerm: 'Al' })
},
{ timeout: 2000 }
)
}, 10000)
})
describe('PossibleValueSelect — error state', () => {
it.each(['success', 'failure'])('keeps newer search results after an older request settles with %s', async (outcome) => {
const user = userEvent.setup()
let resolveOld!: (values: QPossibleValue[]) => void
let rejectOld!: (error: Error) => void
const older = new Promise<QPossibleValue[]>((resolve, reject) => { resolveOld = resolve; rejectOld = reject })
mockFetchTable.mockReturnValueOnce(older).mockResolvedValueOnce([{ id: 2, label: 'Bob' }])
render(<Wrapper />)
await user.click(screen.getByRole('combobox', { name: 'Person' }))
await user.type(screen.getByRole('textbox', { name: 'Search Person options' }), 'Bo')
expect(await screen.findByRole('option', { name: 'Bob' })).toBeVisible()
await act(async () => {
if (outcome === 'success') resolveOld([{ id: 1, label: 'Alice' }])
else rejectOld(new Error('Older request failed'))
})
expect(screen.getByRole('option', { name: 'Bob' })).toBeVisible()
expect(screen.queryByRole('option', { name: 'Alice' })).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(mockFetchTable).toHaveBeenLastCalledWith('person', 'person', { searchTerm: 'Bo' })
})
it('distinguishes a failed request from empty choices and retries on reopen', async () => {
const user = userEvent.setup()
mockFetchTable.mockRejectedValueOnce(new Error('404')).mockResolvedValueOnce(OPTIONS)
render(<Wrapper />)
await user.click(screen.getByRole('combobox', { name: 'Person' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Options could not be loaded.')
expect(screen.queryByText('No options found')).not.toBeInTheDocument()
await user.click(screen.getByRole('combobox', { name: 'Person' }))
await user.click(screen.getByRole('combobox', { name: 'Person' }))
expect(await screen.findByRole('option', { name: 'Alice' })).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('renders error message when error prop is provided', () => {
const { control } = (() => {
let capturedControl: ReturnType<typeof useForm<Record<string, unknown>>>['control'] | undefined
function Inner() {
const form = useForm<Record<string, unknown>>()
capturedControl = form.control
return null
}
render(<Inner />)
return { control: capturedControl! }
})()
const error = { type: 'required', message: 'This field is required' }
render(
<PossibleValueSelect
id="err-pvs"
label="Person"
name="testField"
control={control}
fieldName="person"
context={{ type: 'table', tableName: 'person' }}
error={error}
/>
)
expect(screen.getByText('This field is required')).toBeInTheDocument()
expect(screen.getByRole('alert')).toBeInTheDocument()
})
it('sets aria-invalid on combobox when error is present', () => {
const { control } = (() => {
let capturedControl: ReturnType<typeof useForm<Record<string, unknown>>>['control'] | undefined
function Inner() {
const form = useForm<Record<string, unknown>>()
capturedControl = form.control
return null
}
render(<Inner />)
return { control: capturedControl! }
})()
const error = { type: 'required', message: 'Required' }
render(
<PossibleValueSelect
id="err-pvs2"
label="Person"
name="testField"
control={control}
fieldName="person"
context={{ type: 'table', tableName: 'person' }}
error={error}
/>
)
expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true')
})
})
+172 -17
View File
@@ -1,11 +1,28 @@
'use client'
/*
* 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.
*/
// PossibleValueSelect — async combobox for fields with possibleValueSourceName
// Debounced search hits the backend possible values endpoint
/**
* @file PossibleValueSelect — debounced async combobox for fields with a possibleValueSourceName.
*/
'use client'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import type { Control, FieldError } from 'react-hook-form'
import { Controller } from 'react-hook-form'
import { Controller, useWatch } from 'react-hook-form'
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
@@ -17,26 +34,62 @@ import {
fetchPossibleValues,
} from '@/lib/api/possible-values'
/**
* Props for the {@link PossibleValueSelect} component.
*/
interface PossibleValueSelectProps {
/** The HTML `id` for the combobox trigger element and its associated label. */
id: string
/** Human-readable field label displayed above the combobox. */
label: string
/** The React Hook Form field name used by the `Controller`. */
name: string
/** React Hook Form control object from the parent `useForm` instance. */
control: Control<Record<string, unknown>>
/** The QQQ field name sent to the possible-values API as the field identifier. */
fieldName: string
/** Determines which possible-values endpoint is called (table, process, or standalone). */
context: PossibleValueContext
/** Validation error from React Hook Form; triggers error styling and an error message. */
error?: FieldError
/** When `true`, the combobox trigger is non-interactive and visually dimmed. */
disabled?: boolean
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
required?: boolean
/** Placeholder text shown when no option is selected; defaults to `"-- Select {label} --"`. */
placeholder?: string
/** `data-qqq-id` attribute forwarded to the combobox trigger for CSS customization. */
'data-qqq-id'?: string
/** Possible-value source name, used for standalone (`/possibleValues/{source}`) lookups. */
possibleValueSourceName?: string
/** Label of the value the field already holds (e.g. the record's display value). */
initialLabel?: string
}
/**
* Async combobox for QQQ fields that reference a possible-value source.
*
* On open, fetches an initial list of options from the backend. As the user
* types in the search input, additional fetches are debounced by 300 ms to
* reduce API traffic during fast typing. The dropdown closes on an outside
* `mousedown` event (via a document-level listener) or when an option is
* selected. Integrates with React Hook Form via `Controller` — the stored
* form value is `option.id` (not the display label); the label is only used
* for rendering the selected state in the trigger button.
*
* @param props - See {@link PossibleValueSelectProps}.
* @returns The rendered labeled combobox with a searchable dropdown listbox.
* Shows a loading spinner while options are being fetched and a "No options
* found" message when the search returns an empty result set.
*/
export function PossibleValueSelect({
id,
label,
name,
control,
fieldName,
possibleValueSourceName,
initialLabel,
context,
error,
disabled = false,
@@ -48,14 +101,27 @@ export function PossibleValueSelect({
const [searchTerm, setSearchTerm] = useState('')
const [options, setOptions] = useState<QPossibleValue[]>([])
const [isLoading, setIsLoading] = useState(false)
const [loadFailed, setLoadFailed] = useState(false)
const [selectedOption, setSelectedOption] = useState<QPossibleValue | null>(null)
const [activeIndex, setActiveIndex] = useState(-1)
const triggerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestSequence = useRef(0)
/**
* Fetches possible values for the given search term from the appropriate
* backend endpoint based on `context.type`.
*
* @param term - The search string typed by the user; an empty string returns
* the default/initial set of options.
*/
const fetchOptions = useCallback(
async (term: string) => {
const sequence = ++requestSequence.current
setIsLoading(true)
setLoadFailed(false)
try {
const request = { searchTerm: term || undefined }
let results: QPossibleValue[]
@@ -64,18 +130,27 @@ export function PossibleValueSelect({
} else if (context.type === 'process') {
results = await fetchProcessPossibleValues(context.processName, fieldName, request)
} else {
results = await fetchPossibleValues(fieldName, request)
results = await fetchPossibleValues(possibleValueSourceName ?? fieldName, request)
}
setOptions(results)
if (sequence === requestSequence.current) setOptions(results)
} catch {
setOptions([])
if (sequence === requestSequence.current) {
setOptions([])
setLoadFailed(true)
}
} finally {
setIsLoading(false)
if (sequence === requestSequence.current) setIsLoading(false)
}
},
[context, fieldName]
[context, fieldName, possibleValueSourceName]
)
/**
* Wraps {@link fetchOptions} in a 300 ms debounce, cancelling any pending
* timer before scheduling a new one.
*
* @param term - The search string to debounce.
*/
const debouncedFetch = useCallback(
(term: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
@@ -90,6 +165,26 @@ export function PossibleValueSelect({
}
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
// A value set without its label (e.g. a create-form default) is looked up by id.
const watchedValue = useWatch({ control, name })
const contextKey = JSON.stringify(context)
useEffect(() => {
const held = watchedValue === null || watchedValue === undefined || watchedValue === '' ? '' : String(watchedValue)
if (!held || initialLabel || (selectedOption && String(selectedOption.id) === held)) return
let cancelled = false
const request = { ids: held }
const lookup = context.type === 'table'
? fetchTablePossibleValues(context.tableName, fieldName, request)
: context.type === 'process'
? fetchProcessPossibleValues(context.processName, fieldName, request)
: fetchPossibleValues(possibleValueSourceName ?? fieldName, request)
lookup.then((results) => {
const match = results.find((option) => String(option.id) === held)
if (!cancelled && match) setSelectedOption(match)
}).catch(() => { /* the raw value stays visible */ })
return () => { cancelled = true }
}, [watchedValue, initialLabel, selectedOption, contextKey, fieldName, possibleValueSourceName]) // eslint-disable-line react-hooks/exhaustive-deps
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -101,6 +196,13 @@ export function PossibleValueSelect({
return () => document.removeEventListener('mousedown', handler)
}, [])
/**
* Handles changes to the search input inside the open dropdown.
*
* Updates the local search term state and triggers a debounced fetch.
*
* @param e - The change event from the search `<input>`.
*/
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value
setSearchTerm(term)
@@ -110,6 +212,7 @@ export function PossibleValueSelect({
return (
<div className="flex flex-col gap-1">
<label
id={`${id}-label`}
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
@@ -127,6 +230,41 @@ export function PossibleValueSelect({
setSelectedOption(option)
setIsOpen(false)
setSearchTerm('')
setActiveIndex(-1)
triggerRef.current?.focus()
}
const open = () => {
if (disabled) return
setIsOpen(true)
setTimeout(() => inputRef.current?.focus(), 50)
}
const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault()
open()
}
}
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.min(index + 1, options.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
const option = options[activeIndex] ?? (options.length === 1 ? options[0] : undefined)
if (option) handleSelect(option)
} else if (event.key === 'Escape') {
event.preventDefault()
setIsOpen(false)
setActiveIndex(-1)
triggerRef.current?.focus()
}
}
const handleClear = (e: React.MouseEvent) => {
@@ -136,13 +274,23 @@ export function PossibleValueSelect({
setSearchTerm('')
}
const displayValue = selectedOption?.label ?? (field.value ? String(field.value) : '')
// The held value's label: the chosen option, else the record's display value
// for an unchanged value, else the raw id as a last resort.
const heldValue = field.value === null || field.value === undefined || field.value === '' ? '' : String(field.value)
const displayValue = selectedOption && String(selectedOption.id) === heldValue
? selectedOption.label
: heldValue ? (initialLabel ?? heldValue) : ''
return (
<div ref={containerRef} className="relative">
<div
ref={triggerRef}
id={id}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleTriggerKeyDown}
aria-disabled={disabled || undefined}
role="combobox"
aria-labelledby={`${id}-label`}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-required={required}
@@ -152,10 +300,8 @@ export function PossibleValueSelect({
data-qqq-id={dataQqqId}
onClick={() => {
if (!disabled) {
setIsOpen((o) => !o)
if (!isOpen) {
setTimeout(() => inputRef.current?.focus(), 50)
}
if (isOpen) setIsOpen(false)
else open()
}
}}
className={cn(
@@ -211,9 +357,12 @@ export function PossibleValueSelect({
ref={inputRef}
type="text"
value={searchTerm}
onChange={handleSearchChange}
onChange={(event) => { setActiveIndex(-1); handleSearchChange(event) }}
onKeyDown={handleSearchKeyDown}
placeholder="Search..."
aria-label={`Search ${label} options`}
aria-controls={`${id}-listbox`}
aria-activedescendant={activeIndex >= 0 && options[activeIndex] ? `${id}-option-${activeIndex}` : undefined}
className={cn(
'w-full rounded border border-input px-2 py-1 text-sm',
'bg-muted text-foreground',
@@ -233,23 +382,29 @@ export function PossibleValueSelect({
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
Loading...
</li>
) : loadFailed ? (
<li role="alert" className="py-4 text-center text-sm text-destructive">
Options could not be loaded.
</li>
) : options.length === 0 ? (
<li className="py-4 text-center text-sm text-muted-foreground">
No options found
</li>
) : (
options.map((option) => {
options.map((option, index) => {
const isSelected = String(field.value) === String(option.id)
return (
<li
key={String(option.id)}
id={`${id}-option-${index}`}
role="option"
aria-selected={isSelected}
onClick={() => handleSelect(option)}
className={cn(
'flex cursor-pointer items-center justify-between px-3 py-2 text-sm',
'hover:bg-accent',
isSelected && 'bg-accent text-primary'
isSelected && 'bg-accent text-primary',
index === activeIndex && 'ring-2 ring-inset ring-ring'
)}
>
<span>{option.label}</span>
@@ -0,0 +1,52 @@
/*
* 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.
*/
// Rich-text editor: engine-neutral values without disturbing the user's editing (#649)
import React, { useState } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { RichTextField } from './RichTextField'
function Harness({ onValue }: { onValue: (value: string) => void }) {
const [value, setValue] = useState('')
return <RichTextField aria-label="Html Value" value={value} onChange={(next) => { setValue(next); onValue(next) }} />
}
describe('RichTextField', () => {
it('reports editing-only non-breaking spaces as plain spaces and leaves the editor DOM alone', () => {
const onValue = vi.fn()
render(<Harness onValue={onValue} />)
const editor = screen.getByRole('textbox', { name: 'Html Value' })
// what Firefox leaves after typing "Plain ", Bold, "Strong"
editor.innerHTML = 'Plain&nbsp;<b>Strong</b>'
const bold = editor.querySelector('b')
fireEvent.input(editor)
expect(onValue).toHaveBeenLastCalledWith('Plain <b>Strong</b>')
// the editor keeps its own nodes (rewriting them would move the caret)
expect(editor.querySelector('b')).toBe(bold)
expect(editor.innerHTML).toBe('Plain&nbsp;<b>Strong</b>')
})
it('still writes values that change from outside into the editor', () => {
const { rerender } = render(<RichTextField aria-label="Html Value" value="<i>first</i>" onChange={vi.fn()} />)
const editor = screen.getByRole('textbox', { name: 'Html Value' })
expect(editor.innerHTML).toBe('<i>first</i>')
rerender(<RichTextField aria-label="Html Value" value="second" onChange={vi.fn()} />)
expect(editor.innerHTML).toBe('second')
})
})
+233
View File
@@ -0,0 +1,233 @@
/*
* 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 RichTextField — a controlled textarea with a basic HTML formatting toolbar.
*/
'use client'
import React, { useEffect, useRef } from 'react'
import DOMPurify from 'dompurify'
import { Bold, Italic, Underline, Link } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { normalizeEditorHtml } from '@/lib/utils/editor-html'
/**
* Props for the {@link RichTextField} component.
*/
export interface RichTextFieldProps {
/** Current HTML string value of the field. */
value: string
/** Callback invoked with the updated HTML string whenever the content changes. */
onChange: (value: string) => void
/** When true, the editor and all toolbar buttons are non-interactive. */
disabled?: boolean
/** HTML `id` applied to the contentEditable region for label association. */
id?: string
/** Accessible label for the content-editable region. */
'aria-label'?: string
/** Whether a value is required. */
required?: boolean
/** CSS class names appended to the outermost wrapper div. */
className?: string
}
/** Labels and helpers for each toolbar formatting action. */
const TOOLBAR_ACTIONS = [
{ command: 'bold', icon: Bold, label: 'Bold' },
{ command: 'italic', icon: Italic, label: 'Italic' },
{ command: 'underline', icon: Underline, label: 'Underline' },
] as const
/**
* A controlled rich-text editor built on a `contentEditable` `<div>`.
*
* Provides a lightweight formatting toolbar (Bold, Italic, Underline, Link)
* that uses `document.execCommand` to apply inline HTML formatting. The
* component remains self-contained — no third-party WYSIWYG library is needed.
*
* The `value` / `onChange` interface mirrors a controlled `<textarea>` so the
* parent form can treat this field identically to any other controlled input.
*
* @param props - See {@link RichTextFieldProps}.
* @returns The rendered rich text editor with formatting toolbar.
*/
export function RichTextField({
value,
onChange,
disabled = false,
id,
'aria-label': ariaLabel,
required = false,
className,
}: RichTextFieldProps) {
const editorRef = useRef<HTMLDivElement>(null)
// The editor HTML last reported and the normalized value it was reported as
const emitted = useRef<{ html: string; value: string } | null>(null)
// Write the value into the editor only when it changed from outside (initial load,
// form reset). Re-writing the user's own input would move the caret to the start
// on every keystroke. Values are sanitized to prevent stored XSS.
useEffect(() => {
const editor = editorRef.current
if (!editor) return
if (emitted.current && value === emitted.current.value && editor.innerHTML === emitted.current.html) return
if (editor.innerHTML !== value) editor.innerHTML = DOMPurify.sanitize(value)
}, [value])
/**
* Reports the editor content, normalized so the stored HTML does not depend on the
* browser engine (see {@link normalizeEditorHtml}).
*/
const emitChange = () => {
const editor = editorRef.current
if (!editor) return
const html = editor.innerHTML
const next = normalizeEditorHtml(html)
emitted.current = { html, value: next }
onChange(next)
}
/**
* Applies a `document.execCommand` formatting instruction to the current
* selection inside the contentEditable editor.
*
* @param command - The execCommand command string (e.g. 'bold', 'italic').
*/
const applyFormat = (command: string) => {
if (disabled) return
editorRef.current?.focus()
document.execCommand(command, false)
// Sync updated inner HTML back to the parent form state
emitChange()
}
/**
* Prompts for a URL and wraps the current selection in an anchor tag.
*/
const applyLink = () => {
if (disabled) return
const url = window.prompt('Enter URL:')
if (!url) return
editorRef.current?.focus()
document.execCommand('createLink', false, url)
emitChange()
}
/**
* Syncs the contentEditable HTML to React state on every input event.
*/
const handleInput = () => {
emitChange()
}
return (
<div
className={cn('flex flex-col gap-0', className)}
data-qqq-id="rich-text-field"
>
{/* Formatting toolbar */}
<div
role="toolbar"
aria-label="Text formatting"
className={cn(
'flex items-center gap-0.5 rounded-t-md border border-b-0 border-input bg-muted/50 px-2 py-1',
disabled ? 'opacity-50 pointer-events-none' : ''
)}
data-qqq-id="rich-text-toolbar"
>
{TOOLBAR_ACTIONS.map(({ command, icon: Icon, label }) => (
<button
key={command}
type="button"
onMouseDown={(e) => {
// Prevent the editor from losing focus
e.preventDefault()
applyFormat(command)
}}
disabled={disabled}
aria-label={label}
title={label}
className={cn(
'flex h-7 w-7 items-center justify-center rounded text-sm',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus:outline-none focus:ring-1 focus:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-100'
)}
data-qqq-id={`rich-text-${command}`}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</button>
))}
{/* Separator */}
<div className="mx-1 h-4 w-px bg-border" aria-hidden="true" />
{/* Link */}
<button
type="button"
onMouseDown={(e) => {
e.preventDefault()
applyLink()
}}
disabled={disabled}
aria-label="Insert link"
title="Insert link"
className={cn(
'flex h-7 w-7 items-center justify-center rounded text-sm',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus:outline-none focus:ring-1 focus:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-100'
)}
data-qqq-id="rich-text-link"
>
<Link className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
{/* Content-editable editor */}
<div
ref={editorRef}
id={id}
role="textbox"
aria-multiline="true"
aria-label={ariaLabel}
aria-required={required}
contentEditable={!disabled}
suppressContentEditableWarning
onInput={handleInput}
onBlur={handleInput}
className={cn(
'min-h-[120px] w-full rounded-b-md border border-input bg-background px-3 py-2',
'text-sm text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring',
'disabled:cursor-not-allowed',
disabled ? 'bg-muted text-muted-foreground cursor-not-allowed' : '',
'[&_a]:text-primary [&_a]:underline',
'prose prose-sm max-w-none'
)}
data-qqq-id="rich-text-editor"
/>
<p className="mt-1 text-xs text-muted-foreground">
HTML formatting is supported. Use the toolbar or type HTML directly.
</p>
</div>
)
}
+233
View File
@@ -0,0 +1,233 @@
/*
* 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 ScriptEditor component
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { ScriptEditor } from './ScriptEditor'
describe('ScriptEditor — rendering', () => {
it('renders the label text', () => {
render(
<ScriptEditor
id="test-script"
label="My Script"
value=""
onChange={vi.fn()}
/>
)
expect(screen.getByText('My Script')).toBeInTheDocument()
})
it('renders a textarea element', () => {
render(
<ScriptEditor
id="test-script"
label="My Script"
value="console.log('hello')"
onChange={vi.fn()}
/>
)
const textarea = screen.getByRole('textbox')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue("console.log('hello')")
})
it('associates the label with the textarea via htmlFor/id', () => {
render(
<ScriptEditor
id="my-editor"
label="Editor Label"
value=""
onChange={vi.fn()}
/>
)
const textarea = screen.getByLabelText('Editor Label')
expect(textarea.tagName).toBe('TEXTAREA')
})
it('renders the language badge when language prop is provided', () => {
render(
<ScriptEditor
id="groovy-script"
label="Groovy Script"
value=""
onChange={vi.fn()}
language="groovy"
/>
)
expect(screen.getByText('Groovy')).toBeInTheDocument()
})
it('renders "Text" badge when language is not specified', () => {
render(
<ScriptEditor
id="text-script"
label="Script"
value=""
onChange={vi.fn()}
/>
)
expect(screen.getByText('Text')).toBeInTheDocument()
})
it('renders the line count in the toolbar', () => {
render(
<ScriptEditor
id="multi-line"
label="Script"
value={'line1\nline2\nline3'}
onChange={vi.fn()}
/>
)
expect(screen.getByText(/3 lines/)).toBeInTheDocument()
})
it('sets data-qqq-id on the wrapper', () => {
render(
<ScriptEditor
id="my-script"
label="Script"
value=""
onChange={vi.fn()}
/>
)
expect(
document.querySelector('[data-qqq-id="script-editor-my-script"]')
).toBeInTheDocument()
})
})
describe('ScriptEditor — onChange', () => {
it('calls onChange when the user types', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ScriptEditor
id="typing-test"
label="Script"
value=""
onChange={onChange}
/>
)
await user.type(screen.getByRole('textbox'), 'hello')
expect(onChange).toHaveBeenCalled()
})
})
describe('ScriptEditor — Tab key', () => {
it('inserts two spaces when Tab is pressed instead of moving focus', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ScriptEditor
id="tab-test"
label="Script"
value=""
onChange={onChange}
/>
)
const textarea = screen.getByRole('textbox')
await user.click(textarea)
await user.keyboard('{Tab}')
// onChange should have been called with two spaces
const calls = onChange.mock.calls
expect(calls.length).toBeGreaterThan(0)
// The last call should include two spaces
const lastCallValue = calls[calls.length - 1][0] as string
expect(lastCallValue).toBe(' ')
})
})
describe('ScriptEditor — readOnly', () => {
it('renders the textarea as readonly when readOnly=true', () => {
render(
<ScriptEditor
id="readonly-test"
label="Script"
value="const x = 1"
onChange={vi.fn()}
readOnly={true}
/>
)
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveAttribute('readonly')
})
it('does not render readonly attribute when readOnly=false', () => {
render(
<ScriptEditor
id="editable-test"
label="Script"
value=""
onChange={vi.fn()}
readOnly={false}
/>
)
const textarea = screen.getByRole('textbox')
expect(textarea).not.toHaveAttribute('readonly')
})
})
describe('ScriptEditor — error display', () => {
it('shows the error message when error prop is provided', () => {
render(
<ScriptEditor
id="error-test"
label="Script"
value=""
onChange={vi.fn()}
error={{ message: 'Script is required' }}
/>
)
expect(screen.getByText('Script is required')).toBeInTheDocument()
})
it('does not show an error message when error is undefined', () => {
render(
<ScriptEditor
id="no-error-test"
label="Script"
value=""
onChange={vi.fn()}
/>
)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('sets aria-invalid on the textarea when error is present', () => {
render(
<ScriptEditor
id="aria-invalid-test"
label="Script"
value=""
onChange={vi.fn()}
error={{ message: 'Required' }}
/>
)
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveAttribute('aria-invalid', 'true')
})
})
+224
View File
@@ -0,0 +1,224 @@
/*
* 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 ScriptEditor — a styled textarea for editing QQQ automation scripts.
*
* Provides a monospace editor with:
* - Language badge and line-count toolbar
* - Tab-key interception (inserts 2 spaces instead of changing focus)
* - Read-only mode with distinct styling
* - Inline error message display
*/
import React, { useCallback } from 'react'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link ScriptEditor} component.
*/
export interface ScriptEditorProps {
/** Unique identifier used for the `<label>` / `<textarea>` association and `data-qqq-id`. */
id: string
/** Human-readable label displayed above the editor. */
label: string
/** Current script content value (controlled). */
value: string
/**
* Called whenever the editor content changes.
*
* @param value - The updated script content.
*/
onChange: (value: string) => void
/** Programming language used for the badge display. Defaults to `'text'`. */
language?: 'javascript' | 'groovy' | 'python' | 'sql' | 'json' | 'text'
/** When `true`, the textarea is rendered as read-only with muted styling. */
readOnly?: boolean
/**
* Validation error to display below the editor.
* Compatible with React Hook Form's `FieldError` shape.
*/
error?: { message?: string }
/** Number of visible text rows for the textarea. Defaults to 12. */
rows?: number
}
/** Human-readable display names for each supported language. */
const LANGUAGE_LABELS: Record<NonNullable<ScriptEditorProps['language']>, string> = {
javascript: 'JavaScript',
groovy: 'Groovy',
python: 'Python',
sql: 'SQL',
json: 'JSON',
text: 'Text',
}
/**
* Counts the number of lines in the given string.
*
* @param value - The text content to count lines in.
* @returns The number of lines (minimum 1).
*/
function countLines(value: string): number {
if (!value) return 1
return value.split('\n').length
}
/**
* Renders a styled script editor textarea with toolbar, language badge, and
* optional error display.
*
* Intercepts the Tab key to insert two spaces instead of moving focus, which
* is the standard behavior users expect in code editors.
*
* @param props - {@link ScriptEditorProps}
* @returns The rendered script editor with toolbar and optional error message.
*/
export function ScriptEditor({
id,
label,
value,
onChange,
language = 'text',
readOnly = false,
error,
rows = 12,
}: ScriptEditorProps) {
const lineCount = countLines(value)
const languageLabel = LANGUAGE_LABELS[language]
const hasError = Boolean(error?.message)
/**
* Handles keydown events on the textarea.
*
* Intercepts Tab to insert two spaces at the cursor position instead of
* shifting focus away from the editor.
*
* @param e - The keyboard event from the textarea.
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault()
const textarea = e.currentTarget
const start = textarea.selectionStart
const end = textarea.selectionEnd
const newValue = value.substring(0, start) + ' ' + value.substring(end)
onChange(newValue)
// Restore cursor position after the inserted spaces
// Use setTimeout so the DOM has time to update before repositioning
setTimeout(() => {
textarea.selectionStart = start + 2
textarea.selectionEnd = start + 2
}, 0)
}
},
[value, onChange]
)
return (
<div
className="space-y-1"
data-qqq-id={`script-editor-${id}`}
>
{/* Label */}
<label
htmlFor={id}
className="block text-sm font-medium text-foreground"
>
{label}
</label>
{/* Editor container */}
<div
className={cn(
'rounded-lg border',
hasError ? 'border-destructive' : 'border-input',
readOnly ? 'opacity-75' : ''
)}
>
{/* Toolbar */}
<div
className={cn(
'flex items-center justify-between px-3 py-1',
'border-b border-input rounded-t-lg',
'bg-background text-xs text-muted-foreground'
)}
aria-hidden="true"
>
{/* Language badge */}
<span
className={cn(
'inline-flex items-center rounded px-2 py-0.5 text-xs font-medium',
'bg-muted text-muted-foreground'
)}
>
{languageLabel}
</span>
{/* Right side: line count + keyboard hint */}
<div className="flex items-center gap-3">
{!readOnly && (
<span className="text-muted-foreground/70 hidden sm:inline">
Tab inserts spaces
</span>
)}
<span>
{lineCount} {lineCount === 1 ? 'line' : 'lines'}
</span>
</div>
</div>
{/* Textarea */}
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
readOnly={readOnly}
rows={rows}
aria-label={label}
aria-required={false}
aria-invalid={hasError}
aria-describedby={hasError ? `${id}-error` : undefined}
spellCheck={false}
className={cn(
'font-mono text-sm w-full resize-y p-3',
'bg-muted rounded-b-lg',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0',
'placeholder:text-muted-foreground/50',
readOnly
? 'cursor-default text-muted-foreground'
: 'text-foreground',
'border-0' // border is on the container
)}
/>
</div>
{/* Error message */}
{hasError && (
<p
id={`${id}-error`}
role="alert"
className="text-sm text-destructive"
>
{error!.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,152 @@
/*
* 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 UnsavedChangesDialog component
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { UnsavedChangesDialog } from './UnsavedChangesDialog'
describe('UnsavedChangesDialog — visibility', () => {
it('renders nothing when open=false', () => {
const { container } = render(
<UnsavedChangesDialog open={false} onStay={vi.fn()} onLeave={vi.fn()} />
)
expect(container.firstChild).toBeNull()
})
it('renders the dialog when open=true', () => {
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
)
// The dialog renders two nodes with this text: an sr-only Radix title and the visible h2.
// Use getAllByText and assert at least one is present.
expect(screen.getAllByText('Unsaved Changes').length).toBeGreaterThanOrEqual(1)
})
})
describe('UnsavedChangesDialog — content', () => {
it('displays the warning message body', () => {
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
)
expect(
screen.getByText(/you have unsaved changes/i)
).toBeInTheDocument()
})
it('shows both "Stay" and "Leave" buttons', () => {
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
)
expect(screen.getByRole('button', { name: /^stay$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^leave$/i })).toBeInTheDocument()
})
it('has data-qqq-id="unsaved-changes-dialog" on the content element', () => {
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
)
expect(document.querySelector('[data-qqq-id="unsaved-changes-dialog"]')).toBeInTheDocument()
})
})
describe('UnsavedChangesDialog — callbacks', () => {
it('calls onStay when "Stay" button is clicked', async () => {
const user = userEvent.setup()
const onStay = vi.fn()
render(
<UnsavedChangesDialog open={true} onStay={onStay} onLeave={vi.fn()} />
)
await user.click(screen.getByRole('button', { name: /^stay$/i }))
expect(onStay).toHaveBeenCalledOnce()
})
it('calls onLeave when "Leave" button is clicked', async () => {
const user = userEvent.setup()
const onLeave = vi.fn()
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={onLeave} />
)
await user.click(screen.getByRole('button', { name: /^leave$/i }))
expect(onLeave).toHaveBeenCalledOnce()
})
it('does not call onLeave when "Stay" is clicked', async () => {
const user = userEvent.setup()
const onLeave = vi.fn()
const onStay = vi.fn()
render(
<UnsavedChangesDialog open={true} onStay={onStay} onLeave={onLeave} />
)
await user.click(screen.getByRole('button', { name: /^stay$/i }))
expect(onLeave).not.toHaveBeenCalled()
})
it('does not call onStay when "Leave" is clicked', async () => {
const user = userEvent.setup()
const onStay = vi.fn()
const onLeave = vi.fn()
render(
<UnsavedChangesDialog open={true} onStay={onStay} onLeave={onLeave} />
)
await user.click(screen.getByRole('button', { name: /^leave$/i }))
expect(onStay).not.toHaveBeenCalled()
})
})
describe('UnsavedChangesDialog — accessibility', () => {
it('has an accessible title via sr-only element', () => {
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
)
// The Radix AlertDialog.Title is sr-only but still in the DOM
expect(screen.getAllByText('Unsaved Changes').length).toBeGreaterThanOrEqual(1)
})
it('has the description paragraph with its id for aria-describedby', () => {
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
)
const desc = document.getElementById('unsaved-changes-description')
expect(desc).toBeInTheDocument()
})
it('has data-qqq-id on Stay button', () => {
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
)
expect(document.querySelector('[data-qqq-id="unsaved-changes-stay"]')).toBeInTheDocument()
})
it('has data-qqq-id on Leave button', () => {
render(
<UnsavedChangesDialog open={true} onStay={vi.fn()} onLeave={vi.fn()} />
)
expect(document.querySelector('[data-qqq-id="unsaved-changes-leave"]')).toBeInTheDocument()
})
})
@@ -1,3 +1,23 @@
/*
* 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 UnsavedChangesDialog — accessible confirmation modal preventing accidental loss of unsaved form data.
*/
'use client'
// UnsavedChangesDialog — confirmation dialog shown when navigating away with unsaved form changes
@@ -9,12 +29,29 @@ import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link UnsavedChangesDialog} component.
*/
interface UnsavedChangesDialogProps {
/** Controls dialog visibility; when `false` the component renders nothing. */
open: boolean
/** Called when the user clicks "Stay" or dismisses the dialog (Escape / overlay click). */
onStay: () => void
/** Called when the user clicks "Leave", confirming that unsaved changes can be discarded. */
onLeave: () => void
}
/**
* Accessible confirmation dialog that warns users before they navigate away
* from a form with unsaved changes.
*
* Built on Radix `AlertDialog` primitives which handle focus trapping,
* `aria-modal`, and initial focus automatically. Pressing Escape or clicking
* the overlay calls `onStay`, keeping the user on the current page.
*
* @param props - See {@link UnsavedChangesDialogProps}.
* @returns The rendered confirmation dialog, or null when `open` is false.
*/
export function UnsavedChangesDialog({ open, onStay, onLeave }: UnsavedChangesDialogProps) {
// Radix AlertDialog manages focus automatically (focus trap + initial focus).
// Manual focus management via useEffect/ref.focus() races with Radix's
@@ -1,3 +1,23 @@
/*
* 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 BooleanField — three-state toggle switch for boolean form fields integrated with React Hook Form.
*/
'use client'
// BooleanField — toggle switch for boolean form fields
@@ -11,20 +31,37 @@ import { Minus } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link BooleanField} component.
*/
interface BooleanFieldProps {
/** The HTML `id` for the toggle button and its associated label. */
id: string
/** Human-readable field label rendered next to the toggle. */
label: string
/** The React Hook Form field name used by the `Controller`. */
name: string
/** React Hook Form control object from the parent `useForm` instance. */
control: Control<Record<string, unknown>>
/** Validation error; when present triggers error styling and message. */
error?: FieldError
/** When `true`, the toggle is non-interactive and visually dimmed. */
disabled?: boolean
/**
* When `true`, cycling is two-state only (true/false).
* When `false`, a null/indeterminate state is included in the cycle.
*/
required?: boolean
/** `data-qqq-id` attribute forwarded to the toggle button for CSS customization. */
'data-qqq-id'?: string
}
/**
* Resolves the current boolean state from a field value.
* Returns true, false, or null (for indeterminate/three-state).
*
* @param value - The raw field value from React Hook Form.
* @returns `true`, `false`, or `null` for the indeterminate state.
*/
function resolveBoolState(value: unknown): boolean | null {
if (value === null || value === undefined) return null
@@ -37,6 +74,9 @@ function resolveBoolState(value: unknown): boolean | null {
* Returns the aria-checked value for the current boolean state.
* Uses role="checkbox" (not "switch") because checkbox supports aria-checked="mixed"
* for the indeterminate/null state in three-state mode.
*
* @param state - The resolved boolean state (`true`, `false`, or `null`).
* @returns The ARIA checked string: `'true'`, `'false'`, or `'mixed'`.
*/
function ariaCheckedValue(state: boolean | null): 'true' | 'false' | 'mixed' {
if (state === true) return 'true'
@@ -44,6 +84,17 @@ function ariaCheckedValue(state: boolean | null): 'true' | 'false' | 'mixed' {
return 'mixed'
}
/**
* Toggle switch for boolean form fields integrated with React Hook Form.
*
* When `required` is true the toggle cycles between `true` and `false`.
* When `required` is false it cycles through `null → true → false → null`,
* where `null` represents the indeterminate (unset) state displayed with a
* dash indicator and `aria-checked="mixed"`.
*
* @param props - See {@link BooleanFieldProps}.
* @returns The rendered toggle switch with label and optional error message.
*/
export function BooleanField({
id,
label,
@@ -55,6 +106,12 @@ export function BooleanField({
'data-qqq-id': dataQqqId,
}: BooleanFieldProps) {
// Cycle logic depends on whether the field is required
/**
* Returns the next value in the boolean cycle given the current state.
*
* @param current - The current boolean state (`true`, `false`, or `null`).
* @returns The next boolean state in the cycle.
*/
const getNextValue = useCallback(
(current: boolean | null): boolean | null => {
if (required) {
+43 -1
View File
@@ -1,3 +1,23 @@
/*
* 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 DateField — date input form field with label, validation error display, and accessibility attributes.
*/
'use client'
import React from 'react'
@@ -5,16 +25,37 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link DateField} component.
*/
interface DateFieldProps {
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the input. */
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 input is non-interactive and visually dimmed. */
disabled?: boolean
/** 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. */
'data-qqq-id'?: string
/** Id of help text that describes this control. */
describedBy?: string
}
/**
* Renders an accessible date input field with label and validation error display.
*
* Uses `<input type="date">` which provides native date picker UI in supported
* browsers. The value format is `YYYY-MM-DD` as managed by the browser.
*
* @param props - See {@link DateFieldProps}.
* @returns The rendered date input field with label and optional error message.
*/
export function DateField({
id,
label,
@@ -23,6 +64,7 @@ export function DateField({
disabled = false,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
}: DateFieldProps) {
return (
<div className="flex flex-col gap-1">
@@ -41,7 +83,7 @@ export function DateField({
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
@@ -1,3 +1,23 @@
/*
* 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 DateTimeField — datetime-local input form field with label, timezone hint, validation error display, and accessibility attributes.
*/
'use client'
import React from 'react'
@@ -5,16 +25,39 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link DateTimeField} component.
*/
interface DateTimeFieldProps {
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the input. */
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 input is non-interactive and visually dimmed. */
disabled?: boolean
/** 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. */
'data-qqq-id'?: string
/** Id of help text that describes this control. */
describedBy?: string
}
/**
* Renders an accessible datetime-local input field with label, timezone hint, and validation error display.
*
* Uses `<input type="datetime-local">` which provides combined date + time
* picker UI in supported browsers. The value format is `YYYY-MM-DDTHH:mm`
* as managed by the browser. A small hint line identifies the user's local
* timezone so there is no ambiguity about which timezone is assumed.
*
* @param props - See {@link DateTimeFieldProps}.
* @returns The rendered datetime input field with label, timezone hint, and optional error message.
*/
export function DateTimeField({
id,
label,
@@ -23,7 +66,12 @@ export function DateTimeField({
disabled = false,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
}: DateTimeFieldProps) {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const hintId = `${id}-tz-hint`
const describedByIds = [hintId, error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined
return (
<div className="flex flex-col gap-1">
<label
@@ -37,11 +85,12 @@ export function DateTimeField({
<input
id={id}
type="datetime-local"
step={1}
{...registration}
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
aria-describedby={describedByIds}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
@@ -54,6 +103,9 @@ export function DateTimeField({
: 'border-input'
)}
/>
<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}
@@ -1,3 +1,25 @@
/*
* 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 FileUploadField — file upload for BLOB / FILE_UPLOAD fields: a "Choose file" button
* or a drag-and-drop zone (the FILE_UPLOAD adornment's `format`), with the record's current
* file, React Hook Form integration and keyboard access.
*/
'use client'
import React, { useRef, useState } from 'react'
@@ -7,19 +29,45 @@ import { Upload, X, File as FileIcon } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link FileUploadField} component.
*/
interface FileUploadFieldProps {
/** The HTML `id` for the upload control. */
id: string
/** Human-readable field label. */
label: string
/** The React Hook Form field name used by the `Controller`. */
name: string
/** React Hook Form control object from the parent `useForm` instance. */
control: Control<Record<string, unknown>>
/** Validation error; when present triggers error styling and an error message. */
error?: FieldError
/** When `true`, the control is non-interactive and visually dimmed. */
disabled?: boolean
/** When `true`, an asterisk indicator is shown next to the label. */
required?: boolean
/** Forwarded to the hidden `<input type="file">` `accept` attribute (e.g. `"image/*,.pdf"`). */
accept?: string
/** `button` (default): a "Choose file to upload" button; `dragAndDrop`: a drop zone. */
format?: 'button' | 'dragAndDrop'
/** The file the record already holds (edit screen), shown with a remove action. */
currentFile?: { name: string; url?: string }
/** @deprecated Use `currentFile`. Name of a previously uploaded file. */
existingFileName?: string
/** `data-qqq-id` attribute for CSS customization. */
'data-qqq-id'?: string
}
/**
* File upload field integrated with React Hook Form.
*
* The value is a `File` once chosen, the record's original value while untouched,
* and `null` when the current file is removed (saved as a cleared file).
*
* @param props - See {@link FileUploadFieldProps}.
* @returns The upload control with label, current file and optional error message.
*/
export function FileUploadField({
id,
label,
@@ -29,98 +77,125 @@ export function FileUploadField({
disabled = false,
required = false,
accept,
format = 'button',
currentFile,
existingFileName,
'data-qqq-id': dataQqqId,
}: FileUploadFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const initialValue = useRef<{ captured: boolean; value: unknown }>({ captured: false, value: null })
const [dragOver, setDragOver] = useState(false)
const current = currentFile ?? (existingFileName ? { name: existingFileName } : undefined)
const labelId = `${id}-label`
return (
<div className="flex flex-col gap-1">
<label
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
<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>}
</label>
</span>
<Controller
name={name}
control={control}
render={({ field }) => {
const currentFile = field.value instanceof File ? (field.value as File) : null
const displayName = currentFile?.name ?? existingFileName
if (!initialValue.current.captured) initialValue.current = { captured: true, value: field.value ?? null }
const selected = field.value instanceof File ? field.value : null
const showsCurrent = Boolean(current) && !selected && field.value !== null && field.value !== '' && field.value !== undefined
const choose = () => { if (!disabled) inputRef.current?.click() }
const handleFiles = (files: FileList | null) => {
if (files && files.length > 0) {
field.onChange(files[0])
}
if (files && files.length > 0) field.onChange(files[0])
}
const clearSelection = () => {
field.onChange(initialValue.current.value instanceof File ? null : initialValue.current.value)
if (inputRef.current) inputRef.current.value = ''
}
return (
<div>
<div
id={id}
role="region"
aria-label={`${label} upload area`}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
if (!disabled) handleFiles(e.dataTransfer.files)
}}
className={cn(
'flex flex-col items-center justify-center rounded-md border-2 border-dashed p-6 text-center',
'transition-colors duration-150 cursor-pointer',
dragOver && !disabled ? 'border-primary bg-accent' : 'border-input',
disabled && 'cursor-not-allowed opacity-50',
error && 'border-destructive',
'hover:border-primary'
)}
onClick={() => !disabled && inputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
<div className="space-y-2">
{showsCurrent && current && (
<div className="flex items-center gap-2 text-sm" data-qqq-id={dataQqqId ? `${dataQqqId}-current-file` : undefined}>
<span className="text-muted-foreground">Current File:</span>
{current.url
? <a href={current.url} target="_blank" rel="noopener noreferrer" className="text-primary underline">{current.name}</a>
: <span className="text-foreground">{current.name}</span>}
{!disabled && (
<button type="button" aria-label={`Remove current file ${current.name}`} title="Remove current file"
data-qqq-id={dataQqqId ? `${dataQqqId}-remove-current` : undefined}
onClick={() => field.onChange(null)}
className="rounded p-0.5 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive">
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
)}
{!selected && current && field.value === null && (
<p className="text-sm text-muted-foreground" role="status">The current file will be removed when you save.</p>
)}
{format === 'dragAndDrop' ? (
<div
id={id}
role="group"
aria-labelledby={labelId}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
data-upload-format="dragAndDrop"
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
inputRef.current?.click()
}
}}
tabIndex={disabled ? -1 : 0}
>
{displayName ? (
<div className="flex items-center gap-2 text-sm text-foreground">
<FileIcon className="h-5 w-5 text-primary" aria-hidden="true" />
<span className="max-w-[200px] truncate">{displayName}</span>
{!disabled && (
<button
type="button"
aria-label={`Remove ${displayName}`}
onClick={(e) => {
e.stopPropagation()
field.onChange(null)
if (inputRef.current) inputRef.current.value = ''
}}
className="ml-1 rounded p-0.5 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
) : (
<>
<Upload className="mb-2 h-8 w-8 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
<span className="font-medium text-primary">Click to upload</span>{' '}
or drag and drop
</p>
{accept && (
<p className="mt-1 text-xs text-muted-foreground">{accept}</p>
)}
</>
)}
</div>
setDragOver(false)
if (!disabled) handleFiles(e.dataTransfer.files)
}}
className={cn(
'flex flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed p-6 text-center',
'transition-colors duration-150',
dragOver && !disabled ? 'border-primary bg-accent' : 'border-input',
disabled && 'cursor-not-allowed opacity-50',
error && !dragOver && 'border-destructive'
)}
>
<Upload className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">{dragOver && !disabled ? 'Drop file here' : 'Drag and drop a file'}</p>
<p className="text-xs text-muted-foreground">or</p>
<button type="button" onClick={choose} disabled={disabled} aria-describedby={labelId}
data-qqq-id={dataQqqId ? `${dataQqqId}-browse` : undefined}
className="rounded-md border border-input bg-card px-3 py-1.5 text-sm font-medium text-primary hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed">
Browse files
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-3" data-qqq-id={dataQqqId} data-upload-format="button">
<button id={id} type="button" onClick={choose} disabled={disabled}
aria-labelledby={`${labelId} ${id}`}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId ? `${dataQqqId}-choose` : undefined}
className={cn(
'inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium',
'bg-card text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
error ? 'border-destructive' : 'border-input'
)}>
<Upload className="h-4 w-4" aria-hidden="true" />
Choose file to upload
</button>
</div>
)}
{selected && (
<div className="flex items-center gap-2 text-sm text-foreground" data-qqq-id={dataQqqId ? `${dataQqqId}-selected-file` : undefined}>
<FileIcon className="h-4 w-4 text-primary" aria-hidden="true" />
<span className="max-w-[240px] truncate">{selected.name}</span>
{!disabled && (
<button type="button" aria-label={`Remove ${selected.name}`} onClick={clearSelection}
className="rounded p-0.5 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive">
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
)}
<input
ref={inputRef}
type="file"
@@ -130,6 +205,7 @@ export function FileUploadField({
aria-hidden="true"
tabIndex={-1}
className="sr-only"
data-qqq-id={dataQqqId ? `${dataQqqId}-file-input` : undefined}
onChange={(e) => handleFiles(e.target.files)}
/>
</div>
@@ -1,3 +1,23 @@
/*
* 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 NumberField — numeric input form field supporting integer, long, and decimal types with validation.
*/
'use client'
import React from 'react'
@@ -5,18 +25,52 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link NumberField} component.
*/
interface NumberFieldProps {
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the input. */
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 input is non-interactive and visually dimmed. */
disabled?: boolean
/** Placeholder text shown when the field is empty. */
placeholder?: string
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
required?: boolean
/**
* The `step` attribute for the numeric input.
* 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. */
minValue?: number | string | null
/** Maximum allowed value from field metadata; displayed as hint text below the input. */
maxValue?: number | string | null
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
'data-qqq-id'?: string
/** Id of help text that describes this control. */
describedBy?: string
}
/**
* Renders an accessible numeric input field with label, optional min/max hint, and validation error display.
*
* Used for QQQ field types INTEGER, LONG (with `step={1}`), and DECIMAL
* (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.
*
* @param props - See {@link NumberFieldProps}.
* @returns The rendered numeric input field with label, optional constraint hint, and optional error message.
*/
export function NumberField({
id,
label,
@@ -26,8 +80,14 @@ export function NumberField({
placeholder,
required = false,
step,
minValue,
maxValue,
'data-qqq-id': dataQqqId,
describedBy,
}: NumberFieldProps) {
const hintId = (minValue != null || maxValue != null) ? `${id}-hint` : undefined
const describedByIds = [hintId, error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined
return (
<div className="flex flex-col gap-1">
<label
@@ -47,7 +107,7 @@ export function NumberField({
placeholder={placeholder}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
aria-describedby={describedByIds}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
@@ -61,6 +121,15 @@ export function NumberField({
: 'border-input'
)}
/>
{(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}
@@ -1,3 +1,23 @@
/*
* 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 PasswordField — password input with show/hide toggle, validation error display, and accessibility attributes.
*/
'use client'
import React, { useState } from 'react'
@@ -6,18 +26,42 @@ import { Eye, EyeOff } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link PasswordField} component.
*/
interface PasswordFieldProps {
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the input. */
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 input is non-interactive and visually dimmed. */
disabled?: boolean
/** Placeholder text shown when the field is empty. */
placeholder?: string
/** Forwarded to the `<input>` `maxlength` attribute. */
maxLength?: number
/** 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. */
'data-qqq-id'?: string
/** Id of help text that describes this control. */
describedBy?: string
}
/**
* Renders a password input field with a show/hide toggle button.
*
* Toggling visibility switches the underlying `<input>` between `type="password"`
* and `type="text"`. The button is labelled via `aria-label` and is excluded
* from form submission focus order concerns because it has `type="button"`.
*
* @param props - See {@link PasswordFieldProps}.
* @returns The rendered password input field with show/hide toggle and optional error message.
*/
export function PasswordField({
id,
label,
@@ -28,6 +72,7 @@ export function PasswordField({
maxLength,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
}: PasswordFieldProps) {
const [showPassword, setShowPassword] = useState(false)
@@ -51,7 +96,7 @@ export function PasswordField({
maxLength={maxLength}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 pr-10 text-sm text-foreground',
@@ -1,3 +1,23 @@
/*
* 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 SelectField — native `<select>` field for static option lists, integrated with React Hook Form.
*/
'use client'
import React from 'react'
@@ -6,18 +26,43 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import type { QPossibleValue } from '@/types'
/**
* Props for the {@link SelectField} component.
*/
interface SelectFieldProps {
/** The HTML `id` for the `<select>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the select. */
label: string
/** Return value of `register(fieldName)` from React Hook Form. */
registration: UseFormRegisterReturn
/** The list of selectable options rendered as `<option>` elements. */
options: QPossibleValue[]
/** Validation error; when present triggers error styling and an error message. */
error?: FieldError
/** When `true`, the select is non-interactive and visually dimmed. */
disabled?: boolean
/** When `true`, no blank placeholder option is prepended; an asterisk indicator is shown. */
required?: boolean
/** Text for the blank placeholder option prepended when `required` is false; defaults to `"-- Select {label} --"`. */
placeholder?: string
/** `data-qqq-id` attribute forwarded to the select element for CSS customization. */
'data-qqq-id'?: string
}
/**
* Renders a native `<select>` field for a static list of options.
*
* Unlike {@link PossibleValueSelect} (which fetches options asynchronously),
* this component renders a fixed `options` array synchronously, making it
* suitable for inline enum-like value sets that do not need server-side search.
*
* When `required` is false a blank placeholder option is prepended so the
* user can deselect an existing value.
*
* @param props - See {@link SelectFieldProps}.
* @returns The rendered native select field with label and optional error message.
*/
export function SelectField({
id,
label,
+45 -1
View File
@@ -1,3 +1,23 @@
/*
* 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 TextField — single-line text input form field with label, validation error display, and accessibility attributes.
*/
'use client'
import React from 'react'
@@ -5,18 +25,41 @@ import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
/**
* Props for the {@link TextField} component.
*/
interface TextFieldProps {
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the input. */
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 input is non-interactive and visually dimmed. */
disabled?: boolean
/** Placeholder text shown when the field is empty. */
placeholder?: string
/** Forwarded to the `<input>` `maxlength` attribute. */
maxLength?: number
/** 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. */
'data-qqq-id'?: string
/** Id of help text that describes this control. */
describedBy?: string
}
/**
* Renders an accessible single-line text input field with label and validation error display.
*
* Used as the default field type for QQQ `STRING` fields and as the fallback
* renderer for any unrecognised field type in {@link DynamicFormField}.
*
* @param props - See {@link TextFieldProps}.
* @returns The rendered text input field with label and optional error message.
*/
export function TextField({
id,
label,
@@ -27,6 +70,7 @@ export function TextField({
maxLength,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
}: TextFieldProps) {
return (
<div className="flex flex-col gap-1">
@@ -47,7 +91,7 @@ export function TextField({
maxLength={maxLength}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
+59 -63
View File
@@ -1,82 +1,78 @@
// Tests for Banner component
/*
* 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 Banner component (v1 branding banner shape, keyed by slot)
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import BannerComponent from './Banner'
import type { Banner } from '@/types'
const banners: Record<string, Banner> = {
QFMD_TOP_OF_SITE: { severity: 'INFO', messageText: 'Site notice' },
QFMD_TOP_OF_BODY: { severity: 'WARNING', messageHTML: 'Body <b>bold</b><img src=x onerror="alert(1)">' },
QFMD_SIDE_NAV_UNDER_LOGO: {
severity: 'SUCCESS',
textColor: '#ffffff',
backgroundColor: '#14532d',
additionalStyles: { fontWeight: '700' },
messageText: 'NAV',
},
SOMETHING_ELSE: { severity: 'ERROR', messageText: 'Unknown slot' },
}
describe('Banner', () => {
const banners: Record<string, Banner> = {
QFMD_TOP_OF_SITE: {
text: 'This is a test environment',
severity: 'warning',
dismissible: true,
},
}
it('should render banner text', () => {
render(<BannerComponent banners={banners} />)
expect(screen.getByText('This is a test environment')).toBeInTheDocument()
it('renders the plain-text message of the requested slot in a labelled region', () => {
render(<BannerComponent banners={banners} slot="QFMD_TOP_OF_SITE" />)
const region = screen.getByRole('region', { name: 'Site banner' })
expect(region).toHaveTextContent('Site notice')
expect(region).toHaveAttribute('data-qqq-id', 'banner-QFMD_TOP_OF_SITE')
expect(region).toHaveAttribute('data-severity', 'info')
expect(screen.queryByText('Unknown slot')).not.toBeInTheDocument()
})
it('should have role="alert"', () => {
render(<BannerComponent banners={banners} />)
expect(screen.getByRole('alert')).toBeInTheDocument()
it('renders sanitized HTML in preference to text', () => {
render(<BannerComponent banners={banners} slot="QFMD_TOP_OF_BODY" />)
const region = screen.getByRole('region', { name: 'Page banner' })
expect(region.querySelector('b')).toHaveTextContent('bold')
expect(region.querySelector('img')?.getAttribute('onerror')).toBeFalsy()
expect(region).toHaveAttribute('data-severity', 'warning')
})
it('should show dismiss button for dismissible banners', () => {
render(<BannerComponent banners={banners} />)
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument()
it('applies text color, background color and additional styles from metadata', () => {
render(<BannerComponent banners={banners} slot="QFMD_SIDE_NAV_UNDER_LOGO" />)
const region = screen.getByRole('region', { name: 'Navigation banner' })
expect(region.style.color).toBe('rgb(255, 255, 255)')
expect(region.style.background).toContain('rgb(20, 83, 45)')
expect(region.style.fontWeight).toBe('700')
})
it('should hide banner after dismiss', async () => {
const user = userEvent.setup()
render(<BannerComponent banners={banners} />)
await user.click(screen.getByRole('button', { name: /dismiss/i }))
expect(screen.queryByText('This is a test environment')).not.toBeInTheDocument()
})
it('should not show dismiss button for non-dismissible banners', () => {
const nonDismissibleBanners: Record<string, Banner> = {
QFMD_TOP_OF_SITE: {
text: 'Persistent banner',
severity: 'info',
dismissible: false,
},
}
render(<BannerComponent banners={nonDismissibleBanners} />)
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument()
})
it('should call onDismiss callback when dismissed', async () => {
const user = userEvent.setup()
const onDismiss = vi.fn()
render(<BannerComponent banners={banners} onDismiss={onDismiss} />)
await user.click(screen.getByRole('button', { name: /dismiss/i }))
expect(onDismiss).toHaveBeenCalledWith('QFMD_TOP_OF_SITE')
})
it('should render nothing for empty banners', () => {
const { container } = render(<BannerComponent banners={{}} />)
it('renders nothing for an empty slot, missing banners, or a banner without a message', () => {
const { container, rerender } = render(<BannerComponent banners={{}} slot="QFMD_TOP_OF_SITE" />)
expect(container.firstChild).toBeNull()
rerender(<BannerComponent slot="QFMD_TOP_OF_SITE" />)
expect(container.firstChild).toBeNull()
rerender(<BannerComponent banners={{ QFMD_TOP_OF_SITE: { severity: 'INFO' } }} slot="QFMD_TOP_OF_SITE" />)
expect(container.firstChild).toBeNull()
})
it('should have data-qqq-id on banner element', () => {
render(<BannerComponent banners={banners} />)
expect(screen.getByRole('alert').closest('[data-qqq-id]')).toBeTruthy()
})
it('should render multiple banners', () => {
const multiBanners: Record<string, Banner> = {
banner1: { text: 'First banner', severity: 'info', dismissible: false },
banner2: { text: 'Second banner', severity: 'warning', dismissible: true },
}
render(<BannerComponent banners={multiBanners} />)
expect(screen.getAllByRole('alert')).toHaveLength(2)
it('defaults to info severity', () => {
render(<BannerComponent banners={{ QFMD_TOP_OF_SITE: { messageText: 'Plain' } }} slot="QFMD_TOP_OF_SITE" />)
expect(screen.getByRole('region', { name: 'Site banner' })).toHaveAttribute('data-severity', 'info')
})
})
Loaded 100 of 502 files, more files were not shown because too many files have changed in this diff. Show more