Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
722e5b775a
|
||
|
|
d49c4a5899
|
||
|
|
9b59908629
|
||
|
|
2f82a1fe83
|
||
|
|
d7cca33152
|
||
|
|
5694671227
|
||
|
|
5c7dbe2e52
|
||
|
|
a4ed5de745
|
||
|
|
e3f3fc8045
|
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.
|
||||
@@ -101,6 +101,28 @@ status, code, name, request, area })`: the HTTP status, the axios error code (`E
|
||||
`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.
|
||||
|
||||
**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:
|
||||
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -294,5 +316,6 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
|
||||
|---|---|---|
|
||||
| Axios interceptor | `src/lib/api/client.ts` | 401 → redirect to login |
|
||||
| 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,6 +15,7 @@
|
||||
- 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
|
||||
|
||||
@@ -24,8 +27,13 @@
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -361,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`
|
||||
|
||||
@@ -22,7 +22,7 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { AxiosError, AxiosHeaders, CanceledError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { Toaster, toast } from 'sonner'
|
||||
|
||||
import { HANDLES_OWN_ERRORS, queryClient, queryKeys } from './query-client'
|
||||
import { BACKGROUND_REQUEST, HANDLES_OWN_ERRORS, queryClient, queryKeys } from './query-client'
|
||||
|
||||
describe('queryKeys factory', () => {
|
||||
it('all() returns root key', () => {
|
||||
@@ -145,9 +145,10 @@ async function failQuery(error: unknown, queryKey: readonly unknown[] = queryKey
|
||||
* 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): Promise<void> {
|
||||
await queryClient.getMutationCache().build(queryClient, { mutationFn: () => Promise.reject(error) }).execute(undefined).catch(() => undefined)
|
||||
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', () => {
|
||||
@@ -291,6 +292,32 @@ describe('global error banner', () => {
|
||||
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))
|
||||
|
||||
+36
-5
@@ -113,11 +113,14 @@ function failureMessage(error: unknown, status: number | undefined, context: 'qu
|
||||
* 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', queryKey?: readonly unknown[]): void {
|
||||
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
|
||||
@@ -148,7 +151,10 @@ function handleQueryError(error: unknown, context: 'query' | 'mutation', queryKe
|
||||
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
|
||||
|
||||
const sameForEveryRequest = status === undefined && gotNoResponse(error)
|
||||
toast.error(message, { id: `request-failed:${message}:${sameForEveryRequest ? '' : (description ?? '')}`, description })
|
||||
@@ -191,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.
|
||||
*/
|
||||
/**
|
||||
@@ -212,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', query.queryKey)
|
||||
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,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,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 () => {
|
||||
|
||||
Reference in new issue
Block a user