Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
722e5b775a
|
||
|
|
d49c4a5899
|
||
|
|
9b59908629
|
||
|
|
2f82a1fe83
|
||
|
|
d7cca33152
|
||
|
|
5694671227
|
||
|
|
5c7dbe2e52
|
||
|
|
a4ed5de745
|
||
|
|
e3f3fc8045
|
||
|
|
b2752ac74a
|
||
|
|
787a227602
|
||
|
|
30f4f9d586
|
||
|
|
24bbe4a038
|
||
|
|
8903f272c4
|
||
|
|
4347924658
|
||
|
|
523f59cf38
|
No files matched your search
@@ -78,6 +78,60 @@ Set `supplementalAppMetaData.nextUi.showChildGrid` on the app to override this d
|
||||
widgets already cover its sections and child apps.
|
||||
Only boolean values override the default. Existing visibility and permission rules still apply.
|
||||
|
||||
### Account menu links
|
||||
|
||||
An application can add a small, labelled group of links to the account menu (the avatar at the
|
||||
right of the header), for the tools that go with one environment: on a local development stack,
|
||||
the mail capture inbox, the identity provider's admin page, the message broker's console. The
|
||||
group is shown only when the application declares it, under Preferences and above Sign out.
|
||||
|
||||
Declare a widget of type `ACCOUNT_MENU_LINKS` in the instance. Nothing in QQQ itself changes: the
|
||||
metadata response already publishes every widget's `type`, `label`, `hasPermission` and
|
||||
`defaultValues`, and the Tools shortcut in the same menu is found the same way. The frontend reads
|
||||
this from `GET /qqq/v1/metaData`:
|
||||
|
||||
```json
|
||||
{
|
||||
"widgets": {
|
||||
"localTools": {
|
||||
"name": "localTools",
|
||||
"label": "Local tools",
|
||||
"type": "ACCOUNT_MENU_LINKS",
|
||||
"hasPermission": true,
|
||||
"defaultValues": {
|
||||
"links": [
|
||||
{ "label": "Mail inbox", "url": "http://localhost:8025/", "icon": "mail" },
|
||||
{ "label": "Identity provider", "url": "https://id.example.com/admin/", "icon": "admin_panel_settings" },
|
||||
{ "label": "Message broker", "url": "http://127.0.0.1:15672/" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Part | Meaning |
|
||||
|---|---|
|
||||
| widget `type` | `ACCOUNT_MENU_LINKS`. Each such widget is one group; groups appear in metadata order. |
|
||||
| widget `label` | The group's heading (`Links` when blank). |
|
||||
| widget `hasPermission` | `false` hides the group, so a group can be limited with the widget's permission rules. |
|
||||
| `defaultValues.links` | An array of links, shown in order; at most 12 are shown. |
|
||||
| link `label` | Required text of the menu item. |
|
||||
| link `url` | Required. An absolute `https:` URL, or `http:` to `localhost`, `127.0.0.1` or `[::1]`. No user name or password. |
|
||||
| link `icon` | Optional Material icon name (`mail`, `key`, `dns`, ...), as for other metadata icons. Without one the item shows an external-link icon. |
|
||||
|
||||
A link with any other URL (plain `http:` to another host, `javascript:`, `data:`, a relative path,
|
||||
credentials in the URL), or without a label, is dropped and never rendered; the rest of the group
|
||||
still shows, and a group left without links does not show. Each link opens in a new tab with
|
||||
`rel="noopener noreferrer"` and is a keyboard-reachable menu item. Do not put a secret in a URL:
|
||||
the metadata response reaches every signed-in user who may see the widget.
|
||||
|
||||
On the backend the widget needs a name, the type, a label and a code reference like any other
|
||||
widget (a renderer that returns no data is enough: the menu never asks for the widget's data), and
|
||||
`defaultValues.links` as a list of maps. Do not add the widget to an app: it is not a dashboard
|
||||
widget. Declare it only in the environments that have the tools, for example from an environment
|
||||
variable.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Release candidates only; final 1.0 is held for application soak and owner approval ([#712](https://github.com/QRun-IO/qqq/issues/712), tracker [#713](https://github.com/QRun-IO/qqq/issues/713)).
|
||||
@@ -225,3 +279,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.
|
||||
+57
-46
@@ -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,60 @@ 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
|
||||
}
|
||||
**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.
|
||||
|
||||
// Network failure or unexpected status
|
||||
toast.error('Something went wrong')
|
||||
}
|
||||
```
|
||||
**Background requests show no banner.** A request the page makes on its own, for something the page
|
||||
works without, sets `meta: BACKGROUND_REQUEST`: a poll that keeps an indicator fresh, or a write nobody
|
||||
asked for. Its failure is logged to the console as above (with `background: true`) and never raises the
|
||||
banner: the person did nothing that failed and can do nothing about it, and a poll would raise the banner
|
||||
again on every attempt. The indicator shows its own quiet state, or nothing, until a later attempt
|
||||
succeeds. A request the person started (opening a page, saving, running something) is never a background
|
||||
request. Today's background requests:
|
||||
|
||||
This handler is registered on both `QueryCache` and `MutationCache`:
|
||||
| Request | Where | While it fails |
|
||||
|---|---|---|
|
||||
| Presence poll (`GET …/collaboration/presence`) | `use-collaboration-presence.ts` | `Presence is reconnecting…` in place of the viewer count on a table; on a record, the same line only if other people were shown when the connection was lost, otherwise nothing. Presence leases are renewed outside TanStack Query and never raised a banner. |
|
||||
| Notification bell summary | `AlertsBell.tsx` (`useAlertsInbox`) | the bell keeps its last count, or stays hidden |
|
||||
| Jobs count in the header | `use-my-jobs.ts` (`background` argument, set by `JobsHeaderButton`) | the button keeps its last count; the Jobs page itself still reports its failures |
|
||||
| Break-glass session status | `BreakGlassBanner.tsx` | no banner |
|
||||
| Recently viewed (`POST …/collaboration/recents`) | `use-collaboration-core.ts` (`useRecordRecentView`) | the visit is not added to recents |
|
||||
|
||||
```typescript
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => handleQueryError(error, 'query'),
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => handleQueryError(error, 'mutation'),
|
||||
}),
|
||||
})
|
||||
```
|
||||
A background poll should also back off while it fails. `backoffDelay` in `src/lib/utils/backoff.ts`
|
||||
gives the wait: a ceiling that doubles per consecutive failure, half of it random, so clients that failed
|
||||
together (a server restart) do not all return together. Presence waits 2.5 to 5 s after the first failure
|
||||
and up to a minute after many, instead of its healthy 20 s interval, and does not use the automatic
|
||||
retries described below.
|
||||
|
||||
**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 +149,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 +217,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 +284,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 +315,7 @@ 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 |
|
||||
| `meta: BACKGROUND_REQUEST` | `src/lib/query-client.ts` | A poll or write the page makes on its own → logged, no toast |
|
||||
| Per-component `onError` | Your component | 400 field errors, custom empty states, navigation |
|
||||
| `getErrorStatusCode` | `src/lib/utils/error-utils.ts` | Normalize `AxiosError` and plain-object errors to HTTP status code |
|
||||
@@ -94,7 +94,7 @@ Permissions apply to metadata and data: check table capabilities and the corresp
|
||||
| `/app/search?q=...` | Navigation, recent-record, and available record search results. |
|
||||
| `/app/developer` | Developer information available to permitted users. |
|
||||
|
||||
The header search and `/` dialog locate navigation targets and recent records; record search is offered when the backend supports it. RC8 includes the search-focus correction: Tab/Shift+Tab remain within the search dialog, Clear returns focus to the search input, and Escape or backdrop dismissal restores focus to the opener. A pointer-opened phone search restores focus to its actual button in verified Chromium/mobile cases. RC8 tablet WebKit has two open Clear-search focus failures; see the release notes for remaining browser issues. RC7 does not include this correction. The command palette opens with `.` or Cmd/Ctrl+K and has a header button; `?` opens keyboard help. Keyboard shortcuts on query and record pages are listed in the [feature matrix](../acceptance/feature-matrix.md). The sidebar has a phone drawer, nested app groups, banners, and a user menu. Branding can add a company footer and a themed bar above the shell. `?helpHelp` reveals help-slot keys for content authors.
|
||||
The header search and `/` dialog locate navigation targets and recent records; record search is offered when the backend supports it. RC8 includes the search-focus correction: Tab/Shift+Tab remain within the search dialog, Clear returns focus to the search input, and Escape or backdrop dismissal restores focus to the opener. A pointer-opened phone search restores focus to its actual button in verified Chromium/mobile cases. RC8 tablet WebKit has two open Clear-search focus failures; see the release notes for remaining browser issues. RC7 does not include this correction. The command palette opens with `.` or Cmd/Ctrl+K and has a header button; `?` opens keyboard help. Keyboard shortcuts on query and record pages are listed in the [feature matrix](../acceptance/feature-matrix.md). The sidebar has a phone drawer, nested app groups, banners, and a user menu. An application can add a labelled group of external links to the account menu (an environment's companion tools) by declaring a widget of type `ACCOUNT_MENU_LINKS`; the contract and its URL rules are in the README under [Account menu links](../../README.md#account-menu-links). Branding can add a company footer and a themed bar above the shell. `?helpHelp` reveals help-slot keys for content authors.
|
||||
|
||||
Material's nested `/{app}/{table}/{id}` URLs are not the native Next route. A not-found page now checks the signed-in instance metadata, applies exact or `/*` redirect rules, and resolves nested Material app paths to `/app/...` while preserving query/hash ([`legacy-paths.ts`](../../src/lib/utils/legacy-paths.ts), [`LegacyPathRedirect.tsx`](../../src/components/layout/LegacyPathRedirect.tsx)). Verify a specific old bookmark rather than assuming arbitrary legacy URLs match. For links generated by widgets use [`material-links.ts`](../../src/lib/utils/material-links.ts), which normalizes supported legacy links and preserves a trailing slash for static-export routes.
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
- **Tools:** the `QQQ_TOOLS` widget provides a shared setup workspace for CLI/TUI, mobile scanners and Print Client, with platform-specific install commands, copyable scripts and instance connection details. An account-menu shortcut follows visible Tools metadata. Requires the Tools Qbit; existing device enrollment and permissions remain unchanged.
|
||||
|
||||
- **Application-declared links in the account menu.** An application can add a labelled group of links to the tools that go with one environment (a mail capture inbox, an identity provider's admin page, a message broker's console) by declaring a widget of type `ACCOUNT_MENU_LINKS` whose `defaultValues.links` is a list of `{ "label", "url", "icon" }`. No QQQ change is needed: the metadata response already publishes a widget's type, label, permission and default values. Only absolute `https:` URLs, or `http:` to `localhost`, `127.0.0.1` or `[::1]`, without credentials, are shown; any other link is dropped. Links open in a new tab with `rel="noopener noreferrer"`, are keyboard-reachable menu items, and the group renders nothing when the application declares none. The account menu now scrolls within the space it has when it is taller than the window. Contract: README, [Account menu links](../../README.md#account-menu-links).
|
||||
|
||||
- **UI API 1.6.0 (QQQ 4.1.0-RC.15).** Next reads the typed `clientCapabilities` of table metadata for saved views, sharing, bulk processes and audits, applies `field.validation` (required, length, inclusive and exclusive bounds, patterns) before a request, reads client-neutral `uiSettings` setting by setting with the deprecated `materialDashboard` values as fallback, and shows the process error `traceId`. The full `materialDashboardTheme` still wins over the `uiSettings.theme` subset. Error messages keep their order (`userFacingError`, `error`, then the new `message`). Audits fall back to the permission-filtered instance metadata when a table's capability is unavailable, since RC.15 marks unaudited tables unavailable although explicit audits can exist. Off-spec calls stay at 0.
|
||||
- **What's new:** a paged release history with new badges, safe links and screenshots, notification deep links, and an unread count in the user menu. Uses UI API extension `release-notes` 1.0.0; requires qbit-release-notes 0.1.0-RC.1+.
|
||||
- **Profile photo and avatars everywhere** (QQQ 4.1.0-RC.17, UI API 1.8.0 `avatars`):
|
||||
@@ -13,13 +15,27 @@
|
||||
- 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.
|
||||
|
||||
- **Explore views leave a way back.** Pivot, Compare, Correlation, Crosstab, Shift over time and a saved analysis are states of a table's record list, named by the query string (`?view=correlation`), so the breadcrumb, which read only the path, kept showing the table as the current page and offered no link back to the list. The breadcrumb now ends with the view (`… / People / Correlation`) and the table before it links to the list with its filter, sort, page and quick search as they were. "Back to records" is one step back in history when the list it returns to is the one the view was opened from, so the view stays one Forward away and the browser's Back button no longer meets the same list twice; after a drill changed the filter, or on a link straight to a view, it replaces the view with the list in place, as before. A Back or Forward step between the list and a view now takes the page and page size from the address as well as the filter and search, instead of returning the list to page 1. The views are registered in one place (`src/lib/utils/table-sub-views.ts`), which the list, the breadcrumb and the tests read, so a new view cannot ship without a label and a way back.
|
||||
|
||||
- **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.
|
||||
|
||||
- **A presence outage is no longer shown as a server error.** The collaboration presence endpoint answers 503 on purpose for a while after its shared store restarts, and every table and record with presence showed that as a red `Server error — retrying…` banner, again and again. Presence is not essential to the page, so its failures now raise no banner. On a table the viewer count reads `Presence is reconnecting…` in muted text (a polite status, not an alert) until the server answers; on a record or an edit form that line appears only when other people were shown at the moment the connection was lost, and otherwise nothing changes. The poll and the lease renewal no longer repeat at a fixed 20 seconds or retry three times in seven: after a failure the next attempt waits 2.5 to 5 seconds, then twice as long per failure up to a minute, with jitter, and the first success restores the usual rhythm. In a 45 second outage one table made eleven presence requests and raised the banner twice; it now makes about eight and raises none.
|
||||
- **Background requests do not raise the error banner.** The rule behind the presence fix is general: a request the page makes on its own for something the page works without (`meta: BACKGROUND_REQUEST`) is logged to the console and shows no banner. It now also covers the notification bell's summary poll, the header's jobs count, the break-glass status check and the note of a recently viewed table or record. Requests a person starts are unchanged. See `docs/API-ERROR-HANDLING.md`.
|
||||
|
||||
- **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 audit dialog holds a long record identifier, and sorts without asking again.** With a long unbroken record label (a 64 character identifier) the dialog's heading could not shrink or wrap, so it filled the card and pushed the sort and close buttons out of it, to its right. The heading now takes the space the controls leave, wraps at any character and is cut after three lines, with the whole text in its tooltip and a copy button beside the sort buttons; the controls stay inside the card from 320 px up, in both themes. An empty history is one block (a small icon over `No audits were found for this record.`) instead of a status line with a large icon in a band of its own below it; loading and error states no longer leave an empty band either. Opening the dialog makes one request, as before. Changing the sort order made another request every time; a history under the 1,000 row cap is complete, so the other order is now shown at once from what is loaded, and only a capped history goes back to the server. The audit process is given 25 seconds to answer in the same request instead of the server's default 3, after which the server answers that the run continues as a job, which the dialog showed as `Error loading audits`.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { useUiApiExtensionInstalled, UI_API_EXTENSION } from '@/lib/api/ui-api-extensions'
|
||||
import { getAlertsInboxSummary } from '@/lib/api/alerts'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { BACKGROUND_REQUEST, queryKeys } from '@/lib/query-client'
|
||||
import type { AlertsInboxSummary } from '@/types/alerts'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
|
||||
@@ -99,6 +99,8 @@ export function useAlertsInbox(enabled: boolean) {
|
||||
staleTime: 30_000,
|
||||
refetchInterval: (query) => pollDelay(query.state.data, failures),
|
||||
refetchIntervalInBackground: false,
|
||||
// A poll for the bell's badge: a failure leaves the bell as it was and raises no banner.
|
||||
meta: BACKGROUND_REQUEST,
|
||||
refetchOnWindowFocus: (query) => (query.state.data === null ? false : 'always'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useCollaborationPresence } from '@/lib/hooks/use-collaboration-presence
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import { recordVersion } from '@/lib/utils/record-version'
|
||||
import { CollaborationDialog, collaborationButton } from './CollaborationDialog'
|
||||
import { PresenceAvatars } from './PresenceAvatars'
|
||||
import { PresenceAvatars, PresenceReconnecting } from './PresenceAvatars'
|
||||
|
||||
export interface CollaborationEditProps {
|
||||
table: QTableMetaData
|
||||
@@ -167,7 +167,7 @@ export function CollaborationEdit({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PresenceAvatars viewers={presence.viewers} />
|
||||
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
|
||||
{presence.hadViewers && <PresenceReconnecting />}
|
||||
{conflict && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -20,6 +20,22 @@ import { Pencil } from 'lucide-react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import type { PresenceViewer } from '@/types/collaboration'
|
||||
import { UserAvatar } from '@/components/ui/UserAvatar'
|
||||
/**
|
||||
* The quiet state of a presence indicator while the server cannot answer: one muted line.
|
||||
*
|
||||
* Accessibility: `role="status"` is a polite live region, so a screen reader says it once when it
|
||||
* appears and never interrupts; it is plain muted text, not an alert, and takes no focus.
|
||||
*
|
||||
* @returns The line.
|
||||
*/
|
||||
export function PresenceReconnecting() {
|
||||
return (
|
||||
<span role="status" className="text-xs text-muted-foreground" data-qqq-id="presence-reconnecting">
|
||||
Presence is reconnecting…
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Other viewers' avatars (photo or initials), wrapping at narrow widths.
|
||||
* @param props - Merged people.
|
||||
* @returns Avatars.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// What a presence indicator shows while presence is reconnecting: a quiet status where the
|
||||
// indicator always shows something, and nothing where it shows nothing when nobody else is there.
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { axe } from 'jest-axe'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QRecord, QTableMetaData } from '@/types'
|
||||
import type { PresenceViewer } from '@/types/collaboration'
|
||||
import { useCollaborationPresence } from '@/lib/hooks/use-collaboration-presence'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { PresenceReconnecting } from './PresenceAvatars'
|
||||
import { RecordCollaborationHeader, TablePresence } from './RecordCollaborationHeader'
|
||||
|
||||
vi.mock('@/lib/hooks/use-collaboration-presence', () => ({ useCollaborationPresence: vi.fn() }))
|
||||
vi.mock('@/lib/hooks/use-realtime-channel', () => ({ useRealtimeChannel: vi.fn() }))
|
||||
vi.mock('@/lib/api/collaboration', () => ({ getRecordFeatures: vi.fn(async () => ({})), putAssignment: vi.fn() }))
|
||||
|
||||
const sam: PresenceViewer = { userId: 'sam', displayName: 'Sam', mode: 'VIEWING' }
|
||||
const table: QTableMetaData = { ...structuredClone(qInstance.tables.person), recordFeatures: { features: ['PRESENCE'] } } as QTableMetaData
|
||||
const record = { tableName: 'person', recordLabel: 'Alice', values: { id: 1 } } as QRecord
|
||||
|
||||
type Presence = ReturnType<typeof useCollaborationPresence>
|
||||
const presence = (state: { reconnecting: boolean; hadViewers?: boolean; viewers?: PresenceViewer[] }) =>
|
||||
vi.mocked(useCollaborationPresence).mockReturnValue({ hadViewers: false, viewers: [], ...state } as unknown as Presence)
|
||||
|
||||
function mount(node: React.ReactNode) {
|
||||
return render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>{node}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('presence while reconnecting', () => {
|
||||
beforeEach(() => vi.mocked(useCollaborationPresence).mockReset())
|
||||
|
||||
it('is one muted, polite status line: no alert, no error color, nothing to focus', async () => {
|
||||
const { container } = render(<PresenceReconnecting />)
|
||||
const line = screen.getByRole('status')
|
||||
expect(line).toHaveTextContent('Presence is reconnecting…')
|
||||
expect(line).toHaveAttribute('data-qqq-id', 'presence-reconnecting')
|
||||
expect(line).toHaveClass('text-muted-foreground')
|
||||
expect(line.className).not.toMatch(/destructive|red/)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('button, a, [tabindex]')).toBeNull()
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('a table shows it in place of the viewer count, and the count again once presence is back', () => {
|
||||
presence({ reconnecting: true })
|
||||
const { rerender } = mount(<TablePresence table={table} available />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Presence is reconnecting…')
|
||||
expect(screen.queryByText(/viewing/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
|
||||
presence({ reconnecting: false, viewers: [sam] })
|
||||
rerender(<QueryClientProvider client={new QueryClient()}><TablePresence table={table} available /></QueryClientProvider>)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 person viewing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a record shows nothing when nobody else was there, as when nobody else is there', () => {
|
||||
presence({ reconnecting: true, hadViewers: false })
|
||||
const { container } = mount(<RecordCollaborationHeader table={table} record={record} available />)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/presence/i)).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[data-qqq-id="presence-avatars"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('a record says so when the people it showed had to be taken away', () => {
|
||||
presence({ reconnecting: true, hadViewers: true })
|
||||
mount(<RecordCollaborationHeader table={table} record={record} available />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Presence is reconnecting…')
|
||||
expect(screen.queryByRole('button', { name: /Sam/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@ 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 { PresenceAvatars, PresenceReconnecting } from './PresenceAvatars'
|
||||
import { CollaborationDialog, collaborationButton } from './CollaborationDialog'
|
||||
import { PeoplePicker } from './PeoplePicker'
|
||||
const Share = dynamic(() => import('./ShareDialog').then((m) => m.ShareDialog), { ssr: false })
|
||||
@@ -107,7 +107,9 @@ 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>}
|
||||
{/* Nobody else here looks like nothing, so a lost connection does too; it is only said when
|
||||
the people who were shown had to be taken away. */}
|
||||
{presence.hadViewers && <PresenceReconnecting />}
|
||||
{(owner != null || assignee != null) && (
|
||||
<dl className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{owner != null && (
|
||||
@@ -195,11 +197,12 @@ 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 role={presence.unavailable ? 'status' : undefined}>
|
||||
{presence.unavailable
|
||||
? 'Presence temporarily unavailable'
|
||||
: `${pluralize(presence.viewers.length, 'person', 'people')} viewing`}
|
||||
</span>
|
||||
{/* The count is always here, so its place says when the count is not known for now. */}
|
||||
{presence.reconnecting ? (
|
||||
<PresenceReconnecting />
|
||||
) : (
|
||||
<span>{`${pluralize(presence.viewers.length, 'person', 'people')} viewing`}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const JobsPopover = lazy(() => import('./JobsPopover'))
|
||||
*/
|
||||
export function JobsHeaderButton() {
|
||||
const installed = useJobsInstalled()
|
||||
const active = useMyJobs(installed, { scope: 'mine', state: 'active', limit: 20 })
|
||||
const active = useMyJobs(installed, { scope: 'mine', state: 'active', limit: 20 }, true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const recent = useMyJobs(installed && open, { scope: 'mine', state: 'recent', limit: 5 })
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render as renderWithoutProviders, screen } from '@testing-library/react'
|
||||
import { render as renderWithoutProviders, screen, waitFor, within } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { axe } from 'jest-axe'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
|
||||
@@ -32,6 +33,7 @@ vi.mock('next/navigation', () => ({
|
||||
import { server } from '@/mocks/node'
|
||||
import { uiApiVersionHandler } from '@/mocks/ui-api-version'
|
||||
import { preferencesFixture } from '@/mocks/fixtures/preferences'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { ThemeProvider } from '@/lib/theme/theme-provider'
|
||||
import { setSingleKeyShortcutsEnabled } from '@/lib/keyboard/single-key-shortcuts'
|
||||
import { AccountMenu } from './AccountMenu'
|
||||
@@ -106,6 +108,109 @@ describe('AccountMenu', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AccountMenu application-declared links', () => {
|
||||
/**
|
||||
* Serves instance metadata with one more widget.
|
||||
*
|
||||
* @param widgets - Widgets to add, by name.
|
||||
*/
|
||||
function declare(widgets: Record<string, unknown>) {
|
||||
const named = Object.fromEntries(Object.entries(widgets).map(([name, widget]) => [name, { name, hasPermission: true, ...(widget as object) }]))
|
||||
server.use(http.get('/qqq/v1/metaData', () => HttpResponse.json({ ...qInstance, widgets: { ...qInstance.widgets, ...named } })))
|
||||
}
|
||||
const localTools = {
|
||||
label: 'Local tools',
|
||||
type: 'ACCOUNT_MENU_LINKS',
|
||||
defaultValues: {
|
||||
links: [
|
||||
{ label: 'Mail inbox', url: 'http://localhost:8025/', icon: 'mail' },
|
||||
{ label: 'Identity provider', url: 'https://id.example.com/admin/' },
|
||||
{ label: 'Plain http elsewhere', url: 'http://tools.example.com/' },
|
||||
{ label: 'Script', url: 'javascript:alert(1)' },
|
||||
{ label: 'Credentials', url: 'https://admin:secret@id.example.com/' },
|
||||
{ label: 'Message broker', url: 'http://127.0.0.1:15672/' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
it('shows a declared group under the account menu: safe links only, each a menu item that opens in a new tab', async () => {
|
||||
declare({ localTools })
|
||||
const user = userEvent.setup()
|
||||
render(<AccountMenu userName="Alice" logout={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Account: Alice' }))
|
||||
const group = await screen.findByRole('group', { name: 'Local tools' })
|
||||
expect(group).toHaveAttribute('data-qqq-id', 'account-menu-links-localTools')
|
||||
|
||||
const items = within(group).getAllByRole('menuitem')
|
||||
expect(items.map((item) => [item.textContent, item.getAttribute('href')])).toEqual([
|
||||
['Mail inbox(opens in a new tab)', 'http://localhost:8025/'],
|
||||
['Identity provider(opens in a new tab)', 'https://id.example.com/admin/'],
|
||||
['Message broker(opens in a new tab)', 'http://127.0.0.1:15672/'],
|
||||
])
|
||||
for (const item of items) {
|
||||
expect(item).toHaveAttribute('target', '_blank')
|
||||
expect(item).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(item.tagName).toBe('A')
|
||||
}
|
||||
expect(items[0]).toHaveAccessibleName('Mail inbox (opens in a new tab)')
|
||||
expect(items[0]).toHaveAttribute('data-qqq-id', 'menu-item-link-localTools-0')
|
||||
// the declared icon, from the set the menu's other metadata icons use
|
||||
expect(items[0].querySelector('[data-qqq-icon="mail"]')).not.toBeNull()
|
||||
|
||||
// nothing of a refused link reaches the page
|
||||
const menu = screen.getByRole('menu', { name: 'Account: Alice' })
|
||||
expect(menu).not.toHaveTextContent(/Plain http elsewhere|Script|Credentials/)
|
||||
expect(menu.innerHTML).not.toMatch(/javascript:|secret|tools\.example\.com/)
|
||||
expect(await axe(menu)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('reaches the links with the arrow keys, between Preferences and Sign out, and closes when one is chosen', async () => {
|
||||
declare({ localTools })
|
||||
const user = userEvent.setup()
|
||||
render(<AccountMenu userName="Alice" logout={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Account: Alice' }))
|
||||
const mail = await screen.findByRole('menuitem', { name: /Mail inbox/ })
|
||||
await user.keyboard('{End}')
|
||||
expect(screen.getByRole('menuitem', { name: 'Sign out' })).toHaveFocus()
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(screen.getByRole('menuitem', { name: /Message broker/ })).toHaveFocus()
|
||||
await user.keyboard('{ArrowUp}{ArrowUp}')
|
||||
expect(mail).toHaveFocus()
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(screen.getByRole('menuitemcheckbox', { name: 'Dark mode' })).toHaveFocus()
|
||||
|
||||
mail.addEventListener('click', (event) => event.preventDefault())
|
||||
await user.click(mail)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing for links when the application declares none', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AccountMenu userName="Alice" logout={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Account: Alice' }))
|
||||
await screen.findByRole('menuitem', { name: 'Preferences' })
|
||||
await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Sign out' })).toBeInTheDocument())
|
||||
expect(screen.queryByRole('group')).toBeNull()
|
||||
expect(document.querySelector('[data-qqq-id^="account-menu-links"], [data-qqq-id^="menu-item-link"]')).toBeNull()
|
||||
expect(document.querySelector('a[target="_blank"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing for a group whose links are all refused, or that the user may not see', async () => {
|
||||
declare({
|
||||
allBad: { label: 'Refused', type: 'ACCOUNT_MENU_LINKS', defaultValues: { links: [{ label: 'Script', url: 'javascript:alert(1)' }, { label: 'Http', url: 'http://example.com/' }] } },
|
||||
denied: { ...localTools, label: 'Hidden', hasPermission: false },
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<AccountMenu userName="Alice" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Account: Alice' }))
|
||||
await screen.findByRole('menuitem', { name: 'Preferences' })
|
||||
// metadata has loaded once the instance's own widgets are known; give the menu a turn to use it
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(screen.queryByRole('group')).toBeNull()
|
||||
expect(screen.getByRole('menu')).not.toHaveTextContent(/Refused|Hidden|Mail inbox/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HelpMenu', () => {
|
||||
it('opens help, not shortcuts: Keyboard shortcuts is one of its items (WCAG 3.2.6)', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
/**
|
||||
* @file AccountMenu — the signed-in user's avatar at the far right of the header (the usual
|
||||
* "my account" place): name and email, Preferences and the sections that opt into the menu,
|
||||
* dark mode, QA mode status, and sign out.
|
||||
* dark mode, QA mode status, the links the application declares for its environment, and sign out.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -25,7 +25,7 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Bug, ChevronDown, LogOut, Moon, Settings, Wrench } from 'lucide-react'
|
||||
import { Bug, ChevronDown, ExternalLink, LogOut, Moon, Settings, Wrench } from 'lucide-react'
|
||||
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { toolsTarget } from '@/components/tools/tools-catalog'
|
||||
@@ -39,6 +39,7 @@ import { cn } from '@/lib/utils/cn'
|
||||
import { useMyProfile } from '@/lib/hooks/use-my-profile'
|
||||
import { UserAvatar } from '@/components/ui/UserAvatar'
|
||||
import { MetadataIcon } from './MetadataIcon'
|
||||
import { accountMenuLinkGroups } from './account-menu-links'
|
||||
import { HEADER_MENU_ITEM_CLASS, HeaderMenu, HeaderMenuSeparator } from './HeaderMenu'
|
||||
|
||||
/** Below this width the header shows only Search, Jobs, Notifications and the avatar. */
|
||||
@@ -80,6 +81,8 @@ export function AccountMenu({ userName, userEmail, logout, compactItems }: Accou
|
||||
label={qaMode ? `Account: ${displayName} (QA mode on)` : `Account: ${displayName}`}
|
||||
qqqId="button-account"
|
||||
triggerClassName="gap-1 px-1"
|
||||
// declared links can make the menu taller than a phone: it scrolls within the space it has
|
||||
menuClassName="max-h-[var(--radix-popover-content-available-height)] overflow-y-auto"
|
||||
trigger={(
|
||||
<>
|
||||
<span className="relative">
|
||||
@@ -112,6 +115,7 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
|
||||
const { theme, isDarkMode, setDarkMode } = useTheme()
|
||||
const metadata = useMetaData()
|
||||
const tools = toolsTarget(metadata.data)
|
||||
const linkGroups = accountMenuLinkGroups(metadata.data)
|
||||
const capability = useQaCaptureCapability()
|
||||
const preferences = useQuery({ queryKey: queryKeys.preferencesIndex(), queryFn: getPreferences, retry: false })
|
||||
const qaSection = capability?.enabled && capability.reason === 'QA_MODE' ? capability.qaMode.preferenceSection : undefined
|
||||
@@ -177,6 +181,31 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{/* Links the application declares for this environment (`account-menu-links`): nothing is
|
||||
rendered when it declares none. Accessibility: each group is a `group` named by its
|
||||
heading; each link is a `menuitem`, so the arrow keys, Home and End reach it like any
|
||||
other item; it opens in a new tab, which its name says ("opens in a new tab") and a
|
||||
trailing icon shows; `rel="noopener noreferrer"` keeps the opened page from reaching
|
||||
back into this one or learning the address it came from. */}
|
||||
{linkGroups.map((group) => (
|
||||
<div key={group.name} role="group" aria-labelledby={`account-menu-links-${group.name}`} data-qqq-id={`account-menu-links-${group.name}`}>
|
||||
<HeaderMenuSeparator />
|
||||
<p id={`account-menu-links-${group.name}`} className="truncate px-4 pb-1 pt-2 text-xs font-semibold text-muted-foreground" data-qqq-id="account-menu-links-label">
|
||||
{group.label}
|
||||
</p>
|
||||
{group.links.map((link, index) => (
|
||||
<a key={`${index}:${link.href}`} href={link.href} target="_blank" rel="noopener noreferrer" role="menuitem" onClick={() => close()}
|
||||
className={HEADER_MENU_ITEM_CLASS} data-qqq-id={`menu-item-link-${group.name}-${index}`}>
|
||||
{link.icon
|
||||
? <MetadataIcon iconName={link.icon} kind="section" className="h-4 w-4 text-muted-foreground" />
|
||||
: <ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
<span className="min-w-0 flex-1 truncate" title={link.label}>{link.label}</span>
|
||||
<span className="sr-only">(opens in a new tab)</span>
|
||||
{link.icon && <ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{logout && (
|
||||
<>
|
||||
<HeaderMenuSeparator />
|
||||
|
||||
@@ -21,13 +21,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
let currentPathname = '/app/person'
|
||||
let currentSearch = ''
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => currentPathname,
|
||||
useSearchParams: () => new URLSearchParams(currentSearch),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
import Breadcrumbs, { buildBreadcrumbs, buildDocumentTitle, humanizeSegment } from './Breadcrumbs'
|
||||
import type { ParentAppInfo } from '@/lib/hooks/use-routes'
|
||||
import { TABLE_SUB_VIEWS } from '@/lib/utils/table-sub-views'
|
||||
|
||||
beforeEach(() => vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
@@ -102,6 +105,30 @@ describe('buildBreadcrumbs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBreadcrumbs with a sub-view in the query string', () => {
|
||||
const list = 'page=3&filter=abc&q=smith'
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('$name is the last crumb and the table links back to the list as it was', (view) => {
|
||||
const search = `${list}&view=${view.name}&${view.params.map((name) => `${name}=layout`).join('&')}`
|
||||
const crumbs = buildBreadcrumbs('/app/person', pathToLabelMap, ancestorAppMap, search)
|
||||
expect(crumbs.map((crumb) => crumb.label)).toEqual(['People App', 'Greetings App', 'Person', view.label])
|
||||
expect(crumbs.at(-2)).toEqual({ path: '/app/person', label: 'Person', href: `/app/person?${list}` })
|
||||
})
|
||||
|
||||
it('links back to a saved view with its own path, and to a bare list without a query string', () => {
|
||||
const saved = buildBreadcrumbs('/app/person/savedView/3', pathToLabelMap, ancestorAppMap, new URLSearchParams('view=pivot&pivot=x'))
|
||||
expect(saved.slice(-2).map((crumb) => crumb.label)).toEqual(['Saved View', 'Pivot'])
|
||||
expect(saved.at(-2)?.href).toBe('/app/person/savedView/3')
|
||||
})
|
||||
|
||||
it('leaves the trail alone for the list, an unknown view, and a record page that has its own view parameter', () => {
|
||||
const plain = buildBreadcrumbs('/app/person', pathToLabelMap, ancestorAppMap)
|
||||
expect(buildBreadcrumbs('/app/person', pathToLabelMap, ancestorAppMap, 'page=3&filter=abc')).toEqual(plain)
|
||||
expect(buildBreadcrumbs('/app/person', pathToLabelMap, ancestorAppMap, 'view=nope')).toEqual(plain)
|
||||
expect(buildBreadcrumbs('/app/person/7', pathToLabelMap, ancestorAppMap, 'view=pivot').map((crumb) => crumb.label)).toEqual(['People App', 'Greetings App', 'Person', '7'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDocumentTitle', () => {
|
||||
const crumbs = buildBreadcrumbs('/app/person/1', pathToLabelMap, ancestorAppMap)
|
||||
|
||||
@@ -121,6 +148,14 @@ describe('buildDocumentTitle', () => {
|
||||
describe('Breadcrumbs', () => {
|
||||
beforeEach(() => {
|
||||
currentPathname = '/app/person'
|
||||
currentSearch = ''
|
||||
})
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('inside $name the view is the current page and the table is a link back to the list', (view) => {
|
||||
currentSearch = `page=2&filter=abc&view=${view.name}`
|
||||
render(<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} />)
|
||||
expect(screen.getByText(view.label)).toHaveAttribute('aria-current', 'page')
|
||||
expect(screen.getByRole('link', { name: 'Person' })).toHaveAttribute('href', '/app/person?page=2&filter=abc')
|
||||
})
|
||||
|
||||
it('renders the app hierarchy as links and the current page as text', () => {
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { Suspense } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { Home } from 'lucide-react'
|
||||
|
||||
import type { ParentAppInfo } from '@/lib/hooks/use-routes'
|
||||
import { listSearch, tableSubView } from '@/lib/utils/table-sub-views'
|
||||
|
||||
/**
|
||||
* Props for the Breadcrumbs component.
|
||||
@@ -47,6 +48,8 @@ export interface Breadcrumb {
|
||||
path: string
|
||||
/** Human-readable label. */
|
||||
label: string
|
||||
/** Where the crumb links when that is more than its path: the list behind a sub-view keeps its query string. */
|
||||
href?: string
|
||||
}
|
||||
|
||||
/** Labels for the fixed sub-page segments under a table or record. */
|
||||
@@ -102,15 +105,21 @@ export function humanizeSegment(segment: string): string {
|
||||
* and `/app/{table}/{id}/{process}` URLs) takes that object's label; other
|
||||
* segments without a label are humanized, except a record id, which stays as is.
|
||||
*
|
||||
* A sub-view of a record list lives in the query string, not the path (`?view=correlation`, see
|
||||
* `table-sub-views`). With `search`, an open sub-view becomes the last crumb, and the list's own
|
||||
* crumb before it links back to the list with the rest of the query string (filter, sort, page).
|
||||
*
|
||||
* @param pathname - Current browser path.
|
||||
* @param pathToLabelMap - Route path → label.
|
||||
* @param ancestorAppMap - Node path → enclosing apps.
|
||||
* @param search - Current query string; omit to build the trail from the path alone.
|
||||
* @returns Breadcrumbs from the outermost app to the current page.
|
||||
*/
|
||||
export function buildBreadcrumbs(
|
||||
pathname: string,
|
||||
pathToLabelMap: Record<string, string>,
|
||||
ancestorAppMap: Record<string, ParentAppInfo[]> = {}
|
||||
ancestorAppMap: Record<string, ParentAppInfo[]> = {},
|
||||
search?: URLSearchParams | string | null
|
||||
): Breadcrumb[] {
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
if (segments[0] !== 'app' || segments.length < 2) return []
|
||||
@@ -133,6 +142,14 @@ export function buildBreadcrumbs(
|
||||
?? (index === 0 ? decoded : humanizeSegment(decoded))
|
||||
crumbs.push({ path, label })
|
||||
}
|
||||
|
||||
// Only a record list hosts sub-views: the table itself, or one of its saved views.
|
||||
const subView = rest.length === 0 || rest[0] === 'savedView' ? tableSubView(search) : undefined
|
||||
if (subView) {
|
||||
const list = crumbs[crumbs.length - 1]
|
||||
crumbs[crumbs.length - 1] = { ...list, href: `${list.path}${listSearch(search)}` }
|
||||
crumbs.push({ path: `${list.path}?view=${subView.name}`, label: subView.label })
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
|
||||
@@ -155,14 +172,43 @@ export function buildDocumentTitle(crumbs: Breadcrumb[], pageTitle: string | und
|
||||
/**
|
||||
* Renders a horizontal breadcrumb trail for the current page.
|
||||
*
|
||||
* The trail follows the query string as well as the path, so a sub-view of a record list shows as
|
||||
* the current page with the list as a link back. The query string is read inside a Suspense
|
||||
* boundary (a static export cannot prerender it); until it is known the trail is built from the
|
||||
* path alone.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns A `<nav aria-label="Breadcrumb">` element with slash-separated
|
||||
* `<Link>` crumbs (ancestor pages) followed by a `<span aria-current="page">`
|
||||
* for the current page, or `null` on the dashboard root and before labels load.
|
||||
*/
|
||||
export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homePath = '/app' }: BreadcrumbsProps) {
|
||||
export default function Breadcrumbs(props: BreadcrumbsProps) {
|
||||
return (
|
||||
<Suspense fallback={<BreadcrumbTrail {...props} search={null} />}>
|
||||
<BreadcrumbsForLocation {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the query string for {@link Breadcrumbs}.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The trail for the current path and query string.
|
||||
*/
|
||||
function BreadcrumbsForLocation(props: BreadcrumbsProps) {
|
||||
return <BreadcrumbTrail {...props} search={useSearchParams()} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The trail itself.
|
||||
*
|
||||
* @param props - Component properties, and the query string when it is known.
|
||||
* @returns The `<nav>`, or `null`.
|
||||
*/
|
||||
function BreadcrumbTrail({ pathToLabelMap, ancestorAppMap = {}, homePath = '/app', search }: BreadcrumbsProps & { search: URLSearchParams | null }) {
|
||||
const pathname = usePathname()
|
||||
const breadcrumbs = buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap)
|
||||
const breadcrumbs = buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap, search)
|
||||
const hasLabels = Object.keys(pathToLabelMap).length > 0
|
||||
|
||||
// Until metadata supplies labels, raw URL segments would flash in place of labels
|
||||
@@ -186,7 +232,8 @@ export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homeP
|
||||
<Home className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{/* Every crumb stays a link, in order. A long label truncates with an ellipsis (full text in the title) and the
|
||||
trail wraps onto another line instead of scrolling or hiding crumbs. */}
|
||||
trail wraps onto another line instead of scrolling or hiding crumbs. Accessibility: the last crumb is text
|
||||
with aria-current="page"; every crumb before it, including the list behind an open sub-view, is a link. */}
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={crumb.path + index}>
|
||||
<span className="shrink-0 text-muted-foreground/60" aria-hidden="true">/</span>
|
||||
@@ -202,7 +249,7 @@ export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homeP
|
||||
) : (
|
||||
<Link
|
||||
title={crumb.label}
|
||||
href={crumb.path}
|
||||
href={crumb.href ?? crumb.path}
|
||||
className="min-w-0 max-w-[10rem] truncate text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-w-11 pointer-coarse:py-3 pointer-coarse:text-center"
|
||||
data-qqq-id={`breadcrumb-link-${index}`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { QInstance, QWidgetMetaData } from '@/types'
|
||||
import { ACCOUNT_MENU_LINKS_MAX, ACCOUNT_MENU_LINKS_WIDGET_TYPE, accountMenuLinkGroups, accountMenuLinkHref } from './account-menu-links'
|
||||
|
||||
/**
|
||||
* Instance metadata with the given widgets.
|
||||
*
|
||||
* @param widgets - Widgets by name.
|
||||
* @returns The metadata slice the reader takes.
|
||||
*/
|
||||
function instance(widgets: Record<string, Partial<QWidgetMetaData>>): Pick<QInstance, 'widgets'> {
|
||||
return { widgets: Object.fromEntries(Object.entries(widgets).map(([name, widget]) => [name, { name, label: name, hasPermission: true, ...widget }])) } as Pick<QInstance, 'widgets'>
|
||||
}
|
||||
|
||||
const links = (...declared: unknown[]) => ({ type: ACCOUNT_MENU_LINKS_WIDGET_TYPE, label: 'Local tools', defaultValues: { links: declared } })
|
||||
|
||||
describe('accountMenuLinkHref', () => {
|
||||
it.each([
|
||||
['https://mail.example.com/inbox?folder=1#top', 'https://mail.example.com/inbox?folder=1#top'],
|
||||
['https://example.com', 'https://example.com/'],
|
||||
[' https://example.com/a ', 'https://example.com/a'],
|
||||
['http://localhost:8025/', 'http://localhost:8025/'],
|
||||
['http://LOCALHOST:8025', 'http://localhost:8025/'],
|
||||
['http://127.0.0.1:15672/#/queues', 'http://127.0.0.1:15672/#/queues'],
|
||||
['http://[::1]:9000/admin', 'http://[::1]:9000/admin'],
|
||||
['https://localhost:8443/', 'https://localhost:8443/'],
|
||||
])('allows %s', (declared, href) => {
|
||||
expect(accountMenuLinkHref(declared)).toBe(href)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['plain http to another host', 'http://mail.example.com/'],
|
||||
['http to a name that only starts like this machine', 'http://localhost.example.com/'],
|
||||
['http to an address that only starts like this machine', 'http://127.0.0.1.example.com/'],
|
||||
['http to another loopback spelling', 'http://127.0.0.2/'],
|
||||
['http to a private address', 'http://192.168.1.10/'],
|
||||
['a user name and password', 'https://admin:secret@id.example.com/'],
|
||||
['a user name alone', 'https://admin@id.example.com/'],
|
||||
['credentials on this machine', 'http://admin:secret@localhost:8025/'],
|
||||
['a script', 'javascript:alert(1)'],
|
||||
['a script with odd spelling', ' JaVaScRiPt:alert(1)'],
|
||||
['a data URL', 'data:text/html,<script>alert(1)</script>'],
|
||||
['a file', 'file:///etc/passwd'],
|
||||
['another scheme', 'ftp://files.example.com/'],
|
||||
['a relative path', '/app/person'],
|
||||
['a protocol-relative URL', '//example.com/'],
|
||||
['a bare host', 'example.com'],
|
||||
['an empty string', ''],
|
||||
['blank space', ' '],
|
||||
['a number', 8025],
|
||||
['nothing', undefined],
|
||||
['an object', { href: 'https://example.com/' }],
|
||||
])('refuses %s', (_what, declared) => {
|
||||
expect(accountMenuLinkHref(declared)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountMenuLinkGroups', () => {
|
||||
it('reads a declared group: label, links in order, and well-formed icon names', () => {
|
||||
const groups = accountMenuLinkGroups(instance({
|
||||
localTools: links(
|
||||
{ label: 'Mail inbox', url: 'http://localhost:8025/', icon: 'mail' },
|
||||
{ label: ' Identity provider ', url: 'https://id.example.com/admin/' },
|
||||
{ label: 'Message broker', url: 'http://127.0.0.1:15672/', icon: '<svg onload=alert(1)>' },
|
||||
),
|
||||
}))
|
||||
expect(groups).toEqual([{
|
||||
name: 'localTools',
|
||||
label: 'Local tools',
|
||||
links: [
|
||||
{ label: 'Mail inbox', href: 'http://localhost:8025/', icon: 'mail' },
|
||||
{ label: 'Identity provider', href: 'https://id.example.com/admin/' },
|
||||
{ label: 'Message broker', href: 'http://127.0.0.1:15672/' },
|
||||
],
|
||||
}])
|
||||
})
|
||||
|
||||
it('is empty when the application declares nothing, and before metadata loads', () => {
|
||||
expect(accountMenuLinkGroups(undefined)).toEqual([])
|
||||
expect(accountMenuLinkGroups({ widgets: {} })).toEqual([])
|
||||
expect(accountMenuLinkGroups(instance({ chart: { type: 'chart', defaultValues: { links: [{ label: 'x', url: 'https://example.com/' }] } } }))).toEqual([])
|
||||
})
|
||||
|
||||
it('drops every link that is not a label and an allowed URL, and keeps the rest', () => {
|
||||
const [group] = accountMenuLinkGroups(instance({
|
||||
tools: links(
|
||||
{ label: 'Good', url: 'https://example.com/' },
|
||||
{ label: 'Plain http', url: 'http://example.com/' },
|
||||
{ label: 'Script', url: 'javascript:alert(1)' },
|
||||
{ label: 'Credentials', url: 'https://user:pw@example.com/' },
|
||||
{ label: '', url: 'https://example.com/' },
|
||||
{ label: ' ', url: 'https://example.com/' },
|
||||
{ url: 'https://example.com/' },
|
||||
{ label: 'No address' },
|
||||
{ label: { text: 'Object' }, url: 'https://example.com/' },
|
||||
'https://example.com/',
|
||||
null,
|
||||
['Array', 'https://example.com/'],
|
||||
{ label: 'Also good', url: 'http://[::1]:3000/' },
|
||||
),
|
||||
}))
|
||||
expect(group.links.map((link) => link.label)).toEqual(['Good', 'Also good'])
|
||||
})
|
||||
|
||||
it('shows no group that is left without links, is hidden by permission, or is malformed', () => {
|
||||
expect(accountMenuLinkGroups(instance({
|
||||
empty: links(),
|
||||
allBad: links({ label: 'Script', url: 'javascript:alert(1)' }),
|
||||
denied: { ...links({ label: 'Good', url: 'https://example.com/' }), hasPermission: false },
|
||||
notAList: { type: ACCOUNT_MENU_LINKS_WIDGET_TYPE, defaultValues: { links: { label: 'Good', url: 'https://example.com/' } } },
|
||||
aString: { type: ACCOUNT_MENU_LINKS_WIDGET_TYPE, defaultValues: { links: '[{"label":"Good","url":"https://example.com/"}]' } },
|
||||
noValues: { type: ACCOUNT_MENU_LINKS_WIDGET_TYPE },
|
||||
}))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps several groups in metadata order, names a group without a label, and caps a long one', () => {
|
||||
const many = Array.from({ length: ACCOUNT_MENU_LINKS_MAX + 5 }, (_, index) => ({ label: `Tool ${index + 1}`, url: `https://example.com/${index + 1}` }))
|
||||
const groups = accountMenuLinkGroups(instance({
|
||||
second: { ...links({ label: 'B', url: 'https://example.com/b' }), label: 'Second' },
|
||||
first: { ...links(...many), label: ' ' },
|
||||
}))
|
||||
expect(groups.map((group) => [group.name, group.label, group.links.length])).toEqual([['second', 'Second', 1], ['first', 'Links', ACCOUNT_MENU_LINKS_MAX]])
|
||||
expect(groups[1].links.at(-1)?.label).toBe(`Tool ${ACCOUNT_MENU_LINKS_MAX}`)
|
||||
})
|
||||
|
||||
it('shortens an overlong label instead of trusting it', () => {
|
||||
const [group] = accountMenuLinkGroups(instance({ tools: links({ label: 'x'.repeat(500), url: 'https://example.com/' }) }))
|
||||
expect(group.links[0].label).toHaveLength(80)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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 account-menu-links — links an application declares for the account menu: a labelled
|
||||
* group of links to the tools that go with one environment (a mail capture inbox, an identity
|
||||
* provider's admin page, a message broker's console).
|
||||
*
|
||||
* The contract is a widget in the instance metadata, the same carrier the Tools shortcut uses,
|
||||
* because an application can declare a widget without any change to QQQ itself and the metadata
|
||||
* response already publishes a widget's `type`, `label`, `hasPermission` and `defaultValues`:
|
||||
*
|
||||
* ```json
|
||||
* "widgets": {
|
||||
* "localTools": {
|
||||
* "name": "localTools",
|
||||
* "label": "Local tools",
|
||||
* "type": "ACCOUNT_MENU_LINKS",
|
||||
* "hasPermission": true,
|
||||
* "defaultValues": {
|
||||
* "links": [
|
||||
* { "label": "Mail inbox", "url": "http://localhost:8025/", "icon": "mail" },
|
||||
* { "label": "Identity provider", "url": "https://id.example.com/admin/" }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Everything that arrives is untrusted: a link that is not exactly a label and a safe URL is
|
||||
* dropped, never repaired and never rendered.
|
||||
*/
|
||||
|
||||
import type { QInstance } from '@/types'
|
||||
|
||||
/** Widget `type` that declares a group of account menu links. */
|
||||
export const ACCOUNT_MENU_LINKS_WIDGET_TYPE = 'ACCOUNT_MENU_LINKS'
|
||||
|
||||
/** Most links one group shows; the menu is a menu, not a directory. */
|
||||
export const ACCOUNT_MENU_LINKS_MAX = 12
|
||||
|
||||
/** Longest label kept, in characters. */
|
||||
const LABEL_MAX = 80
|
||||
|
||||
/** A Material icon name, as the menu's other metadata icons use. */
|
||||
const ICON_NAME = /^[a-z0-9_]{1,40}$/
|
||||
|
||||
/** Hosts that are this machine. */
|
||||
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
|
||||
|
||||
/** One link, ready to render. */
|
||||
export interface AccountMenuLink {
|
||||
/** Text of the menu item. */
|
||||
label: string
|
||||
/** The normalized, safe URL. */
|
||||
href: string
|
||||
/** Material icon name, when the application named a well-formed one. */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** One labelled group of links. */
|
||||
export interface AccountMenuLinkGroup {
|
||||
/** The declaring widget's name (a stable key and CSS hook). */
|
||||
name: string
|
||||
/** The group's heading. */
|
||||
label: string
|
||||
/** Its links, in declared order; never empty. */
|
||||
links: AccountMenuLink[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The house rule for a link an application supplies: `https:` anywhere, or `http:` to this
|
||||
* machine (`localhost`, `127.0.0.1`, `[::1]`), absolute, and without credentials. Everything else
|
||||
* (`javascript:`, `data:`, plain `http:` to another host, a relative path, a URL with a user name
|
||||
* or password) is refused.
|
||||
*
|
||||
* @param value - The declared URL.
|
||||
* @returns The normalized URL, or undefined when it is not allowed.
|
||||
*/
|
||||
export function accountMenuLinkHref(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || !value.trim()) return undefined
|
||||
try {
|
||||
const url = new URL(value.trim())
|
||||
const loopback = LOOPBACK_HOSTS.includes(url.hostname)
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) return undefined
|
||||
if (url.username || url.password) return undefined
|
||||
return url.href
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one declared link.
|
||||
*
|
||||
* @param value - An entry of `defaultValues.links`.
|
||||
* @returns The link, or undefined when it has no label or no allowed URL.
|
||||
*/
|
||||
function readLink(value: unknown): AccountMenuLink | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
const { label, url, icon } = value as Record<string, unknown>
|
||||
const text = typeof label === 'string' ? label.trim().slice(0, LABEL_MAX) : ''
|
||||
const href = accountMenuLinkHref(url)
|
||||
if (!text || !href) return undefined
|
||||
return { label: text, href, ...(typeof icon === 'string' && ICON_NAME.test(icon) ? { icon } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The link groups an instance declares for the account menu: every widget of type
|
||||
* {@link ACCOUNT_MENU_LINKS_WIDGET_TYPE} the user may see, in metadata order, with the links
|
||||
* that pass {@link accountMenuLinkHref}. A group left without links is not a group.
|
||||
*
|
||||
* @param metadata - Permission-filtered instance metadata, when loaded.
|
||||
* @returns The groups; empty when the application declares none.
|
||||
*/
|
||||
export function accountMenuLinkGroups(metadata: Pick<QInstance, 'widgets'> | undefined): AccountMenuLinkGroup[] {
|
||||
const groups: AccountMenuLinkGroup[] = []
|
||||
for (const [name, widget] of Object.entries(metadata?.widgets ?? {})) {
|
||||
if (widget?.type !== ACCOUNT_MENU_LINKS_WIDGET_TYPE || widget.hasPermission === false) continue
|
||||
const declared = widget.defaultValues?.links
|
||||
if (!Array.isArray(declared)) continue
|
||||
const links = declared.flatMap((entry) => readLink(entry) ?? []).slice(0, ACCOUNT_MENU_LINKS_MAX)
|
||||
if (links.length === 0) continue
|
||||
const label = typeof widget.label === 'string' && widget.label.trim() ? widget.label.trim().slice(0, LABEL_MAX) : 'Links'
|
||||
groups.push({ name, label, links })
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Every sub-view of the record list (the explore views and the saved analysis viewer) must leave
|
||||
// a way back to the list it was opened from: its own back control, the table's breadcrumb and the
|
||||
// browser's Back button. The tests walk the registry, so a view added to it without a way back,
|
||||
// or one that loses the list's filter or page, fails here.
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { EXPLORE_LABELS, EXPLORE_VIEWS } from '@/lib/utils/data-analysis-explore'
|
||||
import { serializeFilter } from '@/lib/utils/filter-utils'
|
||||
import { listSearch, TABLE_SUB_VIEWS, type TableSubView } from '@/lib/utils/table-sub-views'
|
||||
import Breadcrumbs from '@/components/layout/Breadcrumbs'
|
||||
import { relationshipCapabilities } from '../../../tests/fixtures/data-analysis-compare'
|
||||
import { RecordQuery } from './RecordQuery'
|
||||
|
||||
vi.mock('next/navigation', async () => (await import('../../../tests/helpers/history-navigation')).historyNavigation)
|
||||
vi.mock('@/lib/api/data-analysis', async (original) => ({
|
||||
...(await original<typeof import('@/lib/api/data-analysis')>()),
|
||||
getCapabilities: vi.fn(async () => relationshipCapabilities),
|
||||
listSavedAnalyses: vi.fn(async () => ({ analyses: [] })),
|
||||
runSavedAnalysis: vi.fn(() => new Promise(() => undefined)),
|
||||
}))
|
||||
|
||||
const LIST = '[data-qqq-id="record-query-person"]'
|
||||
const criteria = [{ fieldName: 'firstName', operator: 'EQUALS' as const, values: ['Alice'] }]
|
||||
const orderBys = [{ fieldName: 'firstName', isAscending: true }]
|
||||
const filter = serializeFilter({ criteria, booleanOperator: 'AND', orderBys })
|
||||
/** The list as the person left it: page 2 of a filtered, sorted, searched query. */
|
||||
const listAddress = `/app/person?${new URLSearchParams({ page: '2', pageSize: '25', filter, q: 'ali' })}`
|
||||
|
||||
/**
|
||||
* The page: the header's breadcrumb and the record list, on one address.
|
||||
*
|
||||
* @returns Both.
|
||||
*/
|
||||
function Page() {
|
||||
const tables = structuredClone(qInstance.tables)
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs pathToLabelMap={{ '/app/person': tables.person.label }} />
|
||||
<RecordQuery tableName="person" tableMetaData={tables.person} allTables={tables} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAt(address: string) {
|
||||
// a new entry, so no test starts with another test's Forward entries
|
||||
window.history.pushState(null, '', address)
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}><QContextProvider><Page /></QContextProvider></QueryClientProvider>)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the address says about the list.
|
||||
*
|
||||
* @returns Its page, page size, search, and the filter with its sort.
|
||||
*/
|
||||
function listState() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const encoded = params.get('filter')
|
||||
const decoded = encoded ? JSON.parse(atob(encoded)) as { criteria?: unknown; orderBys?: unknown } : {}
|
||||
return { path: window.location.pathname, page: params.get('page'), pageSize: params.get('pageSize'), q: params.get('q'), criteria: decoded.criteria, orderBys: decoded.orderBys, view: params.get('view') }
|
||||
}
|
||||
const asLeft = { path: '/app/person', page: '2', pageSize: '25', q: 'ali', view: null, criteria, orderBys }
|
||||
|
||||
const viewAddress = (view: TableSubView) => `${listAddress}&view=${view.name}${view.name === 'saved' ? '&saved=7' : ''}`
|
||||
const backControl = (view: TableSubView) => document.querySelector<HTMLElement>(`[data-qqq-id="${view.backId}"]`)
|
||||
const listShown = () => waitFor(() => expect(document.querySelector(LIST)).toBeInTheDocument())
|
||||
const viewShown = (view: TableSubView) => waitFor(() => expect(backControl(view)).toBeInTheDocument(), { timeout: 5000 })
|
||||
|
||||
describe('RecordQuery sub-views leave a way back', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
server.use(
|
||||
http.post('/qqq/v1/table/person/:action', ({ params }) =>
|
||||
HttpResponse.json(params.action === 'query' ? { records: [{ tableName: 'person', recordLabel: 'Alice', values: { id: 1, firstName: 'Alice' } }] } : { count: 30 })),
|
||||
http.get('/qqq/v1/table/data_analysis_saved/:id', () => HttpResponse.json({ error: 'not found' }, { status: 404 })),
|
||||
)
|
||||
})
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('a link to $name opens it; its back control and the breadcrumb return to the list as it was', async (view) => {
|
||||
const user = userEvent.setup()
|
||||
renderAt(viewAddress(view))
|
||||
await viewShown(view)
|
||||
expect(document.querySelector(LIST)).not.toBeInTheDocument()
|
||||
|
||||
// The breadcrumb names the view, and the table is a link to the list behind it
|
||||
expect(screen.getByText(view.label, { selector: '[aria-current="page"]' })).toBeInTheDocument()
|
||||
const crumb = screen.getByRole('link', { name: 'People' })
|
||||
expect(crumb).toHaveAttribute('href', `/app/person${listSearch(window.location.search)}`)
|
||||
expect(new URLSearchParams(crumb.getAttribute('href')!.split('?')[1]).get('page')).toBe('2')
|
||||
|
||||
// Its own control
|
||||
expect(backControl(view)).toHaveAccessibleName('Back to records')
|
||||
await user.click(backControl(view)!)
|
||||
await listShown()
|
||||
expect(backControl(view)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('People', { selector: '[aria-current="page"]' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
|
||||
// Following the breadcrumb link from the view gives the same list
|
||||
act(() => window.history.pushState(null, '', viewAddress(view)))
|
||||
await viewShown(view)
|
||||
const href = screen.getByRole('link', { name: 'People' }).getAttribute('href')!
|
||||
act(() => window.history.pushState(null, '', href))
|
||||
await listShown()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
})
|
||||
|
||||
it.each(EXPLORE_VIEWS.map((name) => TABLE_SUB_VIEWS[name]))('$name opened from the list: browser Back, then its back control, return to the list as it was', async (view) => {
|
||||
const user = userEvent.setup()
|
||||
renderAt(listAddress)
|
||||
await listShown()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
const entries = window.history.length
|
||||
|
||||
const open = async () => {
|
||||
await user.click(await screen.findByRole('button', { name: 'Explore' }))
|
||||
await user.click(await screen.findByRole('menuitem', { name: EXPLORE_LABELS[view.name as keyof typeof EXPLORE_LABELS] }))
|
||||
await viewShown(view)
|
||||
}
|
||||
|
||||
// Browser Back
|
||||
await open()
|
||||
expect(window.history.length).toBe(entries + 1)
|
||||
expect(listState().view).toBe(view.name)
|
||||
act(() => window.history.back())
|
||||
await listShown()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
// the list does not rewrite the address after the step back (that would drop the page)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(listState()).toEqual(asLeft)
|
||||
|
||||
// The view's own control is one step back too: the view stays one Forward away
|
||||
await open()
|
||||
await user.click(backControl(view)!)
|
||||
await listShown()
|
||||
await waitFor(() => expect(listState()).toEqual(asLeft))
|
||||
expect(window.history.length).toBe(entries + 1)
|
||||
act(() => window.history.forward())
|
||||
await viewShown(view)
|
||||
expect(listState().view).toBe(view.name)
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,7 @@ import { canSortField } from '@/lib/utils/secured-data'
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { X, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -39,9 +39,9 @@ import { getCapabilities } from '@/lib/api/data-analysis'
|
||||
import type { PageSize } from '@/lib/hooks/use-record-query'
|
||||
|
||||
import { QUERY_SCREEN_HELP_ROLES } from '@/lib/utils/help-utils'
|
||||
import { encodeExplore, EXPLORE_VIEWS, type RelationshipView, type ExploreLayout } from '@/lib/utils/data-analysis-explore'
|
||||
import { encodeExplore, type RelationshipView, type ExploreLayout } from '@/lib/utils/data-analysis-explore'
|
||||
import { listSearch, tableSubView, TABLE_SUB_VIEW_PARAM, type TableSubViewName } from '@/lib/utils/table-sub-views'
|
||||
import { encodePivot, type PivotLayout } from '@/lib/utils/data-analysis-pivot'
|
||||
import { deserializeFilter } from '@/lib/utils/filter-utils'
|
||||
import { queryRecords, type TableVariant } from '@/lib/api/tables'
|
||||
import { useRecordQuery, hasCapability } from '@/lib/hooks/use-record-query'
|
||||
import { useSavedViews, useSavedView } from '@/lib/hooks/use-saved-views'
|
||||
@@ -122,6 +122,7 @@ interface RecordQueryProps {
|
||||
export function RecordQuery({ tableName, tableMetaData: sourceTableMetaData, allTables, processes, metaData, savedViewId }: RecordQueryProps) {
|
||||
const tableMetaData = useMemo(() => withReadableExposedJoins(sourceTableMetaData, allTables), [sourceTableMetaData, allTables])
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const fromPath = searchParams.get('from')
|
||||
const fromLabel = searchParams.get('fromLabel')
|
||||
@@ -324,36 +325,59 @@ export function RecordQuery({ tableName, tableMetaData: sourceTableMetaData, all
|
||||
const openAnalysis = (field?: { name: string; label: string }) => setAnalysis({ field, filter: rq.filter.baseFilter, returnFocus: field ? document.querySelector<HTMLElement>(`[data-qqq-id="grid-column-menu-${CSS.escape(field.name)}"]`) : document.activeElement instanceof HTMLElement ? document.activeElement : null })
|
||||
const closeAnalysis = () => { const target = analysis?.returnFocus; setAnalysis(null); requestAnimationFrame(() => target?.focus()) }
|
||||
const canAnalyze = capabilities.data?.canAnalyze === true
|
||||
const pivotOpen = searchParams.get('view') === 'pivot'
|
||||
const relationshipView = EXPLORE_VIEWS.find(v => v !== 'pivot' && v === searchParams.get('view')) as RelationshipView | undefined
|
||||
const explorerOpen = pivotOpen || Boolean(relationshipView)
|
||||
const { setUserFilter: restorePivotFilter, setQuickSearch: restorePivotSearch } = rq.filter
|
||||
// Sub-views (the explore views and the saved analysis viewer) are states of this route, named by
|
||||
// the query string. `table-sub-views` is their registry: a view opens only through it, so the
|
||||
// breadcrumb knows every one of them.
|
||||
const subView = tableSubView(searchParams)
|
||||
const pivotOpen = subView?.name === 'pivot'
|
||||
const relationshipView = subView && subView.name !== 'pivot' && subView.name !== 'saved' ? subView.name as RelationshipView : undefined
|
||||
const { restoreFromUrl } = rq
|
||||
// Browser Back and Forward between the list and a sub-view (or two states of the list) change the
|
||||
// address without remounting this page: take the filter, sort, quick search and page from it, so
|
||||
// the list is the one that address names. A step to another page is not this page's to follow.
|
||||
useEffect(() => {
|
||||
const samePath = (a: string, b: string) => a.replace(/\/+$/, '') === b.replace(/\/+$/, '')
|
||||
const restore = () => {
|
||||
if (!samePath(window.location.pathname, pathname)) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (!explorerOpen && !EXPLORE_VIEWS.some(v => v === params.get('view'))) return
|
||||
restorePivotFilter(deserializeFilter(params.get('filter') ?? '', rq.viewState.pageSize))
|
||||
restorePivotSearch(params.get('q') ?? '')
|
||||
restoreFromUrl(window.location.search)
|
||||
setLocalSearchTerm(params.get('q') ?? '')
|
||||
}
|
||||
window.addEventListener('popstate', restore)
|
||||
return () => window.removeEventListener('popstate', restore)
|
||||
}, [explorerOpen, restorePivotFilter, restorePivotSearch, rq.viewState.pageSize])
|
||||
const updatePivotUrl = (open: boolean, request?: PivotLayout) => {
|
||||
}, [pathname, restoreFromUrl])
|
||||
// The list address a sub-view was opened from, while that list is the previous history entry.
|
||||
const openedFromRef = useRef<string | null>(null)
|
||||
// Opens a sub-view, or updates the open one's layout, in the address (a new history entry only
|
||||
// when a view opens, so Back returns to the list and a re-run does not pile up entries).
|
||||
const openSubView = (view: TableSubViewName, layout?: string) => {
|
||||
const url = new URL(window.location.href)
|
||||
if (open) url.searchParams.set('view', 'pivot')
|
||||
else { url.searchParams.delete('view'); url.searchParams.delete('pivot') }
|
||||
if (request) url.searchParams.set('pivot', encodePivot(request))
|
||||
if (open && !pivotOpen) window.history.pushState(null, '', url)
|
||||
else window.history.replaceState(null, '', url)
|
||||
url.searchParams.set(TABLE_SUB_VIEW_PARAM, view)
|
||||
if (layout !== undefined) url.searchParams.set(view, layout)
|
||||
if (subView) window.history.replaceState(null, '', url)
|
||||
else {
|
||||
openedFromRef.current = `${window.location.pathname}${window.location.search}`
|
||||
window.history.pushState(null, '', url)
|
||||
}
|
||||
}
|
||||
// Returns to the list, with whatever filter, sort and page the address now carries. When that is
|
||||
// still the list this view was opened from, it is one step back in history, so the view stays
|
||||
// one Forward away and Back does not meet the same list twice; otherwise (a deep link, or a
|
||||
// drill changed the filter) the list replaces the view in place.
|
||||
const closeSubView = () => {
|
||||
const list = `${window.location.pathname}${listSearch(window.location.search)}`
|
||||
const openedFrom = openedFromRef.current
|
||||
openedFromRef.current = null
|
||||
if (openedFrom === list) window.history.back()
|
||||
else window.history.replaceState(null, '', `${list}${window.location.hash}`)
|
||||
}
|
||||
const updatePivotUrl = (open: boolean, request?: PivotLayout) => {
|
||||
if (open) openSubView('pivot', request ? encodePivot(request) : undefined)
|
||||
else closeSubView()
|
||||
}
|
||||
const updateExploreUrl = (view?: RelationshipView, layout?: ExploreLayout) => {
|
||||
const url = new URL(window.location.href)
|
||||
if (view) url.searchParams.set('view', view)
|
||||
else { url.searchParams.delete('view'); EXPLORE_VIEWS.forEach(v => url.searchParams.delete(v)) }
|
||||
if (view && layout) url.searchParams.set(view, encodeExplore(view, layout))
|
||||
if (view && !explorerOpen) window.history.pushState(null, '', url)
|
||||
else window.history.replaceState(null, '', url)
|
||||
if (view) openSubView(view, layout ? encodeExplore(view, layout) : undefined)
|
||||
else closeSubView()
|
||||
}
|
||||
// A drill on this table applies its filter here: this page reads the URL only on mount.
|
||||
const applyAnalysisDrill = (drill: DataAnalysisDrillV1) => {
|
||||
@@ -608,13 +632,8 @@ export function RecordQuery({ tableName, tableMetaData: sourceTableMetaData, all
|
||||
</>
|
||||
)
|
||||
|
||||
if (searchParams.get('view') === 'saved') {
|
||||
const closeSaved = () => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('view')
|
||||
url.searchParams.delete('saved')
|
||||
window.history.replaceState(null, '', url)
|
||||
}
|
||||
if (subView?.name === 'saved') {
|
||||
const closeSaved = closeSubView
|
||||
return <AnalysisDrillContext.Provider value={drill => {
|
||||
if (!applyAnalysisDrill(drill)) return false
|
||||
closeSaved()
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { Density } from '@/lib/hooks/use-record-query'
|
||||
import type { TableVariant } from '@/lib/api/tables'
|
||||
import type { ColumnPins } from '@/lib/utils/query-columns'
|
||||
import { MATERIAL_BUTTON_VARIANTS } from '@/lib/utils/qqq-id'
|
||||
import { EXPLORE_LABELS, EXPLORE_VIEWS } from '@/lib/utils/data-analysis-explore'
|
||||
|
||||
import { LazyFavoriteToggle } from '@/components/collaboration/LazyFavoriteToggle'
|
||||
import { GotoRecordButton } from '@/components/records/GotoRecordDialog'
|
||||
@@ -189,7 +190,7 @@ export interface RecordQueryToolbarProps {
|
||||
/** Opens table analysis when capabilities permit it. */
|
||||
onAnalyzeTable?: () => void
|
||||
/** Opens an explorer under the table's analysis capability. */
|
||||
onExplore?: (view: 'pivot' | 'compare' | 'correlation' | 'crosstab' | 'shift') => void
|
||||
onExplore?: (view: (typeof EXPLORE_VIEWS)[number]) => void
|
||||
/** The backend table name used in API calls and URL routing. */
|
||||
tableName: string
|
||||
/** Full table metadata from the QQQ backend. */
|
||||
@@ -467,9 +468,9 @@ export function RecordQueryToolbar({
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content align="start" sideOffset={4} className="z-[200] min-w-44 rounded border border-border bg-popover p-1 text-popover-foreground shadow-md">
|
||||
{(['pivot', 'compare', 'correlation', 'crosstab', 'shift'] as const).map(view => (
|
||||
{EXPLORE_VIEWS.map(view => (
|
||||
<DropdownMenuPrimitive.Item key={view} onSelect={() => onExplore(view)} className="cursor-pointer rounded px-3 py-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground" data-qqq-id={`explore-${view}`}>
|
||||
{view === 'shift' ? 'Shift over time' : view[0].toUpperCase() + view.slice(1)}
|
||||
{EXPLORE_LABELS[view]}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
))}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import type { QAuditRecord, QTableMetaData } from '@/types'
|
||||
import { useAuditRecords } from '@/lib/hooks/use-audit-records'
|
||||
@@ -55,3 +56,89 @@ describe('AuditHistoryDialog', () => {
|
||||
expect(vi.mocked(useAuditRecords)).toHaveBeenLastCalledWith(expect.objectContaining({ isSortAscending: false }))
|
||||
})
|
||||
})
|
||||
describe('AuditHistoryDialog layout', () => {
|
||||
const LONG_ID = 'a3f9c2e17b5d4086a3f9c2e17b5d4086a3f9c2e17b5d4086a3f9c2e17b5d4086'
|
||||
const history = (state: Partial<ReturnType<typeof useAuditRecords>>) =>
|
||||
vi.mocked(useAuditRecords).mockReturnValue({ auditRecords: [], total: null, isLoading: false, isError: false, error: null, refetch: vi.fn(), ...state } as unknown as ReturnType<typeof useAuditRecords>)
|
||||
const open = (recordLabel = LONG_ID, primaryKey: string | number = LONG_ID) =>
|
||||
render(<AuditHistoryDialog open onOpenChange={vi.fn()} source="process" tableMetaData={tableMetaData} primaryKey={primaryKey} recordLabel={recordLabel} />)
|
||||
|
||||
it('keeps a 64 character identifier inside the header: it may shrink and wrap, the controls may not', () => {
|
||||
open()
|
||||
const dialog = screen.getByRole('dialog', { name: `Audit for Record Lab: ${LONG_ID}` })
|
||||
const header = dialog.querySelector('[data-qqq-id="audit-history-header"]')!
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const controls = header.querySelector('[data-qqq-id="audit-history-controls"]')!
|
||||
|
||||
// the heading's column is allowed to be narrower than its text, and the text breaks anywhere
|
||||
expect(header).toHaveClass('grid', 'grid-cols-[minmax(0,1fr)_auto]')
|
||||
expect(title).toHaveClass('min-w-0', '[overflow-wrap:anywhere]', 'line-clamp-3')
|
||||
// cut text stays available: in the title attribute and in the dialog's name
|
||||
expect(title).toHaveAttribute('title', `Audit for Record Lab: ${LONG_ID}`)
|
||||
expect(title).toHaveTextContent(LONG_ID)
|
||||
// every control is in the header, in the column that keeps its size
|
||||
expect(controls).toHaveClass('shrink-0')
|
||||
expect(controls.parentElement).toBe(header)
|
||||
for (const name of ['Copy record ID', 'Sort by time ascending (oldest to newest)', 'Sort by time descending (newest to oldest)', 'Close audit history']) {
|
||||
expect(within(controls as HTMLElement).getByRole('button', { name })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('copies the full identifier, and names what it copies', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
open()
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Copy record ID' })) })
|
||||
expect(writeText).toHaveBeenCalledWith(LONG_ID)
|
||||
expect(screen.getByRole('button', { name: 'Copy record ID: copied' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the copy button for a label that is not the record ID', () => {
|
||||
open('Alice Johnson', 7)
|
||||
expect(screen.getByRole('button', { name: 'Copy record label' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty history as one block: icon and sentence together, no list and no band below', async () => {
|
||||
history({ auditRecords: [] })
|
||||
open()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('No audits were found for this record.')
|
||||
expect(status).toHaveAttribute('data-empty', 'true')
|
||||
expect(status).toHaveClass('flex', 'flex-col', 'items-center', 'text-center')
|
||||
// the icon belongs to the sentence, and is the only one outside the header
|
||||
expect(status.querySelectorAll('svg')).toHaveLength(1)
|
||||
expect(status.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(status).not.toHaveClass('border-b')
|
||||
// header and status are the whole dialog
|
||||
expect(dialog.children).toHaveLength(2)
|
||||
expect(dialog.lastElementChild).toBe(status)
|
||||
expect(screen.queryByRole('list', { name: 'Audits' })).not.toBeInTheDocument()
|
||||
expect(await axe(dialog)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('shows loading and an error as a line, without the empty-state icon or an empty body', () => {
|
||||
history({ isLoading: true })
|
||||
const { unmount } = open()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading audits...')
|
||||
expect(screen.getByRole('status').querySelector('svg')).toBeNull()
|
||||
expect(screen.getByRole('dialog').children).toHaveLength(2)
|
||||
unmount()
|
||||
|
||||
history({ isError: true, error: new Error('boom') })
|
||||
open()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Error loading audits')
|
||||
expect(screen.getByRole('alert').querySelector('svg')).toBeNull()
|
||||
expect(screen.getByRole('dialog').children).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the count line above the list when there are audits', () => {
|
||||
history({ auditRecords: records, total: null })
|
||||
open('Record 7', 7)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('Showing all 3 audits for this record')
|
||||
expect(status).toHaveClass('border-b')
|
||||
expect(status.querySelector('svg')).toBeNull()
|
||||
expect(screen.getByRole('list', { name: 'Audits' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { ArrowDown, ArrowUp, History, X } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, Check, Copy, History, X } from 'lucide-react'
|
||||
|
||||
import type { QAuditRecord, QTableMetaData } from '@/types'
|
||||
import type { AuditSource } from '@/lib/api/audits'
|
||||
@@ -127,11 +127,21 @@ export function AuditHistoryDialog({ open, onOpenChange, source, tableMetaData,
|
||||
const { auditRecords, total, isLoading, isError, error } = useAuditRecords({ capabilities: tableMetaData.clientCapabilities,
|
||||
source, tableName: tableMetaData.name, primaryKey, enabled: open && sortReady, isSortAscending,
|
||||
})
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const timer = setTimeout(() => setCopied(false), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [copied])
|
||||
|
||||
let status: string
|
||||
if (isLoading) status = 'Loading audits...'
|
||||
else if (isError) status = getErrorStatusCode(error) === 403 ? 'You do not have permission to view audits' : 'Error loading audits'
|
||||
else status = countSentence(auditRecords.length, total)
|
||||
const isEmpty = !isLoading && !isError && auditRecords.length === 0
|
||||
const heading = `Audit for ${tableMetaData.label}: ${recordLabel}`
|
||||
// what the copy button copies is what the heading may have cut short
|
||||
const copyLabel = recordLabel === String(primaryKey) ? 'Copy record ID' : 'Copy record label'
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
@@ -152,10 +162,23 @@ export function AuditHistoryDialog({ open, onOpenChange, source, tableMetaData,
|
||||
'rounded-xl border border-border bg-card shadow-lg focus:outline-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight text-foreground">
|
||||
Audit for {tableMetaData.label}: {recordLabel}
|
||||
{/* The heading takes the space the controls leave (`minmax(0,1fr)` lets it shrink below its
|
||||
text), so a long record identifier wraps inside the card, at any character, instead of
|
||||
pushing the sort and close buttons out of it. Past three lines it is cut with an
|
||||
ellipsis; the whole text stays in its `title`, in the dialog's accessible name, and on
|
||||
the copy button. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border px-4 py-4 sm:gap-x-3 sm:px-6" data-qqq-id="audit-history-header">
|
||||
<DialogPrimitive.Title title={heading} data-qqq-id="audit-history-title"
|
||||
className="line-clamp-3 min-w-0 pt-1 text-lg font-semibold leading-snug tracking-tight text-foreground [overflow-wrap:anywhere]">
|
||||
{heading}
|
||||
</DialogPrimitive.Title>
|
||||
<div className="flex shrink-0 items-center gap-1" data-qqq-id="audit-history-controls">
|
||||
<button type="button" aria-label={copied ? `${copyLabel}: copied` : copyLabel} title={copyLabel}
|
||||
onClick={() => { void navigator.clipboard?.writeText(recordLabel).then(() => setCopied(true), () => setCopied(false)) }}
|
||||
data-qqq-id="button-audit-copy-record"
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{copied ? <Check className="h-4 w-4 text-green-600 dark:text-green-400" aria-hidden="true" /> : <Copy className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1" role="group" aria-label="Audit sort order">
|
||||
<button type="button" aria-label="Sort by time ascending (oldest to newest)" aria-pressed={isSortAscending}
|
||||
onClick={() => { setSortAscending(true); localStorage.setItem('audit.sortDirection', 'true') }}
|
||||
@@ -177,17 +200,21 @@ export function AuditHistoryDialog({ open, onOpenChange, source, tableMetaData,
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* One status element for every state (the dialog is described by it). With audits it is a
|
||||
line above the list. With none it is the whole body: a small icon and the sentence as
|
||||
one centered block, with no list and no empty band under it. */}
|
||||
<p id="audit-history-status" role={isError ? 'alert' : 'status'} data-qqq-id="audit-history-status"
|
||||
className={cn('border-b border-border px-6 py-3 text-sm', isError ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
data-empty={isEmpty ? 'true' : undefined}
|
||||
className={cn('px-4 text-sm sm:px-6', isError ? 'text-destructive' : 'text-muted-foreground',
|
||||
isEmpty ? 'flex flex-col items-center gap-2 py-8 text-center' : 'py-3', auditRecords.length > 0 && 'border-b border-border')}>
|
||||
{isEmpty && <History className="h-6 w-6 text-muted-foreground/60" aria-hidden="true" />}
|
||||
{status}
|
||||
</p>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{!isLoading && !isError && auditRecords.length === 0 && (
|
||||
<History className="mx-auto h-10 w-10 text-muted-foreground/30" aria-hidden="true" />
|
||||
)}
|
||||
{auditRecords.length > 0 && <div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6">
|
||||
<ol aria-label="Audits">
|
||||
{groupAuditsByDate(auditRecords).map((group) => (
|
||||
<li key={`${localAuditDate(group[0].timestamp)}-${group[0].id}`}>
|
||||
@@ -197,7 +224,7 @@ export function AuditHistoryDialog({ open, onOpenChange, source, tableMetaData,
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
|
||||
@@ -21,7 +21,7 @@ import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getSecuredDataStatus, type BreakGlassSession } from '@/lib/api/secured-data'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { BACKGROUND_REQUEST, queryKeys } from '@/lib/query-client'
|
||||
import { countdownText, remainingSeconds } from '@/lib/utils/secured-data'
|
||||
|
||||
/**
|
||||
@@ -36,6 +36,8 @@ export default function BreakGlassBanner() {
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
// Asked on every navigation and then on a timer, for a banner: a failure raises no error banner.
|
||||
meta: BACKGROUND_REQUEST,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.breakGlass && remainingSeconds(query.state.data.breakGlass.expiresAt) > 0
|
||||
? 60_000
|
||||
|
||||
@@ -32,6 +32,13 @@ export const AUDIT_PROCESS_NAME = 'GetAuditsForRecord'
|
||||
export const AUDIT_TABLE_NAME = 'audit'
|
||||
/** Most audit rows read for one record, as in the Material dashboard. */
|
||||
export const AUDIT_LIMIT = 1000
|
||||
/**
|
||||
* How long the server may take to answer the audit process in the same request. Without it the
|
||||
* server waits 3 seconds and then answers that the run continues as a job, which this read does
|
||||
* not follow, so a history that took longer than 3 seconds ended as an error. 25 seconds is under
|
||||
* the API client's own 30 second limit.
|
||||
*/
|
||||
export const AUDIT_STEP_TIMEOUT_MILLIS = 25_000
|
||||
|
||||
/** How a record's audits can be read by the current user, if at all. */
|
||||
export type AuditSource = 'process' | 'table' | null
|
||||
@@ -63,6 +70,8 @@ interface ProcessCompleteResponse {
|
||||
values?: Record<string, unknown>
|
||||
error?: string
|
||||
userFacingError?: string
|
||||
/** Present when the run outlived the step timeout and continues as a job. */
|
||||
jobUUID?: string
|
||||
}
|
||||
|
||||
/** Audit entries plus the optional count when the 1,000-detail cap was reached. */
|
||||
@@ -90,9 +99,11 @@ export async function getAuditRecords(source: Exclude<AuditSource, null>, tableN
|
||||
if (!processName) throw new Error('Audit process is unavailable.')
|
||||
const formData = new FormData()
|
||||
formData.append('values', JSON.stringify({ tableName, recordId: String(primaryKey), isSortAscending, limit: AUDIT_LIMIT }))
|
||||
formData.append('stepTimeoutMillis', String(AUDIT_STEP_TIMEOUT_MILLIS))
|
||||
const response = await apiClient.post<ProcessCompleteResponse>(`/processes/${encodeURIComponent(processName)}/init`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
if (response?.jobUUID && !response.values) throw new Error('The audit history is taking too long to load.')
|
||||
if (response?.type === 'ERROR' || (response?.type !== 'COMPLETE' && !response?.values)) {
|
||||
throw new Error(getErrorMessage(response, 'Error loading audits'))
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import React from 'react'
|
||||
|
||||
import type { QInstance, QRecord } from '@/types'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { AUDIT_LIMIT, getAuditRecords, auditSource, groupAuditRows } from '@/lib/api/audits'
|
||||
import { AUDIT_LIMIT, AUDIT_STEP_TIMEOUT_MILLIS, getAuditRecords, auditSource, groupAuditRows } from '@/lib/api/audits'
|
||||
import { useAuditRecords } from './use-audit-records'
|
||||
|
||||
vi.mock('@/lib/api/client', () => ({ default: { post: vi.fn(), get: vi.fn() } }))
|
||||
@@ -55,7 +55,10 @@ const rows = [
|
||||
auditRow(1, 'Record was Inserted'),
|
||||
]
|
||||
|
||||
beforeEach(() => vi.mocked(apiClient.post).mockReset())
|
||||
// a block body: a function returned from beforeEach is run as a cleanup hook, and mockReset returns the mock
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiClient.post).mockReset()
|
||||
})
|
||||
|
||||
describe('groupAuditRows', () => {
|
||||
it('groups detail rows into one entry per audit, keeping order and backend sentences', () => {
|
||||
@@ -93,7 +96,21 @@ describe('useAuditRecords', () => {
|
||||
expect(JSON.parse((body as FormData).get('values') as string)).toEqual({ tableName: 'recordLab', recordId: '7', isSortAscending: false, limit: 1000 })
|
||||
})
|
||||
|
||||
it('requests oldest first and uses a separate query key for that order', async () => {
|
||||
it('gives the server longer than its 3 second default to answer in the same request', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ type: 'COMPLETE', values: { audits: rows } })
|
||||
await getAuditRecords('process', 'recordLab', 7)
|
||||
const body = vi.mocked(apiClient.post).mock.calls[0][1] as FormData
|
||||
expect(body.get('stepTimeoutMillis')).toBe(String(AUDIT_STEP_TIMEOUT_MILLIS))
|
||||
expect(AUDIT_STEP_TIMEOUT_MILLIS).toBeGreaterThan(3_000)
|
||||
expect(AUDIT_STEP_TIMEOUT_MILLIS).toBeLessThan(30_000)
|
||||
})
|
||||
|
||||
it('reports a run that continues as a job as too slow, not as an empty history', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ type: 'JOB_STARTED', processUUID: 'p-1', jobUUID: 'j-1' })
|
||||
await expect(getAuditRecords('process', 'recordLab', 7)).rejects.toThrow('The audit history is taking too long to load.')
|
||||
})
|
||||
|
||||
it('opens with one request, and re-sorts a complete history without asking the server again', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ type: 'COMPLETE', values: { audits: rows } })
|
||||
const wrapper = createWrapper()
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -101,12 +118,57 @@ describe('useAuditRecords', () => {
|
||||
{ wrapper, initialProps: { ascending: false } }
|
||||
)
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.auditRecords.map((entry) => entry.id)).toEqual([2, 1])
|
||||
// each audit keeps its details in their own order
|
||||
expect(result.current.auditRecords[0].fieldChanges.map((change) => change.fieldName)).toEqual(['title', 'hint'])
|
||||
|
||||
rerender({ ascending: true })
|
||||
await waitFor(() => expect(apiClient.post).toHaveBeenCalledTimes(2))
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.auditRecords.map((entry) => entry.id)).toEqual([1, 2])
|
||||
expect(result.current.auditRecords[1].fieldChanges.map((change) => change.fieldName)).toEqual(['title', 'hint'])
|
||||
rerender({ ascending: false })
|
||||
expect(result.current.auditRecords.map((entry) => entry.id)).toEqual([2, 1])
|
||||
rerender({ ascending: true })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('asks the server for the other order when the history was capped, because its first rows differ', async () => {
|
||||
vi.mocked(apiClient.post).mockImplementation(async (_url, body) => {
|
||||
const ascending = JSON.parse((body as FormData).get('values') as string).isSortAscending
|
||||
return { type: 'COMPLETE', values: { audits: ascending ? [auditRow(1, 'Record was Inserted')] : [auditRow(9, 'Record was Edited')], distinctCount: 5000 } }
|
||||
})
|
||||
const wrapper = createWrapper()
|
||||
const { result, rerender } = renderHook(
|
||||
({ ascending }) => useAuditRecords({ source: 'process', tableName: 'recordLab', primaryKey: 7, isSortAscending: ascending }),
|
||||
{ wrapper, initialProps: { ascending: false } }
|
||||
)
|
||||
await waitFor(() => expect(result.current.auditRecords.map((entry) => entry.id)).toEqual([9]))
|
||||
expect(result.current.total).toBe(5000)
|
||||
rerender({ ascending: true })
|
||||
await waitFor(() => expect(result.current.auditRecords.map((entry) => entry.id)).toEqual([1]))
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(2)
|
||||
const body = vi.mocked(apiClient.post).mock.calls[1][1] as FormData
|
||||
expect(JSON.parse(body.get('values') as string).isSortAscending).toBe(true)
|
||||
})
|
||||
|
||||
it('asks for the chosen order when it changes before anything has loaded', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ type: 'COMPLETE', values: { audits: rows } })
|
||||
const wrapper = createWrapper()
|
||||
const { result, rerender } = renderHook(
|
||||
({ ascending, enabled }) => useAuditRecords({ source: 'process', tableName: 'recordLab', primaryKey: 7, isSortAscending: ascending, enabled }),
|
||||
{ wrapper, initialProps: { ascending: false, enabled: false } }
|
||||
)
|
||||
rerender({ ascending: true, enabled: false })
|
||||
expect(apiClient.post).not.toHaveBeenCalled()
|
||||
rerender({ ascending: true, enabled: true })
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1)
|
||||
const body = vi.mocked(apiClient.post).mock.calls[0][1] as FormData
|
||||
expect(JSON.parse(body.get('values') as string).isSortAscending).toBe(true)
|
||||
})
|
||||
|
||||
it('queries the audit table joined to its details without process permission', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ records: rows })
|
||||
const { result } = renderHook(
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import type { TableCapabilitiesV1 } from '@/types'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getAuditRecords, type AuditSource } from '@/lib/api/audits'
|
||||
@@ -45,20 +46,36 @@ interface UseAuditRecordsOptions {
|
||||
/**
|
||||
* Loads a record's audit history in the requested order.
|
||||
*
|
||||
* One request loads the history. Changing the order asks the server again only when it has to:
|
||||
* a history under the row cap is complete, so the other order is the same entries reversed (the
|
||||
* server sorts audits by time and id in one direction, and each audit's details always ascending)
|
||||
* and is shown at once without a request. A capped history (`total` is set) is a different first
|
||||
* 1,000 rows in each direction, so only then does the order go back to the server.
|
||||
*
|
||||
* @param options - See {@link UseAuditRecordsOptions}.
|
||||
* @returns The entries plus loading and error state (the caller renders errors).
|
||||
*/
|
||||
export function useAuditRecords({ source, capabilities, tableName, primaryKey, enabled = true, isSortAscending = false }: UseAuditRecordsOptions) {
|
||||
// the order the server is asked for; it follows the requested order only while that is needed
|
||||
const [serverAscending, setServerAscending] = useState(isSortAscending)
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: [...queryKeys.audits(tableName, primaryKey), isSortAscending, capabilities],
|
||||
queryFn: () => getAuditRecords(source!, tableName, primaryKey, isSortAscending, capabilities),
|
||||
queryKey: [...queryKeys.audits(tableName, primaryKey), serverAscending, capabilities],
|
||||
queryFn: () => getAuditRecords(source!, tableName, primaryKey, serverAscending, capabilities),
|
||||
enabled: enabled && source !== null,
|
||||
staleTime: 0,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const complete = data !== undefined && data.total === null
|
||||
// Nothing loaded yet, or a capped history: the server sorts. (Adjusting state while rendering
|
||||
// restarts this render with the new order, before anything is shown or fetched for the old one.)
|
||||
if (serverAscending !== isSortAscending && !complete) setServerAscending(isSortAscending)
|
||||
const auditRecords = useMemo(
|
||||
() => (data ? (serverAscending === isSortAscending ? data.records : [...data.records].reverse()) : []),
|
||||
[data, serverAscending, isSortAscending]
|
||||
)
|
||||
|
||||
return {
|
||||
auditRecords: data?.records ?? [],
|
||||
auditRecords,
|
||||
total: data?.total ?? null,
|
||||
isLoading,
|
||||
isError,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { CollaborationTarget } from '@/types/collaboration'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { BACKGROUND_REQUEST, queryKeys } from '@/lib/query-client'
|
||||
|
||||
/** The API module, imported once (concurrent first calls share one import). */
|
||||
let apiModule: Promise<typeof import('@/lib/api/collaboration')> | undefined
|
||||
@@ -66,6 +66,9 @@ export function useRecordRecentView(target: CollaborationTarget) {
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (recent: CollaborationTarget) => (await api()).recordRecent(recent),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: queryKeys.collaborationRecentsRoot() }),
|
||||
// Written on every table and record the person opens, without their asking: a failure costs
|
||||
// one entry in their recents and raises no error banner.
|
||||
meta: BACKGROUND_REQUEST,
|
||||
})
|
||||
const { targetType, targetName, recordId } = target
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// A presence outage: the server answers 503 on purpose for a while after its shared store
|
||||
// restarts. These tests run the hook on the application's own query client, with its real global
|
||||
// error handler, so they fail if a presence failure ever reaches the error banner again.
|
||||
|
||||
import React from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AxiosError, AxiosHeaders, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { toast } from 'sonner'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import * as api from '@/lib/api/collaboration'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { PRESENCE_INTERVAL_MS, useCollaborationPresence } from './use-collaboration-presence'
|
||||
|
||||
vi.mock('@/lib/context/q-context', async () => {
|
||||
const { createContext } = await import('react')
|
||||
return { useQContext: () => ({ userId: 'me' }), QContext: createContext({ userId: 'me' }) }
|
||||
})
|
||||
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(),
|
||||
beaconPresence: vi.fn(),
|
||||
}))
|
||||
|
||||
/**
|
||||
* The answer of a presence endpoint while its store recovers.
|
||||
*
|
||||
* @param method - The request's method.
|
||||
* @param url - The request's path under the API base.
|
||||
* @returns The axios error a 503 becomes.
|
||||
*/
|
||||
function recovering(method: string, url: string): AxiosError {
|
||||
const config = { method, url, baseURL: '/qqq/v1', headers: new AxiosHeaders() } as InternalAxiosRequestConfig
|
||||
const data = { error: 'Presence is recovering; active viewers are rejoining. Please retry.' }
|
||||
return new AxiosError('Request failed with status code 503', 'ERR_BAD_RESPONSE', config, {}, { status: 503, data, statusText: '', headers: {}, config })
|
||||
}
|
||||
|
||||
const sam = { userId: 'sam', displayName: 'Sam', mode: 'VIEWING' as const }
|
||||
const banner = vi.spyOn(toast, 'error')
|
||||
const warn = vi.spyOn(console, 'warn')
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
/** Calls of a mocked API function so far. */
|
||||
const calls = (fn: unknown) => vi.mocked(fn as () => unknown).mock.calls.length
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
// the middle of the jitter range: waits of 3.75 s, 7.5 s, 15 s, 30 s, then 45 s
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
banner.mockClear()
|
||||
warn.mockClear().mockImplementation(() => undefined)
|
||||
let lease = 0
|
||||
vi.mocked(api.openPresenceLease).mockImplementation(async () => ({ connectionId: `lease:${++lease}` }))
|
||||
vi.mocked(api.getPresence).mockResolvedValue({ table: 'person', viewers: [sam] })
|
||||
vi.mocked(api.renewPresence).mockResolvedValue({ table: 'person', viewers: [sam] })
|
||||
vi.mocked(api.leavePresence).mockResolvedValue({})
|
||||
})
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
vi.mocked(Math.random).mockRestore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('an outage raises no error banner, shows as reconnecting, backs off with jitter and recovers on its own', async () => {
|
||||
let down = true
|
||||
vi.mocked(api.getPresence).mockImplementation(async () => {
|
||||
if (down) throw recovering('get', '/qbit/collaboration/presence')
|
||||
return { table: 'person', viewers: [sam] }
|
||||
})
|
||||
vi.mocked(api.openPresenceLease).mockImplementation(async () => {
|
||||
if (down) throw recovering('post', '/qbit/collaboration/presence/leases')
|
||||
return { connectionId: 'lease:1' }
|
||||
})
|
||||
const hook = renderHook(() => useCollaborationPresence('person'), { wrapper })
|
||||
|
||||
// First attempts fail: one request each, no automatic retries on top
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(hook.result.current.reconnecting).toBe(true)
|
||||
expect(hook.result.current.viewers).toEqual([])
|
||||
expect(hook.result.current.hadViewers).toBe(false)
|
||||
expect([calls(api.getPresence), calls(api.openPresenceLease)]).toEqual([1, 1])
|
||||
|
||||
// The next attempts come after 3.75 s, then 7.5 s more, then 15 s more: not every few seconds,
|
||||
// and not at the fixed interval of a healthy poll
|
||||
await act(() => vi.advanceTimersByTimeAsync(3700))
|
||||
expect([calls(api.getPresence), calls(api.openPresenceLease)]).toEqual([1, 1])
|
||||
await act(() => vi.advanceTimersByTimeAsync(100))
|
||||
expect([calls(api.getPresence), calls(api.openPresenceLease)]).toEqual([2, 2])
|
||||
await act(() => vi.advanceTimersByTimeAsync(7400))
|
||||
expect([calls(api.getPresence), calls(api.openPresenceLease)]).toEqual([2, 2])
|
||||
await act(() => vi.advanceTimersByTimeAsync(200))
|
||||
expect([calls(api.getPresence), calls(api.openPresenceLease)]).toEqual([3, 3])
|
||||
await act(() => vi.advanceTimersByTimeAsync(14700))
|
||||
expect([calls(api.getPresence), calls(api.openPresenceLease)]).toEqual([3, 3])
|
||||
expect(hook.result.current.reconnecting).toBe(true)
|
||||
|
||||
// 26 seconds of 503 answers put nothing over the page; each failure is in the console for a developer
|
||||
expect(banner).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith('[API] Request failed:', expect.objectContaining({ status: 503, background: true, request: 'GET /qqq/v1/qbit/collaboration/presence' }))
|
||||
|
||||
// The store is back: the next attempt succeeds without anyone doing anything
|
||||
down = false
|
||||
await act(() => vi.advanceTimersByTimeAsync(300))
|
||||
expect(calls(api.openPresenceLease)).toBe(4)
|
||||
expect(calls(api.renewPresence)).toBe(1)
|
||||
expect(hook.result.current.reconnecting).toBe(false)
|
||||
expect(hook.result.current.viewers).toEqual([sam])
|
||||
|
||||
// and the healthy rhythm returns: one renewal per interval, which carries the snapshot
|
||||
const polls = calls(api.getPresence)
|
||||
await act(() => vi.advanceTimersByTimeAsync(PRESENCE_INTERVAL_MS - 1000))
|
||||
expect(calls(api.renewPresence)).toBe(1)
|
||||
await act(() => vi.advanceTimersByTimeAsync(1100))
|
||||
expect(calls(api.renewPresence)).toBe(2)
|
||||
expect(calls(api.getPresence) - polls).toBeLessThanOrEqual(1)
|
||||
expect(banner).not.toHaveBeenCalled()
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('says that people were shown when the connection was lost, and does not show them while it is', async () => {
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(hook.result.current).toMatchObject({ reconnecting: false, hadViewers: false, viewers: [sam] })
|
||||
|
||||
vi.mocked(api.getPresence).mockRejectedValue(recovering('get', '/qbit/collaboration/presence'))
|
||||
vi.mocked(api.renewPresence).mockRejectedValue(recovering('post', '/qbit/collaboration/presence/leases/renew'))
|
||||
await act(() => vi.advanceTimersByTimeAsync(PRESENCE_INTERVAL_MS + 10))
|
||||
expect(hook.result.current).toMatchObject({ reconnecting: true, hadViewers: true, viewers: [] })
|
||||
expect(banner).not.toHaveBeenCalled()
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('a renewal that keeps failing backs off instead of repeating every 20 seconds', async () => {
|
||||
vi.mocked(api.renewPresence).mockRejectedValue(recovering('post', '/qbit/collaboration/presence/leases/renew'))
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(calls(api.renewPresence)).toBe(1)
|
||||
// 3.75 s, 7.5 s, 15 s, 30 s, then 45 s apart: five attempts in the first 100 seconds and ten in
|
||||
// five minutes, where a fixed 20 second interval made six and sixteen
|
||||
await act(() => vi.advanceTimersByTimeAsync(100_000))
|
||||
expect(calls(api.renewPresence)).toBe(5)
|
||||
await act(() => vi.advanceTimersByTimeAsync(200_000))
|
||||
expect(calls(api.renewPresence)).toBe(10)
|
||||
expect(calls(api.openPresenceLease)).toBe(1)
|
||||
expect(hook.result.current.reconnecting).toBe(true)
|
||||
hook.unmount()
|
||||
})
|
||||
@@ -98,10 +98,10 @@ it('reports failed renewals even when reads succeed, and recovers on a successfu
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(hook.result.current.unavailable).toBe(true)
|
||||
expect(hook.result.current.reconnecting).toBe(true)
|
||||
expect(hook.result.current.viewers).toEqual([])
|
||||
await act(() => vi.advanceTimersByTimeAsync(20000))
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
expect(hook.result.current.reconnecting).toBe(false)
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
|
||||
hook.unmount()
|
||||
})
|
||||
@@ -127,7 +127,7 @@ it('ignores a failed renewal from the previous record', async () => {
|
||||
await act(async () => {
|
||||
rejectOld(new Error('Old request failed'))
|
||||
})
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
expect(hook.result.current.reconnecting).toBe(false)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
@@ -174,7 +174,7 @@ it('rejoins once with a new handle after an explicit lease expiry', async () =>
|
||||
['lease:1', 1],
|
||||
['lease:2', 1],
|
||||
])
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
expect(hook.result.current.reconnecting).toBe(false)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
@@ -207,7 +207,7 @@ it('bounds immediate rejoin attempts when replacement leases also expire', async
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
|
||||
expect(hook.result.current.unavailable).toBe(true)
|
||||
expect(hook.result.current.reconnecting).toBe(true)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Ephemeral presence leases with focus changes, polling fallback and realtime refresh. */
|
||||
/**
|
||||
* @file Ephemeral presence leases with focus changes, polling fallback and realtime refresh.
|
||||
*
|
||||
* Presence is not essential to the page it sits on. Its requests are background requests (no
|
||||
* global error banner), and while the server cannot answer (it answers 503 for a while after its
|
||||
* shared store restarts) the hook reports `reconnecting` and tries again with backoff and jitter
|
||||
* until an attempt succeeds.
|
||||
*/
|
||||
'use client'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -27,10 +34,16 @@ import {
|
||||
getPresence,
|
||||
beaconPresence,
|
||||
} from '@/lib/api/collaboration'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { BACKGROUND_REQUEST, queryKeys } from '@/lib/query-client'
|
||||
import { QContext } from '@/lib/context/q-context'
|
||||
import { backoffDelay } from '@/lib/utils/backoff'
|
||||
import { useRealtimeChannel } from './use-realtime-channel'
|
||||
|
||||
/** A healthy presence poll, and a healthy lease renewal, repeat at this interval. */
|
||||
export const PRESENCE_INTERVAL_MS = 20_000
|
||||
/** After a failure the next attempt waits 2.5 to 5 s, then twice as long per failure, up to a minute. */
|
||||
export const PRESENCE_BACKOFF = { baseMs: 5_000, maxMs: 60_000 } as const
|
||||
|
||||
/** Deduplicates tabs, excludes the current user, and prefers editing leases.
|
||||
* @param viewers - Snapshot entries.
|
||||
* @param userId - Current identity.
|
||||
@@ -49,7 +62,8 @@ export function otherViewers(viewers: PresenceViewer[], userId?: string) {
|
||||
* @param editing - Form open.
|
||||
* @param fieldName - Focused form field.
|
||||
* @param enabled - PRESENCE is installed and enabled.
|
||||
* @returns Other viewers and query state.
|
||||
* @returns Other viewers and query state; `reconnecting` while the server cannot answer, and
|
||||
* `hadViewers` when other people were shown at the moment the connection was lost.
|
||||
*/
|
||||
export function useCollaborationPresence(
|
||||
table: string,
|
||||
@@ -66,11 +80,27 @@ export function useCollaborationPresence(
|
||||
const userId = useContext(QContext)?.userId
|
||||
const cache = useQueryClient()
|
||||
const key = queryKeys.collaborationResource(table, pk ?? '', 'presence')
|
||||
// The wait before the next poll is decided once per attempt (the jitter must not change on a
|
||||
// re-render): the usual interval after a success, a longer and longer one after failures.
|
||||
const poll = useRef({ failures: 0, delay: PRESENCE_INTERVAL_MS as number })
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => getPresence({ table, pk }),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const snapshot = await getPresence({ table, pk })
|
||||
poll.current = { failures: 0, delay: PRESENCE_INTERVAL_MS }
|
||||
return snapshot
|
||||
} catch (error) {
|
||||
const failures = poll.current.failures + 1
|
||||
poll.current = { failures, delay: backoffDelay(failures, PRESENCE_BACKOFF) }
|
||||
throw error
|
||||
}
|
||||
},
|
||||
enabled: enabled && !ended,
|
||||
refetchInterval: 20000,
|
||||
// One attempt per poll: the next poll is the retry, and it backs off.
|
||||
retry: false,
|
||||
refetchInterval: () => poll.current.delay,
|
||||
meta: BACKGROUND_REQUEST,
|
||||
})
|
||||
useRealtimeChannel(
|
||||
pk ? `record:${table}:${pk}` : `table:${table}`,
|
||||
@@ -101,10 +131,20 @@ export function useCollaborationPresence(
|
||||
let sequence = 0
|
||||
let generation = 0
|
||||
let opening = false
|
||||
let failures = 0
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
// Every beat schedules the next one: the usual interval after a success, backoff with jitter
|
||||
// after a failure (a fixed short interval would keep knocking on a server that is recovering).
|
||||
const schedule = (delay: number) => {
|
||||
clearTimeout(timer)
|
||||
if (active && visible) timer = setTimeout(() => void heartbeat(), delay)
|
||||
}
|
||||
const heartbeat = async (rejoining = false) => {
|
||||
if (!active || !visible || opening) return
|
||||
clearTimeout(timer)
|
||||
const requestGeneration = generation
|
||||
let requestSequence = sequence
|
||||
let next: number = PRESENCE_INTERVAL_MS
|
||||
try {
|
||||
if (!connectionId) {
|
||||
opening = true
|
||||
@@ -129,6 +169,9 @@ export function useCollaborationPresence(
|
||||
fieldName: focus.current.fieldName,
|
||||
})
|
||||
if (active && visible && generation === requestGeneration && sequence === requestSequence) {
|
||||
failures = 0
|
||||
// a renewal carries the snapshot too, so the poll is healthy again as well
|
||||
poll.current = { failures: 0, delay: PRESENCE_INTERVAL_MS }
|
||||
setRenewalFailed(false)
|
||||
cache.setQueryData(queryKeys.collaborationResource(table, pk ?? '', 'presence'), snapshot)
|
||||
}
|
||||
@@ -144,17 +187,21 @@ export function useCollaborationPresence(
|
||||
) {
|
||||
connectionId = undefined
|
||||
generation++
|
||||
if (!rejoining) void heartbeat(true)
|
||||
// An expired lease is rejoined at once; only a rejoin that fails too backs off.
|
||||
if (!rejoining) {
|
||||
void heartbeat(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
next = backoffDelay(++failures, PRESENCE_BACKOFF)
|
||||
} finally {
|
||||
schedule(next)
|
||||
}
|
||||
}
|
||||
refresh.current = () => {
|
||||
void heartbeat()
|
||||
}
|
||||
void heartbeat()
|
||||
const timer = setInterval(() => {
|
||||
void heartbeat()
|
||||
}, 20000)
|
||||
const release = (beacon: boolean) => {
|
||||
generation++
|
||||
opening = false
|
||||
@@ -172,6 +219,7 @@ export function useCollaborationPresence(
|
||||
}
|
||||
const hide = () => {
|
||||
visible = false
|
||||
clearTimeout(timer)
|
||||
release(true)
|
||||
}
|
||||
const show = () => {
|
||||
@@ -186,7 +234,7 @@ export function useCollaborationPresence(
|
||||
active = false
|
||||
release(false)
|
||||
refresh.current = () => undefined
|
||||
clearInterval(timer)
|
||||
clearTimeout(timer)
|
||||
window.removeEventListener('pagehide', hide)
|
||||
window.removeEventListener('pageshow', show)
|
||||
document.removeEventListener('visibilitychange', visibility)
|
||||
@@ -195,12 +243,13 @@ export function useCollaborationPresence(
|
||||
useEffect(() => {
|
||||
refresh.current()
|
||||
}, [editing, fieldName])
|
||||
const reconnecting = enabled && !ended && (query.isError || renewalFailed)
|
||||
// the last snapshot the server gave; while reconnecting it may no longer be true, so it is not shown
|
||||
const known = enabled && !ended ? otherViewers(query.data?.viewers ?? [], userId) : []
|
||||
return {
|
||||
...query,
|
||||
unavailable: enabled && !ended && (query.isError || renewalFailed),
|
||||
viewers:
|
||||
enabled && !ended && !query.isError && !renewalFailed
|
||||
? otherViewers(query.data?.viewers ?? [], userId)
|
||||
: [],
|
||||
reconnecting,
|
||||
hadViewers: reconnecting && known.length > 0,
|
||||
viewers: reconnecting ? [] : known,
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listJobs } from '@/lib/api/jobs'
|
||||
import { useUiApiFeatureSupported } from '@/lib/api/ui-api-extensions'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { BACKGROUND_REQUEST, queryKeys } from '@/lib/query-client'
|
||||
import { useMetaData } from './use-metadata'
|
||||
import { useRealtimeChannel } from './use-realtime-channel'
|
||||
import { jobAccessDenied, useJobVisibility } from './use-job-progress'
|
||||
@@ -41,11 +41,14 @@ export function useJobsInstalled() {
|
||||
/** Observes a job list with realtime refresh and a ten-second visible-tab fallback.
|
||||
* @param enabled - Capability/access gate.
|
||||
* @param filter - Mine by default; admin scope is explicitly requested by the Jobs page.
|
||||
* @param background - True for the header's count, which is polled on every page: its failures
|
||||
* raise no error banner. The Jobs page, which the person opened, leaves it false.
|
||||
* @returns Query state.
|
||||
*/
|
||||
export function useMyJobs(
|
||||
enabled: boolean,
|
||||
filter: JobListFilter = { scope: 'mine', state: 'all', limit: 20 }
|
||||
filter: JobListFilter = { scope: 'mine', state: 'all', limit: 20 },
|
||||
background = false
|
||||
) {
|
||||
const visible = useJobVisibility()
|
||||
const query = useQuery({
|
||||
@@ -67,6 +70,7 @@ export function useMyJobs(
|
||||
visible && q.state.data !== null && !jobAccessDenied(q.state.error) ? 10_000 : false,
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: false,
|
||||
meta: background ? BACKGROUND_REQUEST : undefined,
|
||||
})
|
||||
const { refetch } = query
|
||||
const denied = query.data === null || jobAccessDenied(query.error)
|
||||
|
||||
@@ -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'
|
||||
@@ -141,6 +142,40 @@ export type RecordQueryAction =
|
||||
| { type: 'TOGGLE_FILTER_PANEL' }
|
||||
| { type: 'RESET_FILTER' }
|
||||
| { type: 'APPLY_VIEW'; view: ViewState }
|
||||
| { type: 'RESTORE_URL'; url: UrlQueryState }
|
||||
|
||||
/** The part of the query state the address carries: `page`, `pageSize`, `filter` (with a non-default sort) and `q`. */
|
||||
export interface UrlQueryState {
|
||||
pageNum: number
|
||||
pageSize: PageSize
|
||||
userFilter: QQueryFilter
|
||||
sortOrder: QFilterOrderBy[]
|
||||
quickSearchTerm: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the query state an address carries, the inverse of the hook's URL sync.
|
||||
*
|
||||
* @param params - The address' query parameters.
|
||||
* @param fallbackPageSize - Page size when the address names none (the sync omits the default).
|
||||
* @param defaultSort - Sort when the filter carries none (the sync omits the default).
|
||||
* @param fallbackFilter - Filter when the address carries none; an empty filter by default.
|
||||
* @returns The state.
|
||||
*/
|
||||
export function readUrlQueryState(params: URLSearchParams, fallbackPageSize: PageSize, defaultSort: QFilterOrderBy[], fallbackFilter?: QQueryFilter | null): UrlQueryState {
|
||||
const pageSizeParam = Number(params.get('pageSize'))
|
||||
const pageSize: PageSize = (PAGE_SIZE_OPTIONS as readonly unknown[]).includes(pageSizeParam) ? pageSizeParam as PageSize : fallbackPageSize
|
||||
const filterParam = params.get('filter')
|
||||
const filter = filterParam ? deserializeFilter(filterParam, pageSize) : fallbackFilter ?? emptyFilter(pageSize)
|
||||
const pageParam = parseInt(params.get('page') ?? '', 10)
|
||||
return {
|
||||
pageNum: Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1,
|
||||
pageSize,
|
||||
userFilter: { ...filter, orderBys: [] },
|
||||
sortOrder: filter.orderBys?.length ? filter.orderBys : defaultSort,
|
||||
quickSearchTerm: params.get('q') ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reducer for `RecordQueryState`. Any change to the result set returns to page 1.
|
||||
@@ -210,13 +245,20 @@ function recordQueryReducer(state: RecordQueryState, action: RecordQueryAction):
|
||||
selectionMode: 'rows',
|
||||
subsetSize: null,
|
||||
}
|
||||
case 'RESTORE_URL': {
|
||||
// The address is the source here (a history step), so the page is kept, not reset.
|
||||
const { url } = action
|
||||
const unchanged = state.pageNum === url.pageNum && state.pageSize === url.pageSize && state.quickSearchTerm === url.quickSearchTerm
|
||||
&& JSON.stringify(state.userFilter) === JSON.stringify(url.userFilter) && JSON.stringify(state.sortOrder) === JSON.stringify(url.sortOrder)
|
||||
return unchanged ? state : { ...state, ...url }
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions after which basic mode is checked against the filter again. */
|
||||
const BASIC_MODE_ACTIONS: ReadonlySet<RecordQueryAction['type']> = new Set(['SET_USER_FILTER', 'SET_FILTER_MODE', 'APPLY_VIEW', 'RESET_FILTER'])
|
||||
const BASIC_MODE_ACTIONS: ReadonlySet<RecordQueryAction['type']> = new Set(['SET_USER_FILTER', 'SET_FILTER_MODE', 'APPLY_VIEW', 'RESET_FILTER', 'RESTORE_URL'])
|
||||
|
||||
/**
|
||||
* Keeps basic mode consistent with the filter (Material's
|
||||
@@ -302,7 +344,7 @@ interface UseRecordQueryOptions {
|
||||
*
|
||||
* @param options - Configuration including table name, metadata, page size and variant.
|
||||
* @returns Grouped state and actions: `pagination`, `filter`, `columns`, `selection`, `data`,
|
||||
* `joins`, `density`, and `viewState`/`applyView` for saved views.
|
||||
* `joins`, `density`, `viewState`/`applyView` for saved views, and `restoreFromUrl` for history steps.
|
||||
*/
|
||||
export function useRecordQuery({
|
||||
tableName,
|
||||
@@ -320,9 +362,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`
|
||||
@@ -334,25 +380,19 @@ export function useRecordQuery({
|
||||
const initialStateRef = useRef<RecordQueryState | null>(null)
|
||||
if (!initialStateRef.current) {
|
||||
const isPageSize = (value: unknown): value is PageSize => (PAGE_SIZE_OPTIONS as readonly unknown[]).includes(value)
|
||||
const pageSizeParam = Number(searchParams.get('pageSize'))
|
||||
const pageSize: PageSize = isPageSize(pageSizeParam) ? pageSizeParam : isPageSize(initialView?.pageSize) ? initialView.pageSize : initialPageSize
|
||||
const filterParam = searchParams.get('filter')
|
||||
const viewPageSize: PageSize = isPageSize(initialView?.pageSize) ? initialView.pageSize : initialPageSize
|
||||
const pageSize: PageSize = isPageSize(Number(searchParams.get('pageSize'))) ? Number(searchParams.get('pageSize')) as PageSize : viewPageSize
|
||||
const viewFilter = initialView ? { ...initialView.userFilter, orderBys: initialView.sortOrder, skip: 0, limit: pageSize } : null
|
||||
const initialFilter = filterParam ? deserializeFilter(filterParam, pageSize) : viewFilter ?? emptyFilter(pageSize)
|
||||
const pageParam = parseInt(searchParams.get('page') ?? '', 10)
|
||||
const fromUrl = readUrlQueryState(new URLSearchParams(searchParams.toString()), viewPageSize, defaultSort, viewFilter)
|
||||
// the mode and added quick filters are remembered per table (Material keeps them in the stored view)
|
||||
const storedMode = readStored(filterModeKey, (v): v is 'basic' | 'advanced' => v === 'basic' || v === 'advanced')
|
||||
const storedQuickFilters = readStored(quickFilterFieldsKey, (v): v is string[] => Array.isArray(v) && v.every((name) => typeof name === 'string'))
|
||||
// Columns: the view's, else what earlier versions stored per table
|
||||
const legacy = initialView ? null : readLegacyColumnState(tableName)
|
||||
initialStateRef.current = reconcileState({
|
||||
pageNum: Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1,
|
||||
pageSize,
|
||||
userFilter: { ...initialFilter, orderBys: [] },
|
||||
quickSearchTerm: searchParams.get('q') ?? '',
|
||||
...fromUrl,
|
||||
filterMode: initialView?.filterMode ?? storedMode ?? 'basic',
|
||||
quickFilterFieldNames: (initialView?.quickFilterFieldNames ?? storedQuickFilters ?? []).filter((name) => Boolean(tableMetaData && resolveField(tableMetaData, name))),
|
||||
sortOrder: initialFilter.orderBys?.length ? initialFilter.orderBys : defaultSort,
|
||||
columnVisibility: initialView?.columnVisibility ?? legacy?.columnVisibility ?? (tableMetaData ? defaultColumnVisibility(tableMetaData) : {}),
|
||||
columnOrder: initialView?.columnOrder ?? legacy?.columnOrder ?? [],
|
||||
columnWidths: initialView?.columnWidths ?? legacy?.columnWidths ?? {},
|
||||
@@ -577,6 +617,14 @@ export function useRecordQuery({
|
||||
const toggleFilterPanel = useCallback(() => dispatch({ type: 'TOGGLE_FILTER_PANEL' }), [])
|
||||
const resetFilter = useCallback(() => dispatch({ type: 'RESET_FILTER' }), [])
|
||||
const applyView = useCallback((view: ViewState) => dispatch({ type: 'APPLY_VIEW', view }), [])
|
||||
/**
|
||||
* Takes the filter, sort, quick search, page and page size from an address the browser moved to
|
||||
* (Back or Forward). Only a history step calls it: the hook's own changes go the other way.
|
||||
*/
|
||||
const restoreFromUrl = useCallback(
|
||||
(search: string) => dispatch({ type: 'RESTORE_URL', url: readUrlQueryState(new URLSearchParams(search), initialPageSize, defaultSort) }),
|
||||
[initialPageSize, defaultSort]
|
||||
)
|
||||
|
||||
const viewState = useMemo<ViewState>(() => ({
|
||||
userFilter: state.userFilter,
|
||||
@@ -649,5 +697,6 @@ export function useRecordQuery({
|
||||
setDensity,
|
||||
viewState,
|
||||
applyView,
|
||||
restoreFromUrl,
|
||||
}
|
||||
}
|
||||
@@ -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 { BACKGROUND_REQUEST, HANDLES_OWN_ERRORS, queryClient, queryKeys } from './query-client'
|
||||
|
||||
describe('queryKeys factory', () => {
|
||||
it('all() returns root key', () => {
|
||||
@@ -109,3 +114,219 @@ 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.
|
||||
* @param meta - The mutation's meta.
|
||||
*/
|
||||
async function failMutation(error: unknown, meta?: Record<string, unknown>): Promise<void> {
|
||||
await queryClient.getMutationCache().build(queryClient, { mutationFn: () => Promise.reject(error), meta }).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('shows no banner for a background request, whatever failed, and still logs it', async () => {
|
||||
const PRESENCE = { method: 'get', url: '/qbit/collaboration/presence?table=order' }
|
||||
const recovering = { status: 503, data: { error: 'Presence is recovering; active viewers are rejoining. Please retry.' } }
|
||||
await failQuery(axiosError(PRESENCE, recovering), queryKeys.collaborationResource('order', '', 'presence'), BACKGROUND_REQUEST)
|
||||
await failQuery(axiosError(PRESENCE), queryKeys.collaborationResource('order', '', 'presence'), BACKGROUND_REQUEST)
|
||||
await failQuery(axiosError(PRESENCE, { status: 403, data: { error: 'Not yours' } }), queryKeys.collaborationResource('order', '', 'presence'), BACKGROUND_REQUEST)
|
||||
await failMutation(axiosError({ method: 'post', url: '/qbit/collaboration/recents' }, recovering), BACKGROUND_REQUEST)
|
||||
render(React.createElement(Toaster))
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 20)))
|
||||
|
||||
expect(banner).not.toHaveBeenCalled()
|
||||
expect(shown()).toEqual([])
|
||||
expect(warn.mock.calls).toEqual([
|
||||
['[API] Request failed:', { context: 'query', status: 503, code: 'ERR_BAD_REQUEST', name: 'AxiosError', request: 'GET /qqq/v1/qbit/collaboration/presence', area: 'collaboration', background: true }],
|
||||
['[API] Request failed:', { context: 'query', status: undefined, code: 'ERR_NETWORK', name: 'AxiosError', request: 'GET /qqq/v1/qbit/collaboration/presence', area: 'collaboration', background: true }],
|
||||
['[API] Request failed:', { context: 'query', status: 403, code: 'ERR_BAD_REQUEST', name: 'AxiosError', request: 'GET /qqq/v1/qbit/collaboration/presence', area: 'collaboration', background: true }],
|
||||
['[API] Request failed:', { context: 'mutation', status: 503, code: 'ERR_BAD_REQUEST', name: 'AxiosError', request: 'POST /qqq/v1/qbit/collaboration/recents', area: undefined, background: true }],
|
||||
])
|
||||
})
|
||||
|
||||
it('still shows the banner for the same failure of a request the person started', async () => {
|
||||
const recovering = { status: 503, data: { error: 'Presence is recovering; active viewers are rejoining. Please retry.' } }
|
||||
await failQuery(axiosError({ method: 'get', url: '/qbit/collaboration/presence?table=order' }, recovering), queryKeys.collaborationResource('order', '', 'presence'))
|
||||
expect(banners()).toEqual([['Server error — retrying…', 'GET /qqq/v1/qbit/collaboration/presence (HTTP 503)']])
|
||||
})
|
||||
|
||||
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/)
|
||||
})
|
||||
})
|
||||
+146
-33
@@ -20,23 +20,110 @@
|
||||
|
||||
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.
|
||||
*
|
||||
* A background request (see {@link BACKGROUND_REQUEST}) is logged the same way and shows no banner.
|
||||
*
|
||||
* @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.
|
||||
* @param background - True for a request the page made on its own; it is logged, never shown.
|
||||
*/
|
||||
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[], background = false): 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 +132,32 @@ 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,
|
||||
...(background ? { background } : {}),
|
||||
})
|
||||
// Nothing the person did failed, and the page works without it: no banner.
|
||||
if (background) return
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +197,8 @@ function exponentialBackoff(attemptIndex: number): number {
|
||||
* - 10-minute GC time keeps recently visited pages snappy when navigating back.
|
||||
* - Smart query retry: never retries 4xx errors; retries server errors up to 3 times. Mutations are not retried.
|
||||
* - Global `QueryCache.onError` and `MutationCache.onError` surface toast notifications
|
||||
* for all unhandled API errors without requiring per-call error handling.
|
||||
* for all unhandled API errors without requiring per-call error handling, except for
|
||||
* background requests ({@link BACKGROUND_REQUEST}), which are only logged.
|
||||
* - Pending queries are included in dehydration so SSR can pass them to the client shell.
|
||||
*/
|
||||
/**
|
||||
@@ -130,16 +219,40 @@ function handlesOwnErrors(meta: Record<string, unknown> | undefined): boolean {
|
||||
return meta?.handlesOwnErrors === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta flag for a request the page makes on its own, for something the page works without: a
|
||||
* poll that keeps an indicator fresh (presence, the notification bell, the jobs tray, the
|
||||
* assistant's unread count) or a write nobody asked for (noting a recently viewed record).
|
||||
*
|
||||
* The rule: a background request never raises the global error banner. The person did nothing
|
||||
* that failed and can do nothing about it, and a poll would raise the banner again on every
|
||||
* attempt. The failure is still logged to the console, and the indicator it feeds shows its own
|
||||
* quiet state or nothing until a later attempt succeeds. A request the person started (opening a
|
||||
* page, saving, running something) is never a background request.
|
||||
*/
|
||||
export const BACKGROUND_REQUEST = { backgroundRequest: true } as const
|
||||
|
||||
/**
|
||||
* Whether a query or mutation is a background request.
|
||||
*
|
||||
* @param meta - The query or mutation `meta` object.
|
||||
* @returns `true` when `meta.backgroundRequest` is set.
|
||||
*/
|
||||
function isBackgroundRequest(meta: Record<string, unknown> | undefined): boolean {
|
||||
return meta?.backgroundRequest === true
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
// Queries that render their own error state (records, widgets) opt out with HANDLES_OWN_ERRORS.
|
||||
// Queries that render their own error state (records, widgets) opt out with HANDLES_OWN_ERRORS;
|
||||
// background requests (polls for an indicator) are logged without a banner (BACKGROUND_REQUEST).
|
||||
onError: (error, query) => {
|
||||
if (!handlesOwnErrors(query.meta)) handleQueryError(error, 'query')
|
||||
if (!handlesOwnErrors(query.meta)) handleQueryError(error, 'query', query.queryKey, isBackgroundRequest(query.meta))
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error, _variables, _context, mutation) => {
|
||||
if (!handlesOwnErrors(mutation.meta)) handleQueryError(error, 'mutation')
|
||||
if (!handlesOwnErrors(mutation.meta)) handleQueryError(error, 'mutation', undefined, isBackgroundRequest(mutation.meta))
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { backoffDelay } from './backoff'
|
||||
|
||||
const limits = { baseMs: 5_000, maxMs: 60_000 }
|
||||
|
||||
describe('backoffDelay', () => {
|
||||
it('doubles its ceiling per failure, up to the maximum', () => {
|
||||
const ceilings = [1, 2, 3, 4, 5, 6, 50].map((failures) => backoffDelay(failures, { ...limits, random: () => 1 }))
|
||||
expect(ceilings).toEqual([5_000, 10_000, 20_000, 40_000, 60_000, 60_000, 60_000])
|
||||
})
|
||||
|
||||
it('waits between half the ceiling and the ceiling', () => {
|
||||
expect(backoffDelay(3, { ...limits, random: () => 0 })).toBe(10_000)
|
||||
expect(backoffDelay(3, { ...limits, random: () => 0.5 })).toBe(15_000)
|
||||
expect(backoffDelay(3, { ...limits, random: () => 0.999 })).toBeLessThanOrEqual(20_000)
|
||||
})
|
||||
|
||||
it('spreads clients that failed at the same moment', () => {
|
||||
const waits = new Set(Array.from({ length: 50 }, () => backoffDelay(4, limits)))
|
||||
expect(waits.size).toBeGreaterThan(10)
|
||||
for (const wait of waits) {
|
||||
expect(wait).toBeGreaterThanOrEqual(20_000)
|
||||
expect(wait).toBeLessThanOrEqual(40_000)
|
||||
}
|
||||
})
|
||||
|
||||
it('treats no failure yet like the first', () => {
|
||||
expect(backoffDelay(0, { ...limits, random: () => 1 })).toBe(5_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file backoff — how long a background request waits before it tries again after failing.
|
||||
*/
|
||||
|
||||
/** Limits of a backoff. */
|
||||
export interface BackoffOptions {
|
||||
/** Ceiling of the wait after the first failure, in milliseconds. */
|
||||
baseMs: number
|
||||
/** Ceiling of any wait, in milliseconds. */
|
||||
maxMs: number
|
||||
/** Source of randomness in `[0, 1)`; `Math.random` unless a test fixes it. */
|
||||
random?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* The wait before the next attempt, after `failures` consecutive failures: a ceiling that doubles
|
||||
* per failure from `baseMs` up to `maxMs`, of which half is fixed and half is random ("equal
|
||||
* jitter"). The random half spreads out clients that all failed at the same moment (a server
|
||||
* restart), so they do not all return at the same moment.
|
||||
*
|
||||
* @param failures - Consecutive failures so far (1 after the first).
|
||||
* @param options - See {@link BackoffOptions}.
|
||||
* @returns Milliseconds, between half the ceiling and the ceiling.
|
||||
*/
|
||||
export function backoffDelay(failures: number, { baseMs, maxMs, random = Math.random }: BackoffOptions): number {
|
||||
const ceiling = Math.min(maxMs, baseMs * 2 ** Math.max(0, Math.min(failures, 31) - 1))
|
||||
return Math.round(ceiling / 2 + random() * (ceiling / 2))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 { EXPLORE_VIEWS } from './data-analysis-explore'
|
||||
import { listSearch, TABLE_SUB_VIEWS, tableSubView } from './table-sub-views'
|
||||
|
||||
describe('table sub-views', () => {
|
||||
it('registers every explore view, and the saved analysis viewer', () => {
|
||||
expect(Object.keys(TABLE_SUB_VIEWS).sort()).toEqual([...EXPLORE_VIEWS, 'saved'].sort())
|
||||
})
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('$name has a label, a back control and its own parameters', (view) => {
|
||||
expect(TABLE_SUB_VIEWS[view.name]).toBe(view)
|
||||
expect(view.label.trim()).not.toBe('')
|
||||
expect(view.backId).toMatch(/-back$/)
|
||||
expect(view.params.length).toBeGreaterThan(0)
|
||||
expect(tableSubView(`?view=${view.name}`)).toBe(view)
|
||||
})
|
||||
|
||||
it('opens no sub-view for the list, an unknown view or an inherited property name', () => {
|
||||
expect(tableSubView('')).toBeUndefined()
|
||||
expect(tableSubView(null)).toBeUndefined()
|
||||
expect(tableSubView('?view=tabs')).toBeUndefined()
|
||||
expect(tableSubView('?view=constructor')).toBeUndefined()
|
||||
expect(tableSubView(new URLSearchParams({ filter: 'abc' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(Object.values(TABLE_SUB_VIEWS))('the list behind $name keeps filter, sort, page and search', (view) => {
|
||||
const params = new URLSearchParams({ page: '3', pageSize: '50', filter: 'abc', q: 'smith', from: '/app/home', view: view.name })
|
||||
for (const name of view.params) params.set(name, 'layout')
|
||||
expect(listSearch(params)).toBe('?page=3&pageSize=50&filter=abc&q=smith&from=%2Fapp%2Fhome')
|
||||
expect(listSearch(`?${params}`)).toBe('?page=3&pageSize=50&filter=abc&q=smith&from=%2Fapp%2Fhome')
|
||||
})
|
||||
|
||||
it('leaves nothing of one view behind when another view was in the address', () => {
|
||||
expect(listSearch('?view=pivot&pivot=a&correlation=b&saved=7')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 table-sub-views — the views of a table's record list that are selected by the query
|
||||
* string (`?view=pivot`, `?view=correlation`, `?view=saved&saved=7`) instead of a path.
|
||||
*
|
||||
* A sub-view replaces the list on the same route, so nothing in the path says it is open. This
|
||||
* registry is the one place that does: the record list opens a view only through it, the
|
||||
* breadcrumb reads it to show the view as the current page and the table as a link back, and the
|
||||
* tests walk it, so a view cannot be added without a label and a way back.
|
||||
*/
|
||||
|
||||
import { EXPLORE_LABELS, EXPLORE_VIEWS } from './data-analysis-explore'
|
||||
|
||||
/** The query parameter that names the open sub-view. */
|
||||
export const TABLE_SUB_VIEW_PARAM = 'view'
|
||||
|
||||
/** Name of a sub-view: every explore view, and the saved analysis viewer. */
|
||||
export type TableSubViewName = (typeof EXPLORE_VIEWS)[number] | 'saved'
|
||||
|
||||
/** One sub-view of a table's record list. */
|
||||
export interface TableSubView {
|
||||
/** Value of the `view` parameter. */
|
||||
name: TableSubViewName
|
||||
/** The breadcrumb's label for the view. */
|
||||
label: string
|
||||
/** Query parameters that belong to the view (its layout or id), dropped with it on the way back. */
|
||||
params: readonly string[]
|
||||
/** `data-qqq-id` of the control inside the view that returns to the list. */
|
||||
backId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sub-view, by name. The key type makes a new explore view a compile error until it is
|
||||
* registered here.
|
||||
*/
|
||||
export const TABLE_SUB_VIEWS: Record<TableSubViewName, TableSubView> = {
|
||||
pivot: { name: 'pivot', label: EXPLORE_LABELS.pivot, params: ['pivot'], backId: 'pivot-back' },
|
||||
compare: { name: 'compare', label: EXPLORE_LABELS.compare, params: ['compare'], backId: 'explore-back' },
|
||||
correlation: { name: 'correlation', label: EXPLORE_LABELS.correlation, params: ['correlation'], backId: 'explore-back' },
|
||||
crosstab: { name: 'crosstab', label: EXPLORE_LABELS.crosstab, params: ['crosstab'], backId: 'explore-back' },
|
||||
shift: { name: 'shift', label: EXPLORE_LABELS.shift, params: ['shift'], backId: 'explore-back' },
|
||||
saved: { name: 'saved', label: 'Saved analysis', params: ['saved'], backId: 'saved-back' },
|
||||
}
|
||||
|
||||
/** Every parameter a sub-view can own, including `view` itself. */
|
||||
const SUB_VIEW_PARAMS = [TABLE_SUB_VIEW_PARAM, ...new Set(Object.values(TABLE_SUB_VIEWS).flatMap((view) => view.params))]
|
||||
|
||||
/**
|
||||
* The sub-view a query string opens, if any. An unknown `view` value opens none (a record page
|
||||
* uses `view=tabs` for its own layout).
|
||||
*
|
||||
* @param search - The query string or its parameters.
|
||||
* @returns The sub-view, or undefined when the list itself is shown.
|
||||
*/
|
||||
export function tableSubView(search: URLSearchParams | string | null | undefined): TableSubView | undefined {
|
||||
const name = new URLSearchParams(search ?? '').get(TABLE_SUB_VIEW_PARAM)
|
||||
return name !== null && Object.prototype.hasOwnProperty.call(TABLE_SUB_VIEWS, name) ? TABLE_SUB_VIEWS[name as TableSubViewName] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The query string of the list behind a sub-view: the same parameters (filter, sort, page, page
|
||||
* size, quick search and anything else) without the view's own.
|
||||
*
|
||||
* @param search - The current query string or its parameters.
|
||||
* @returns The list's query string, with a leading `?` unless it is empty.
|
||||
*/
|
||||
export function listSearch(search: URLSearchParams | string | null | undefined): string {
|
||||
const params = new URLSearchParams(search ?? '')
|
||||
for (const name of SUB_VIEW_PARAMS) params.delete(name)
|
||||
const rest = params.toString()
|
||||
return rest ? `?${rest}` : ''
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
// The record audit dialog with a 64 character record identifier: every control stays inside the
|
||||
// dialog's card at phone to desktop widths in both themes, an empty history is one block, and
|
||||
// opening and re-sorting ask the server once.
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
|
||||
const LONG_ID = 'a3f9c2e17b5d4086a3f9c2e17b5d4086a3f9c2e17b5d4086a3f9c2e17b5d4086'
|
||||
const DIALOG = '[data-qqq-id="audit-history-dialog"]'
|
||||
const CONTROLS = ['button-audit-copy-record', 'button-audit-sort-ascending', 'button-audit-sort-descending', 'button-close-audit-history']
|
||||
|
||||
const audit = (id: number, day: number, detail: number) => ({
|
||||
tableName: 'audit',
|
||||
values: {
|
||||
id, recordId: 1, message: 'Record was Edited', timestamp: `2026-01-${String(day).padStart(2, '0')}T10:00:00Z`, auditUserId: 3,
|
||||
'auditDetail.id': detail, 'auditDetail.fieldName': 'email', 'auditDetail.message': `Changed Email from "a${detail}@example.com" to "b${detail}@example.com"`,
|
||||
},
|
||||
displayValues: { auditUserId: 'Alice (sample)', auditTableId: 'People' },
|
||||
})
|
||||
|
||||
/**
|
||||
* Serves the record with a long label and the audit process with the given rows.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @param audits - Rows the process answers with (newest first).
|
||||
* @returns The audit requests made so far.
|
||||
*/
|
||||
async function mockAudits(page: Page, audits: unknown[]) {
|
||||
await setupApiMocks(page)
|
||||
const auditProcess = { ...METADATA.processes.importData, name: 'GetAuditsForRecord', label: 'Audits', isHidden: true, frontendSteps: [] }
|
||||
await page.route((url) => url.pathname === '/qqq/v1/metaData', (route) =>
|
||||
route.fulfill({ json: { ...METADATA, processes: { ...METADATA.processes, GetAuditsForRecord: auditProcess } } })
|
||||
)
|
||||
await page.route(/\/qqq\/v1\/table\/person\/1(\?|$)/, async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
const response = await route.fetch().catch(() => null)
|
||||
const body = response ? await response.json().catch(() => null) : null
|
||||
const record = body?.record ?? { tableName: 'person', values: { id: 1 } }
|
||||
return route.fulfill({ json: { record: { ...record, recordLabel: LONG_ID } } })
|
||||
})
|
||||
const requests: Record<string, unknown>[] = []
|
||||
await page.route('**/qqq/v1/processes/GetAuditsForRecord/init**', (route) => {
|
||||
const values = /name="values"\r?\n\r?\n([^\r\n]+)/.exec(route.request().postData() ?? '')?.[1]
|
||||
const stepTimeoutMillis = /name="stepTimeoutMillis"\r?\n\r?\n([^\r\n]+)/.exec(route.request().postData() ?? '')?.[1]
|
||||
const sent = { ...(values ? JSON.parse(values) : {}), stepTimeoutMillis }
|
||||
requests.push(sent)
|
||||
return route.fulfill({ json: { type: 'COMPLETE', values: { audits: sent.isSortAscending ? [...audits].reverse() : audits } } })
|
||||
})
|
||||
return requests
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every box lies inside the outer one.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @returns Names of the controls that leave the dialog's card.
|
||||
*/
|
||||
async function controlsOutside(page: Page): Promise<string[]> {
|
||||
const card = (await page.locator(DIALOG).boundingBox())!
|
||||
const outside: string[] = []
|
||||
for (const id of [...CONTROLS, 'audit-history-title', 'audit-history-status']) {
|
||||
const box = (await page.locator(`${DIALOG} [data-qqq-id="${id}"]`).boundingBox())!
|
||||
const inside = box.x >= card.x - 0.5 && box.y >= card.y - 0.5 && box.x + box.width <= card.x + card.width + 0.5 && box.y + box.height <= card.y + card.height + 0.5
|
||||
if (!inside) outside.push(id)
|
||||
}
|
||||
return outside
|
||||
}
|
||||
|
||||
for (const width of [320, 390, 768, 1440])
|
||||
test(`audit dialog keeps a 64 character identifier and its controls inside the card at ${width}px, light and dark`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 })
|
||||
await mockAudits(page, [])
|
||||
await page.goto('/app/person/1')
|
||||
await page.locator('[data-qqq-id="button-audit"]').click()
|
||||
const dialog = page.locator(DIALOG)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole('heading', { level: 2 })).toHaveAttribute('title', `Audit for People: ${LONG_ID}`)
|
||||
const status = dialog.locator('[data-qqq-id="audit-history-status"]')
|
||||
await expect(status).toHaveText('No audits were found for this record.')
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
await page.evaluate((value) => document.documentElement.classList.toggle('dark', value), dark)
|
||||
expect(await controlsOutside(page)).toEqual([])
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
// the dialog does not scroll sideways, and its heading is not wider than the card
|
||||
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
|
||||
// The empty state is one block: the icon sits inside the status, above its sentence, and
|
||||
// nothing follows it in the card
|
||||
const icon = (await status.locator('svg').boundingBox())!
|
||||
const block = (await status.boundingBox())!
|
||||
expect(icon.y).toBeGreaterThanOrEqual(block.y)
|
||||
expect(icon.y + icon.height).toBeLessThanOrEqual(block.y + block.height)
|
||||
expect(icon.height).toBeLessThanOrEqual(28)
|
||||
expect(await dialog.evaluate((element) => element.lastElementChild?.getAttribute('data-qqq-id'))).toBe('audit-history-status')
|
||||
expect(await dialog.locator('svg').count()).toBe(CONTROLS.length + 1)
|
||||
|
||||
expect((await new AxeBuilder({ page }).include(DIALOG).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()).violations).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test('audit dialog asks the server once to open and not again to re-sort a complete history', async ({ page }) => {
|
||||
const rows = [audit(3, 5, 31), audit(3, 5, 32), audit(2, 4, 21), audit(1, 3, 11)]
|
||||
const requests = await mockAudits(page, rows)
|
||||
await page.goto('/app/person/1')
|
||||
await page.locator('[data-qqq-id="button-audit"]').click()
|
||||
const dialog = page.locator(DIALOG)
|
||||
await expect(dialog.locator('[data-qqq-id="audit-history-status"]')).toHaveText('Showing all 3 audits for this record')
|
||||
const order = () => dialog.locator('[data-qqq-id^="audit-entry-"][data-qqq-id$="1"], [data-qqq-id^="audit-entry-"][data-qqq-id$="2"], [data-qqq-id^="audit-entry-"][data-qqq-id$="3"]')
|
||||
.evaluateAll((entries) => entries.map((entry) => entry.getAttribute('data-qqq-id')).filter((id) => /^audit-entry-\d+$/.test(id ?? '')))
|
||||
expect(await order()).toEqual(['audit-entry-3', 'audit-entry-2', 'audit-entry-1'])
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]).toMatchObject({ tableName: 'person', recordId: '1', isSortAscending: false, limit: 1000 })
|
||||
expect(Number(requests[0].stepTimeoutMillis)).toBeGreaterThan(3000)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Sort by time ascending (oldest to newest)' }).click()
|
||||
await expect.poll(order).toEqual(['audit-entry-1', 'audit-entry-2', 'audit-entry-3'])
|
||||
await dialog.getByRole('button', { name: 'Sort by time descending (newest to oldest)' }).click()
|
||||
await expect.poll(order).toEqual(['audit-entry-3', 'audit-entry-2', 'audit-entry-1'])
|
||||
await page.waitForTimeout(300)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(await controlsOutside(page)).toEqual([])
|
||||
})
|
||||
@@ -37,7 +37,7 @@ async function openRecord(page: Page) {
|
||||
})
|
||||
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)
|
||||
await expect(page.locator('[data-qqq-id="presence-reconnecting"]')).toHaveCount(0)
|
||||
}
|
||||
async function concurrentEdit(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
|
||||
@@ -262,3 +262,67 @@ test('Explore is absent without the capability', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: 'Toggle advanced filter panel' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Explore', exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
// Every relationship view leaves three ways back to the list it was opened from: the browser's
|
||||
// Back button, its own "Back to records" button and the table's breadcrumb. Each returns the list
|
||||
// with its filter and page as they were.
|
||||
const VIEW_LABELS = {
|
||||
compare: 'Compare',
|
||||
correlation: 'Correlation',
|
||||
crosstab: 'Crosstab',
|
||||
shift: 'Shift over time',
|
||||
} as const
|
||||
for (const view of ['compare', 'correlation', 'crosstab', 'shift'] as const)
|
||||
test(`${view} leaves a way back: browser Back, Back to records and the breadcrumb`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockRelationships(page)
|
||||
const filter = Buffer.from(JSON.stringify(baseFilter)).toString('base64')
|
||||
await page.goto(`/app/person?${new URLSearchParams({ filter, page: '2' })}`)
|
||||
const crumbs = page.getByRole('navigation', { name: 'Breadcrumb', exact: true })
|
||||
const current = crumbs.locator('[aria-current="page"]')
|
||||
const list = page.locator('[data-qqq-id="record-query-person"]')
|
||||
const explorer = page.locator(`[data-qqq-id="${view}-explorer"]`)
|
||||
const address = () => {
|
||||
const params = new URL(page.url()).searchParams
|
||||
const encoded = params.get('filter')
|
||||
return {
|
||||
view: params.get('view'),
|
||||
layout: params.has(view),
|
||||
page: params.get('page'),
|
||||
criteria: encoded ? JSON.parse(Buffer.from(encoded, 'base64').toString()).criteria : null,
|
||||
}
|
||||
}
|
||||
const asLeft = { view: null, layout: false, page: '2', criteria: baseFilter.criteria }
|
||||
const backAtList = async () => {
|
||||
await expect(explorer).toHaveCount(0)
|
||||
await expect(list).toBeVisible()
|
||||
await expect(current).toHaveText('People')
|
||||
await expect.poll(address).toEqual(asLeft)
|
||||
}
|
||||
await expect(list).toBeVisible()
|
||||
await backAtList()
|
||||
|
||||
// The breadcrumb follows the view; the table becomes a link
|
||||
await build(page, view)
|
||||
await expect(current).toHaveText(VIEW_LABELS[view])
|
||||
await expect(crumbs.getByRole('link', { name: 'People', exact: true })).toBeVisible()
|
||||
expect(address()).toMatchObject({ view, layout: true, page: '2' })
|
||||
|
||||
// Browser Back, after the view ran
|
||||
await page.goBack()
|
||||
await backAtList()
|
||||
|
||||
// The view's own button is one step back too, so the view stays one Forward away
|
||||
await page.goForward()
|
||||
await expect(explorer).toBeVisible()
|
||||
await explorer.getByRole('button', { name: 'Back to records' }).click()
|
||||
await backAtList()
|
||||
await page.goForward()
|
||||
await expect(explorer).toBeVisible()
|
||||
await expect(current).toHaveText(VIEW_LABELS[view])
|
||||
|
||||
// The table's breadcrumb
|
||||
await crumbs.getByRole('link', { name: 'People', exact: true }).click()
|
||||
await backAtList()
|
||||
})
|
||||
@@ -118,6 +118,53 @@ test('browser Back returns to the grid and Forward restores the layout', async (
|
||||
await expect(page.locator(EXPLORER).getByRole('link', { name: '14', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('pivot leaves a way back: browser Back, Back to records and the breadcrumb', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPivot(page)
|
||||
const filter = Buffer.from(JSON.stringify(baseFilter)).toString('base64')
|
||||
await page.goto(`/app/person?${new URLSearchParams({ filter, page: '2' })}`)
|
||||
const crumbs = page.getByRole('navigation', { name: 'Breadcrumb', exact: true })
|
||||
const current = crumbs.locator('[aria-current="page"]')
|
||||
const explorer = page.locator(EXPLORER)
|
||||
const address = () => {
|
||||
const params = new URL(page.url()).searchParams
|
||||
const encoded = params.get('filter')
|
||||
return {
|
||||
view: params.get('view'),
|
||||
layout: params.has('pivot'),
|
||||
page: params.get('page'),
|
||||
criteria: encoded ? JSON.parse(Buffer.from(encoded, 'base64').toString()).criteria : null,
|
||||
}
|
||||
}
|
||||
const backAtList = async () => {
|
||||
await expect(explorer).toHaveCount(0)
|
||||
await expect(page.locator('[data-qqq-id="record-query-person"]')).toBeVisible()
|
||||
await expect(current).toHaveText('People')
|
||||
await expect
|
||||
.poll(address)
|
||||
.toEqual({ view: null, layout: false, page: '2', criteria: baseFilter.criteria })
|
||||
}
|
||||
await backAtList()
|
||||
|
||||
await buildPivot(page)
|
||||
await expect(current).toHaveText('Pivot')
|
||||
await expect(crumbs.getByRole('link', { name: 'People', exact: true })).toBeVisible()
|
||||
await page.goBack()
|
||||
await backAtList()
|
||||
|
||||
// The pivot's own button is one step back too, so the pivot stays one Forward away
|
||||
await page.goForward()
|
||||
await expect(explorer).toBeVisible()
|
||||
await explorer.getByRole('button', { name: 'Back to records' }).click()
|
||||
await backAtList()
|
||||
await page.goForward()
|
||||
await expect(explorer).toBeVisible()
|
||||
|
||||
await crumbs.getByRole('link', { name: 'People', exact: true }).click()
|
||||
await backAtList()
|
||||
})
|
||||
|
||||
for (const width of [390, 768, 1440]) {
|
||||
test(`pivot stays within ${width}px and passes axe in both themes`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 history-navigation — a `next/navigation` stand-in for unit tests whose hooks follow the
|
||||
* address, as the App Router's do: `usePathname` and `useSearchParams` re-render after
|
||||
* `history.pushState`, `history.replaceState` and a Back or Forward step, and nothing is
|
||||
* remounted. Use it for components whose state lives in the query string:
|
||||
*
|
||||
* ```ts
|
||||
* vi.mock('next/navigation', async () => (await import('../../../tests/helpers/history-navigation')).historyNavigation)
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
const notify = () => listeners.forEach((listener) => listener())
|
||||
let installed = false
|
||||
|
||||
/** Makes every write to the history, and every traversal, tell the hooks. Idempotent. */
|
||||
function install(): void {
|
||||
if (installed) return
|
||||
installed = true
|
||||
for (const method of ['pushState', 'replaceState'] as const) {
|
||||
const original = window.history[method].bind(window.history)
|
||||
window.history[method] = (...args: Parameters<History['pushState']>) => {
|
||||
original(...args)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
window.addEventListener('popstate', notify)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes a hook to address changes.
|
||||
*
|
||||
* @param listener - Called after every change.
|
||||
* @returns The unsubscribe function.
|
||||
*/
|
||||
function subscribe(listener: () => void): () => void {
|
||||
install()
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to an address as a router navigation would: a new history entry, and the hooks follow.
|
||||
*
|
||||
* @param href - Path and query string.
|
||||
*/
|
||||
function push(href: string): void {
|
||||
install()
|
||||
window.history.pushState(null, '', href)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current address as a router navigation would.
|
||||
*
|
||||
* @param href - Path and query string.
|
||||
*/
|
||||
function replace(href: string): void {
|
||||
install()
|
||||
window.history.replaceState(null, '', href)
|
||||
}
|
||||
|
||||
/** The module to return from `vi.mock('next/navigation', ...)`. */
|
||||
export const historyNavigation = {
|
||||
useRouter: () => ({ push, replace, back: () => window.history.back(), forward: () => window.history.forward(), refresh: () => undefined, prefetch: () => undefined }),
|
||||
usePathname: () => useSyncExternalStore(subscribe, () => window.location.pathname),
|
||||
useSearchParams: () => {
|
||||
const search = useSyncExternalStore(subscribe, () => window.location.search)
|
||||
return useMemo(() => new URLSearchParams(search), [search])
|
||||
},
|
||||
redirect: (href: string) => replace(href),
|
||||
}
|
||||
Reference in new issue
Block a user