Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5694671227
|
||
|
|
787a227602
|
||
|
|
30f4f9d586
|
||
|
|
24bbe4a038
|
||
|
|
8903f272c4
|
||
|
|
2345039911
|
||
|
|
4347924658
|
||
|
|
523f59cf38
|
||
|
|
fefd5be0d4
|
||
|
|
c0e5b285db
|
||
|
|
42b509f35c
|
||
|
|
37c4234324
|
||
|
|
412d884402
|
||
|
|
27e8754f32
|
||
|
|
00c00b0e71
|
||
|
|
3a72c8b88a
|
||
|
|
bfcc008ca2 | ||
|
|
fdf12d3c40 | ||
|
|
04ab9ad6c9 | ||
|
|
70618bd143 | ||
|
|
aaa77d7e6e | ||
|
|
73b1ea16d7 | ||
|
|
94726bb52c | ||
|
|
56ab7fe21c |
No files matched your search
@@ -0,0 +1 @@
|
||||
dynamic-components/** -text
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Generated
+419
-474
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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, '&')}" 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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 }] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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.' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 = []
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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()}`
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }] : []
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user