Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.2 5694671227 feat(query): a table can declare the sort its record list opens with
build / maven (push) Successful in 11m7s
maven / build (push) Successful in 11m7s
The record list fixed its initial sort at primary key descending
(use-record-query) and read no default from the table metadata, so an
application could not make a list open newest first by a date field.

QQQ core serves no table-level default sort or default view (checked at
4.1.0-RC.32: the table metadata has built-in views only for record ownership,
and no order-by). It does publish a table's supplemental metadata, where this
frontend already reads a hint object, supplementalMetaData.nextUi, for
defaultColumns. The contract is one more key in that object:

  "supplementalMetaData": { "nextUi": { "defaultSort": [
    { "fieldName": "receivedAt", "isAscending": false } ] } }

Each entry has the shape of a query filter order-by (QFilterOrderBy);
isAscending defaults to ascending. Entries naming an unknown field, a field
the user may not sort by, or with a non-boolean direction are ignored; with no
usable entry the default stays primary key descending.

The declared sort replaces the one default the hook already had, so every use
follows it: the list opens with it, the address omits it, New View and Reset
All Changes return to it, clearing a sort returns to it, and it is not an
unsaved change. A sort in the address, a saved view's sort, and the sort the
browser remembers for the table each win over it.

Documented in the README under "Default sort", including what an application
adds on the backend to fill the hint.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 03:43:12 -06:00
james.maes 787a227602 Merge pull request 'fix(preferences): an action that answers with an address opens it; a section says how a connection ended' (#214) from fix/process-link-and-preference-redirect into main
build / maven (push) Successful in 10m17s
maven / build (push) Successful in 10m17s
2026-11-11 12:38:10 +00:00
james.maesandClaude Fable 5.2 30f4f9d586 fix(preferences): an action that answers with an address opens it; a section says how a connection ended
build / maven (push) Successful in 5m2s
maven / build (push) Successful in 5m2s
A preference action that starts an OAuth connection (Connect Slack)
answers with the consent page's address in the action result's message,
because the UI API has no redirect field. The address was the text of a
success toast: long, not clickable and gone in seconds.

- A message that is nothing but one absolute https address is where the
  browser goes, in this tab, and is never shown (continuationUrl,
  leaveTo). A message that only mentions an address is still a message.
  Plain http, credentials in the address and other schemes are not
  followed.
- A flow that returns to /app/preferences?section=<name>&result=<code>
  or &error=<code> gets its outcome shown once above the section's form,
  as the section's label and the code as words ("Slack: connected.",
  "Slack: authorization denied."), as a status or an alert, and taken
  off the address. Only a fixed lowercase code is read, never a sentence.
- Links in an HTML process component use the link color and an
  underline; they looked like the text around them. A link's own inline
  style still wins.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 06:31:38 -06:00
james.maes 24bbe4a038 Merge pull request 'fix(errors): the error banner says what failed and no longer stacks' (#210) from fix/error-banner-names-cause into main
build / maven (push) Successful in 5m7s
maven / build (push) Successful in 5m7s
2026-11-11 00:58:17 +00:00
james.maes 8903f272c4 Merge pull request 'fix(header): the Ask button's activity dot and unread badge sit on its icon' (#213) from fix/ask-indicator-anchor into main
build / maven (push) Successful in 8m37s
maven / build (push) Successful in 8m37s
2026-11-11 00:58:15 +00:00
james.maesandClaude Fable 5.2 2345039911 fix(header): the Ask button's activity dot and unread badge sit on its icon
build / maven (push) Successful in 14m44s
maven / build (push) Successful in 14m45s
The dot shown while a reply is being written was positioned against the
button (absolute bottom-0 right-0). The button's box is its hit area: 10 px
of padding around the icon and, from 1440 px, the "Ask" label, with no
visible edge at rest. The dot therefore landed past the label and under its
baseline, detached from the icon; the unread badge landed over the end of
the label.

Header indicators are now positioned against the icon: HeaderIconAnchor is
a box exactly the size of the icon, with HEADER_BADGE_CLASS (top-right) and
HEADER_STATUS_DOT_CLASS (bottom-right). Ask, the bell and Jobs share it; on
the 40 px icon-only buttons the badge keeps its pixels (2 px inside the
button's top-right corner).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 18:40:45 -06:00
james.maesandClaude Fable 5.2 4347924658 docs(releases): note the 403 fallback and the silent cases of the error banner
build / maven (push) Successful in 4m13s
maven / build (push) Successful in 4m13s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:39:00 -06:00
james.maesandClaude Fable 5.2 523f59cf38 fix(errors): the error banner says what failed and no longer stacks
build / maven (push) Successful in 4m33s
maven / build (push) Successful in 4m33s
The global query/mutation error handler showed "Something went wrong" for
every failure without an expected status, and a polling query that kept
failing added one banner per attempt.

- No response: "No response from the server" (or "The server took too long
  to respond" for a timeout), with the request below it as METHOD /path:
  no query string, and ids shown as {id}.
- Unexpected status: the server's own message, else "Request failed (HTTP 409)".
- An error raised by the application shows its own sentence; a runtime error
  shows only its type.
- The banner's sonner id is its text plus the request, so a repeat updates
  the banner on screen. A lost connection has one banner for every request.
- 401, cancelled requests and aborted fetches stay silent. 403 falls back to
  "Permission denied" instead of the axios transport text.
- Each banner logs status, axios code, error name and request to the console,
  never a message, body, header or query string.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:29:49 -06:00
james.maes fefd5be0d4 Merge pull request 'fix(auth): every sign-in forces re-authentication (prompt=login, max_age=0)' (#209) from fix/signin-always-asks-credentials into main
build / maven (push) Successful in 4m22s
maven / build (push) Successful in 4m22s
2026-11-09 20:59:19 +00:00
james.maesandClaude Sonnet 5.5 c0e5b285db fix(auth): every sign-in forces re-authentication (prompt=login, max_age=0)
build / maven (push) Successful in 7m37s
maven / build (push) Successful in 7m37s
Sign out already ends the provider session (RP-initiated logout); the authorize
request now also sends prompt=login and max_age=0 so a lingering provider
session can never sign the next user in silently. prompt=login alone was seen
reusing retained SSO on real Authentik.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:43:02 -06:00
james.maes 42b509f35c Merge pull request 'ci: use shared ci-workflows Maven workflow' (#208) from ci/ci-workflows into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 9s
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (push) Successful in 27s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (push) Successful in 12m7s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (push) Successful in 29m53s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (push) Successful in 16s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Successful in 30m15s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
build / maven (push) Successful in 13m3s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 13m3s
2026-11-06 21:50:54 +00:00
james.maesandClaude Opus 5.5 37c4234324 ci: pin v1, build the Next export before Maven, drop the old release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
build / maven (push) Successful in 10m42s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 23s
maven / build (push) Successful in 10m42s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Successful in 12m17s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 26m38s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Successful in 18s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Successful in 27m2s
Every push verifies (pnpm typecheck/lint/test/build:export, then Maven);
vX.Y.Z and vX.Y.Z-RC.N tags publish to the Gitea registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:38:06 +00:00
james.maesandClaude Opus 5.5 412d884402 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
build / maven (push) Failing after 43s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 27s
maven / build (push) Failing after 43s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Successful in 11m58s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Failing after 23m41s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Failing after 12s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Failing after 28m35s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:15:45 +00:00
james.maes 27e8754f32 Merge pull request 'Preserve workflow, presence and dependency updates in the canonical Next release' (#207) from kof22/next-workflow-assets-on-pr206 into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (push) Successful in 36s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (push) Successful in 11m12s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (push) Successful in 21m37s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (push) Successful in 8s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Successful in 21m49s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-05 17:22:27 +00:00
james.maesandClaude Sonnet 5.5 00c00b0e71 Merge owner PR206 46e9f121 into the PR207 branch: scoped record filter control lookups
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 49s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Successful in 11m59s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 23m10s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Successful in 23m21s
Takes the owner's test-only fix (src/components/query/RecordQuery.test.tsx scopes role queries with within(); docs/QBIT-RELEASE-STATUS.md)
for the 'confirms clearing filters and preserves the selected sort' case that timed out at the default 5000 ms on the loaded CI runner
(5150/5239 ms in CI 4993 node, twice; 1.3-1.5 s unloaded locally with the fix vs 1.5-1.7 s without). No assertion, user-semantic or
sort behavior changes; no f49, Tools, owner 205/206, zod 4 or lockfile content touched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:46:00 -06:00
james.maesandClaude Sonnet 5.5 3a72c8b88a fix(authentik): give the access editor's inherited-permissions schema its zod 4 key type
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 8s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 3d75c629c2706be133505268b6696fe9b1ae420c] (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 21m26s
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 36s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Failing after 12m36s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Failing after 16s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Failing after 12m57s
The owner's access editor (PR205/206) was written against zod 3's single-argument z.record(value). With zod ^4.6.7 (the reviewed f49
dependency set) z.record needs the key schema too: TS2554 at (32,16), then 'inherited' infers Record<..., unknown> giving TS18046 at (84,147)
and TS2322 at (103,128) (CI 4726, node 75004 and e2e 75005, the only three type errors). Use z.record(z.string(), z.array(z.string())), the
same validation and the exact Record<string, string[]> type the component already declares; no cast, no behavior or permission change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 08:29:27 -06:00
James MaesandClaude Sonnet 5.5 bfcc008ca2 test: carry the reviewed f49 test-only fixes that keep upstream coverage equivalent
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (pull_request) Successful in 13s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 7s
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 1m11s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Failing after 2m13s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Failing after 2m20s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Failing after 17s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Failing after 1h10m50s
WidgetRenderer lazy-chunk wait, the mark-all-mentions-read My Work test (dce92cfb) and the help-results wait before
search navigation (8d372142), taken from f49. Owner-side test changes in PR205/206 (keyboard shortcuts, security headers
with blob: connect-src) are kept as they are newer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:02:39 -06:00
James MaesandClaude Sonnet 5.5 fdf12d3c40 fix(deps,test): zod 4 migration and portable standalone tests from the reviewed f49 commit 0c156b22
The schema, form-adjuster, possible-values, assistant-normalize and zod-from-metadata changes zod 4 needs, plus the
existing Windows-portable standalone acceptance-stage and the two dev/write page test adjustments. Taken from f49;
on these files PR206 differed from f49 only by the zod 3 forms.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:01:30 -06:00
James MaesandClaude Sonnet 5.5 04ab9ad6c9 fix(deps): apply the reviewed f49 dependency advisory updates and zod 4 to the 206 line
next 16.4.1 -> 16.4.3, dompurify ^3.4.17, storybook 10.6.2, vitest/coverage/ui ^4.1.12, vite 7.3.7, zod ^4.6.7 and the
pnpm overrides (follow-redirects, undici@>=7 <8, fast-uri@>=3 <4) exactly as in the reviewed f49de335 package.json
dependency sections and pnpm-lock.yaml. PR206 carried no dependency change of its own beyond f49's (codemirror,
pdfjs-dist, typescript-editor are identical), so the f49 lockfile is used as is; `pnpm install --lockfile-only
--frozen-lockfile` (pnpm 9.15.9, Node 22.20.0) accepts it unchanged. Scripts, version RC.38 and the caller are untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:01:29 -06:00
James MaesandClaude Sonnet 5.5 70618bd143 build: pin the workflow bundle lock to the component main merge ede4eba
Component PR 5 merged normally as ede4eba875ca17c5921ad4caf61a08bdff6f794d; its tree 9efc0921... equals the qualified
source tree, so the qualified bundle and map (sha256 unchanged) are the canonical main output; no rebuild.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:57:56 -06:00
james.maesandCodex aaa77d7e6e fix: retain Auth0 display profile with canonical presence identity
Keep the backend user ID without dropping provider display fields on sign-in or reload. Do not reuse a different user's cached profile. Reproduced callback/reload failures; full 3664-test suite, TypeScript and lint pass. Real mobile flow assertions pass locally, with Windows export diagnostics tracked separately; Linux acceptance remains required.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-05 06:57:31 -06:00
James MaesandClaude Sonnet 5.5 73b1ea16d7 feat(collaboration): use server-issued presence leases in the hook (port of published RC.37 de5c1bd0)
The current line has the lease API client (renew/open/beacon with sequence) but the hook still generated a client
connection id and never opened or ordered a lease. Port the hook, auth-storage, realtime invalidation, header and
mock changes of de5c1bd0 from the published v1.0.0-RC.37, so RC.38 does not regress against qbit-collaboration's
issued-lease protocol. CI, version and acceptance-stage conflicts resolved to the current line (no version or
dependency change); the people-tasks beacon test keeps the current line's equivalent assertion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:57:30 -06:00
James MaesandClaude Sonnet 5.5 94726bb52c build: package the qualified precompiled QSequentialWorkflow bundle as tracked Next library assets
Track the component bundle and map compiled from the reviewed component source e6036d2 (Node 22.20.0, npm ci
--ignore-scripts, existing 3 suites passed, webpack) in dynamic-components/q-sequential-workflow/ with a sha256 lock
(scripts/dynamic-components.lock.json: source commit/tree, toolchain image digest, lockfile hash). The stage script
copies them into the export (build, dev and build:export) and refuses a missing or altered file; the pom enforcer
requires the staged bundle and map, so a release without the workflow editor fails packaging. No network, token or
component fetch is needed in the producer. The component main commit is recorded after its merge (pending in the lock).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:34:45 -06:00
James MaesandClaude Sonnet 5.5 56ab7fe21c fix(widgets): run dynamic components on the React 19 host with Material's defaultValues Map and qController.processRun
Port of the KOF22 bridge fixes onto the current line: widget defaultValues are handed to dynamic components as a
Map that still allows property access, and the bridge exposes Material's qController.processRun (real process
init/run through the existing UI API). Needed by the QSequentialWorkflow editor bundle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:33:21 -06:00
74 changed files with 17434 additions and 738 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
dynamic-components/** -text
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0.
# https://www.apache.org/licenses/LICENSE-2.0
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
with:
node-version: '22.20.0'
prebuild: corepack enable && pnpm install --frozen-lockfile && pnpm typecheck && pnpm lint && pnpm test && pnpm build:export
-14
View File
@@ -1,14 +0,0 @@
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0.
# https://www.apache.org/licenses/LICENSE-2.0
name: QRun Next dashboard release
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-next-release.yaml@327e9b3a9d45a08665fa9468f0f9741e972d8e3e
with:
runner: browser-build
+3
View File
@@ -10,6 +10,8 @@
# next.js
/.next/
/.next-collaboration/
/.next-presence-live/
/out/
# production
@@ -43,3 +45,4 @@ target/
/storybook-static/
/public/scripting-generated/
/public/pdf-generated/
/public/dynamic-qfmd-components/
+35
View File
@@ -225,3 +225,38 @@ Saved views and legacy stored visibility (including `{}`, meaning all base colum
are preserved. New View and Reset All Changes on an unsaved view restore these
metadata defaults. Quick search searches only visible string fields. Responsive
card/grid behavior is unchanged.
## Default sort
A record list opens sorted by primary key, descending. An application can declare another
sort for a table (newest first by a date field, for example) in the same hint object:
```json
{
"supplementalMetaData": {
"nextUi": {
"defaultSort": [
{ "fieldName": "receivedAt", "isAscending": false }
]
}
}
}
```
`defaultSort` is an array of order-bys in the shape a query filter uses (`QFilterOrderBy`):
`fieldName` is a field of the table, or of an exposed join as `joinTable.fieldName`, and
`isAscending` is a boolean (ascending when absent). Several entries sort by the first, then
the next. The legacy `supplementalTableMetaData.nextUi.defaultSort` path is also accepted.
The declared sort is the table's default, not a setting that overrides people: a sort in the
address, the sort of a saved view, and the sort the browser remembers for the table from an
earlier visit are each used instead. New View and Reset All Changes return to the declared
sort, and it is not counted as an unsaved change. An entry that names an unknown field, a
field the user may not sort by, or carries a non-boolean `isAscending` is ignored; when no
entry is usable, or the hint is malformed, the list sorts by primary key, descending, as
before.
QQQ has no table-level default sort of its own (as of 4.1.0-RC.32), so the application supplies
the hint: a `QSupplementalTableMetaData` subclass whose `getType()` is `nextUi`, that returns
`true` from `includeInFullFrontendMetaData()` and exposes `getDefaultSort()` as a list of
`QFilterOrderBy` (and `getDefaultColumns()` for the hint above), attached to the table with
`withSupplementalMetaData(...)`. The table metadata response publishes it as shown.
+37 -49
View File
@@ -23,10 +23,12 @@ Axios interceptor
▼
TanStack Query cache (onError)
│ 401 → silently ignored (already handled)
│ 403 → toast "Permission denied" / "You do not have permission…"
│ 403 → toast the server's message, or "Permission denied"
│ 404 → toast "Resource not found"
│ 5xx → toast "Server error — retrying…" / "Server error — please try again"
│ network → toast "Something went wrong"
│ other status → toast the server's message, or "Request failed (HTTP 409)"
│ no response → toast "No response from the server" / "The server took too long to respond"
│ every toast names the request, and a repeat of the same failure updates it
▼
Component (optional per-query onError)
│ Only needed when generic toast is wrong for the specific UX
@@ -66,52 +68,38 @@ The `unauthorizedCallback` is wired to the auth provider's logout/redirect funct
**Location:** `src/lib/query-client.ts` — `handleQueryError` function
```typescript
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
const status = getErrorStatusCode(error)
It is registered on both `QueryCache.onError` and `MutationCache.onError`. A query or mutation whose
component renders its own error opts out with `meta: HANDLES_OWN_ERRORS`.
if (status === 401) return // handled by axios interceptor
| Failure | Banner text |
|---|---|
| Cancelled request (an axios cancel or an aborted `fetch`), 401 | none |
| 403 | the server's message (of a JSON body), or `Permission denied` |
| 404 | `Resource not found` |
| 5xx | `Server error — retrying…` (query), `Server error — please try again` (mutation) |
| Any other status (400, 409, 422, …) | the server's message (`userFacingError`, `error` or `message` of a JSON body), or `Request failed (HTTP 409)` |
| No response (connection failed) | `No response from the server` |
| No response (the 30 s timeout) | `The server took too long to respond` |
| An error the application raised (`throw new Error('Invalid audit response')`) | that sentence |
| A runtime error (`TypeError`, a schema error, …) | `The page hit an unexpected error (TypeError)` |
if (status === 403) {
toast.error(
context === 'mutation'
? 'You do not have permission to perform this action'
: 'Permission denied'
)
return
}
Under the text, every banner names what failed: the request as `POST /qqq/v1/table/order/query (HTTP 409)`,
or `While loading records` (the query key's namespace in plain words: `possibleValues` reads
`possible values`) for a query that failed outside a request. The
request is written without its query string, and every path segment that is not a plain word (a record
id, a UUID, an encoded value) is shown as `{id}`. Nothing else of the request or response is shown.
if (status === 404) {
toast.error('Resource not found')
return
}
**One banner per failure.** The banner's sonner `id` is its text plus the request, so a failure that
repeats (a polling query that fails on every attempt) updates the banner already on screen instead of
adding one. A different failure gets its own banner. A lost connection is one cause whichever request
met it, so it has a single banner, which names the request that failed most recently. Without the id
every failed poll added a banner, and sonner pauses a toast's timer while the tab is hidden or the
pointer rests on the stack, so the banners could pile up.
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'),
}),
})
```
**Console.** Each banner is also logged once with `console.warn('[API] Request failed:', { context,
status, code, name, request, area })`: the HTTP status, the axios error code (`ERR_NETWORK`,
`ECONNABORTED`, `ERR_BAD_REQUEST`), the error's name, the request as above and the query namespace.
It never logs a message, body, header or query string, because those can carry record data.
**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.
@@ -139,7 +127,7 @@ Returns `undefined` for anything else (non-object errors, null, network failures
### 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.
The global handler toasts the server's message for a 400 (or `Request failed (HTTP 400)` when it sent none). 400s typically carry field-level validation messages that belong beside individual form fields, so a form handles 400 explicitly, and sets `meta: HANDLES_OWN_ERRORS` on the mutation when the toast would repeat what the form shows.
```typescript
import { getErrorStatusCode } from '@/lib/utils/error-utils'
@@ -207,7 +195,7 @@ No additional per-component handling is needed for 5xx unless you want a custom
### 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.
Treated as non-4xx by the retry logic — queries retry up to 3 times. After all retries are exhausted the global handler toasts "No response from the server" (or "The server took too long to respond" for the 30 s timeout) with the request that failed. No per-component handling needed for the common case.
---
@@ -274,8 +262,8 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
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.
// The global handler toasts the server's message for a 400; the form
// places each field's message beside it.
const body = (error as AxiosError<{ errors: Record<string, string> }>).response?.data
if (body?.errors) {
for (const [field, message] of Object.entries(body.errors)) {
@@ -305,6 +293,6 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
| 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 |
| Global query handler | `src/lib/query-client.ts` | Every status but 401, no response, page errors → one toast per failure, naming the request |
| 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 |
+9
View File
@@ -13,11 +13,20 @@
- One `UserAvatar` (photo via one batched `POST /avatars/lookup` and `GET /avatars/{avatarId}`, otherwise initials) shows people in the user menu, presence, comments, mention candidates, the people picker, activity and your own assistant messages. A missing avatar is remembered for the session, so it isn't requested again.
- **Privacy:** branding `gravatarDefault` no longer sends your email hash to Gravatar. A Gravatar appears only if you turn it on in your Profile, and the server serves it.
- Image fields take a full form row at every width. e2e coverage is `tests/e2e/profile-avatars.spec.ts`, including the 6-width light/dark screenshot matrix.
- **A table can declare the sort its record list opens with.** The list's initial sort was fixed at primary key descending and read nothing from the table's metadata, so an application could not make a list open newest first by a date field. A table's Next UI hint now takes `defaultSort` beside `defaultColumns`: `supplementalMetaData.nextUi.defaultSort`, an array of `{ "fieldName", "isAscending" }` in the shape of a query filter's order-bys. The list opens with it when the address carries no sort, no saved view is open and the browser remembers no sort for the table; each of those wins when present. New View and Reset All Changes return to it, and it is not counted as an unsaved change. Unknown or unsortable fields are ignored, and a table without a usable hint sorts by primary key descending as before. QQQ itself has no table-level default sort (as of 4.1.0-RC.32), so the application supplies the hint through a supplemental table metadata object of type `nextUi`; see the README, [Default sort](../../README.md#default-sort).
## Product fixes
- **A preference action that connects another service takes you there.** A provider that starts an OAuth connection (Connect Slack, for example) answers with the address of the service's consent page in the action result's `message`, since the UI API has no redirect field. That address was shown as the text of a success toast: long, not clickable and gone in seconds. A message that is nothing but one absolute `https:` address is now where the browser goes, in the same tab, and is never shown. A message that only mentions an address is still a message. Plain `http:`, addresses with credentials and every other scheme are not followed.
- **Preferences says how a connection ended.** A flow that left the dashboard returns to `/app/preferences?section=<name>&result=<code>` when it succeeded or `&error=<code>` when it did not. The section now shows the outcome once, above its form, as the section's label and the code as words (`Slack: connected.`, `Slack: authorization denied.`), as a status or an alert, and takes it off the address so a reload does not repeat it. Only a fixed lowercase code is read from the address, never a sentence.
- **Links in process HTML look like links.** An `HTML` process component rendered links in the text color with no underline, so a link the backend put on a screen (a bulk load template, a "Continue to Slack" link) could not be told from the words around it. They now use the link color and an underline; a link's own inline style still wins.
- **The Ask button's activity dot sits on its icon.** While a reply was being written, the dot was placed in the bottom-right corner of the button's hit area, which has no visible edge and, from 1440 px, extends past the "Ask" label, so the dot floated under and to the right of the label. The dot and the unread badge are now positioned against the sparkles icon, at every width and with the panel open or closed. The bell and Jobs badges use the same anchor and keep their place.
- **Logout no longer races an in-flight request.** Logout ends the API client's session generation first: every request still in flight (axios and the realtime and assistant streams) is aborted, and any response of the old generation that still arrives is discarded, so none can refresh the `sessionId` cookie the logout expires or change client auth state (SEC-037, SEC-048).
- **The error banner says what failed, and no longer stacks.** The banner for a failed request used to read "Something went wrong" for every failure without an expected status, and a polling request that kept failing added one banner per attempt. It now reads `No response from the server`, `The server took too long to respond`, the server's own message, or `Request failed (HTTP 409)`, and names the request below it (`POST /qqq/v1/table/order/query`, without the query string, with ids shown as `{id}`). A repeat of the same failure updates the banner on screen; a lost connection shows one banner whichever requests met it. The 403, 404 and 5xx banners keep their wording and gain the request line; a 403 without a server message reads `Permission denied`. A cancelled request, an aborted `fetch` and a 401 show no banner. Each banner logs its status, axios code, error name and request to the console once (`[API] Request failed:`), never a message, body, header or query string.
## Browser testing and infrastructure
None yet.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"version":3,"file":"q-sequential-workflow.bundle.js","mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACD,O;;;;;;;;;;;;;;ACVA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kDAAkD;AAClD;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;AClDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,gCAAgC,0CAA0C;AAC1E;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,0BAA0B;AACtD;AACA;AACA;AACA;AACA;AACA,4BAA4B,4BAA4B;AACxD;AACA;AACA;AACA;AACA;AACA,CAAC;AACkB;AACnB,mC;;;;;;;;;;;;;;AC/DA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb;AACA,yCAAyC,iBAAiB;AAC1D;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;;;;;ACzDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,qBAAqB,SAAI,IAAI,SAAI;AACjC,6EAA6E,OAAO;AACpF;AACA;AACA;AACA;AACA;AACA;AACA;AACmD;AACE;AACH;AACJ;AAC9C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,8BAA8B;AAC1D,yCAAyC,2DAAc;AACvD;AACA;AACA;AACA;AACA,4BAA4B,yGAAyG;AACrI,wDAAwD,2DAAc;AACtE;AACA;AACA,4BAA4B,uDAAY;AACxC;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6CAA6C,6DAAY;AACzD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,4BAA4B;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kEAAkE,gBAAgB;AAClF;AACA;AACA;AACA;AACA;AACA;AACA,mDAAmD,gBAAgB;AACnE;AACA;AACA;AACA;AACA;AACA;AACA,qDAAqD,gBAAgB;AACrE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAAS;AACT;AACA,2DAA2D,WAAW,iJAAiJ;AACvN;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;AC5JA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,yBAAyB;AACrD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,yBAAyB;AACrD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,oCAAoC,wBAAwB;AAC5D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAAS;AACT,gDAAgD;AAChD;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACuB;AACxB,wC;;;;;;;;;;;;;;AC3GA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,8CAA8C;AAC/C,6C;;;;;;;;;;;;;;AC9CA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,qBAAqB,SAAI,IAAI,SAAI;AACjC,6EAA6E,OAAO;AACpF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AAC0B;AAC3B,2C;;;;;;;;;;;;;;AClDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;;;ACvCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACoD;AACF;AAClD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,0CAA0C;AAC1C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,wBAAwB,6EAA6E;AACrG;AACA,8BAA8B,6DAAe;AAC7C;AACA,wBAAwB,6EAA6E;AACrG;AACA,8BAA8B,2DAAc;AAC5C;AACA,wBAAwB,+EAA+E;AACvG;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,iDAAiD,gBAAgB;AACjE;AACA;AACA;AACA;AACA;AACA;AACA,iDAAiD,gBAAgB;AACjE;AACA;AACA;AACA;AACA;AACA;AACA,mDAAmD,gBAAgB;AACnE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACuB;AACxB,wC;;;;;;;;;;;;;;;;;;ACjHA;AACgG;AACjB;AAC/E,8BAA8B,sEAA2B,CAAC,+EAAqC;AAC/F;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACALine truncated
+2
View File
@@ -20,6 +20,8 @@ const BACKEND_PREFIXES: string[] = backendPrefixes
const nextConfig: NextConfig = {
reactStrictMode: true,
// Allow acceptance servers to run without sharing the developer server's lock/cache.
distDir: process.env.QQQ_NEXT_DIST_DIR || '.next',
// 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
+19 -14
View File
@@ -6,9 +6,10 @@
"scripts": {
"scripting:worker": "node scripts/build-scripting-worker.mjs",
"scripting:worker:check": "node scripts/build-scripting-worker.mjs && node scripts/check-scripting-worker.mjs",
"dev": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && next dev --turbopack",
"build": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && next build",
"build:export": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/build-export.mjs",
"dev": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && next dev --turbopack",
"build": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && next build",
"build:export": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && node scripts/build-export.mjs",
"workflow:bundle": "node scripts/stage-dynamic-components.mjs",
"build:jar": "pnpm build:export && mvn -B -q clean package",
"start": "next start",
"lint": "eslint src && node scripts/check-license-headers.mjs",
@@ -59,10 +60,10 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.16",
"dompurify": "^3.4.17",
"lodash": "^4.18.1",
"lucide-react": "^0.485.0",
"next": "16.4.1",
"next": "16.4.3",
"pdfjs-dist": "6.4.299",
"rapidoc": "10.1.0",
"react": "^19.2.4",
@@ -77,15 +78,15 @@
"sql-formatter": "15.6.6",
"tailwind-merge": "^3.5.0",
"typescript-editor": "npm:typescript@6.0.3",
"zod": "^3.25.76"
"zod": "^4.6.7"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@eslint/eslintrc": "^3.3.4",
"@playwright/test": "1.64.0-alpha-2026-10-01",
"@storybook/addon-docs": "10.6.0",
"@storybook/nextjs-vite": "10.6.0",
"@storybook/react": "10.6.0",
"@storybook/addon-docs": "10.6.2",
"@storybook/nextjs-vite": "10.6.2",
"@storybook/react": "10.6.2",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -96,8 +97,8 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"@vitest/coverage-v8": "^4.1.12",
"@vitest/ui": "^4.1.12",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-config-next": "16.4.1",
@@ -108,10 +109,11 @@
"msw": "^2.12.10",
"postcss": "^8.5.28",
"prettier": "^3.8.1",
"storybook": "10.6.0",
"storybook": "10.6.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
"vite": "7.3.7",
"vitest": "^4.1.12"
},
"msw": {
"workerDirectory": [
@@ -129,7 +131,10 @@
"serialize-javascript": ">=7.0.3",
"@humanfs/node": ">=0.16.8",
"@babel/core": ">=7.29.6 <8",
"source-map-js": ">=1.2.2"
"source-map-js": ">=1.2.2",
"follow-redirects": ">=1.16.2",
"undici@>=7 <8": ">=7.30.1 <8",
"fast-uri@>=3 <4": ">=3.1.9 <4"
},
"ignoredOptionalDependencies": [
"electron"
+1 -1
View File
@@ -34,7 +34,7 @@ export default defineConfig({
webServer: {
command: `pnpm dev --port ${port}`,
url: baseURL,
env: { NEXT_PUBLIC_MOCK_API: 'true' },
env: { NEXT_PUBLIC_MOCK_API: 'true', QQQ_NEXT_DIST_DIR: '.next-collaboration' },
reuseExistingServer: false,
timeout: 120_000,
},
+419 -474
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -89,8 +89,10 @@
<files>
<file>${project.basedir}/out/index.html</file>
<file>${project.basedir}/out/app/_/_/index.html</file>
<file>${project.basedir}/out/dynamic-qfmd-components/q-sequential-workflow.bundle.js</file>
<file>${project.basedir}/out/dynamic-qfmd-components/q-sequential-workflow.bundle.js.map</file>
</files>
<message>Run "QQQ_NEXT_OUTPUT=export pnpm build" (or pnpm build:jar) before packaging.</message>
<message>Run pnpm build:export (or pnpm build:jar) before packaging; the export must contain the QSequentialWorkflow editor bundle staged from dynamic-components/.</message>
</requireFilesExist>
</rules>
</configuration>
+27
View File
@@ -0,0 +1,27 @@
{
"component": {
"repository": "QRun-IO/qqq-frontend-component-sequential-workflow",
"sourceCommit": "e6036d2a474a90d80c72bc4c5f242e666b3415f6",
"sourceTree": "9efc09210a81dc4121aa53e156443b7f4fcb6dd7",
"mainCommit": "ede4eba875ca17c5921ad4caf61a08bdff6f794d",
"build": {
"node": "v22.20.0",
"npm": "10.9.3",
"image": "node@sha256:b21fe589dfbe5cc39365d0544b9be3f1f33f55f3c86c87a76ff65a02f8f5848e",
"commands": [
"npm ci --ignore-scripts",
"npm test (existing 3 suites, 62 tests)",
"npx webpack"
],
"packageLockSha256": "737cc1a3e416d942c050076dfae36345e9ba3f62d679dec71f04c2a2382b1a21",
"qualifiedUtc": "2026-11-05T12:31Z"
},
"mainTree": "9efc09210a81dc4121aa53e156443b7f4fcb6dd7",
"mainMerge": "normal merge of PR 5 (parents a32a7b4d437f09c09b03eb8828fccfd7de4eae7a, e6036d2a474a90d80c72bc4c5f242e666b3415f6); its tree equals the qualified source tree, so the qualified assets are the canonical main build output"
},
"assetDirectory": "dynamic-components/q-sequential-workflow",
"files": {
"q-sequential-workflow.bundle.js": "9f8358b105d0c157cb32a476313a7750281549f8e9423dd7d125ceaaafe8f32d",
"q-sequential-workflow.bundle.js.map": "776c716690a357213e00d109652ab7eb9c9025073b362c995564e11ca5c5ee92"
}
}
+45
View File
@@ -0,0 +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 https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Stage the versioned, precompiled QSequentialWorkflow editor bundle (the qbit-workflows `QSequentialWorkflow` editor)
* under `public/dynamic-qfmd-components/`, which is where qbit-workflows' widget metadata points
* (`/dynamic-qfmd-components/q-sequential-workflow.bundle.js`) and where the single-origin host serves the UI from.
* The bundle and its map are tracked library assets in `dynamic-components/q-sequential-workflow/`, compiled from the
* canonical component repository (source commit and toolchain recorded in `dynamic-components.lock.json`). Nothing is
* downloaded or built here, and a file is refused unless its sha256 matches the lock. A missing or altered bundle fails the
* build, so a release can never silently omit the workflow editor.
*/
import { createHash } from 'node:crypto'
import { copyFile, mkdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
/** Copies every locked file from `assetDir` to `outDir`; throws on a missing file or a hash mismatch. */
export async function stageDynamicComponents({ assetDir, lock, outDir }) {
await mkdir(outDir, { recursive: true })
const staged = []
for (const [name, expected] of Object.entries(lock.files)) {
if (path.basename(name) !== name) throw new Error(`Locked file name must be a plain file name: ${name}`)
if (!/^[0-9a-f]{64}$/.test(expected ?? '')) throw new Error(`${name} has no sha256 in the dynamic component lock`)
const source = path.join(assetDir, name)
const actual = sha256(await readFile(source).catch(() => { throw new Error(`Missing ${name} in ${assetDir}`) }))
if (actual !== expected) throw new Error(`${name} sha256 ${actual} does not match the lock ${expected}`)
await copyFile(source, path.join(outDir, name))
staged.push(name)
}
return staged
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const lock = JSON.parse(await readFile(path.join(here, 'dynamic-components.lock.json'), 'utf8'))
const staged = await stageDynamicComponents({ assetDir: path.join(here, '..', lock.assetDirectory), lock, outDir: path.join(here, '../public/dynamic-qfmd-components') })
console.log(`Staged dynamic components compiled from ${lock.component.repository}@${lock.component.sourceCommit.slice(0, 8)}: ${staged.join(', ')}`)
}
@@ -113,9 +113,8 @@ function renderPage() {
}
describe('RecordDeveloperViewPage', () => {
// Transform the real lazy module before DOM waits begin. Cold Vitest transforms
// on Windows can otherwise consume the first test's entire five-second wait.
beforeAll(async () => { await import('@/components/developer/RecordDeveloperView') })
// The page lazy-loads this view; transforming its module graph cold must not count against the first test's waits
beforeAll(async () => { await import('@/components/developer/RecordDeveloperView') }, 120_000)
beforeEach(() => {
vi.mocked(getRecord).mockResolvedValue(record)
@@ -17,7 +17,7 @@
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 { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import apiClient from '@/lib/api/client'
import { QContextProvider } from '@/lib/context/q-context'
@@ -54,6 +54,8 @@ function renderPage(page: React.ReactNode) {
}
describe('Create and base edit use full metadata and actual write contracts', () => {
// The pages lazy-load the form; transforming its module graph cold must not count against the first test's waits
beforeAll(async () => { await import('@/components/forms/EntityForm') }, 120_000)
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
it('edits the base without requesting denied children or replacing associations', async () => {
+2 -4
View File
@@ -180,10 +180,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
// Sync user ID to QContext
useEffect(() => {
if (user?.email) {
setUserId(user.email)
}
}, [user?.email, setUserId])
setUserId(user?.id ?? user?.email)
}, [user?.id, user?.email, setUserId])
// Redirect to login if not authenticated
useEffect(() => {
+13 -11
View File
@@ -35,7 +35,7 @@ import { getAlertsInboxSummary } from '@/lib/api/alerts'
import { queryKeys } from '@/lib/query-client'
import type { AlertsInboxSummary } from '@/types/alerts'
import type { QInstance } from '@/types/metadata'
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
const AlertsBellPopover = lazy(() => import('./AlertsBellPopover'))
@@ -132,16 +132,18 @@ export function AlertsBell(_props: { metaData: QInstance | undefined }) {
aria-expanded={open}
data-qqq-id="button-notifications"
>
<Bell className="h-5 w-5" aria-hidden="true" />
{unread > 0 && (
<span
className={`absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
aria-hidden="true"
data-qqq-id="alerts-unread-badge"
>
{unread > 99 ? '99+' : unread}
</span>
)}
<HeaderIconAnchor>
<Bell className="h-5 w-5" aria-hidden="true" />
{unread > 0 && (
<span
className={`${HEADER_BADGE_CLASS} ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
aria-hidden="true"
data-qqq-id="alerts-unread-badge"
>
{unread > 99 ? '99+' : unread}
</span>
)}
</HeaderIconAnchor>
</button>
<span className="sr-only" aria-live="polite" data-qqq-id="alerts-unread-announcer">{label}</span>
{open && (
@@ -198,7 +198,12 @@ describe('AlertsBell', () => {
expect(bell).toHaveAttribute('aria-expanded', 'false')
// severity is in the name, not only in the badge color
expect(bell).toHaveAccessibleName('Notifications, 2 unread, critical')
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
const badge = screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })
expect(badge).toHaveClass('bg-destructive')
// the badge is positioned against the icon (the header's shared anchor), inside the button
expect(badge.parentElement).toHaveAttribute('data-qqq-id', 'header-icon-anchor')
expect(badge.parentElement).toHaveClass('relative')
expect(badge.parentElement?.parentElement).toBe(bell)
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
first.unmount()
@@ -0,0 +1,177 @@
/*
* 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.
*/
// The header's Ask button: its busy dot and unread badge stay on the sparkles icon in every state.
// jsdom has no layout, so these tests assert what decides the layout: which element is each
// indicator's containing block, what gives that element its size, and the indicator's offsets.
import React from 'react'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HEADER_ICON_ANCHOR_CLASS, HEADER_STATUS_DOT_CLASS } from '@/components/layout/HeaderMenu'
import { assistantKeys, type AssistantController } from './assistant-context'
import { AssistantHeaderButton } from './AssistantHeaderButton'
import { AssistantProvider } from './AssistantProvider'
const ANCHOR = '[data-qqq-id="header-icon-anchor"]'
const BUSY = '[data-qqq-id="assistant-busy"]'
const UNREAD = '[data-qqq-id="assistant-unread-badge"]'
/**
* The class tokens of an element (an SVG element's `className` is not a string).
*
* @param element - The element.
* @returns Its classes.
*/
function classes(element: Element): string[] {
return (element.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
}
/**
* The containing block of an absolutely positioned element: its nearest positioned ancestor.
*
* @param element - The absolutely positioned element.
* @returns The ancestor its offsets are measured from, or null (the viewport).
*/
function containingBlock(element: Element): Element | null {
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
if (classes(parent).some((name) => ['relative', 'absolute', 'fixed', 'sticky'].includes(name))) return parent
}
return null
}
/**
* The children that give an element its size: every child that is not absolutely positioned.
*
* @param element - The element.
* @returns Its in-flow children.
*/
function inFlowChildren(element: Element): Element[] {
return Array.from(element.children).filter((child) => !classes(child).includes('absolute'))
}
/**
* Renders the button in a given state.
*
* @param state - The state.
* @param state.busy - Whether a reply is being written.
* @param state.unreadCount - Unread replies.
* @param state.open - Whether the panel is open.
* @param state.enabled - Whether the assistant is available.
* @returns The button, or null when it renders nothing.
*/
function renderButton({ busy = false, unreadCount = 0, open = false, enabled = true }: { busy?: boolean; unreadCount?: number; open?: boolean; enabled?: boolean }): HTMLElement | null {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
client.setQueryData(assistantKeys.busy(), busy)
const controller: AssistantController = {
config: null,
enabled,
open,
setOpen: vi.fn(),
toggle: vi.fn(),
activeConversationId: null,
setActiveConversationId: vi.fn(),
unreadCount,
openWith: vi.fn(),
pendingRequest: null,
consumeRequest: vi.fn(),
triggerRef: React.createRef<HTMLButtonElement>(),
}
render(
<QueryClientProvider client={client}>
<AssistantProvider value={controller}>
<header><AssistantHeaderButton /></header>
</AssistantProvider>
</QueryClientProvider>
)
return screen.queryByRole('button', { name: /^Ask the assistant/ })
}
describe('AssistantHeaderButton', () => {
it('renders nothing when the assistant is unavailable', () => {
expect(renderButton({ enabled: false, busy: true, unreadCount: 2 })).toBeNull()
})
it('idle: the icon anchor holds only the icon, and the label sits beside it', () => {
const button = renderButton({})!
const anchor = button.querySelector(ANCHOR)!
expect(anchor.parentElement).toBe(button)
expect(Array.from(anchor.children).map((child) => child.tagName.toLowerCase())).toEqual(['svg'])
expect(button.querySelector(BUSY)).toBeNull()
expect(button.querySelector(UNREAD)).toBeNull()
const label = screen.getByText('Ask')
expect(label.parentElement).toBe(button)
expect(anchor.contains(label)).toBe(false)
expect(button).toHaveAttribute('aria-pressed', 'false')
})
const states = [
{ name: 'a reply being written', busy: true, unreadCount: 0 },
{ name: 'unread replies', busy: false, unreadCount: 3 },
{ name: 'a reply being written and unread replies', busy: true, unreadCount: 12 },
]
const cases = states.flatMap((state) => [{ ...state, open: false, panel: 'closed' }, { ...state, open: true, panel: 'open' }])
it.each(cases)('$name, panel $panel: every indicator is positioned against the icon, never the button', ({ busy, unreadCount, open }) => {
const button = renderButton({ busy, unreadCount, open })!
expect(button).toHaveAttribute('aria-pressed', String(open))
const anchor = button.querySelector(ANCHOR)!
const indicators = Array.from(button.querySelectorAll(`${BUSY}, ${UNREAD}`))
expect(indicators).toHaveLength((busy ? 1 : 0) + (unreadCount > 0 ? 1 : 0))
for (const indicator of indicators) {
expect(classes(indicator)).toContain('absolute')
// the button is positioned too (its focus ring and the Jobs ring need that), so only an
// indicator inside the anchor is measured from the icon
expect(containingBlock(indicator)).toBe(anchor)
expect(containingBlock(indicator)).not.toBe(button)
}
// the anchor is exactly the icon: nothing else in flow, so the label and the button's
// padding cannot move an indicator
expect(classes(anchor)).toContain('relative')
expect(inFlowChildren(anchor).map((child) => child.tagName.toLowerCase())).toEqual(['svg'])
expect(anchor.contains(screen.getByText('Ask'))).toBe(false)
})
it('places the dot on the icon\'s bottom-right corner and the badge over its top-right corner', () => {
const button = renderButton({ busy: true, unreadCount: 3 })!
const dot = screen.getByRole('status', { name: 'Assistant is answering' })
expect(dot).toBe(button.querySelector(BUSY))
expect(classes(dot)).toEqual(expect.arrayContaining(['-bottom-0.5', '-right-0.5', 'h-2', 'w-2', 'rounded-full']))
const badge = button.querySelector(UNREAD)!
expect(badge).toHaveTextContent('3')
expect(classes(badge)).toEqual(expect.arrayContaining(['-right-2', '-top-2', 'h-4', 'min-w-4']))
// the offsets of the button's own corner (the defect: past the label, under its baseline)
for (const indicator of [dot, badge]) {
for (const offset of ['bottom-0', 'right-0', 'right-0.5', 'top-0.5']) expect(classes(indicator)).not.toContain(offset)
}
})
it('caps the badge at 9+ and names the unread count', () => {
const button = renderButton({ unreadCount: 12 })!
expect(button.querySelector(UNREAD)).toHaveTextContent('9+')
expect(button).toHaveAccessibleName('Ask the assistant, 12 unread (Ctrl+J or ⌘J)')
})
it('shares one anchor and one badge geometry with the bell and Jobs', () => {
expect(HEADER_ICON_ANCHOR_CLASS.split(' ')).toEqual(expect.arrayContaining(['relative', 'shrink-0']))
expect(HEADER_BADGE_CLASS.split(' ')).toEqual(expect.arrayContaining(['absolute', '-right-2', '-top-2']))
expect(HEADER_STATUS_DOT_CLASS.split(' ')).toEqual(expect.arrayContaining(['absolute', '-bottom-0.5', '-right-0.5']))
// an 8 px offset from a 20 px icon stays inside the 10 px padding of the 40 px button
expect(HEADER_BUTTON_CLASS.split(' ')).toEqual(expect.arrayContaining(['h-10', 'min-w-10', 'px-2.5']))
})
})
@@ -25,13 +25,14 @@ import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { Sparkles } from 'lucide-react'
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HEADER_STATUS_DOT_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
import { cn } from '@/lib/utils/cn'
import { assistantKeys, useAssistant } from './assistant-context'
/**
* Toggles the assistant panel ("Ask"); the badge counts unread replies. The sparkles icon alone is
* ambiguous, so from 1440 px it carries the text label "Ask" (its tooltip says so below that).
* Toggles the assistant panel ("Ask"); the badge counts unread replies and the dot shows a reply
* being written. The sparkles icon alone is ambiguous, so from 1440 px it carries the text label
* "Ask" (its tooltip says so below that).
*
* @returns The button, or null when the assistant is unavailable.
*/
@@ -50,24 +51,23 @@ export function AssistantHeaderButton() {
event.currentTarget.focus({ preventScroll: true })
toggle()
}}
className={cn(HEADER_BUTTON_CLASS, 'gap-1.5')}
className={cn(HEADER_BUTTON_CLASS, 'gap-2')}
aria-label={label}
title="Ask the assistant (Ctrl+J or ⌘J)"
aria-pressed={open}
data-qqq-id="button-assistant"
>
<Sparkles className="h-5 w-5" aria-hidden="true" />
{/* the indicators sit on the icon: the button's corner is past the label from 1440 px */}
<HeaderIconAnchor>
<Sparkles className="h-5 w-5" aria-hidden="true" />
{busy && <span className={cn(HEADER_STATUS_DOT_CLASS, 'bg-primary motion-safe:animate-pulse')} role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
{unreadCount > 0 && (
<span className={cn(HEADER_BADGE_CLASS, 'bg-primary text-primary-foreground')} aria-hidden="true" data-qqq-id="assistant-unread-badge">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</HeaderIconAnchor>
<span className="hidden text-sm font-medium min-[1440px]:inline" aria-hidden="true">Ask</span>
{busy && <span className="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-primary motion-safe:animate-pulse" role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
{unreadCount > 0 && (
<span
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
aria-hidden="true"
data-qqq-id="assistant-unread-badge"
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
)
}
@@ -29,7 +29,7 @@ const optionSchema = z.object({ id: z.string(), label: z.string() })
const fieldSchema = z.object({
catalogWidget: z.string().optional(), name: z.string().min(1), label: z.string(), source: z.string().optional(),
selected: z.array(optionSchema).default([]), choices: z.array(optionSchema).optional(),
inherited: z.record(z.array(z.string())).optional(), policyPermissions: z.array(z.string()).optional(),
inherited: z.record(z.string(), z.array(z.string())).optional(), policyPermissions: z.array(z.string()).optional(),
})
const payloadSchema = z.object({ fields: z.array(fieldSchema) })
type Field = z.infer<typeof fieldSchema>
@@ -167,6 +167,7 @@ export function CollaborationEdit({
return (
<div className="space-y-3">
<PresenceAvatars viewers={presence.viewers} />
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
{conflict && (
<div
role="alert"
@@ -49,11 +49,9 @@ vi.mock('@/lib/hooks/use-metadata', () => ({
}))
vi.mock('@/lib/api/tables', () => ({
getRecord: vi.fn(),
queryRecords: vi
.fn()
.mockResolvedValue({
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
}),
queryRecords: vi.fn().mockResolvedValue({
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
}),
}))
vi.mock('@/lib/api/collaboration', () => ({
searchPeople: vi.fn(),
@@ -66,6 +64,7 @@ vi.mock('@/lib/api/collaboration', () => ({
createTask: vi.fn(),
patchTask: vi.fn(),
getMyWork: vi.fn(),
markMentionsRead: vi.fn().mockResolvedValue({ success: true }),
listMyTasks: vi.fn(),
}))
const record = { table: 'person', pk: '1' }
@@ -302,6 +301,16 @@ it('renders My work with overdue task, assigned table and unread mentions links'
)
expect(screen.getByText(/Overdue:/)).toBeVisible()
expect(screen.getByRole('link', { name: '3 unread mentions' })).toBeVisible()
vi.mocked(api.getMyWork).mockResolvedValue({
openTaskCount: 1,
tasks: [],
assignedRecordsByTable: { person: 1 },
unreadMentionCount: 0,
})
fireEvent.click(screen.getByRole('button', { name: 'Mark all mentions read' }))
await waitFor(() => expect(api.markMentionsRead).toHaveBeenCalledTimes(1))
expect(await screen.findByRole('link', { name: '0 unread mentions' })).toBeVisible()
expect(screen.queryByRole('button', { name: 'Mark all mentions read' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /People \(1\)|Person \(1\)/ })).toHaveAttribute(
'href',
expect.stringContaining('CurrentUser')
+18 -2
View File
@@ -17,11 +17,11 @@
/** @file Personal tasks, assigned record links and unread mentions. */
'use client'
import Link from 'next/link'
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
import { useCollaborationAvailable } from '@/lib/hooks/use-collaboration-core'
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
import { getMyWork, listMyTasks } from '@/lib/api/collaboration'
import { getMyWork, listMyTasks, markMentionsRead } from '@/lib/api/collaboration'
import { queryRecords } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { collaborationButton } from './CollaborationDialog'
@@ -78,6 +78,11 @@ function AssignedTable({ name, label, count }: { name: string; label: string; co
* @returns Work page.
*/
export function MyWork() {
const cache = useQueryClient()
const read = useMutation({
mutationFn: markMentionsRead,
onSuccess: () => cache.invalidateQueries({ queryKey: queryKeys.collaborationMyWork() }),
})
const available = useCollaborationAvailable()
const metadata = useMetaData()
const work = useQuery({
@@ -158,6 +163,17 @@ export function MyWork() {
</section>
<section className="rounded-xl border border-border bg-card p-4">
<h2 className="text-lg font-semibold">Unread mentions</h2>
{!!work.data.unreadMentionCount && (
<button
type="button"
className={collaborationButton}
disabled={read.isPending}
onClick={() => read.mutate()}
>
Mark all mentions read
</button>
)}
{read.isError && <p role="alert">Could not mark mentions read.</p>}
<Link
href="/app/notifications?status=unread"
className="text-sm underline"
@@ -24,6 +24,8 @@ import { useCollaborationPresence } from '@/lib/hooks/use-collaboration-presence
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
import { getRecordFeatures, putAssignment } from '@/lib/api/collaboration'
import { queryKeys } from '@/lib/query-client'
import { updateRecord } from '@/lib/api/tables'
import { recordVersion } from '@/lib/utils/record-version'
import { pluralize } from '@/lib/utils/string-utils'
import { PresenceAvatars } from './PresenceAvatars'
import { CollaborationDialog, collaborationButton } from './CollaborationDialog'
@@ -66,7 +68,23 @@ export function RecordCollaborationHeader({
enabled: available && !!features,
})
const assignment = useMutation({
mutationFn: () => putAssignment(target, { [role]: selected[0] ?? null }),
mutationFn: async () => {
const nativeField =
role === 'ownerUserId'
? features?.ownership?.ownerFieldName
: features?.ownership?.assigneeFieldName
if (nativeField) {
await updateRecord(
table.name,
pk,
{ [nativeField]: selected[0] ?? null },
undefined,
recordVersion(table, record)
)
} else {
await putAssignment(target, { [role]: selected[0] ?? null })
}
},
onSuccess: () => {
setAssign(false)
void cache.invalidateQueries({ queryKey: queryKeys.collaborationRecord(table.name, pk) })
@@ -89,6 +107,7 @@ export function RecordCollaborationHeader({
data-qqq-id="record-collaboration-header"
>
{presence.viewers.length > 0 && <PresenceAvatars viewers={presence.viewers} />}
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
{(owner != null || assignee != null) && (
<dl className="flex flex-wrap items-center gap-x-3 gap-y-1">
{owner != null && (
@@ -151,11 +170,11 @@ export function RecordCollaborationHeader({
<PeoplePicker record={target} selected={selected} onChange={setSelected} requireRead />
<button
className={collaborationButton}
disabled={assignment.isPending || !selected.length}
disabled={assignment.isPending}
onClick={() => assignment.mutate()}
data-qqq-id="assignment-save"
>
Assign
{selected.length ? 'Assign' : 'Clear assignment'}
</button>
{assignment.isError && <p role="alert">{assignment.error.message}</p>}
</CollaborationDialog>
@@ -176,7 +195,11 @@ export function TablePresence({ table, available }: { table: QTableMetaData; ava
return (
<div className="flex flex-wrap items-center gap-2 text-sm" data-qqq-id="table-presence">
<PresenceAvatars viewers={presence.viewers} />
<span>{pluralize(presence.viewers.length, 'person', 'people')} viewing</span>
<span role={presence.unavailable ? 'status' : undefined}>
{presence.unavailable
? 'Presence temporarily unavailable'
: `${pluralize(presence.viewers.length, 'person', 'people')} viewing`}
</span>
</div>
)
}
@@ -106,6 +106,12 @@ describe('JobsHeaderButton and JobsPopover', () => {
it('shows an active badge, both lists, keyboard trap and restores focus on Escape', async () => {
renderConnected(<JobsHeaderButton />)
const button = await screen.findByRole('button', { name: 'Jobs, 1 active' })
// the badge is positioned against the icon (the header's shared anchor), inside the button
const badge = button.querySelector('[data-qqq-id="jobs-active-badge"]')
expect(badge).toHaveTextContent('1')
expect(badge?.parentElement).toHaveAttribute('data-qqq-id', 'header-icon-anchor')
expect(badge?.parentElement).toHaveClass('relative')
expect(badge?.parentElement?.parentElement).toBe(button)
fireEvent.click(button)
const dialog = await screen.findByRole('dialog', { name: 'Jobs' })
expect(await screen.findByText('Import')).toBeInTheDocument()
+13 -11
View File
@@ -18,7 +18,7 @@
'use client'
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { BriefcaseBusiness } from 'lucide-react'
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
import { useJobsInstalled, useMyJobs } from '@/lib/hooks/use-my-jobs'
import './jobs.css'
@@ -76,16 +76,18 @@ export function JobsHeaderButton() {
className="qqq-job-ring absolute inset-1 rounded-full border border-primary"
/>
)}
<BriefcaseBusiness aria-hidden="true" className="h-5 w-5" />
{count > 0 && (
<span
aria-hidden="true"
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
data-qqq-id="jobs-active-badge"
>
{count > 99 ? '99+' : count}
</span>
)}
<HeaderIconAnchor>
<BriefcaseBusiness aria-hidden="true" className="h-5 w-5" />
{count > 0 && (
<span
aria-hidden="true"
className={`${HEADER_BADGE_CLASS} bg-primary text-primary-foreground`}
data-qqq-id="jobs-active-badge"
>
{count > 99 ? '99+' : count}
</span>
)}
</HeaderIconAnchor>
</button>
<span aria-live="polite" className="sr-only" data-qqq-id="jobs-announcer">
{announcement}
+28
View File
@@ -33,6 +33,34 @@ export const HEADER_BUTTON_CLASS = cn(
'hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)
/**
* The positioning context of a header button's indicators: a box exactly the size of its icon.
* The button's own box is the hit area (padding, and a text label from 1440 px on Ask) with no
* visible edge at rest, so an indicator placed against it drifts away from the icon.
*/
export const HEADER_ICON_ANCHOR_CLASS = 'relative flex shrink-0'
/**
* A count badge over the icon's top-right corner (unread, active). On a 40 px icon-only button this
* is 2 px inside the button's top-right corner. Add the fill and text colors.
*/
export const HEADER_BADGE_CLASS = 'absolute -right-2 -top-2 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none'
/** A status dot on the icon's bottom-right corner (activity). Add the fill color. */
export const HEADER_STATUS_DOT_CLASS = 'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full'
/**
* A header button's icon together with its indicators ({@link HEADER_BADGE_CLASS},
* {@link HEADER_STATUS_DOT_CLASS}), which stay on the icon whatever else the button holds.
*
* @param props - Component properties.
* @param props.children - The icon, then its indicators.
* @returns The anchor.
*/
export function HeaderIconAnchor({ children }: { children: React.ReactNode }) {
return <span className={HEADER_ICON_ANCHOR_CLASS} data-qqq-id="header-icon-anchor">{children}</span>
}
/** A menu row: full width, 44 px tall on touch. */
export const HEADER_MENU_ITEM_CLASS = cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-foreground pointer-coarse:min-h-11',
@@ -32,8 +32,15 @@ vi.mock('@/lib/hooks/use-container-width', () => ({
useContainerWidth: () => [undefined, layout.width],
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
const navigation = vi.hoisted(() => ({ leaveTo: vi.fn() }))
vi.mock('@/lib/utils/external-navigation', async (original) => ({
...(await original<typeof import('@/lib/utils/external-navigation')>()),
leaveTo: navigation.leaveTo,
}))
beforeEach(() => {
layout.width = 500
navigation.leaveTo.mockClear()
vi.mocked(toast.success).mockClear()
})
const tokens = preferencesFixture.sections.find((section) => section.name === 'access-tokens')!
function renderForm(section: PreferenceSection = structuredClone(tokens)) {
@@ -387,3 +394,36 @@ it('shows each returned secret and restores focus to the returned row after ackn
expect(screen.queryByDisplayValue('recovery-test-only')).not.toBeInTheDocument()
persist.mockRestore()
})
it('sends the browser to an address-only result and never shows the address', async () => {
const authorize =
'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&redirect_uri=https%3A%2F%2Fapp.example.test%2Fqqq%2Fhooks%2Fslack%2Foauth%2Fcallback&state=abc.def'
const section = structuredClone(tokens)
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
HttpResponse.json({ section, message: authorize })
)
)
renderForm(section)
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
await waitFor(() => expect(navigation.leaveTo).toHaveBeenCalledWith(authorize))
expect(toast.success).not.toHaveBeenCalled()
expect(screen.queryByText(/slack\.com/)).not.toBeInTheDocument()
})
it('still shows a result that only mentions an address as a message', async () => {
const section = structuredClone(tokens)
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
HttpResponse.json({ section, message: 'Connected. See https://example.test/help for more.' })
)
)
renderForm(section)
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith('Connected. See https://example.test/help for more.')
)
expect(navigation.leaveTo).not.toHaveBeenCalled()
})
@@ -32,6 +32,7 @@ import type {
} from '@/types/preferences'
import { runPreferenceAction } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
import { continuationUrl, leaveTo } from '@/lib/utils/external-navigation'
import { BUTTON_CLASS, buttonClass } from '@/components/ui/controls'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { PreferenceField } from './PreferenceField'
@@ -381,7 +382,11 @@ export function usePreferenceActions({
showingSecrets.current = Boolean(result.secrets?.length)
onSaved(result.section)
setSelected(null)
if (result.message) toast.success(result.message)
// An answer that is only an address (the consent page of a service being connected) is
// where the browser goes next. It is never shown: nobody can use an address in a toast.
const destination = continuationUrl(result.message)
if (destination) leaveTo(destination)
else if (result.message) toast.success(result.message)
setSecrets(result.secrets ?? [])
} catch (error) {
if (mounted.current) setErrors(preferenceMessages(error))
@@ -22,7 +22,7 @@ import { beforeEach, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import { ThemeProvider } from '@/lib/theme/theme-provider'
import { PreferencesPage } from './PreferencesPage'
import { PreferencesPage, sectionOutcome } from './PreferencesPage'
const state = vi.hoisted(() => ({
search: '',
@@ -107,3 +107,42 @@ it('offers retry on errors and keeps local display settings accessible', async (
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
})
it('says once that a flow that returned to a section succeeded, and takes the outcome off the address', async () => {
state.search = 'section=regional&result=connected'
renderPage()
await screen.findByRole('heading', { name: 'Regional settings' })
const outcome = screen.getByText('Regional settings: connected.')
expect(outcome).toHaveAttribute('role', 'status')
expect(outcome).toHaveAttribute('data-qqq-id', 'preferences-section-outcome')
expect(state.replace).toHaveBeenCalledWith('/app/preferences?section=regional', { scroll: false })
await userEvent.setup().click(screen.getByRole('button', { name: /Notifications What reaches/ }))
expect(screen.queryByText('Regional settings: connected.')).not.toBeInTheDocument()
})
it('names the reason a flow that returned to a section failed', async () => {
state.search = 'section=regional&error=authorization_denied'
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent(
'Regional settings: authorization denied.'
)
})
it('reads only fixed outcome codes from the address, never a sentence', () => {
const read = (search: string) => sectionOutcome(new URLSearchParams(search))
expect(read('section=slack&result=connected')).toEqual({
section: 'slack',
failed: false,
words: 'connected',
})
expect(read('section=slack&error=sign_in_required&result=connected')).toEqual({
section: 'slack',
failed: true,
words: 'sign in required',
})
expect(read('section=slack')).toBeNull()
expect(read('result=connected')).toBeNull()
expect(read('section=slack&error=Call+555-0100+now')).toBeNull()
expect(read('section=slack&error=%3Cb%3Ex%3C%2Fb%3E')).toBeNull()
expect(read(`section=slack&error=${'a_'.repeat(20)}a`)).toBeNull()
})
+50 -2
View File
@@ -17,13 +17,14 @@
/** @file Preferences route content, with device display settings and server-owned sections. */
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { getPreferences } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
import { useQContext } from '@/lib/context/q-context'
import { useMediaQuery } from '@/lib/hooks/use-media-query'
import { cn } from '@/lib/utils/cn'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { DisplayPreferencesPanel } from '@/components/layout/DisplayPreferencesPanel'
@@ -43,6 +44,30 @@ export function PreferencesSkeleton() {
)
}
/** What a flow that left the dashboard reports when it returns to a section. */
export interface SectionOutcome {
section: string
failed: boolean
/** The provider's outcome code as words: `authorization_denied` reads "authorization denied". */
words: string
}
/** Reads the outcome a flow that left the dashboard (a consent page of a connected service)
* puts on the address when it returns: `?section=<name>&result=<code>` when it succeeded,
* `&error=<code>` when it did not. The code is one of the provider's fixed lowercase names
* (`connected`, `authorization_denied`). It is shown as words after the section's own label;
* anything else on the address is ignored, so a link cannot put its own sentence on the page.
* @param params - The page's query string.
* @returns The outcome, or `null` when the address reports none.
*/
export function sectionOutcome(params: URLSearchParams): SectionOutcome | null {
const section = params.get('section')
const error = params.get('error')
const code = error ?? params.get('result')
if (!section || !code || !/^[a-z]+(?:_[a-z]+){0,7}$/.test(code)) return null
return { section, failed: error !== null, words: code.replace(/_/g, ' ') }
}
/** Every registered section and a client-only Display section, selected through the URL.
* @returns Preferences navigation and selected section.
*/
@@ -59,6 +84,13 @@ export function PreferencesPage() {
})
// A client-only key cannot collide with the backend's [a-z] section names.
const requested = params.get('section')
const [outcome, setOutcome] = useState(() => sectionOutcome(params))
const reported = params.has('result') || params.has('error')
// An outcome is reported once: off the address it goes, so a reload does not repeat it.
useEffect(() => {
if (reported && requested)
router.replace(`/app/preferences?section=${encodeURIComponent(requested)}`, { scroll: false })
}, [reported, requested, router])
const selected =
requested === 'display' && !query.data?.sections.some((section) => section.name === 'display')
? '__display'
@@ -73,13 +105,15 @@ export function PreferencesPage() {
...(query.data?.sections ?? []),
]
const section = query.data?.sections.find((section) => section.name === selected)
const navigate = (name: string) =>
const navigate = (name: string) => {
setOutcome(null)
router.replace(
name === '__display'
? '/app/preferences'
: `/app/preferences?section=${encodeURIComponent(name)}`,
{ scroll: false }
)
}
return (
<div className="mx-auto w-full max-w-7xl space-y-6" data-qqq-id="preferences-page">
<h1 className="text-2xl font-semibold text-foreground">Preferences</h1>
@@ -159,6 +193,20 @@ export function PreferencesPage() {
<PreferencesSkeleton />
) : section ? (
<>
{outcome?.section === section.name && (
<p
role={outcome.failed ? 'alert' : 'status'}
className={cn(
'rounded-md border p-3 text-sm',
outcome.failed
? 'border-destructive text-destructive'
: 'border-border bg-muted text-foreground'
)}
data-qqq-id="preferences-section-outcome"
>
{`${section.label}: ${outcome.words}.`}
</p>
)}
<PreferenceSectionForm key={section.name} section={section} />
</>
) : (
+2 -1
View File
@@ -56,9 +56,10 @@ export function HtmlComponent({ index }: HtmlComponentProps) {
const raw = values[`${step.name}.html`]
const html = useMemo(() => (typeof raw === 'string' && raw ? sanitizeProcessHtml(raw) : ''), [raw])
if (!html) return null
// links read as links: without it they inherit the text color and have no underline
return (
<div
className="prose prose-sm max-w-none text-foreground"
className="prose prose-sm max-w-none text-foreground [&_a]:text-link [&_a]:underline"
data-qqq-id={`process-html-${index}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
@@ -205,6 +205,24 @@ describe('ProcessSummaryLines', () => {
})
})
describe('HTML component', () => {
it('shows a link the backend put on the last screen as a link that opens in this tab', () => {
const address = 'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&state=abc.def'
const step: QFrontendStepMetaData = { name: 'result', label: 'Continue to Slack', components: [{ type: 'HTML' }] }
renderStep(step, {
noMoreSteps: true,
'result.html': `<p>Slack will ask you to approve the connection.</p><p><a href="${address.replace(/&/g, '&amp;')}" style="display:inline-block;padding:8px 16px;background:#4A154B;color:#ffffff">Continue to Slack</a></p><p>This link expires in 10 minutes.</p>`,
})
const link = screen.getByRole('link', { name: 'Continue to Slack' })
expect(link).toHaveAttribute('href', address)
expect(link).not.toHaveAttribute('target')
expect(link.getAttribute('style')).toContain('background')
expect(link.closest('[data-qqq-id="process-html-0"]')).toHaveClass('[&_a]:text-link', '[&_a]:underline')
expect(screen.getByText('This link expires in 10 minutes.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Return' })).toBeInTheDocument()
})
})
describe('process helpers', () => {
it('sanitizes HTML but keeps CSV and text template downloads', () => {
const html = sanitizeProcessHtml('<a href="data:text/csv;base64,QQ==" download="t.csv">t</a><a href="data:text/html,<b>x</b>">h</a><img src="x" onerror="alert(1)"><script>alert(1)</script>')
@@ -0,0 +1,180 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// The sort a record list opens with: the one its table declares
// (`supplementalMetaData.nextUi.defaultSort`), unless the person has a sort of their own (in the
// address, in a saved view, or remembered by the browser), and primary key descending otherwise.
import { act, render, renderHook, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { QFilterOrderBy, QTableMetaData } from '@/types'
import type { QueryRecordsRequest } from '@/lib/api/tables'
import { QContextProvider } from '@/lib/context/q-context'
import { useRecordQuery } from '@/lib/hooks/use-record-query'
import { emptyFilter, serializeFilter } from '@/lib/utils/filter-utils'
import { defaultColumnVisibility } from '@/lib/utils/query-columns'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import { RecordQuery } from './RecordQuery'
let search = ''
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
usePathname: () => '/app/person',
useSearchParams: () => new URLSearchParams(search),
}))
const NEWEST_FIRST: QFilterOrderBy[] = [{ fieldName: 'modifyDate', isAscending: false }]
const PRIMARY_KEY_DESCENDING: QFilterOrderBy[] = [{ fieldName: 'id', isAscending: false }]
/**
* The people table, declaring a default sort unless told otherwise.
*
* @param defaultSort - The declared sort; `null` for a table that declares none.
* @returns Table metadata and the table registry.
*/
function tables(defaultSort: unknown = NEWEST_FIRST) {
const allTables = structuredClone(qInstance.tables)
if (defaultSort !== null) allTables.person.supplementalMetaData = { nextUi: { defaultSort } }
return { tableName: 'person', tableMetaData: allTables.person as QTableMetaData, allTables }
}
function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={client}><QContextProvider>{children}</QContextProvider></QueryClientProvider>
}
/**
* Records what the list asks the server for.
*
* @returns The sort of the latest query request.
*/
function captureSort() {
const sorts: (QFilterOrderBy[] | undefined)[] = []
server.use(http.post('/qqq/v1/table/person/:action', async ({ request, params }) => {
const body = await request.json() as QueryRecordsRequest
if (params.action === 'query') sorts.push(body.filter.orderBys)
return HttpResponse.json(params.action === 'query' ? { records: [{ tableName: 'person', recordLabel: 'Alice', values: { id: 1, firstName: 'Alice' } }] } : { count: 1 })
}))
return () => sorts.at(-1)
}
/** The sort the address carries, if any. */
const addressSort = () => {
const encoded = new URLSearchParams(window.location.search).get('filter')
return encoded ? (JSON.parse(atob(encoded)) as { orderBys?: QFilterOrderBy[] }).orderBys : undefined
}
describe('record list default sort', () => {
beforeEach(() => {
search = ''
localStorage.clear()
window.history.replaceState(null, '', '/app/person')
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
})
afterEach(() => vi.unstubAllGlobals())
it('opens with the sort the table declares, without putting it in the address', async () => {
const lastSort = captureSort()
render(<RecordQuery {...tables()} />, { wrapper })
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
expect(lastSort()).toEqual(NEWEST_FIRST)
await userEvent.setup().click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
expect(screen.getByRole('combobox', { name: 'Sort field' })).toHaveValue('modifyDate')
// a default is not a choice: nothing to share in the address, nothing to save or reset
expect(addressSort()).toBeUndefined()
expect(screen.queryByRole('button', { name: 'Reset All Changes' })).not.toBeInTheDocument()
})
it('opens by primary key descending when the table declares nothing, or nothing usable', async () => {
for (const declared of [null, [], [{ fieldName: 'noSuchField', isAscending: false }], 'modifyDate']) {
const lastSort = captureSort()
const view = render(<RecordQuery {...tables(declared)} />, { wrapper })
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
expect(lastSort()).toEqual(PRIMARY_KEY_DESCENDING)
expect(addressSort()).toBeUndefined()
view.unmount()
localStorage.clear()
}
})
it('a sort in the address wins over the declared one', async () => {
const mine = [{ fieldName: 'lastName', isAscending: true }]
search = new URLSearchParams({ filter: serializeFilter({ criteria: [], booleanOperator: 'AND', orderBys: mine }) }).toString()
const lastSort = captureSort()
render(<RecordQuery {...tables()} />, { wrapper })
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
expect(lastSort()).toEqual(mine)
})
it("a person's own sort is kept, remembered for the next visit, and Reset returns to the declared one", async () => {
const user = userEvent.setup()
const lastSort = captureSort()
const first = render(<RecordQuery {...tables()} />, { wrapper })
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
const mode = within(screen.getByRole('group', { name: 'Filter mode' }))
await user.selectOptions(mode.getByRole('combobox', { name: 'Sort field' }), 'lastName')
await waitFor(() => expect(lastSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
// now it is a choice: it is in the address, and it can be reset
await waitFor(() => expect(addressSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
expect(screen.getByRole('button', { name: 'Reset All Changes' })).toBeInTheDocument()
// the next visit (no sort in the address) opens with the remembered sort, not the declared one
first.unmount()
window.history.replaceState(null, '', '/app/person')
render(<RecordQuery {...tables()} />, { wrapper })
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
await waitFor(() => expect(lastSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
await user.click(await screen.findByRole('button', { name: 'Reset All Changes' }))
await waitFor(() => expect(lastSort()).toEqual(NEWEST_FIRST))
await waitFor(() => expect(addressSort()).toBeUndefined())
expect(screen.queryByRole('button', { name: 'Reset All Changes' })).not.toBeInTheDocument()
})
it("a saved view's sort wins, when the view is opened and when it is applied later", () => {
const { tableMetaData, allTables } = tables()
const viewSort = [{ fieldName: 'firstName', isAscending: true }]
const view = {
userFilter: emptyFilter(50), sortOrder: viewSort, columnVisibility: defaultColumnVisibility(tableMetaData),
columnOrder: [], columnWidths: {}, pageSize: 50, filterMode: 'basic' as const,
}
const opened = renderHook(() => useRecordQuery({ tableName: 'person', tableMetaData, allTables, initialView: view, paused: true }), { wrapper })
expect(opened.result.current.filter.sortOrder).toEqual(viewSort)
expect(opened.result.current.filter.defaultSort).toEqual(NEWEST_FIRST)
const applied = renderHook(() => useRecordQuery({ tableName: 'person', tableMetaData, allTables, paused: true }), { wrapper })
expect(applied.result.current.filter.sortOrder).toEqual(NEWEST_FIRST)
act(() => applied.result.current.applyView(view))
expect(applied.result.current.filter.sortOrder).toEqual(viewSort)
// clearing a sort returns to the declared default, not to the primary key
act(() => applied.result.current.filter.setSort([]))
expect(applied.result.current.filter.sortOrder).toEqual(NEWEST_FIRST)
})
it('keeps the same default sort when metadata is loaded again with the same declaration', () => {
const { tableMetaData, allTables } = tables()
const hook = renderHook(({ table }) => useRecordQuery({ tableName: 'person', tableMetaData: table, allTables, paused: true }), { wrapper, initialProps: { table: tableMetaData } })
const before = hook.result.current.filter.defaultSort
hook.rerender({ table: structuredClone(tableMetaData) })
expect(hook.result.current.filter.defaultSort).toBe(before)
})
})
+14 -1
View File
@@ -242,6 +242,19 @@ interface DynamicComponentProps {
qfmdBridge: QfmdBridge
}
/**
* Material's `QWidgetMetaData.defaultValues` is a `Map`, and bundles such as the QSequentialWorkflow editor read it with
* `defaultValues.get(name)`. Hand them a Map that also keeps plain property access, so existing readers keep working.
*
* @param values - The widget's default values as delivered by the API.
* @returns A Map of the same entries that is also readable as an object.
*/
function materialDefaultValues(values: Record<string, unknown> | undefined): Map<string, unknown> & Record<string, unknown> {
const map = new Map<string, unknown>(Object.entries(values ?? {}))
for (const [key, value] of map) if (!(key in map)) Object.defineProperty(map, key, { value, enumerable: true })
return map as Map<string, unknown> & Record<string, unknown>
}
/**
* Renders a QQQ `customComponent`: loads the bundle at the widget's
* `componentSourceUrl` default value and renders `window[componentName][componentName]`
@@ -284,7 +297,7 @@ export function QqqCustomComponentWidget({ widgetMetaData, data, recordContext }
const sx = widgetMetaData.defaultValues?.sx
return (
<div data-qqq-id={`custom-component-${widgetMetaData.name}`} style={isPlainObject(sx) ? (sx as React.CSSProperties) : undefined}>
<Component props={{ widgetMetaData, widgetData: data, record: recordContext?.record }} qContext={qContext ?? {}} qfmdBridge={qfmdBridge} />
<Component props={{ widgetMetaData: { ...widgetMetaData, defaultValues: materialDefaultValues(widgetMetaData.defaultValues) }, widgetData: data, record: recordContext?.record }} qContext={qContext ?? {}} qfmdBridge={qfmdBridge} />
</div>
)
}
@@ -18,7 +18,7 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
vi.mock('@/lib/api/tables', () => ({ getRecord: vi.fn(() => new Promise(() => {})), queryRecords: vi.fn(() => new Promise(() => {})) }))
@@ -87,6 +87,8 @@ describe('WidgetRenderer', () => {
<WidgetRenderer widgetMetaData={{ name: 'untyped', label: 'Untyped', hasPermission: true }} data={{ type: 'chart', title: 'Persons', chartData: { labels: ['Jan'], datasets: [{ label: 'People', data: [17] }] } }} />
</QueryClientProvider>
)
// Await the real lazy chunk before checking content under a busy full-suite worker pool.
await act(async () => { await vi.dynamicImportSettled() })
expect(await screen.findByText('Persons', { selector: '[data-qqq-id="chart-title-untyped"]' })).toBeInTheDocument()
})
@@ -148,4 +148,19 @@ describe('customComponent widget', () => {
fireEvent.click(screen.getByRole('button', { name: 'Recolor' }))
expect(screen.getByTestId('accent')).toHaveTextContent('#123456')
})
it('hands the component Material-style defaultValues: a Map with get(), still readable as plain properties', async () => {
const meta: QWidgetMetaData = { name: 'accDefaults', label: 'Defaults', type: 'customComponent', hasPermission: true,
defaultValues: { componentName: 'OwnedDefaultsComponent', componentSourceUrl: 'http://127.0.0.1:9/owned-defaults.js', readonlyState: 'initially' } }
render(<QContextProvider><QqqCustomComponentWidget widgetMetaData={meta} data={{}} /></QContextProvider>)
const script = document.head.querySelector('script[src="http://127.0.0.1:9/owned-defaults.js"]') as HTMLScriptElement
type DefaultsProps = { props: { widgetMetaData: { defaultValues: Map<string, unknown> & Record<string, unknown> } } }
;(window as unknown as Record<string, unknown>).OwnedDefaultsComponent = {
OwnedDefaultsComponent: ({ props }: DefaultsProps) => {
const values = props.widgetMetaData.defaultValues
return <span data-testid="defaults">{String(values.get('readonlyState'))}/{String(values.readonlyState)}/{String(values.get('missing') ?? 'none')}</span>
},
}
await act(async () => { script.dispatchEvent(new Event('load')) })
expect(await screen.findByTestId('defaults')).toHaveTextContent('initially/initially/none')
})
})
+5
View File
@@ -38,6 +38,8 @@ import { DynamicForm } from '@/components/forms/DynamicForm'
import type { BlockActionCallback } from './widget-types'
import { isPlainObject } from './widget-types'
import { widgetField } from './widget-field-values'
import { runProcessToCompletion } from '@/lib/api/process-run'
import type { ProcessRunResult } from '@/lib/api/process-run'
import { WidgetIcon } from './WidgetIcon'
import { ConnectedWidget } from './ConnectedWidget'
@@ -63,6 +65,8 @@ export interface QfmdBridge {
makeWidget: (widgetName: string, tableName?: string, entityPrimaryKey?: string, record?: BridgeRecord, actionCallback?: BlockActionCallback) => React.ReactElement
/** A Material icon by name. */
makeIcon: (name: string) => React.ReactElement
/** Material's `QController` subset: `processRun` runs a screen-less process to completion (the headers and async flag are accepted and ignored). */
qController: { processRun: (processName: string, input?: unknown, formDataHeaders?: unknown, dontGoAsyncOnBackend?: boolean) => Promise<ProcessRunResult> }
}
/**
@@ -304,6 +308,7 @@ function BridgeWidget({ widgetName, tableName, entityPrimaryKey, record, actionC
/** The bridge handed to every customComponent widget's component. */
export const qfmdBridge: QfmdBridge = {
qController: { processRun: (processName, input) => runProcessToCompletion(processName, input) },
makeAlert: (text, color, mayManuallyClose) => <BridgeAlert text={text} color={color} mayManuallyClose={mayManuallyClose} />,
makeButton: (label, onClick, extra) => <BridgeButton label={label} onClick={onClick} extra={extra} />,
makeForm: (fields, record, handleChange, handleSubmit, helpRoles) => (
+6 -5
View File
@@ -49,7 +49,7 @@ const nullableText = z.preprocess((value) => (typeof value === 'number' || typeo
const idValue = z.union([z.string(), z.number()]).catch('')
const optionalText = z.string().optional().catch(undefined)
const optionalNumber = z.number().finite().optional().catch(undefined)
const record = z.record(z.unknown()).catch({})
const record = z.record(z.string(), z.unknown()).catch({})
/**
* A list whose items are parsed one by one: a non-list becomes `[]` and an item that is not an
@@ -66,13 +66,14 @@ function list<T extends z.ZodTypeAny>(item: T) {
const parsed = item.safeParse(entry)
return parsed.success ? [parsed.data] : []
})
})
// zod 4 treats an absent key of a bare unknown().transform() as required; catch repairs it to [] as before
}).catch([] as Array<z.output<T>>)
}
// ─── Renders ──────────────────────────────────────────────────────────────────
const recordRef = z.object({ tableName: text, tableLabel: text, id: idValue, label: text, href: text })
const cellValues = z.record(nullableText).catch({})
const cellValues = z.record(z.string(), nullableText).catch({})
const RENDER_SCHEMAS = {
records: z.object({
@@ -126,7 +127,7 @@ const BLOCK_SCHEMAS: Record<string, z.ZodTypeAny> = {
table: z.object({
kind: z.literal('table'), title: optionalText,
columns: list(z.object({ key: text, label: text, align: z.enum(['left', 'center', 'right']).optional().catch(undefined) })),
rows: list(z.object({ cells: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()]).catch(null)).catch({}), display: z.record(z.string()).optional().catch(undefined), href: optionalText })),
rows: list(z.object({ cells: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]).catch(null)).catch({}), display: z.record(z.string(), z.string()).optional().catch(undefined), href: optionalText })),
}),
chart: z.object({
kind: z.literal('chart'), chartType: z.string().catch(''), title: optionalText,
@@ -160,7 +161,7 @@ export function normalizePresentationPart(raw: Record<string, unknown>): Assista
const schema = BLOCK_SCHEMAS[kind]
if (!schema) return [block as { kind: string }]
const parsed = schema.safeParse(block)
return parsed.success ? [parsed.data] : []
return parsed.success ? [parsed.data as { kind: string }] : []
}),
}
}
+4 -4
View File
@@ -48,11 +48,11 @@ export interface FormAdjusterOutput {
/** Unknown keys (and a null) are tolerated; each known key is checked where the form uses it. */
const FormAdjusterOutputSchema = z.object({
updatedFieldMetaData: z.record(z.object({ name: z.string().optional() }).passthrough()).nullish(),
updatedFieldValues: z.record(z.unknown()).nullish(),
updatedFieldDisplayValues: z.record(z.string().nullable()).nullish(),
updatedFieldMetaData: z.record(z.string(), z.object({ name: z.string().optional() }).passthrough()).nullish(),
updatedFieldValues: z.record(z.string(), z.unknown()).nullish(),
updatedFieldDisplayValues: z.record(z.string(), z.string().nullable()).nullish(),
fieldsToClear: z.array(z.string()).nullish(),
updatedSectionMetaData: z.record(z.object({ name: z.string().optional() }).passthrough()).nullish(),
updatedSectionMetaData: z.record(z.string(), z.object({ name: z.string().optional() }).passthrough()).nullish(),
isFormDisabled: z.boolean().nullish(),
formDisabledMessage: z.string().nullish(),
}).passthrough()
+2 -2
View File
@@ -102,8 +102,8 @@ const possibleValuesResponse = z.union([
options: z.array(z.object({
id: z.union([z.string(), z.number().finite()]),
label: z.string(),
values: z.record(z.unknown()).optional(),
displayValues: z.record(z.string()).optional(),
values: z.record(z.string(), z.unknown()).optional(),
displayValues: z.record(z.string(), z.string()).optional(),
}).passthrough()),
hasMore: z.boolean().optional(),
}),
+57
View File
@@ -0,0 +1,57 @@
/*
* 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 Screen-less process runs for dynamic components (Material's QController.processRun contract). */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ init: vi.fn(), status: vi.fn() }))
vi.mock('./processes', () => ({ processInit: mocks.init, processStatus: mocks.status }))
import { processRunValues, runProcessToCompletion } from './process-run'
beforeEach(() => vi.clearAllMocks())
describe('processRunValues', () => {
it('reads a query string, FormData and a plain object', () => {
const form = new FormData(); form.append('workflowId', '7'); form.append('file', new File(['x'], 'x.txt'))
expect(processRunValues('workflowId=1&name=a%20b')).toEqual({ workflowId: '1', name: 'a b' })
expect(processRunValues(form)).toEqual({ workflowId: '7' })
expect(processRunValues({ a: 1 })).toEqual({ a: 1 })
expect(processRunValues(undefined)).toEqual({})
})
})
describe('runProcessToCompletion', () => {
it('returns the values of a process that completes on init', async () => {
mocks.init.mockResolvedValue({ type: 'COMPLETE', processUUID: 'p', values: { workflow: '{}' } })
expect(await runProcessToCompletion('getWorkflowRevision', 'workflowId=1')).toEqual({ values: { workflow: '{}' } })
expect(mocks.init).toHaveBeenCalledWith('getWorkflowRevision', expect.objectContaining({ values: { workflowId: '1' } }))
})
it('polls an async job until it completes', async () => {
mocks.init.mockResolvedValue({ type: 'JOB_STARTED', processUUID: 'p', jobUUID: 'j' })
mocks.status.mockResolvedValueOnce({ type: 'RUNNING', processUUID: 'p' }).mockResolvedValueOnce({ type: 'COMPLETE', processUUID: 'p', values: { ok: true } })
expect(await runProcessToCompletion('x', {}, { pollIntervalMillis: 1 })).toEqual({ values: { ok: true } })
expect(mocks.status).toHaveBeenCalledTimes(2)
})
it('reports a refusal with the user-facing text, and a timeout, without throwing', async () => {
mocks.init.mockResolvedValue({ type: 'ERROR', error: 'denied', userFacingError: 'You may not do that', status: 403 })
expect(await runProcessToCompletion('x', {})).toEqual({ error: 'You may not do that', userFacingError: 'You may not do that' })
mocks.init.mockResolvedValue({ type: 'JOB_STARTED', processUUID: 'p', jobUUID: 'j' })
mocks.status.mockResolvedValue({ type: 'RUNNING', processUUID: 'p' })
expect((await runProcessToCompletion('x', {}, { pollIntervalMillis: 1, timeoutMillis: 5 })).error).toMatch(/Timed out/)
})
})
+82
View File
@@ -0,0 +1,82 @@
/*
* 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 Run a screen-less process to completion through the v1 init/status routes, with the
* result shape Material's `QController.processRun` gives dynamic components: `{ values }` when
* complete and `{ error, userFacingError }` when refused or failed.
*/
import { processInit, processStatus } from './processes'
/** What `processRun` resolves to (a subset of Material's QJobComplete / QJobError). */
export interface ProcessRunResult {
values?: Record<string, unknown>
error?: string
userFacingError?: string
}
/** Polling bounds for a process that continues as an async job. */
export interface ProcessRunOptions {
pollIntervalMillis?: number
timeoutMillis?: number
}
/**
* Reads process input given as a `a=b&c=d` query string, a FormData or a plain object.
*
* @param input - The input in any of those forms.
* @returns Text values keyed by name (uploaded files are not part of a screen-less run).
*/
export function processRunValues(input: unknown): Record<string, unknown> {
const values: Record<string, unknown> = {}
if (typeof input === 'string') {
for (const [key, value] of new URLSearchParams(input)) values[key] = value
} else if (typeof FormData !== 'undefined' && input instanceof FormData) {
for (const [key, value] of input.entries()) if (typeof value === 'string') values[key] = value
} else if (input && typeof input === 'object') {
Object.assign(values, input)
}
return values
}
/**
* Runs the process to completion: init, then poll the async job when the backend continues it as one.
*
* @param processName - Backend process name.
* @param input - Process input (query string, FormData or object).
* @param options - Polling bounds.
* @returns The completed values, or the refusal/failure text; never throws.
*/
export async function runProcessToCompletion(processName: string, input: unknown, options: ProcessRunOptions = {}): Promise<ProcessRunResult> {
const { pollIntervalMillis = 500, timeoutMillis = 120_000 } = options
const deadline = Date.now() + timeoutMillis
let response = await processInit(processName, { values: processRunValues(input), stepTimeoutMillis: 30_000 })
let jobUUID: string | undefined
for (;;) {
if (response.type === 'COMPLETE') return { values: response.values }
if (response.type === 'ERROR') return { error: response.userFacingError ?? response.error ?? response.message ?? 'Process failed', userFacingError: response.userFacingError }
if (response.type === 'JOB_STARTED') jobUUID = response.jobUUID
if (!jobUUID || !response.processUUID) return { error: 'Unexpected server response.' }
if (Date.now() >= deadline) return { error: 'Timed out waiting for the process to finish.' }
await new Promise((resolve) => setTimeout(resolve, pollIntervalMillis))
try {
response = await processStatus(processName, response.processUUID, jobUUID)
} catch {
return { error: 'The server could not be reached while the process was running.' }
}
}
}
+7 -7
View File
@@ -47,17 +47,17 @@ import { z } from 'zod'
const StatusMessageSchema = z.union([z.string(), z.object({ message: z.string() })])
.transform((status) => typeof status === 'string' ? status : status.message)
export const QRecordSchema: z.ZodType<import('@/types').QRecord, z.ZodTypeDef, unknown> = z.object({
export const QRecordSchema: z.ZodType<import('@/types').QRecord> = z.object({
/** Name of the table this record belongs to. */
tableName: z.string(),
/** Human-readable label computed by the backend. */
recordLabel: z.string().optional(),
/** Raw field values keyed by field name. */
values: z.record(z.unknown()),
values: z.record(z.string(), z.unknown()),
/** Pre-formatted display strings keyed by field name. */
displayValues: z.record(z.string()).optional(),
displayValues: z.record(z.string(), z.string()).optional(),
/** Optional map of relationship name → associated records for joined or child data. */
associatedRecords: z.record(z.array(z.lazy(() => QRecordSchema))).optional(),
associatedRecords: z.record(z.string(), z.array(z.lazy(() => QRecordSchema))).optional(),
/** Validation or server-side errors associated with this record. */
errors: z.array(StatusMessageSchema).optional(),
/** Non-fatal warnings associated with this record. */
@@ -106,13 +106,13 @@ export const CountRecordsResponseSchema = z.object({
*/
export const QInstanceMinimalSchema = z.object({
/** All registered apps (sidebar navigation source). */
apps: z.record(z.unknown()),
apps: z.record(z.string(), z.unknown()),
/** Navigation tree nodes. */
appTree: z.array(z.unknown()),
/** All registered tables (drives every record page). */
tables: z.record(z.unknown()),
tables: z.record(z.string(), z.unknown()),
/** All registered processes. */
processes: z.record(z.unknown()),
processes: z.record(z.string(), z.unknown()),
/** Branding configuration; every field is optional in QQQ branding, and branding itself may be absent. */
branding: z.object({
companyName: z.string().optional(),
+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
*/
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
vi.mock('@/lib/api/ui-api-version', () => ({ checkUiApiCompatibility: vi.fn(async () => ({ compatible: true, info: { apiVersion: '1.1.0', features: [], extensions: [], preSemver: false } })) }))
vi.mock('@/lib/api/auth', () => ({
getAuthenticationMetaData: vi.fn(async () => ({ name: 'auth0', type: 'AUTH_0' })),
manageSession: vi.fn(), resumeSession: vi.fn(), logout: vi.fn(), clearAuthMetadataCache: vi.fn(),
}))
import { manageSession, resumeSession } from '@/lib/api/auth'
import { AuthProvider } from './auth-provider'
import { useAuth } from './use-auth'
import { getStoredUser, setSessionHint, storeUser } from './auth-storage'
import * as oidc from './oidc'
let auth: ReturnType<typeof useAuth> | null = null
/** Exposes the real provider state for callback and reload assertions. */
function Probe() {
auth = useAuth()
if (auth.isLoading) return <p>loading</p>
return <p>{auth.isAuthenticated ? `${auth.user?.name} <${auth.user?.email}>` : 'signed out'}</p>
}
describe('Auth0 identity with a server-issued user ID', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.mocked(manageSession).mockReset()
vi.mocked(resumeSession).mockReset()
auth = null
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
sessionStorage.clear()
})
it('keeps the provider display identity and server ID after sign-in', async () => {
const payload = btoa(JSON.stringify({ name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' }))
vi.spyOn(oidc, 'exchangeAuth0Code').mockResolvedValue({ access_token: 'test-access', id_token: `h.${payload}.s` })
vi.mocked(manageSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|dana' } } })
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('signed out')
sessionStorage.setItem(oidc.PKCE_STORAGE.state, 'expected-state')
await act(async () => { await auth?.handleOAuthCallback('code', 'expected-state', 'verifier') })
expect(screen.getByText('Dana Owner (OIDC) <dana@qrun.example>')).toBeInTheDocument()
expect(getStoredUser()).toEqual({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
})
it('retains the display identity on reload when the server supplies the same ID', async () => {
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
setSessionHint(true)
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|dana' } } })
render(<AuthProvider><Probe /></AuthProvider>)
expect(await screen.findByText('Dana Owner (OIDC) <dana@qrun.example>')).toBeInTheDocument()
})
it('prefers server display fields and does not reuse a different user profile', async () => {
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
setSessionHint(true)
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|eli', name: 'Eli' } } })
render(<AuthProvider><Probe /></AuthProvider>)
expect(await screen.findByText('Eli <undefined>')).toBeInTheDocument()
expect(auth?.user).toEqual({ id: 'oidc|eli', name: 'Eli', email: undefined })
})
it('does not reuse a cached profile when an ID-only session changes user', async () => {
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
setSessionHint(true)
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|eli' } } })
render(<AuthProvider><Probe /></AuthProvider>)
await screen.findByText('undefined <undefined>')
expect(auth?.user?.id).toBe('oidc|eli')
})
})
+8 -2
View File
@@ -185,11 +185,17 @@ function signInErrorMessage(error: unknown): string {
* The identity to display for a session.
*
* @param response - The manageSession response.
* @param fallback - Identity to use when the session carries none.
* @param fallback - Display identity when the session carries no display fields.
* @returns The user.
*/
function sessionUser(response: SessionResponse, fallback: AuthUser | null): AuthUser {
return userFromSessionValues(response.values) ?? fallback ?? {}
const session = userFromSessionValues(response.values)
if (!session) return fallback ?? {}
if (session.name || session.email) return session
// An ID-only backend identity must retain its authoritative ID without dropping
// the provider's display profile. Never reuse a profile for a different user ID.
if (session.id && fallback?.id && session.id !== fallback.id) return session
return { ...session, name: fallback?.name, email: fallback?.email }
}
/**
+45 -12
View File
@@ -43,6 +43,16 @@ afterEach(() => {
})
describe('auth-storage', () => {
it('preserves the canonical identity independently of the display email', () => {
const user = userFromSessionValues({
user: { id: 'alice', name: 'Alice', email: 'alice@example.test' },
})
expect(user).toEqual({ id: 'alice', name: 'Alice', email: 'alice@example.test' })
storeUser(user!)
expect(getStoredUser()?.id).toBe('alice')
expect(userFromSessionValues({ user: { id: 'service-user' } })?.id).toBe('service-user')
})
it.each([null, 'true', 'false'])('preserves appearance preference %s on logout', (preference) => {
if (preference !== null) localStorage.setItem('qqq-dark-mode', preference)
clearUserClientData()
@@ -50,14 +60,22 @@ describe('auth-storage', () => {
})
it('reads the displayed user from manageSession values (QRun-IO/qqq#332)', () => {
expect(userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
expect(
userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })
).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
expect(userFromSessionValues({ user: { name: '', email: 7 } })).toBeNull()
expect(userFromSessionValues(undefined)).toBeNull()
})
it('shows a TABLE_BASED username as the identity line (QRun-IO/qqq#700)', () => {
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess.table' })
expect(userFromSessionValues({ user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual(
{ name: 'Tess Table', email: 'tess.table' }
)
expect(
userFromSessionValues({
user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' },
})
).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
})
it('persists and validates the stored user', () => {
@@ -70,7 +88,10 @@ describe('auth-storage', () => {
})
it('keeps the full session values for analytics identity until sign-out (QRun-IO/qqq#730)', () => {
const values = { user: { name: 'Alice (sample)', email: 'sample:alice' }, analyticsValues: { user_id: 'u-1' } }
const values = {
user: { name: 'Alice (sample)', email: 'sample:alice' },
analyticsValues: { user_id: 'u-1' },
}
storeSessionValues(values)
expect(getStoredSessionValues()).toEqual(values)
storeSessionValues(undefined)
@@ -109,15 +130,19 @@ describe('auth-storage', () => {
expect(localStorage.getItem('accessToken')).toBeNull()
})
it('clears every table\'s remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)', () => {
it("clears every table's remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)", () => {
for (const table of ['person', 'pet']) {
writeStoredQueryView(table, {
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'empty',
})
writeCurrentSavedViewId(table, 7)
expect(readStoredQueryView(table)).toMatchObject({
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
})
expect(readCurrentSavedViewId(table)).toBe(7)
}
@@ -136,20 +161,25 @@ describe('auth-storage', () => {
expect(readStoredQueryView(table)).toBeNull()
expect(readCurrentSavedViewId(table)).toBeNull()
}
for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value)
for (const [key, value] of Object.entries(preferences))
expect(localStorage.getItem(key)).toBe(value)
})
it('keeps remembered query state for the same identity and clears it before another identity claims the browser (QRun-IO/qqq#1006)', () => {
claimClientData({ name: 'Alice', email: 'alice@example.test' })
writeStoredQueryView('person', {
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'savedView:7',
})
writeCurrentSavedViewId('person', 7)
claimClientData({ name: 'Alice', email: 'alice@example.test' })
expect(readStoredQueryView('person')).toMatchObject({
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
queryFilter: {
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
},
viewIdentity: 'savedView:7',
})
expect(readCurrentSavedViewId('person')).toBe(7)
@@ -160,9 +190,12 @@ describe('auth-storage', () => {
expect(localStorage.getItem('qqq.clientDataOwner')).toBe('bob@example.test')
})
it('clears the previous user\'s data when someone else signs in in this browser (QRun-IO/qqq#696)', () => {
it("clears the previous user's data when someone else signs in in this browser (QRun-IO/qqq#696)", () => {
claimClientData({ name: 'Alice', email: 'alice@example.com' })
localStorage.setItem('qqq-recent-records', JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }]))
localStorage.setItem(
'qqq-recent-records',
JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }])
)
storeUser({ name: 'Alice', email: 'alice@example.com' })
claimClientData({ name: 'Alice', email: 'alice@example.com' })
+19 -6
View File
@@ -31,6 +31,7 @@ const SESSION_HINT_KEY = 'qqq.sessionHint'
/** The displayed identity of the signed-in user. */
export interface StoredUser {
id?: string
name?: string
email?: string
}
@@ -102,16 +103,24 @@ export function setSessionHint(present: boolean): void {
* @param values - The session values for the frontend.
* @returns The user when it has a name or email.
*/
export function userFromSessionValues(values: Record<string, unknown> | undefined): StoredUser | null {
export function userFromSessionValues(
values: Record<string, unknown> | undefined
): StoredUser | null {
const user = values?.user
if (!user || typeof user !== 'object') return null
const { name, email, username } = user as Record<string, unknown>
const identity = typeof email === 'string' && email ? email : typeof username === 'string' && username ? username : undefined
const { id, name, email, username } = user as Record<string, unknown>
const identity =
typeof email === 'string' && email
? email
: typeof username === 'string' && username
? username
: undefined
const result: StoredUser = {
...(typeof id === 'string' && id ? { id } : {}),
name: typeof name === 'string' && name ? name : undefined,
email: identity,
}
return result.name || result.email ? result : null
return result.id || result.name || result.email ? result : null
}
/**
@@ -162,7 +171,9 @@ export function storeSessionValues(values: Record<string, unknown> | undefined):
export function getStoredSessionValues(): Record<string, unknown> | null {
try {
const parsed = JSON.parse(storage('local')?.getItem(SESSION_VALUES_KEY) ?? 'null') as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
@@ -179,7 +190,9 @@ export function recordReauthAttempt(now = Date.now()): boolean {
const session = storage('session')
let attempts: number[] = []
try {
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter((time) => now - time < 60_000)
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter(
(time) => now - time < 60_000
)
} catch {
attempts = []
}
+10
View File
@@ -57,6 +57,7 @@ describe('oidc helpers (QRun-IO/qqq#670)', () => {
expect(Object.fromEntries(url.searchParams)).toEqual({
response_type: 'code', client_id: 'qqq', redirect_uri: `${window.location.origin}/token`,
scope: 'openid profile email', state: 'state-1', code_challenge: 'challenge', code_challenge_method: 'S256',
prompt: 'login', max_age: '0',
})
})
@@ -67,6 +68,15 @@ describe('oidc helpers (QRun-IO/qqq#670)', () => {
expect(url.searchParams.get('redirect_uri')).toBe(`${window.location.origin}/`)
})
it('forces re-authentication on every sign-in for both provider types, so a lingering provider session cannot sign anyone in silently', async () => {
mockDiscovery()
for (const meta of [oauth2, auth0]) {
const url = new URL(await buildAuthorizationUrl(meta, 'c', 's'))
expect(url.searchParams.get('prompt')).toBe('login')
expect(url.searchParams.get('max_age')).toBe('0')
}
})
it('rejects metadata without a client id', async () => {
await expect(buildAuthorizationUrl({ ...oauth2, values: { baseUrl: 'https://idp.example' } }, 'c', 's')).rejects.toThrow(/clientId/)
})
+8 -1
View File
@@ -158,7 +158,8 @@ export function discoverProvider(baseUrl: string): Promise<OidcProviderMetadata>
/**
* Builds the authorization-code + PKCE URL for OAUTH2 (discovered endpoint) or
* AUTH_0 (`{baseUrl}/authorize`, with the API audience).
* AUTH_0 (`{baseUrl}/authorize`, with the API audience). Always forces re-authentication
* (`prompt=login`, `max_age=0`).
*
* @param authMeta - Backend authentication metadata.
* @param challenge - The S256 code challenge.
@@ -178,6 +179,12 @@ export async function buildAuthorizationUrl(authMeta: QAuthenticationMetaData, c
state,
code_challenge: challenge,
code_challenge_method: 'S256',
// The identity provider must be invisible: every sign-in asks for credentials. A session left
// behind in the browser (another person, or a logout that only ended the app session) must
// never sign the next user in silently. `prompt=login` alone was seen reusing retained SSO on
// real Authentik, so `max_age=0` (re-authenticate, any session is too old) is sent as well.
prompt: 'login',
max_age: '0',
})
if (authMeta.values?.audience) params.set('audience', authMeta.values.audience)
return `${endpoint}?${params.toString()}`
+43
View File
@@ -0,0 +1,43 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/** @file The tracked workflow-editor bundle is staged only when its bytes match the committed lock. */
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { stageDynamicComponents } from '../../scripts/stage-dynamic-components.mjs'
const bytes = Buffer.from('window.QSequentialWorkflow={QSequentialWorkflow:function(){}}')
const sha = (data: Buffer) => createHash('sha256').update(data).digest('hex')
const lock = { files: { 'q-sequential-workflow.bundle.js': sha(bytes) } }
describe('stageDynamicComponents', () => {
it('copies a bundle whose sha256 matches the lock to the served path name', async () => {
const assets = await mkdtemp(path.join(tmpdir(), 'dyn-assets-')); const out = await mkdtemp(path.join(tmpdir(), 'dyn-out-'))
await writeFile(path.join(assets, 'q-sequential-workflow.bundle.js'), bytes)
expect(await stageDynamicComponents({ assetDir: assets, lock, outDir: out })).toEqual(['q-sequential-workflow.bundle.js'])
expect(await readFile(path.join(out, 'q-sequential-workflow.bundle.js'))).toEqual(bytes)
})
it('refuses a bundle that differs from the lock, a missing one and a lock without a hash', async () => {
const assets = await mkdtemp(path.join(tmpdir(), 'dyn-assets-')); const out = await mkdtemp(path.join(tmpdir(), 'dyn-out-'))
await expect(stageDynamicComponents({ assetDir: assets, lock, outDir: out })).rejects.toThrow(/Missing/)
await writeFile(path.join(assets, 'q-sequential-workflow.bundle.js'), 'tampered')
await expect(stageDynamicComponents({ assetDir: assets, lock, outDir: out })).rejects.toThrow(/does not match the lock/)
await expect(stageDynamicComponents({ assetDir: assets, lock: { files: { 'q-sequential-workflow.bundle.js': null } }, outDir: out })).rejects.toThrow(/no sha256/)
})
it('the tracked canonical assets match the committed lock', async () => {
const real = JSON.parse(await readFile(path.join(process.cwd(), 'scripts/dynamic-components.lock.json'), 'utf8'))
expect(real.component.sourceCommit).toMatch(/^[0-9a-f]{40}$/)
for (const [name, hash] of Object.entries(real.files)) {
expect(hash).toMatch(/^[0-9a-f]{64}$/)
expect(sha(await readFile(path.join(process.cwd(), real.assetDirectory, name)))).toBe(hash)
}
})
})
@@ -26,6 +26,7 @@ vi.mock('@/lib/context/q-context', async () => {
})
vi.mock('./use-realtime-channel', () => ({ useRealtimeChannel: vi.fn() }))
vi.mock('@/lib/api/collaboration', () => ({
openPresenceLease: vi.fn(),
renewPresence: vi.fn(),
leavePresence: vi.fn(),
getPresence: vi.fn(),
@@ -33,6 +34,10 @@ vi.mock('@/lib/api/collaboration', () => ({
}))
beforeEach(() => {
vi.useFakeTimers()
let nextLease = 0
vi.mocked(api.openPresenceLease).mockImplementation(async () => ({
connectionId: `lease:${++nextLease}`,
}))
vi.mocked(api.getPresence).mockResolvedValue({ table: 'person', viewers: [] })
vi.mocked(api.renewPresence).mockResolvedValue({ table: 'person', viewers: [] })
vi.mocked(api.leavePresence).mockResolvedValue({})
@@ -50,12 +55,15 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
wrapper,
initialProps: { field: 'firstName' },
})
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.renewPresence).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'EDITING', fieldName: 'firstName' })
)
await act(() => vi.advanceTimersByTimeAsync(20000))
expect(api.renewPresence).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.renewPresence).mock.calls.map(([input]) => input.sequence)).toEqual([1, 2])
hook.rerender({ field: 'lastName' })
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ fieldName: 'lastName' })
)
@@ -64,9 +72,9 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
expect.objectContaining({ fieldName: 'lastName' })
)
hook.unmount()
expect(api.leavePresence).toHaveBeenCalledTimes(1)
expect(api.leavePresence).toHaveBeenCalledWith(
expect.objectContaining({ table: 'person', pk: '1' })
expect(api.leavePresence).not.toHaveBeenCalled()
expect(api.beaconPresence).toHaveBeenCalledWith(
expect.objectContaining({ table: 'person', pk: '1', sequence: 4 })
)
})
it('merges tabs and excludes my own tabs, preferring editors', () => {
@@ -81,3 +89,155 @@ it('merges tabs and excludes my own tabs, preferring editors', () => {
)
).toEqual([{ userId: 'sam', displayName: 'Sam', mode: 'EDITING', fieldName: 'name' }])
})
it('reports failed renewals even when reads succeed, and recovers on a successful renewal', async () => {
vi.mocked(api.renewPresence).mockRejectedValueOnce(new Error('Unavailable'))
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(10))
expect(hook.result.current.unavailable).toBe(true)
expect(hook.result.current.viewers).toEqual([])
await act(() => vi.advanceTimersByTimeAsync(20000))
expect(hook.result.current.unavailable).toBe(false)
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
hook.unmount()
})
it('ignores a failed renewal from the previous record', async () => {
let rejectOld: (reason: Error) => void = () => undefined
vi.mocked(api.renewPresence).mockReturnValueOnce(
new Promise((_, reject) => {
rejectOld = reject
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(({ pk }) => useCollaborationPresence('person', pk), {
wrapper,
initialProps: { pk: '1' },
})
await act(() => vi.advanceTimersByTimeAsync(0))
hook.rerender({ pk: '2' })
await act(() => vi.advanceTimersByTimeAsync(10))
await act(async () => {
rejectOld(new Error('Old request failed'))
})
expect(hook.result.current.unavailable).toBe(false)
hook.unmount()
})
it('releases a late reservation without activating a closed surface', async () => {
let resolveOpen!: (value: { connectionId: string }) => void
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
new Promise((resolve) => {
resolveOpen = resolve
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
hook.unmount()
await act(async () => {
resolveOpen({ connectionId: 'lease:late' })
})
expect(api.renewPresence).not.toHaveBeenCalled()
expect(api.leavePresence).toHaveBeenCalledWith({
table: 'person',
pk: '1',
connectionId: 'lease:late',
sequence: 1,
})
})
it('rejoins once with a new handle after an explicit lease expiry', async () => {
vi.mocked(api.renewPresence).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
})
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(
vi.mocked(api.renewPresence).mock.calls.map(([input]) => [input.connectionId, input.sequence])
).toEqual([
['lease:1', 1],
['lease:2', 1],
])
expect(hook.result.current.unavailable).toBe(false)
hook.unmount()
})
it('opens a fresh lease on pageshow instead of reviving the departed handle', async () => {
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
act(() => window.dispatchEvent(new Event('pagehide')))
act(() => window.dispatchEvent(new Event('pageshow')))
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:2', sequence: 1 })
)
hook.unmount()
})
it('bounds immediate rejoin attempts when replacement leases also expire', async () => {
vi.mocked(api.renewPresence).mockRejectedValue({
isAxiosError: true,
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
})
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
await act(() => vi.advanceTimersByTimeAsync(0))
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
expect(hook.result.current.unavailable).toBe(true)
hook.unmount()
})
it('discards an old pending open after hide and show while preserving the new handle', async () => {
let resolveOld!: (value: { connectionId: string }) => void
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
new Promise((resolve) => {
resolveOld = resolve
})
)
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
)
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
act(() => window.dispatchEvent(new Event('pagehide')))
act(() => window.dispatchEvent(new Event('pageshow')))
await act(() => vi.advanceTimersByTimeAsync(0))
await act(async () => {
resolveOld({ connectionId: 'lease:old' })
})
expect(api.renewPresence).toHaveBeenCalledTimes(1)
expect(api.renewPresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:1', sequence: 1 })
)
expect(api.leavePresence).toHaveBeenCalledWith(
expect.objectContaining({ connectionId: 'lease:old', sequence: 1 })
)
hook.unmount()
expect(api.leavePresence).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionId: 'lease:1', sequence: 2 })
)
})
+121 -26
View File
@@ -16,10 +16,17 @@
/** @file Ephemeral presence leases with focus changes, polling fallback and realtime refresh. */
'use client'
import { useContext, useEffect, useState } from 'react'
import { useContext, useEffect, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { isAxiosError } from 'axios'
import type { PresenceSnapshot, PresenceViewer } from '@/types/collaboration'
import { renewPresence, leavePresence, getPresence, beaconPresence } from '@/lib/api/collaboration'
import {
openPresenceLease,
renewPresence,
leavePresence,
getPresence,
beaconPresence,
} from '@/lib/api/collaboration'
import { queryKeys } from '@/lib/query-client'
import { QContext } from '@/lib/context/q-context'
import { useRealtimeChannel } from './use-realtime-channel'
@@ -51,8 +58,11 @@ export function useCollaborationPresence(
fieldName?: string,
enabled = true
) {
const [connectionId] = useState(() => crypto.randomUUID())
const focus = useRef({ editing, fieldName })
focus.current = { editing, fieldName }
const refresh = useRef<() => void>(() => undefined)
const [ended, setEnded] = useState(false)
const [renewalFailed, setRenewalFailed] = useState(false)
const userId = useContext(QContext)?.userId
const cache = useQueryClient()
const key = queryKeys.collaborationResource(table, pk ?? '', 'presence')
@@ -65,7 +75,14 @@ export function useCollaborationPresence(
useRealtimeChannel(
pk ? `record:${table}:${pk}` : `table:${table}`,
({ event, data }) => {
if (event === 'session-ended') setEnded(true)
if (
event === 'session-ended' ||
(event === 'channel-revoked' &&
data.channel === (pk ? `record:${table}:${pk}` : `table:${table}`))
) {
setEnded(true)
cache.setQueryData(key, { table, pk, viewers: [] })
}
if (
event === 'presence.changed' &&
data.table === table &&
@@ -78,34 +95,112 @@ export function useCollaborationPresence(
)
useEffect(() => {
if (!enabled || ended) return
const input = {
connectionId,
table,
pk,
mode: editing ? ('EDITING' as const) : ('VIEWING' as const),
fieldName,
}
const heartbeat = () => {
void renewPresence(input)
.then((snapshot) =>
let active = true
let visible = document.visibilityState !== 'hidden'
let connectionId: string | undefined
let sequence = 0
let generation = 0
let opening = false
const heartbeat = async (rejoining = false) => {
if (!active || !visible || opening) return
const requestGeneration = generation
let requestSequence = sequence
try {
if (!connectionId) {
opening = true
const lease = await openPresenceLease({ table, pk })
if (!active || !visible || generation !== requestGeneration) {
void leavePresence({ table, pk, connectionId: lease.connectionId, sequence: 1 }).catch(
() => undefined
)
return
}
opening = false
connectionId = lease.connectionId
sequence = 0
}
requestSequence = ++sequence
const snapshot = await renewPresence({
table,
pk,
connectionId,
sequence: requestSequence,
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
fieldName: focus.current.fieldName,
})
if (active && visible && generation === requestGeneration && sequence === requestSequence) {
setRenewalFailed(false)
cache.setQueryData(queryKeys.collaborationResource(table, pk ?? '', 'presence'), snapshot)
)
.catch(() => undefined)
}
} catch (error) {
if (!active || !visible || generation !== requestGeneration || sequence !== requestSequence)
return
opening = false
setRenewalFailed(true)
if (
isAxiosError(error) &&
error.response?.status === 409 &&
error.response.data?.code === 'PRESENCE_LEASE_EXPIRED'
) {
connectionId = undefined
generation++
if (!rejoining) void heartbeat(true)
}
}
}
heartbeat()
const timer = setInterval(heartbeat, 20000)
const hide = () => beaconPresence(input)
refresh.current = () => {
void heartbeat()
}
void heartbeat()
const timer = setInterval(() => {
void heartbeat()
}, 20000)
const release = (beacon: boolean) => {
generation++
opening = false
const previous = connectionId
connectionId = undefined
if (!previous) return
const input = { table, pk, connectionId: previous, sequence: ++sequence }
if (beacon)
beaconPresence({
...input,
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
fieldName: focus.current.fieldName,
})
else void leavePresence(input).catch(() => undefined)
}
const hide = () => {
visible = false
release(true)
}
const show = () => {
visible = document.visibilityState !== 'hidden'
void heartbeat()
}
const visibility = () => (document.visibilityState === 'hidden' ? hide() : show())
window.addEventListener('pagehide', hide)
window.addEventListener('pageshow', show)
document.addEventListener('visibilitychange', visibility)
return () => {
active = false
release(false)
refresh.current = () => undefined
clearInterval(timer)
window.removeEventListener('pagehide', hide)
window.removeEventListener('pageshow', show)
document.removeEventListener('visibilitychange', visibility)
}
}, [connectionId, table, pk, editing, fieldName, enabled, ended, cache])
}, [table, pk, enabled, ended, cache])
useEffect(() => {
if (!enabled || ended) return
return () => {
void leavePresence({ connectionId, table, pk }).catch(() => undefined)
}
}, [connectionId, table, pk, enabled, ended])
return { ...query, viewers: otherViewers(query.data?.viewers ?? [], userId) }
refresh.current()
}, [editing, fieldName])
return {
...query,
unavailable: enabled && !ended && (query.isError || renewalFailed),
viewers:
enabled && !ended && !query.isError && !renewalFailed
? otherViewers(query.data?.viewers ?? [], userId)
: [],
}
}
+8 -3
View File
@@ -56,6 +56,7 @@ import { isColumnVisible, type ViewState } from '@/lib/utils/saved-view-utils'
import { getDefaultQuickFilterFieldNames, reconcileBasicMode } from '@/lib/utils/quick-filter-utils'
import { defaultColumnVisibility, hasCapability, withReadableExposedJoins, type ColumnPins } from '@/lib/utils/query-columns'
import { DENSITY_STORAGE_KEY, readLegacyColumnState } from '@/lib/utils/query-view-storage'
import { tableDefaultSort } from '@/lib/utils/query-default-sort'
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
import { useColumnConfig } from '@/lib/hooks/use-column-config'
import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/lib/constants'
@@ -320,9 +321,13 @@ export function useRecordQuery({
const [storedDensity, setDensity] = useLocalStorage<Density>(DENSITY_STORAGE_KEY, 'standard')
const density: Density = (['compact', 'standard', 'comfortable'] as const).includes(storedDensity) ? storedDensity : 'standard'
const primaryKey = tableMetaData?.primaryKeyField
/** Material's default sort: primary key, descending. */
const defaultSort = useMemo<QFilterOrderBy[]>(() => (primaryKey ? [{ fieldName: primaryKey, isAscending: false }] : []), [primaryKey])
/**
* The sort the list opens with and resets to: the one the table's metadata declares
* (`supplementalMetaData.nextUi.defaultSort`), else Material's default, primary key descending.
* Keyed by its content, so reloaded metadata that declares the same sort changes nothing.
*/
const defaultSortKey = JSON.stringify(tableDefaultSort(tableMetaData))
const defaultSort = useMemo<QFilterOrderBy[]>(() => JSON.parse(defaultSortKey) as QFilterOrderBy[], [defaultSortKey])
/** Quick filters every user sees in basic mode (Material defaultQuickFilterFieldNames, else T1 fields). */
const defaultQuickFilterFieldNames = useMemo(() => (tableMetaData ? getDefaultQuickFilterFieldNames(tableMetaData) : []), [tableMetaData])
const filterModeKey = `qqq-${tableName}-filter-mode`
+197 -3
View File
@@ -14,10 +14,15 @@
* limitations under the License.
*/
// Tests for query key factory
// Tests for the query key factory and the global error banner
import { describe, it, expect } from 'vitest'
import { queryKeys } from './query-client'
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { AxiosError, AxiosHeaders, CanceledError, type InternalAxiosRequestConfig } from 'axios'
import { Toaster, toast } from 'sonner'
import { HANDLES_OWN_ERRORS, queryClient, queryKeys } from './query-client'
describe('queryKeys factory', () => {
it('all() returns root key', () => {
@@ -109,3 +114,192 @@ describe('queryKeys factory', () => {
expect(k1).not.toBe(k2)
})
})
/**
* An axios error as the API client rejects with it. Its request carries a token, a body and a
* query string, none of which may reach a banner or the console.
*
* @param request - The request's method and URL (relative to the `/qqq/v1` base).
* @param response - The response's status and body; omit for a request that got no response.
* @param code - The axios error code.
* @returns The error.
*/
function axiosError(request: { method: string; url: string }, response?: { status: number; data?: unknown }, code = response ? 'ERR_BAD_REQUEST' : 'ERR_NETWORK'): AxiosError {
const config = { ...request, baseURL: '/qqq/v1', headers: new AxiosHeaders({ Authorization: 'Bearer secret-token' }), data: '{"name":"Private Person"}' } as InternalAxiosRequestConfig
const message = code === 'ECONNABORTED' ? 'timeout of 30000ms exceeded' : response ? `Request failed with status code ${response.status}` : 'Network Error'
return new AxiosError(message, code, config, {}, response ? { ...response, data: response.data, statusText: '', headers: {}, config } : undefined)
}
/**
* Fails a query through the shared client, as a component's `useQuery` would.
*
* @param error - What the query function rejects with.
* @param queryKey - The query's key.
* @param meta - The query's meta.
*/
async function failQuery(error: unknown, queryKey: readonly unknown[] = queryKeys.tableRecords('order'), meta?: Record<string, unknown>): Promise<void> {
await queryClient.fetchQuery({ queryKey, queryFn: () => Promise.reject(error), retry: false, staleTime: 0, meta }).catch(() => undefined)
}
/**
* Fails a mutation through the shared client, as a component's `useMutation` would.
*
* @param error - What the mutation function rejects with.
*/
async function failMutation(error: unknown): Promise<void> {
await queryClient.getMutationCache().build(queryClient, { mutationFn: () => Promise.reject(error) }).execute(undefined).catch(() => undefined)
}
describe('global error banner', () => {
const QUERY = { method: 'post', url: '/table/order/query?filter=%7B%22name%22%3A%22Private%20Person%22%7D' }
const banner = vi.spyOn(toast, 'error')
const warn = vi.spyOn(console, 'warn')
/**
* The banners requested so far.
*
* @returns Each banner's text and description.
*/
const banners = () => banner.mock.calls.map(([message, options]) => [message, options?.description])
/**
* The banners on screen.
*
* @returns Each banner's text.
*/
const shown = () => Array.from(document.querySelectorAll('[data-sonner-toast]')).map((element) => element.textContent)
beforeEach(() => {
banner.mockClear()
warn.mockClear().mockImplementation(() => undefined)
})
afterEach(() => {
cleanup()
toast.dismiss()
queryClient.clear()
})
it('says the server did not respond, and which request it was, without the query string', async () => {
await failQuery(axiosError(QUERY))
expect(banners()).toEqual([['No response from the server', 'POST /qqq/v1/table/order/query']])
})
it('says the server took too long when the request timed out', async () => {
await failQuery(axiosError({ method: 'get', url: '/jobs' }, undefined, 'ECONNABORTED'))
expect(banners()).toEqual([['The server took too long to respond', 'GET /qqq/v1/jobs']])
})
it('says the server did not respond when a fetch fails', async () => {
await failQuery(new TypeError('Failed to fetch'), ['qqq', 'realtime', 'subscription-7'])
await failQuery(new TypeError('Load failed'), queryKeys.tablePossibleValues('order', 'customerId', 'Private Person'))
expect(banners()).toEqual([
['No response from the server', 'While loading realtime'],
['No response from the server', 'While loading possible values'],
])
})
it("shows the server's own message for an unexpected status", async () => {
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 409, data: { error: 'This order was changed by someone else.' } }))
expect(banners()).toEqual([['This order was changed by someone else.', 'POST /qqq/v1/table/order (HTTP 409)']])
})
it('names the status when the server sent no message, never a non-JSON body', async () => {
await failMutation(axiosError({ method: 'put', url: '/table/order/42' }, { status: 413, data: '<html>Request Entity Too Large</html>' }))
await failQuery(axiosError(QUERY, { status: 400 }))
expect(banners()).toEqual([
['Request failed (HTTP 413)', 'PUT /qqq/v1/table/order/{id}'],
['Request failed (HTTP 400)', 'POST /qqq/v1/table/order/query'],
])
})
it('keeps the 403, 404 and 5xx wording and adds the request', async () => {
await failQuery(axiosError({ method: 'get', url: '/table/order/42' }, { status: 403, data: { error: 'Permission denied.' } }))
await failQuery(axiosError({ method: 'get', url: '/table/order/43' }, { status: 403, data: '<html>Forbidden</html>' }))
await failQuery(axiosError({ method: 'get', url: '/table/order/42' }, { status: 404 }))
await failQuery(axiosError({ method: 'get', url: '/metaData' }, { status: 502 }))
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 500 }))
expect(banners()).toEqual([
['Permission denied.', 'GET /qqq/v1/table/order/{id} (HTTP 403)'],
['Permission denied', 'GET /qqq/v1/table/order/{id} (HTTP 403)'],
['Resource not found', 'GET /qqq/v1/table/order/{id} (HTTP 404)'],
['Server error — retrying…', 'GET /qqq/v1/metaData (HTTP 502)'],
['Server error — please try again', 'POST /qqq/v1/table/order (HTTP 500)'],
])
})
it('masks every path segment that is not a plain word, and reads an absolute URL by its path', async () => {
await failQuery(axiosError({ method: 'get', url: '/jobs/3f2b8c1e-9d4a-4c6b-8e21-5a7d9c0b1f33/progress' }, { status: 409 }))
await failQuery(axiosError({ method: 'get', url: '/table/person/private.person%40example.com' }, { status: 409 }))
await failQuery(axiosError({ method: 'get', url: 'https://files.example.com/download/abc123def456?signature=secret-token#part' }, { status: 409 }))
expect(banners().map(([, description]) => description)).toEqual([
'GET /qqq/v1/jobs/{id}/progress',
'GET /qqq/v1/table/person/{id}',
'GET /download/{id}',
])
})
it("shows an application error's own sentence, and only the type of a runtime error", async () => {
await failQuery(new Error('Invalid audit response'))
await failQuery(new TypeError("Cannot read properties of undefined (reading 'Private Person')"), ['qqq', 'search', 'records', 'Private Person'])
await failMutation('not an error object')
await failMutation(undefined)
expect(banners()).toEqual([
['Invalid audit response', 'While loading records'],
['The page hit an unexpected error (TypeError)', 'While loading search'],
['not an error object', undefined],
['The page hit an unexpected error', undefined],
])
})
it('shows one banner for a failure that repeats, and another for a different failure', async () => {
render(React.createElement(Toaster))
// a polling query failing on every attempt
for (let attempt = 0; attempt < 3; attempt++) await act(() => failQuery(axiosError(QUERY, { status: 400 })))
await waitFor(() => expect(shown()).toHaveLength(1))
expect(shown()[0]).toContain('Request failed (HTTP 400)')
// the same status from another endpoint is a different failure; sonner shows banners in
// the order they were requested, so by now all three repeats have been applied
await act(() => failQuery(axiosError({ method: 'get', url: '/jobs' }, { status: 400 }), queryKeys.jobs()))
await waitFor(() => expect(shown()).toHaveLength(2))
expect(banner).toHaveBeenCalledTimes(4)
expect(new Set(banner.mock.calls.map(([, options]) => options?.id)).size).toBe(2)
})
it('shows one banner for a lost connection, whichever requests met it', async () => {
render(React.createElement(Toaster))
await act(() => failQuery(axiosError(QUERY)))
await act(() => failQuery(axiosError({ method: 'get', url: '/jobs' }), queryKeys.jobs()))
await act(() => failQuery(axiosError({ method: 'get', url: '/alerts/inbox/summary' }), queryKeys.alertsInbox()))
// the banner names the request that failed most recently
await waitFor(() => expect(shown()[0]).toContain('GET /qqq/v1/alerts/inbox/summary'))
expect(shown()).toHaveLength(1)
expect(shown()[0]).toContain('No response from the server')
})
it('stays silent for a cancelled request, a 401 and a query that shows its own error', async () => {
await failQuery(new CanceledError('canceled'))
await failQuery(new DOMException('The operation was aborted.', 'AbortError'))
await failQuery(axiosError({ method: 'get', url: '/metaData' }, { status: 401, data: { error: 'Session expired' } }))
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 401 }))
await failQuery(axiosError(QUERY), queryKeys.tableRecords('order'), HANDLES_OWN_ERRORS)
expect(banner).not.toHaveBeenCalled()
expect(warn).not.toHaveBeenCalled()
})
it('logs the technical detail once per failure, without message, body, headers or query string', async () => {
await failQuery(axiosError(QUERY, { status: 409, data: { error: 'Private Person is locked', record: { name: 'Private Person' } } }))
await failQuery(axiosError(QUERY))
expect(warn.mock.calls).toEqual([
['[API] Request failed:', { context: 'query', status: 409, code: 'ERR_BAD_REQUEST', name: 'AxiosError', request: 'POST /qqq/v1/table/order/query', area: 'records' }],
['[API] Request failed:', { context: 'query', status: undefined, code: 'ERR_NETWORK', name: 'AxiosError', request: 'POST /qqq/v1/table/order/query', area: 'records' }],
])
expect(JSON.stringify(warn.mock.calls)).not.toMatch(/Private|secret-token|filter/)
expect(JSON.stringify(banners()[1])).not.toMatch(/Private|secret-token|filter/)
})
})
+112 -30
View File
@@ -20,23 +20,107 @@
import { QueryClient, QueryCache, MutationCache, defaultShouldDehydrateQuery } from '@tanstack/react-query'
import { toast } from 'sonner'
import { isCancel } from 'axios'
import { AxiosError, isCancel } from 'axios'
import { getErrorStatusCode, getErrorMessage } from '@/lib/utils/error-utils'
/** JavaScript's own error types (and schema errors): their messages are written for developers. */
const RUNTIME_ERROR = /^(Type|Range|Reference|Syntax|Eval|URI|Aggregate|Internal|Zod)Error$/
/** How a browser words a `fetch` that got no response (Chrome, Firefox, Safari). */
const FETCH_NO_RESPONSE = /failed to fetch|networkerror|load failed/i
/**
* Returns a toast message for a query/mutation error based on HTTP status.
* The request an error came from, as `METHOD /path`, for a banner and the console. The query
* string is dropped, and every path segment that is not a plain word (a record id, a UUID, an
* encoded value) becomes `{id}`, so no record data or token is shown and one endpoint has one text.
*
* @param error - The unknown error thrown by the query or mutation.
* @returns The request, or `undefined` when the error carries none.
*/
function requestLine(error: unknown): string | undefined {
const config = error instanceof AxiosError ? error.config : undefined
if (!config?.url) return undefined
const absolute = /^[a-z][a-z\d+.-]*:\/\//i.test(config.url)
try {
const { pathname } = new URL(absolute ? config.url : `${config.baseURL ?? ''}${config.url.startsWith('/') ? '' : '/'}${config.url}`, 'http://localhost')
const path = pathname.split('/').filter(Boolean).map((segment) => (/^[A-Za-z][A-Za-z_-]{0,39}\d{0,2}$/.test(segment) ? segment : '{id}')).join('/')
return `${config.method ? `${config.method.toUpperCase()} ` : ''}/${path}`
} catch {
return undefined
}
}
/**
* The server's own explanation of a failed request, read from the response body only (never
* axios' generic "Request failed with status code 400", and never a non-JSON body).
*
* @param error - The unknown error thrown by the query or mutation.
* @returns The message, or an empty string when the server sent none.
*/
function serverMessage(error: unknown): string {
const body: unknown = error instanceof AxiosError ? error.response?.data : error
return body && typeof body === 'object' ? getErrorMessage(body, '') : ''
}
/**
* Whether a request ended without any response (the connection failed, or the request timed out).
*
* @param error - The unknown error thrown by the query or mutation.
* @returns True for an axios error with no response and for a failed `fetch`.
*/
function gotNoResponse(error: unknown): boolean {
return error instanceof AxiosError ? !error.response : error instanceof TypeError && FETCH_NO_RESPONSE.test(error.message)
}
/**
* What the banner says about a failed query or mutation.
*
* @param error - The unknown error thrown by the query or mutation.
* @param status - Its HTTP status, when it has one.
* @param context - `'query'` or `'mutation'` — selects message wording.
* @returns The banner's text.
*/
function failureMessage(error: unknown, status: number | undefined, context: 'query' | 'mutation'): string {
if (status === 403) return serverMessage(error) || 'Permission denied'
if (status === 404) return 'Resource not found'
if (status !== undefined && status >= 500) return context === 'mutation' ? 'Server error — please try again' : 'Server error — retrying…'
// Any other status (400, 409, 422, …): the server's own words when it sent any.
if (status !== undefined) return serverMessage(error) || `Request failed (HTTP ${status})`
if (gotNoResponse(error)) {
const code = error instanceof AxiosError ? error.code : undefined
const timedOut = code === 'ETIMEDOUT' || (code === 'ECONNABORTED' && /timeout/i.test((error as Error).message))
return timedOut ? 'The server took too long to respond' : 'No response from the server'
}
// The page failed before or after the request. An error the application raised carries a
// sentence written for the user; a runtime error's message is written for developers.
const name = error instanceof Error ? error.name : undefined
const written = name && RUNTIME_ERROR.test(name) ? '' : getErrorMessage(error, '')
return written || `The page hit an unexpected error${name ? ` (${name})` : ''}`
}
/**
* Shows one error banner for a failed query or mutation, and logs its technical detail.
*
* Every banner says what failed: the request (`METHOD /path`, see {@link requestLine}) or, for a
* query that failed outside a request, the area it was loading. The banner's id is that text, so
* the same failure repeating (a polling query that keeps failing) updates the banner already on
* screen instead of adding one. A lost connection is one cause whichever request met it, so it
* has one banner, which names the request that failed most recently.
*
* 401 responses are silently ignored — the axios interceptor already redirects
* to login, so surfacing a toast would be redundant and confusing.
*
* @param error - The unknown error thrown by the query or mutation.
* @param context - `'query'` or `'mutation'` — selects message wording.
* @param queryKey - The failed query's key; only its namespace (a word from the code) is read.
*/
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
// A cancelled request (aborted, or discarded because the session ended at logout) has no
// result to report.
if (isCancel(error)) return
function handleQueryError(error: unknown, context: 'query' | 'mutation', queryKey?: readonly unknown[]): void {
// A cancelled request (an axios cancel, an aborted `fetch`, or a response discarded because
// the session ended at logout) has no result to report.
if (isCancel(error) || (error as { name?: unknown } | null)?.name === 'AbortError') return
const status = getErrorStatusCode(error)
@@ -45,31 +129,29 @@ function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
return
}
if (status === 403) {
toast.error(getErrorMessage(error, 'Permission denied'))
return
}
const message = failureMessage(error, status, context)
const request = requestLine(error)
const namespace = queryKey?.[0] === 'qqq' ? queryKey[1] : queryKey?.[0]
const area = typeof namespace === 'string' && /^[A-Za-z][A-Za-z-]*$/.test(namespace) ? namespace : undefined
const http = status !== undefined && !message.includes(`HTTP ${status}`) ? `HTTP ${status}` : undefined
// `possibleValues` and `release-notes` read as "possible values" and "release notes".
const areaWords = area?.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/-/g, ' ').toLowerCase()
const where = request ?? (areaWords ? `While loading ${areaWords}` : undefined)
const description = where && http ? `${where} (${http})` : (where ?? http)
if (status === 404) {
toast.error('Resource not found')
return
}
// For a developer: no message, body, header or query string, which can carry record data.
const code = (error as { code?: unknown } | null)?.code
console.warn('[API] Request failed:', {
context,
status,
code: typeof code === 'string' ? code : undefined,
name: error instanceof Error ? error.name : undefined,
request,
area,
})
if (status !== undefined && status >= 500) {
if (context === 'mutation') {
toast.error('Server error — please try again')
} else {
toast.error('Server error — retrying…')
}
return
}
// Network failure (no status) or unexpected status codes.
if (context === 'mutation') {
toast.error('Something went wrong')
} else {
toast.error('Something went wrong')
}
const sameForEveryRequest = status === undefined && gotNoResponse(error)
toast.error(message, { id: `request-failed:${message}:${sameForEveryRequest ? '' : (description ?? '')}`, description })
}
/**
@@ -134,7 +216,7 @@ export const queryClient = new QueryClient({
queryCache: new QueryCache({
// Queries that render their own error state (records, widgets) opt out with HANDLES_OWN_ERRORS.
onError: (error, query) => {
if (!handlesOwnErrors(query.meta)) handleQueryError(error, 'query')
if (!handlesOwnErrors(query.meta)) handleQueryError(error, 'query', query.queryKey)
},
}),
mutationCache: new MutationCache({
+17
View File
@@ -122,6 +122,23 @@ it('invalidates records, panel counts, tasks, notifications and resync queries',
invalidateRealtime(cache, { event: 'resync', data: {} })
expect(invalidate).toHaveBeenLastCalledWith({ queryKey: queryKeys.all() })
})
it('presence hints invalidate only their record or table room without trusting viewer payloads', () => {
const cache = new QueryClient()
const invalidate = vi.spyOn(cache, 'invalidateQueries')
const recordKey = queryKeys.collaborationResource('person', '1', 'presence')
cache.setQueryData(recordKey, { viewers: [] })
invalidateRealtime(cache, {
event: 'presence.invalidated',
data: { table: 'person', pk: 1, viewers: [{ userId: 'untrusted' }] },
})
expect(invalidate).toHaveBeenCalledExactlyOnceWith({ queryKey: recordKey })
expect(cache.getQueryData(recordKey)).toEqual({ viewers: [] })
invalidate.mockClear()
invalidateRealtime(cache, { event: 'presence.invalidated', data: { table: 'person' } })
expect(invalidate).toHaveBeenCalledExactlyOnceWith({
queryKey: queryKeys.collaborationResource('person', '', 'presence'),
})
})
class Bus {
static peers = new Set<Bus>()
onmessage?: (event: MessageEvent) => void
+5
View File
@@ -41,6 +41,11 @@ export function invalidateRealtime(client: QueryClient, { event, data }: Realtim
}
if (event === 'task.changed') invalidate(queryKeys.collaborationMyWork())
if (typeof data.table !== 'string') return
if (event === 'presence.invalidated') {
// Hints contain no viewer state; reread through the authenticated presence API.
invalidate(queryKeys.collaborationResource(data.table, String(data.pk ?? ''), 'presence'))
return
}
if (event === 'record.changed' || event === 'record.deleted' || event === 'table.changed')
invalidate(queryKeys.tableRecords(data.table))
if (data.pk === undefined || data.pk === null) return
+43
View File
@@ -0,0 +1,43 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { expect, it } from 'vitest'
import { continuationUrl } from './external-navigation'
const AUTHORIZE =
'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&redirect_uri=https%3A%2F%2Fapp.example.test%2Fqqq%2Fhooks%2Fslack%2Foauth%2Fcallback&state=abc.def'
it('recognizes an answer that is only an absolute https address, and returns it unchanged', () => {
expect(continuationUrl(AUTHORIZE)).toBe(AUTHORIZE)
expect(continuationUrl(` ${AUTHORIZE}\n`)).toBe(AUTHORIZE)
})
it.each([
['an ordinary message', 'Slack disconnected.'],
['a sentence that contains an address', `Open ${AUTHORIZE}`],
['two addresses', `${AUTHORIZE} ${AUTHORIZE}`],
['plain http', 'http://slack.com/oauth/v2/authorize'],
['a script address', 'javascript:alert(1)'],
['a data address', 'data:text/html,<p>x</p>'],
['a protocol-relative address', '//slack.com/oauth'],
['a local path', '/app/preferences'],
['credentials in the address', 'https://user:secret@slack.com/oauth'],
['no host', 'https://'],
['an empty answer', ''],
['no answer', undefined],
])('leaves %s as a message', (_name, text) => {
expect(continuationUrl(text)).toBeNull()
})
+49
View File
@@ -0,0 +1,49 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file external-navigation — leaving the dashboard for another site, such as the consent
* page of a service the user is connecting. An address is something to go to, never text to
* show: a backend answer that is only an address is recognized here and opened.
*/
/**
* The address a backend answer names when the answer is nothing but one absolute `https:`
* address: no other words, no whitespace, no credentials in it. A sentence that merely
* contains an address is an ordinary message and is not matched.
*
* @param text - The backend's answer (an action result message, for example).
* @returns The address exactly as sent, or `null` when the answer is anything else.
*/
export function continuationUrl(text: string | null | undefined): string | null {
const address = text?.trim()
if (!address || /\s/.test(address) || !/^https:\/\//i.test(address)) return null
try {
const url = new URL(address)
return url.protocol === 'https:' && url.hostname && !url.username && !url.password ? address : null
} catch {
return null
}
}
/**
* Sends the browser to another site in this tab, so the site can return the user here.
*
* @param url - An absolute address (see {@link continuationUrl}).
*/
export function leaveTo(url: string): void {
window.location.assign(url)
}
+112
View File
@@ -0,0 +1,112 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest'
import type { QTableMetaData } from '@/types'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { declaredDefaultSort, tableDefaultSort } from './query-default-sort'
const PRIMARY_KEY_DESCENDING = [{ fieldName: 'id', isAscending: false }]
/**
* The people table with a Next UI hint.
*
* @param nextUi - The hint object.
* @param legacy - Put it under the legacy `supplementalTableMetaData` key.
* @returns Table metadata.
*/
function person(nextUi?: unknown, legacy = false): QTableMetaData {
const table = structuredClone(qInstance.tables.person)
if (nextUi !== undefined) table[legacy ? 'supplementalTableMetaData' : 'supplementalMetaData'] = { nextUi } as Record<string, unknown>
return table
}
describe('tableDefaultSort', () => {
it('is primary key descending for a table that declares nothing', () => {
expect(tableDefaultSort(person())).toEqual(PRIMARY_KEY_DESCENDING)
expect(tableDefaultSort(person({ defaultColumns: ['id', 'firstName'] }))).toEqual(PRIMARY_KEY_DESCENDING)
expect(declaredDefaultSort(person())).toEqual([])
})
it('is empty before metadata loads and for a table without a primary key', () => {
expect(tableDefaultSort(undefined)).toEqual([])
expect(tableDefaultSort({ ...person(), primaryKeyField: '' })).toEqual([])
})
it('honours a declared sort, in order, with ascending as the direction when none is given', () => {
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'modifyDate', isAscending: false }] }))).toEqual([{ fieldName: 'modifyDate', isAscending: false }])
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'lastName' }, { fieldName: 'firstName', isAscending: true }, { fieldName: 'id', isAscending: false }] }))).toEqual([
{ fieldName: 'lastName', isAscending: true },
{ fieldName: 'firstName', isAscending: true },
{ fieldName: 'id', isAscending: false },
])
})
it('reads the legacy supplemental key, and prefers the current one', () => {
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'lastName', isAscending: true }] }, true))).toEqual([{ fieldName: 'lastName', isAscending: true }])
const both = person({ defaultSort: [{ fieldName: 'firstName', isAscending: true }] })
both.supplementalTableMetaData = { nextUi: { defaultSort: [{ fieldName: 'lastName', isAscending: true }] } }
expect(tableDefaultSort(both)).toEqual([{ fieldName: 'firstName', isAscending: true }])
})
it('accepts a field of an exposed join', () => {
const table = person()
const join = table.exposedJoins?.find((candidate) => Object.keys(candidate.joinTable?.fields ?? {}).length > 0)
const fieldName = `${join?.joinTable?.name}.${Object.keys(join?.joinTable?.fields ?? {})[0]}`
expect(join).toBeTruthy()
table.supplementalMetaData = { nextUi: { defaultSort: [{ fieldName, isAscending: false }] } }
expect(tableDefaultSort(table)).toEqual([{ fieldName, isAscending: false }])
})
it('drops entries it cannot use and keeps the rest', () => {
const table = person({
defaultSort: [
{ fieldName: 'noSuchField', isAscending: false },
{ fieldName: 'lastName', isAscending: 'yes' },
{ isAscending: false },
'modifyDate',
null,
['modifyDate', false],
{ fieldName: 'modifyDate', isAscending: false },
{ fieldName: 'modifyDate', isAscending: true },
{ fieldName: 'noSuchJoin.name', isAscending: true },
],
})
expect(tableDefaultSort(table)).toEqual([{ fieldName: 'modifyDate', isAscending: false }])
})
it('does not sort by a field the user may not sort by', () => {
const table = person({ defaultSort: [{ fieldName: 'email', isAscending: true }, { fieldName: 'lastName', isAscending: true }] })
table.fields.email = { ...table.fields.email, isSortable: false }
expect(tableDefaultSort(table)).toEqual([{ fieldName: 'lastName', isAscending: true }])
})
it.each([
['an empty list', []],
['a single object', { fieldName: 'modifyDate', isAscending: false }],
['a string', 'modifyDate'],
['null', null],
['only unusable entries', [{ fieldName: 'noSuchField' }, { fieldName: 'lastName', isAscending: 1 }]],
])('falls back to primary key descending for %s', (_what, defaultSort) => {
expect(tableDefaultSort(person({ defaultSort }))).toEqual(PRIMARY_KEY_DESCENDING)
})
it('falls back when the hint itself is not an object', () => {
expect(tableDefaultSort(person('newest'))).toEqual(PRIMARY_KEY_DESCENDING)
expect(tableDefaultSort(person(null))).toEqual(PRIMARY_KEY_DESCENDING)
})
})
+77
View File
@@ -0,0 +1,77 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file query-default-sort — the sort a table's record list opens with.
*
* A table can declare it, beside its default columns, in the Next UI hint of its supplemental
* metadata:
*
* ```json
* "supplementalMetaData": { "nextUi": { "defaultSort": [{ "fieldName": "receivedAt", "isAscending": false }] } }
* ```
*
* Each entry has the shape of an order-by in a query filter (`QFilterOrderBy`). Without a usable
* declaration the list sorts by primary key, descending, as it always has.
*/
import type { QFilterOrderBy, QTableMetaData } from '@/types'
import { resolveField } from './filter-utils'
import { canSortField } from './secured-data'
/**
* The sort a table's metadata declares for its record list, validated: an entry is kept when its
* `fieldName` names a field of the table (or of an exposed join, as `joinTable.field`) that the
* user may sort by, and its `isAscending` is a boolean or absent (ascending, as for an order-by).
* A field named twice counts once. Anything else is dropped.
*
* @param table - Table metadata.
* @returns The declared order-bys that can be used; empty when the table declares none.
*/
export function declaredDefaultSort(table: QTableMetaData): QFilterOrderBy[] {
const hint = (table.supplementalMetaData?.nextUi ?? table.supplementalTableMetaData?.nextUi) as { defaultSort?: unknown } | null | undefined
const declared = hint && typeof hint === 'object' ? hint.defaultSort : undefined
if (!Array.isArray(declared)) return []
const orderBys: QFilterOrderBy[] = []
for (const entry of declared) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
const { fieldName, isAscending } = entry as Record<string, unknown>
if (typeof fieldName !== 'string' || (isAscending !== undefined && typeof isAscending !== 'boolean')) continue
if (orderBys.some((orderBy) => orderBy.fieldName === fieldName)) continue
const field = resolveField(table, fieldName)?.field
if (!field || !canSortField(field)) continue
orderBys.push({ fieldName, isAscending: isAscending ?? true })
}
return orderBys
}
/**
* The sort a table's record list opens with, and returns to on a reset: the one the table
* declares, else its primary key, descending (Material's default).
*
* A person's own choice always comes first: a sort in the address, the sort of a saved view, or
* the sort the browser remembers for the table is used instead of this one.
*
* @param table - Table metadata, when loaded.
* @returns The default order-bys; empty only for a table without a primary key.
*/
export function tableDefaultSort(table: QTableMetaData | undefined): QFilterOrderBy[] {
if (!table) return []
const declared = declaredDefaultSort(table)
if (declared.length > 0) return declared
return table.primaryKeyField ? [{ fieldName: table.primaryKeyField, isAscending: false }] : []
}
+3 -3
View File
@@ -27,6 +27,7 @@ import type { QFieldMetaData, QFilterCriteria, QFilterOrderBy, QQueryFilter, QTa
import { formatDateTime } from './datetime-utils'
import { normalizeFilter, isCriterionComplete, emptyFilter, isFilterExpression, describeExpression, resolveField } from './filter-utils'
import { arrangePinnedColumns, effectivePins, getQueryColumns, orderColumns, type ColumnPin, type ColumnPins } from './query-columns'
import { tableDefaultSort } from './query-default-sort'
/** One column entry in a saved view (Material `QQueryColumns.Column`). */
export interface SavedViewColumn {
@@ -634,9 +635,8 @@ export function reconcileView(table: QTableMetaData, view: RecordQueryView): Rec
removedFields.add(orderBy.fieldName)
return false
})
if (savedOrderBys.length > 0 && orderBys.length === 0 && table.primaryKeyField) {
orderBys.push({ fieldName: table.primaryKeyField, isAscending: false })
}
// a view whose every sort field is gone falls back to the table's default sort
if (savedOrderBys.length > 0 && orderBys.length === 0) orderBys.push(...tableDefaultSort(table))
const quickFilterFieldNames = (view.quickFilterFieldNames ?? []).filter((name) => {
if (exists(name)) return true
removedFields.add(name)
+7 -7
View File
@@ -43,7 +43,7 @@ function buildStringSchema(isRequired: boolean, label: string, maxLength?: numbe
let schema = z.string()
if (isRequired) schema = schema.min(1, requiredMessage(label))
const limited = maxLength !== undefined
? schema.refine((value) => value.length <= maxLength, () => ({ message: tooLongMessage(label, maxLength) }))
? schema.refine((value) => value.length <= maxLength, { message: tooLongMessage(label, maxLength) })
: schema
return isRequired ? limited : limited.optional()
}
@@ -151,7 +151,7 @@ function buildNumberSchema(
minInclusive = true,
maxInclusive = true
): z.ZodTypeAny {
let schema: z.ZodNumber = isInteger
let schema = isInteger
? z.coerce.number({ message: notANumberMessage(label, true) }).int(notANumberMessage(label, true))
: z.coerce.number({ message: notANumberMessage(label, false) })
if (minValue !== undefined && minValue !== null) {
@@ -161,7 +161,7 @@ function buildNumberSchema(
schema = schema[maxInclusive ? 'max' : 'lt'](Number(maxValue), outOfRangeMessage(label, 'max', maxValue, maxInclusive))
}
return isRequired
? z.union([z.string().trim().min(1, requiredMessage(label)), z.number()]).pipe(schema)
? z.union([z.string().trim().min(1, requiredMessage(label)), z.number()]).pipe(schema as unknown as z.ZodType<number, string | number>)
: blankOr(schema).optional()
}
@@ -214,7 +214,7 @@ export function zodFieldFromMetadata(field: QFieldMetaData, { enforceMaxLength =
// unset (QRun-IO/qqq#761). A required one needs true or false, as in Material.
const message = requiredMessage(label ?? type)
return isRequired
? z.boolean({ required_error: message, invalid_type_error: message })
? z.boolean({ error: message })
: z.boolean().nullable().optional()
}
@@ -226,7 +226,7 @@ export function zodFieldFromMetadata(field: QFieldMetaData, { enforceMaxLength =
case 'BLOB': {
// A new File, the stored value (download URL or bytes) left unchanged, or null when
// the stored file is removed. A required file must be present.
const file = z.union([z.instanceof(File), z.string(), z.null()], { errorMap: () => ({ message: `${label ?? type} must be a file` }) })
const file = z.union([z.instanceof(File), z.string(), z.null()], { error: `${label ?? type} must be a file` })
return isRequired
? file.refine((value) => value instanceof File || (typeof value === 'string' && value !== ''), requiredMessage(label ?? type))
: file.optional()
@@ -237,8 +237,8 @@ export function zodFieldFromMetadata(field: QFieldMetaData, { enforceMaxLength =
case 'HTML':
case 'STRING':
default:
return buildStringSchema(isRequired ?? false, label ?? type, maxLength).refine((value: string | undefined) => {
if (!value || !field.validation?.pattern) return true
return buildStringSchema(isRequired ?? false, label ?? type, maxLength).refine((value: unknown) => {
if (typeof value !== 'string' || !value || !field.validation?.pattern) return true
try { return new RegExp('^(?:' + field.validation.pattern + ')$').test(value) }
catch { return true } // An unsupported server pattern must not prevent submitting to the authority.
}, field.validation?.patternMessage ?? `${label} has an invalid format`)
+27 -4
View File
@@ -601,6 +601,8 @@ const viewers = [
{ userId: 'sam', displayName: 'Sam Parker', mode: 'EDITING', fieldName: 'firstName' },
{ userId: 'alex', displayName: 'Alex Rivera', mode: 'VIEWING' },
]
// Demo peers are fixed fixtures; only the current browser's issued handles are tracked.
const presenceLeases = new Map<string, { table: string; pk?: string }>()
const taskStore: import('@/types/collaboration').CollaborationTaskV1[] = [
{
id: 8001,
@@ -671,11 +673,28 @@ collaborationHandlers.push(
const params = new URL(request.url).searchParams
return HttpResponse.json({ table: params.get('table'), pk: params.get('pk'), viewers })
}),
http.post(`${BASE}/presence`, async ({ request }) => {
const input = (await request.json()) as Record<string, unknown>
http.post(`${BASE}/presence/leases`, async ({ request }) => {
const target = (await request.json()) as { table: string; pk?: string }
const connectionId = `lease:${crypto.randomUUID()}`
presenceLeases.set(connectionId, target)
return HttpResponse.json({ connectionId })
}),
http.post(`${BASE}/presence/leases/renew`, async ({ request }) => {
const input = (await request.json()) as {
table: string
pk?: string
connectionId: string
}
const target = presenceLeases.get(input.connectionId)
if (!target || target.table !== input.table || target.pk !== input.pk)
return HttpResponse.json({ code: 'PRESENCE_LEASE_EXPIRED' }, { status: 409 })
return HttpResponse.json({ table: input.table, pk: input.pk, viewers })
}),
http.delete(`${BASE}/presence`, () => HttpResponse.json({ success: true })),
http.delete(`${BASE}/presence`, async ({ request }) => {
const input = (await request.json()) as { connectionId: string }
presenceLeases.delete(input.connectionId)
return HttpResponse.json({ success: true })
}),
http.get(`${BASE}/people`, ({ request }) => {
const q = new URL(request.url).searchParams.get('q')?.toLowerCase() ?? ''
return HttpResponse.json({
@@ -770,7 +789,11 @@ collaborationHandlers.push(
completedByDisplayName: nameOf('me'),
})
if (input.status === 'OPEN')
Object.assign(task, { completedAt: null, completedByUserId: null, completedByDisplayName: null })
Object.assign(task, {
completedAt: null,
completedByUserId: null,
completedByDisplayName: null,
})
publishCollaborationEvent('task.changed', {
table: task.tableName,
pk: task.recordId,
+5
View File
@@ -91,6 +91,11 @@ export interface PreferenceSecret {
/** Only section is safe to retain in the preferences cache. */
export interface PreferenceActionResponse {
section: PreferenceSection
/**
* The outcome, shown once. The UI API has no redirect field, so a message that is nothing but
* one absolute `https:` address is where the browser goes next (an OAuth consent page) and is
* never shown; see `continuationUrl`.
*/
message?: string
secrets?: PreferenceSecret[]
focusRowKey?: string
@@ -123,6 +123,10 @@ test.describe('command palette and search', () => {
await expect(pageLinks.filter({ hasText: /^Pet/ })).toHaveText([/^Pet(?! )/, /^Pet Species/, /^Pet Note/])
for (const text of await pageLinks.allTextContents()) expect(text).toMatch(/pet/i)
}
// Help results load above Pages and can move the link during a pointer gesture.
// Wait for the actual result, including after reload, before testing navigation.
await expect(page.locator('[data-qqq-id="help-search-results"] [data-qqq-id="help-result-table:petNote;slot:help"]')).toBeVisible()
await expect(page.locator('[data-qqq-id="help-search-results"]').getByRole('status')).toHaveCount(0)
await page.getByRole('region', { name: 'Pages' }).getByRole('link', { name: /^Pet Note/ }).click()
await expect(page).toHaveURL(/\/app\/petNote\/?$/)
expect(searchStatuses.length).toBeGreaterThan(0)
@@ -19,9 +19,25 @@ import path from 'node:path'
import { test, expect, type Page } from '@playwright/test'
async function openRecord(page: Page) {
const opened = page.waitForResponse(
(r) => r.url().endsWith('/presence/leases') && r.request().method() === 'POST'
)
const renewed = page.waitForResponse(
(r) => r.url().endsWith('/presence/leases/renew') && r.request().method() === 'POST'
)
await page.goto('/app/person/1')
expect((await opened).status()).toBe(200)
const renewal = await renewed
expect(renewal.status()).toBe(200)
expect(renewal.request().postDataJSON()).toMatchObject({
table: 'person',
pk: '1',
mode: 'VIEWING',
connectionId: expect.stringMatching(/^lease:/),
})
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toHaveText('Alice Johnson')
await expect(page.locator('[data-qqq-id="presence-sam"]')).toBeVisible()
await expect(page.getByText('Presence temporarily unavailable', { exact: true })).toHaveCount(0)
}
async function concurrentEdit(page: Page) {
await page.evaluate(async () => {
+8 -1
View File
@@ -41,6 +41,8 @@ afterEach(() => { for (const directory of directories.splice(0)) rmSync(director
it('keeps the variant build, static assets and entrypoint after the main build directory is replaced', () => {
const { source, staged } = fixture()
const serverMode = statSync(path.join(source, '.next/standalone/server.js')).mode & 0o777
const staticMode = statSync(path.join(source, '.next/static')).mode & 0o777
stageStandalone(staged, backend, source)
rmSync(path.join(source, '.next'), { recursive: true })
expect(() => assertStandaloneTarget(staged, backend)).not.toThrow()
@@ -49,8 +51,13 @@ it('keeps the variant build, static assets and entrypoint after the main build d
expect(readFileSync(path.join(staged, 'public/icon.svg'), 'utf8')).toBe('<svg/>')
expect(readFileSync(path.join(staged, 'qqq-server.mjs'), 'utf8')).toBe('container entrypoint')
expect(readFileSync(path.join(staged, 'branding-assets.mjs'), 'utf8')).toBe('declared branding aliases')
expect(path.normalize(readlinkSync(path.join(staged, 'node_modules/example')))).toBe(path.join('.pnpm', 'example'))
const dependencyLink = readlinkSync(path.join(staged, 'node_modules/example'))
expect(path.isAbsolute(dependencyLink)).toBe(false)
expect(path.normalize(dependencyLink)).toBe(path.join('.pnpm', 'example'))
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
// Preserve the permissions represented by the host; Windows mode bits are not POSIX ACLs.
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(serverMode)
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(staticMode)
})
// Windows permissions are ACL-based; Node's mode bits do not represent this policy.
+5 -1
View File
@@ -37,7 +37,11 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
".next/dev/types/**/*.ts",
".next-collaboration/types/**/*.ts",
".next-collaboration/dev/types/**/*.ts",
".next-presence-live/types/**/*.ts",
".next-presence-live/dev/types/**/*.ts"
],
"exclude": [
"node_modules"