Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5694671227
|
||
|
|
787a227602
|
||
|
|
30f4f9d586
|
||
|
|
24bbe4a038
|
||
|
|
8903f272c4
|
||
|
|
4347924658
|
||
|
|
523f59cf38
|
No files matched your search
@@ -225,3 +225,38 @@ Saved views and legacy stored visibility (including `{}`, meaning all base colum
|
||||
are preserved. New View and Reset All Changes on an unsaved view restore these
|
||||
metadata defaults. Quick search searches only visible string fields. Responsive
|
||||
card/grid behavior is unchanged.
|
||||
## Default sort
|
||||
|
||||
A record list opens sorted by primary key, descending. An application can declare another
|
||||
sort for a table (newest first by a date field, for example) in the same hint object:
|
||||
|
||||
```json
|
||||
{
|
||||
"supplementalMetaData": {
|
||||
"nextUi": {
|
||||
"defaultSort": [
|
||||
{ "fieldName": "receivedAt", "isAscending": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`defaultSort` is an array of order-bys in the shape a query filter uses (`QFilterOrderBy`):
|
||||
`fieldName` is a field of the table, or of an exposed join as `joinTable.fieldName`, and
|
||||
`isAscending` is a boolean (ascending when absent). Several entries sort by the first, then
|
||||
the next. The legacy `supplementalTableMetaData.nextUi.defaultSort` path is also accepted.
|
||||
|
||||
The declared sort is the table's default, not a setting that overrides people: a sort in the
|
||||
address, the sort of a saved view, and the sort the browser remembers for the table from an
|
||||
earlier visit are each used instead. New View and Reset All Changes return to the declared
|
||||
sort, and it is not counted as an unsaved change. An entry that names an unknown field, a
|
||||
field the user may not sort by, or carries a non-boolean `isAscending` is ignored; when no
|
||||
entry is usable, or the hint is malformed, the list sorts by primary key, descending, as
|
||||
before.
|
||||
|
||||
QQQ has no table-level default sort of its own (as of 4.1.0-RC.32), so the application supplies
|
||||
the hint: a `QSupplementalTableMetaData` subclass whose `getType()` is `nextUi`, that returns
|
||||
`true` from `includeInFullFrontendMetaData()` and exposes `getDefaultSort()` as a list of
|
||||
`QFilterOrderBy` (and `getDefaultColumns()` for the hint above), attached to the table with
|
||||
`withSupplementalMetaData(...)`. The table metadata response publishes it as shown.
|
||||
+37
-49
@@ -23,10 +23,12 @@ Axios interceptor
|
||||
▼
|
||||
TanStack Query cache (onError)
|
||||
│ 401 → silently ignored (already handled)
|
||||
│ 403 → toast "Permission denied" / "You do not have permission…"
|
||||
│ 403 → toast the server's message, or "Permission denied"
|
||||
│ 404 → toast "Resource not found"
|
||||
│ 5xx → toast "Server error — retrying…" / "Server error — please try again"
|
||||
│ network → toast "Something went wrong"
|
||||
│ other status → toast the server's message, or "Request failed (HTTP 409)"
|
||||
│ no response → toast "No response from the server" / "The server took too long to respond"
|
||||
│ every toast names the request, and a repeat of the same failure updates it
|
||||
▼
|
||||
Component (optional per-query onError)
|
||||
│ Only needed when generic toast is wrong for the specific UX
|
||||
@@ -66,52 +68,38 @@ The `unauthorizedCallback` is wired to the auth provider's logout/redirect funct
|
||||
|
||||
**Location:** `src/lib/query-client.ts` — `handleQueryError` function
|
||||
|
||||
```typescript
|
||||
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
|
||||
const status = getErrorStatusCode(error)
|
||||
It is registered on both `QueryCache.onError` and `MutationCache.onError`. A query or mutation whose
|
||||
component renders its own error opts out with `meta: HANDLES_OWN_ERRORS`.
|
||||
|
||||
if (status === 401) return // handled by axios interceptor
|
||||
| Failure | Banner text |
|
||||
|---|---|
|
||||
| Cancelled request (an axios cancel or an aborted `fetch`), 401 | none |
|
||||
| 403 | the server's message (of a JSON body), or `Permission denied` |
|
||||
| 404 | `Resource not found` |
|
||||
| 5xx | `Server error — retrying…` (query), `Server error — please try again` (mutation) |
|
||||
| Any other status (400, 409, 422, …) | the server's message (`userFacingError`, `error` or `message` of a JSON body), or `Request failed (HTTP 409)` |
|
||||
| No response (connection failed) | `No response from the server` |
|
||||
| No response (the 30 s timeout) | `The server took too long to respond` |
|
||||
| An error the application raised (`throw new Error('Invalid audit response')`) | that sentence |
|
||||
| A runtime error (`TypeError`, a schema error, …) | `The page hit an unexpected error (TypeError)` |
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(
|
||||
context === 'mutation'
|
||||
? 'You do not have permission to perform this action'
|
||||
: 'Permission denied'
|
||||
)
|
||||
return
|
||||
}
|
||||
Under the text, every banner names what failed: the request as `POST /qqq/v1/table/order/query (HTTP 409)`,
|
||||
or `While loading records` (the query key's namespace in plain words: `possibleValues` reads
|
||||
`possible values`) for a query that failed outside a request. The
|
||||
request is written without its query string, and every path segment that is not a plain word (a record
|
||||
id, a UUID, an encoded value) is shown as `{id}`. Nothing else of the request or response is shown.
|
||||
|
||||
if (status === 404) {
|
||||
toast.error('Resource not found')
|
||||
return
|
||||
}
|
||||
**One banner per failure.** The banner's sonner `id` is its text plus the request, so a failure that
|
||||
repeats (a polling query that fails on every attempt) updates the banner already on screen instead of
|
||||
adding one. A different failure gets its own banner. A lost connection is one cause whichever request
|
||||
met it, so it has a single banner, which names the request that failed most recently. Without the id
|
||||
every failed poll added a banner, and sonner pauses a toast's timer while the tab is hidden or the
|
||||
pointer rests on the stack, so the banners could pile up.
|
||||
|
||||
if (status !== undefined && status >= 500) {
|
||||
toast.error(
|
||||
context === 'mutation'
|
||||
? 'Server error — please try again'
|
||||
: 'Server error — retrying…'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Network failure or unexpected status
|
||||
toast.error('Something went wrong')
|
||||
}
|
||||
```
|
||||
|
||||
This handler is registered on both `QueryCache` and `MutationCache`:
|
||||
|
||||
```typescript
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => handleQueryError(error, 'query'),
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => handleQueryError(error, 'mutation'),
|
||||
}),
|
||||
})
|
||||
```
|
||||
**Console.** Each banner is also logged once with `console.warn('[API] Request failed:', { context,
|
||||
status, code, name, request, area })`: the HTTP status, the axios error code (`ERR_NETWORK`,
|
||||
`ECONNABORTED`, `ERR_BAD_REQUEST`), the error's name, the request as above and the query namespace.
|
||||
It never logs a message, body, header or query string, because those can carry record data.
|
||||
|
||||
**Implication for components:** For most queries and mutations, you do not need `onError` at all. The global handler covers toasting. Add per-query handling only when you need to do something the global handler cannot — such as mapping a 400 error's field messages back to form fields.
|
||||
|
||||
@@ -139,7 +127,7 @@ Returns `undefined` for anything else (non-object errors, null, network failures
|
||||
|
||||
### 400 — Validation Error
|
||||
|
||||
The global handler does NOT toast on 400 because 400s typically carry field-level validation messages that must be mapped back to individual form fields. Handle 400 explicitly wherever you submit a form.
|
||||
The global handler toasts the server's message for a 400 (or `Request failed (HTTP 400)` when it sent none). 400s typically carry field-level validation messages that belong beside individual form fields, so a form handles 400 explicitly, and sets `meta: HANDLES_OWN_ERRORS` on the mutation when the toast would repeat what the form shows.
|
||||
|
||||
```typescript
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
@@ -207,7 +195,7 @@ No additional per-component handling is needed for 5xx unless you want a custom
|
||||
|
||||
### Network Failure (no response)
|
||||
|
||||
Treated as non-4xx by the retry logic — queries retry up to 3 times. The global handler toasts "Something went wrong" after all retries are exhausted. No per-component handling needed for the common case.
|
||||
Treated as non-4xx by the retry logic — queries retry up to 3 times. After all retries are exhausted the global handler toasts "No response from the server" (or "The server took too long to respond" for the 30 s timeout) with the request that failed. No per-component handling needed for the common case.
|
||||
|
||||
---
|
||||
|
||||
@@ -274,8 +262,8 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
|
||||
onError: (error) => {
|
||||
if (getErrorStatusCode(error) === 400) {
|
||||
// Map field errors from the server response body to form fields.
|
||||
// The global handler does NOT toast on 400, so we must do it here or
|
||||
// rely on inline field error messages in the form UI.
|
||||
// The global handler toasts the server's message for a 400; the form
|
||||
// places each field's message beside it.
|
||||
const body = (error as AxiosError<{ errors: Record<string, string> }>).response?.data
|
||||
if (body?.errors) {
|
||||
for (const [field, message] of Object.entries(body.errors)) {
|
||||
@@ -305,6 +293,6 @@ export function EditOrderForm({ orderId, schema, defaultValues }: Props) {
|
||||
| Layer | File | Handles |
|
||||
|---|---|---|
|
||||
| Axios interceptor | `src/lib/api/client.ts` | 401 → redirect to login |
|
||||
| Global query handler | `src/lib/query-client.ts` | 403, 404, 5xx, network → toast |
|
||||
| Global query handler | `src/lib/query-client.ts` | Every status but 401, no response, page errors → one toast per failure, naming the request |
|
||||
| Per-component `onError` | Your component | 400 field errors, custom empty states, navigation |
|
||||
| `getErrorStatusCode` | `src/lib/utils/error-utils.ts` | Normalize `AxiosError` and plain-object errors to HTTP status code |
|
||||
@@ -13,13 +13,20 @@
|
||||
- One `UserAvatar` (photo via one batched `POST /avatars/lookup` and `GET /avatars/{avatarId}`, otherwise initials) shows people in the user menu, presence, comments, mention candidates, the people picker, activity and your own assistant messages. A missing avatar is remembered for the session, so it isn't requested again.
|
||||
- **Privacy:** branding `gravatarDefault` no longer sends your email hash to Gravatar. A Gravatar appears only if you turn it on in your Profile, and the server serves it.
|
||||
- Image fields take a full form row at every width. e2e coverage is `tests/e2e/profile-avatars.spec.ts`, including the 6-width light/dark screenshot matrix.
|
||||
- **A table can declare the sort its record list opens with.** The list's initial sort was fixed at primary key descending and read nothing from the table's metadata, so an application could not make a list open newest first by a date field. A table's Next UI hint now takes `defaultSort` beside `defaultColumns`: `supplementalMetaData.nextUi.defaultSort`, an array of `{ "fieldName", "isAscending" }` in the shape of a query filter's order-bys. The list opens with it when the address carries no sort, no saved view is open and the browser remembers no sort for the table; each of those wins when present. New View and Reset All Changes return to it, and it is not counted as an unsaved change. Unknown or unsortable fields are ignored, and a table without a usable hint sorts by primary key descending as before. QQQ itself has no table-level default sort (as of 4.1.0-RC.32), so the application supplies the hint through a supplemental table metadata object of type `nextUi`; see the README, [Default sort](../../README.md#default-sort).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- **A preference action that connects another service takes you there.** A provider that starts an OAuth connection (Connect Slack, for example) answers with the address of the service's consent page in the action result's `message`, since the UI API has no redirect field. That address was shown as the text of a success toast: long, not clickable and gone in seconds. A message that is nothing but one absolute `https:` address is now where the browser goes, in the same tab, and is never shown. A message that only mentions an address is still a message. Plain `http:`, addresses with credentials and every other scheme are not followed.
|
||||
- **Preferences says how a connection ended.** A flow that left the dashboard returns to `/app/preferences?section=<name>&result=<code>` when it succeeded or `&error=<code>` when it did not. The section now shows the outcome once, above its form, as the section's label and the code as words (`Slack: connected.`, `Slack: authorization denied.`), as a status or an alert, and takes it off the address so a reload does not repeat it. Only a fixed lowercase code is read from the address, never a sentence.
|
||||
- **Links in process HTML look like links.** An `HTML` process component rendered links in the text color with no underline, so a link the backend put on a screen (a bulk load template, a "Continue to Slack" link) could not be told from the words around it. They now use the link color and an underline; a link's own inline style still wins.
|
||||
|
||||
- **The Ask button's activity dot sits on its icon.** While a reply was being written, the dot was placed in the bottom-right corner of the button's hit area, which has no visible edge and, from 1440 px, extends past the "Ask" label, so the dot floated under and to the right of the label. The dot and the unread badge are now positioned against the sparkles icon, at every width and with the panel open or closed. The bell and Jobs badges use the same anchor and keep their place.
|
||||
|
||||
- **Logout no longer races an in-flight request.** Logout ends the API client's session generation first: every request still in flight (axios and the realtime and assistant streams) is aborted, and any response of the old generation that still arrives is discarded, so none can refresh the `sessionId` cookie the logout expires or change client auth state (SEC-037, SEC-048).
|
||||
|
||||
- **The error banner says what failed, and no longer stacks.** The banner for a failed request used to read "Something went wrong" for every failure without an expected status, and a polling request that kept failing added one banner per attempt. It now reads `No response from the server`, `The server took too long to respond`, the server's own message, or `Request failed (HTTP 409)`, and names the request below it (`POST /qqq/v1/table/order/query`, without the query string, with ids shown as `{id}`). A repeat of the same failure updates the banner on screen; a lost connection shows one banner whichever requests met it. The 403, 404 and 5xx banners keep their wording and gain the request line; a 403 without a server message reads `Permission denied`. A cancelled request, an aborted `fetch` and a 401 show no banner. Each banner logs its status, axios code, error name and request to the console once (`[API] Request failed:`), never a message, body, header or query string.
|
||||
|
||||
## Browser testing and infrastructure
|
||||
|
||||
None yet.
|
||||
|
||||
@@ -32,8 +32,15 @@ vi.mock('@/lib/hooks/use-container-width', () => ({
|
||||
useContainerWidth: () => [undefined, layout.width],
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
|
||||
const navigation = vi.hoisted(() => ({ leaveTo: vi.fn() }))
|
||||
vi.mock('@/lib/utils/external-navigation', async (original) => ({
|
||||
...(await original<typeof import('@/lib/utils/external-navigation')>()),
|
||||
leaveTo: navigation.leaveTo,
|
||||
}))
|
||||
beforeEach(() => {
|
||||
layout.width = 500
|
||||
navigation.leaveTo.mockClear()
|
||||
vi.mocked(toast.success).mockClear()
|
||||
})
|
||||
const tokens = preferencesFixture.sections.find((section) => section.name === 'access-tokens')!
|
||||
function renderForm(section: PreferenceSection = structuredClone(tokens)) {
|
||||
@@ -387,3 +394,36 @@ it('shows each returned secret and restores focus to the returned row after ackn
|
||||
expect(screen.queryByDisplayValue('recovery-test-only')).not.toBeInTheDocument()
|
||||
persist.mockRestore()
|
||||
})
|
||||
|
||||
it('sends the browser to an address-only result and never shows the address', async () => {
|
||||
const authorize =
|
||||
'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&redirect_uri=https%3A%2F%2Fapp.example.test%2Fqqq%2Fhooks%2Fslack%2Foauth%2Fcallback&state=abc.def'
|
||||
const section = structuredClone(tokens)
|
||||
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
|
||||
server.use(
|
||||
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
|
||||
HttpResponse.json({ section, message: authorize })
|
||||
)
|
||||
)
|
||||
renderForm(section)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
|
||||
await waitFor(() => expect(navigation.leaveTo).toHaveBeenCalledWith(authorize))
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/slack\.com/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows a result that only mentions an address as a message', async () => {
|
||||
const section = structuredClone(tokens)
|
||||
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
|
||||
server.use(
|
||||
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
|
||||
HttpResponse.json({ section, message: 'Connected. See https://example.test/help for more.' })
|
||||
)
|
||||
)
|
||||
renderForm(section)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith('Connected. See https://example.test/help for more.')
|
||||
)
|
||||
expect(navigation.leaveTo).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from '@/types/preferences'
|
||||
import { runPreferenceAction } from '@/lib/api/preferences'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { continuationUrl, leaveTo } from '@/lib/utils/external-navigation'
|
||||
import { BUTTON_CLASS, buttonClass } from '@/components/ui/controls'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { PreferenceField } from './PreferenceField'
|
||||
@@ -381,7 +382,11 @@ export function usePreferenceActions({
|
||||
showingSecrets.current = Boolean(result.secrets?.length)
|
||||
onSaved(result.section)
|
||||
setSelected(null)
|
||||
if (result.message) toast.success(result.message)
|
||||
// An answer that is only an address (the consent page of a service being connected) is
|
||||
// where the browser goes next. It is never shown: nobody can use an address in a toast.
|
||||
const destination = continuationUrl(result.message)
|
||||
if (destination) leaveTo(destination)
|
||||
else if (result.message) toast.success(result.message)
|
||||
setSecrets(result.secrets ?? [])
|
||||
} catch (error) {
|
||||
if (mounted.current) setErrors(preferenceMessages(error))
|
||||
|
||||
@@ -22,7 +22,7 @@ import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
import { server } from '@/mocks/node'
|
||||
import { ThemeProvider } from '@/lib/theme/theme-provider'
|
||||
import { PreferencesPage } from './PreferencesPage'
|
||||
import { PreferencesPage, sectionOutcome } from './PreferencesPage'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
search: '',
|
||||
@@ -107,3 +107,42 @@ it('offers retry on errors and keeps local display settings accessible', async (
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('says once that a flow that returned to a section succeeded, and takes the outcome off the address', async () => {
|
||||
state.search = 'section=regional&result=connected'
|
||||
renderPage()
|
||||
await screen.findByRole('heading', { name: 'Regional settings' })
|
||||
const outcome = screen.getByText('Regional settings: connected.')
|
||||
expect(outcome).toHaveAttribute('role', 'status')
|
||||
expect(outcome).toHaveAttribute('data-qqq-id', 'preferences-section-outcome')
|
||||
expect(state.replace).toHaveBeenCalledWith('/app/preferences?section=regional', { scroll: false })
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /Notifications What reaches/ }))
|
||||
expect(screen.queryByText('Regional settings: connected.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the reason a flow that returned to a section failed', async () => {
|
||||
state.search = 'section=regional&error=authorization_denied'
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Regional settings: authorization denied.'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads only fixed outcome codes from the address, never a sentence', () => {
|
||||
const read = (search: string) => sectionOutcome(new URLSearchParams(search))
|
||||
expect(read('section=slack&result=connected')).toEqual({
|
||||
section: 'slack',
|
||||
failed: false,
|
||||
words: 'connected',
|
||||
})
|
||||
expect(read('section=slack&error=sign_in_required&result=connected')).toEqual({
|
||||
section: 'slack',
|
||||
failed: true,
|
||||
words: 'sign in required',
|
||||
})
|
||||
expect(read('section=slack')).toBeNull()
|
||||
expect(read('result=connected')).toBeNull()
|
||||
expect(read('section=slack&error=Call+555-0100+now')).toBeNull()
|
||||
expect(read('section=slack&error=%3Cb%3Ex%3C%2Fb%3E')).toBeNull()
|
||||
expect(read(`section=slack&error=${'a_'.repeat(20)}a`)).toBeNull()
|
||||
})
|
||||
@@ -17,13 +17,14 @@
|
||||
/** @file Preferences route content, with device display settings and server-owned sections. */
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getPreferences } from '@/lib/api/preferences'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { BUTTON_CLASS } from '@/components/ui/controls'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { DisplayPreferencesPanel } from '@/components/layout/DisplayPreferencesPanel'
|
||||
@@ -43,6 +44,30 @@ export function PreferencesSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
/** What a flow that left the dashboard reports when it returns to a section. */
|
||||
export interface SectionOutcome {
|
||||
section: string
|
||||
failed: boolean
|
||||
/** The provider's outcome code as words: `authorization_denied` reads "authorization denied". */
|
||||
words: string
|
||||
}
|
||||
|
||||
/** Reads the outcome a flow that left the dashboard (a consent page of a connected service)
|
||||
* puts on the address when it returns: `?section=<name>&result=<code>` when it succeeded,
|
||||
* `&error=<code>` when it did not. The code is one of the provider's fixed lowercase names
|
||||
* (`connected`, `authorization_denied`). It is shown as words after the section's own label;
|
||||
* anything else on the address is ignored, so a link cannot put its own sentence on the page.
|
||||
* @param params - The page's query string.
|
||||
* @returns The outcome, or `null` when the address reports none.
|
||||
*/
|
||||
export function sectionOutcome(params: URLSearchParams): SectionOutcome | null {
|
||||
const section = params.get('section')
|
||||
const error = params.get('error')
|
||||
const code = error ?? params.get('result')
|
||||
if (!section || !code || !/^[a-z]+(?:_[a-z]+){0,7}$/.test(code)) return null
|
||||
return { section, failed: error !== null, words: code.replace(/_/g, ' ') }
|
||||
}
|
||||
|
||||
/** Every registered section and a client-only Display section, selected through the URL.
|
||||
* @returns Preferences navigation and selected section.
|
||||
*/
|
||||
@@ -59,6 +84,13 @@ export function PreferencesPage() {
|
||||
})
|
||||
// A client-only key cannot collide with the backend's [a-z] section names.
|
||||
const requested = params.get('section')
|
||||
const [outcome, setOutcome] = useState(() => sectionOutcome(params))
|
||||
const reported = params.has('result') || params.has('error')
|
||||
// An outcome is reported once: off the address it goes, so a reload does not repeat it.
|
||||
useEffect(() => {
|
||||
if (reported && requested)
|
||||
router.replace(`/app/preferences?section=${encodeURIComponent(requested)}`, { scroll: false })
|
||||
}, [reported, requested, router])
|
||||
const selected =
|
||||
requested === 'display' && !query.data?.sections.some((section) => section.name === 'display')
|
||||
? '__display'
|
||||
@@ -73,13 +105,15 @@ export function PreferencesPage() {
|
||||
...(query.data?.sections ?? []),
|
||||
]
|
||||
const section = query.data?.sections.find((section) => section.name === selected)
|
||||
const navigate = (name: string) =>
|
||||
const navigate = (name: string) => {
|
||||
setOutcome(null)
|
||||
router.replace(
|
||||
name === '__display'
|
||||
? '/app/preferences'
|
||||
: `/app/preferences?section=${encodeURIComponent(name)}`,
|
||||
{ scroll: false }
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl space-y-6" data-qqq-id="preferences-page">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Preferences</h1>
|
||||
@@ -159,6 +193,20 @@ export function PreferencesPage() {
|
||||
<PreferencesSkeleton />
|
||||
) : section ? (
|
||||
<>
|
||||
{outcome?.section === section.name && (
|
||||
<p
|
||||
role={outcome.failed ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
outcome.failed
|
||||
? 'border-destructive text-destructive'
|
||||
: 'border-border bg-muted text-foreground'
|
||||
)}
|
||||
data-qqq-id="preferences-section-outcome"
|
||||
>
|
||||
{`${section.label}: ${outcome.words}.`}
|
||||
</p>
|
||||
)}
|
||||
<PreferenceSectionForm key={section.name} section={section} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -56,9 +56,10 @@ export function HtmlComponent({ index }: HtmlComponentProps) {
|
||||
const raw = values[`${step.name}.html`]
|
||||
const html = useMemo(() => (typeof raw === 'string' && raw ? sanitizeProcessHtml(raw) : ''), [raw])
|
||||
if (!html) return null
|
||||
// links read as links: without it they inherit the text color and have no underline
|
||||
return (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-foreground"
|
||||
className="prose prose-sm max-w-none text-foreground [&_a]:text-link [&_a]:underline"
|
||||
data-qqq-id={`process-html-${index}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
@@ -205,6 +205,24 @@ describe('ProcessSummaryLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTML component', () => {
|
||||
it('shows a link the backend put on the last screen as a link that opens in this tab', () => {
|
||||
const address = 'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&state=abc.def'
|
||||
const step: QFrontendStepMetaData = { name: 'result', label: 'Continue to Slack', components: [{ type: 'HTML' }] }
|
||||
renderStep(step, {
|
||||
noMoreSteps: true,
|
||||
'result.html': `<p>Slack will ask you to approve the connection.</p><p><a href="${address.replace(/&/g, '&')}" style="display:inline-block;padding:8px 16px;background:#4A154B;color:#ffffff">Continue to Slack</a></p><p>This link expires in 10 minutes.</p>`,
|
||||
})
|
||||
const link = screen.getByRole('link', { name: 'Continue to Slack' })
|
||||
expect(link).toHaveAttribute('href', address)
|
||||
expect(link).not.toHaveAttribute('target')
|
||||
expect(link.getAttribute('style')).toContain('background')
|
||||
expect(link.closest('[data-qqq-id="process-html-0"]')).toHaveClass('[&_a]:text-link', '[&_a]:underline')
|
||||
expect(screen.getByText('This link expires in 10 minutes.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Return' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('process helpers', () => {
|
||||
it('sanitizes HTML but keeps CSV and text template downloads', () => {
|
||||
const html = sanitizeProcessHtml('<a href="data:text/csv;base64,QQ==" download="t.csv">t</a><a href="data:text/html,<b>x</b>">h</a><img src="x" onerror="alert(1)"><script>alert(1)</script>')
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// The sort a record list opens with: the one its table declares
|
||||
// (`supplementalMetaData.nextUi.defaultSort`), unless the person has a sort of their own (in the
|
||||
// address, in a saved view, or remembered by the browser), and primary key descending otherwise.
|
||||
|
||||
import { act, render, renderHook, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QFilterOrderBy, QTableMetaData } from '@/types'
|
||||
import type { QueryRecordsRequest } from '@/lib/api/tables'
|
||||
import { QContextProvider } from '@/lib/context/q-context'
|
||||
import { useRecordQuery } from '@/lib/hooks/use-record-query'
|
||||
import { emptyFilter, serializeFilter } from '@/lib/utils/filter-utils'
|
||||
import { defaultColumnVisibility } from '@/lib/utils/query-columns'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import { RecordQuery } from './RecordQuery'
|
||||
|
||||
let search = ''
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
usePathname: () => '/app/person',
|
||||
useSearchParams: () => new URLSearchParams(search),
|
||||
}))
|
||||
|
||||
const NEWEST_FIRST: QFilterOrderBy[] = [{ fieldName: 'modifyDate', isAscending: false }]
|
||||
const PRIMARY_KEY_DESCENDING: QFilterOrderBy[] = [{ fieldName: 'id', isAscending: false }]
|
||||
|
||||
/**
|
||||
* The people table, declaring a default sort unless told otherwise.
|
||||
*
|
||||
* @param defaultSort - The declared sort; `null` for a table that declares none.
|
||||
* @returns Table metadata and the table registry.
|
||||
*/
|
||||
function tables(defaultSort: unknown = NEWEST_FIRST) {
|
||||
const allTables = structuredClone(qInstance.tables)
|
||||
if (defaultSort !== null) allTables.person.supplementalMetaData = { nextUi: { defaultSort } }
|
||||
return { tableName: 'person', tableMetaData: allTables.person as QTableMetaData, allTables }
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return <QueryClientProvider client={client}><QContextProvider>{children}</QContextProvider></QueryClientProvider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Records what the list asks the server for.
|
||||
*
|
||||
* @returns The sort of the latest query request.
|
||||
*/
|
||||
function captureSort() {
|
||||
const sorts: (QFilterOrderBy[] | undefined)[] = []
|
||||
server.use(http.post('/qqq/v1/table/person/:action', async ({ request, params }) => {
|
||||
const body = await request.json() as QueryRecordsRequest
|
||||
if (params.action === 'query') sorts.push(body.filter.orderBys)
|
||||
return HttpResponse.json(params.action === 'query' ? { records: [{ tableName: 'person', recordLabel: 'Alice', values: { id: 1, firstName: 'Alice' } }] } : { count: 1 })
|
||||
}))
|
||||
return () => sorts.at(-1)
|
||||
}
|
||||
|
||||
/** The sort the address carries, if any. */
|
||||
const addressSort = () => {
|
||||
const encoded = new URLSearchParams(window.location.search).get('filter')
|
||||
return encoded ? (JSON.parse(atob(encoded)) as { orderBys?: QFilterOrderBy[] }).orderBys : undefined
|
||||
}
|
||||
|
||||
describe('record list default sort', () => {
|
||||
beforeEach(() => {
|
||||
search = ''
|
||||
localStorage.clear()
|
||||
window.history.replaceState(null, '', '/app/person')
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('opens with the sort the table declares, without putting it in the address', async () => {
|
||||
const lastSort = captureSort()
|
||||
render(<RecordQuery {...tables()} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
expect(lastSort()).toEqual(NEWEST_FIRST)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
|
||||
expect(screen.getByRole('combobox', { name: 'Sort field' })).toHaveValue('modifyDate')
|
||||
// a default is not a choice: nothing to share in the address, nothing to save or reset
|
||||
expect(addressSort()).toBeUndefined()
|
||||
expect(screen.queryByRole('button', { name: 'Reset All Changes' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens by primary key descending when the table declares nothing, or nothing usable', async () => {
|
||||
for (const declared of [null, [], [{ fieldName: 'noSuchField', isAscending: false }], 'modifyDate']) {
|
||||
const lastSort = captureSort()
|
||||
const view = render(<RecordQuery {...tables(declared)} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
expect(lastSort()).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
expect(addressSort()).toBeUndefined()
|
||||
view.unmount()
|
||||
localStorage.clear()
|
||||
}
|
||||
})
|
||||
|
||||
it('a sort in the address wins over the declared one', async () => {
|
||||
const mine = [{ fieldName: 'lastName', isAscending: true }]
|
||||
search = new URLSearchParams({ filter: serializeFilter({ criteria: [], booleanOperator: 'AND', orderBys: mine }) }).toString()
|
||||
const lastSort = captureSort()
|
||||
render(<RecordQuery {...tables()} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
expect(lastSort()).toEqual(mine)
|
||||
})
|
||||
|
||||
it("a person's own sort is kept, remembered for the next visit, and Reset returns to the declared one", async () => {
|
||||
const user = userEvent.setup()
|
||||
const lastSort = captureSort()
|
||||
const first = render(<RecordQuery {...tables()} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
|
||||
const mode = within(screen.getByRole('group', { name: 'Filter mode' }))
|
||||
await user.selectOptions(mode.getByRole('combobox', { name: 'Sort field' }), 'lastName')
|
||||
await waitFor(() => expect(lastSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
|
||||
// now it is a choice: it is in the address, and it can be reset
|
||||
await waitFor(() => expect(addressSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
|
||||
expect(screen.getByRole('button', { name: 'Reset All Changes' })).toBeInTheDocument()
|
||||
|
||||
// the next visit (no sort in the address) opens with the remembered sort, not the declared one
|
||||
first.unmount()
|
||||
window.history.replaceState(null, '', '/app/person')
|
||||
render(<RecordQuery {...tables()} />, { wrapper })
|
||||
expect((await screen.findAllByText('Alice'))[0]).toBeVisible()
|
||||
await waitFor(() => expect(lastSort()).toEqual([{ fieldName: 'lastName', isAscending: false }]))
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Reset All Changes' }))
|
||||
await waitFor(() => expect(lastSort()).toEqual(NEWEST_FIRST))
|
||||
await waitFor(() => expect(addressSort()).toBeUndefined())
|
||||
expect(screen.queryByRole('button', { name: 'Reset All Changes' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("a saved view's sort wins, when the view is opened and when it is applied later", () => {
|
||||
const { tableMetaData, allTables } = tables()
|
||||
const viewSort = [{ fieldName: 'firstName', isAscending: true }]
|
||||
const view = {
|
||||
userFilter: emptyFilter(50), sortOrder: viewSort, columnVisibility: defaultColumnVisibility(tableMetaData),
|
||||
columnOrder: [], columnWidths: {}, pageSize: 50, filterMode: 'basic' as const,
|
||||
}
|
||||
const opened = renderHook(() => useRecordQuery({ tableName: 'person', tableMetaData, allTables, initialView: view, paused: true }), { wrapper })
|
||||
expect(opened.result.current.filter.sortOrder).toEqual(viewSort)
|
||||
expect(opened.result.current.filter.defaultSort).toEqual(NEWEST_FIRST)
|
||||
|
||||
const applied = renderHook(() => useRecordQuery({ tableName: 'person', tableMetaData, allTables, paused: true }), { wrapper })
|
||||
expect(applied.result.current.filter.sortOrder).toEqual(NEWEST_FIRST)
|
||||
act(() => applied.result.current.applyView(view))
|
||||
expect(applied.result.current.filter.sortOrder).toEqual(viewSort)
|
||||
// clearing a sort returns to the declared default, not to the primary key
|
||||
act(() => applied.result.current.filter.setSort([]))
|
||||
expect(applied.result.current.filter.sortOrder).toEqual(NEWEST_FIRST)
|
||||
})
|
||||
|
||||
it('keeps the same default sort when metadata is loaded again with the same declaration', () => {
|
||||
const { tableMetaData, allTables } = tables()
|
||||
const hook = renderHook(({ table }) => useRecordQuery({ tableName: 'person', tableMetaData: table, allTables, paused: true }), { wrapper, initialProps: { table: tableMetaData } })
|
||||
const before = hook.result.current.filter.defaultSort
|
||||
hook.rerender({ table: structuredClone(tableMetaData) })
|
||||
expect(hook.result.current.filter.defaultSort).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,7 @@ import { isColumnVisible, type ViewState } from '@/lib/utils/saved-view-utils'
|
||||
import { getDefaultQuickFilterFieldNames, reconcileBasicMode } from '@/lib/utils/quick-filter-utils'
|
||||
import { defaultColumnVisibility, hasCapability, withReadableExposedJoins, type ColumnPins } from '@/lib/utils/query-columns'
|
||||
import { DENSITY_STORAGE_KEY, readLegacyColumnState } from '@/lib/utils/query-view-storage'
|
||||
import { tableDefaultSort } from '@/lib/utils/query-default-sort'
|
||||
import { useLocalStorage } from '@/lib/hooks/use-local-storage'
|
||||
import { useColumnConfig } from '@/lib/hooks/use-column-config'
|
||||
import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/lib/constants'
|
||||
@@ -320,9 +321,13 @@ export function useRecordQuery({
|
||||
const [storedDensity, setDensity] = useLocalStorage<Density>(DENSITY_STORAGE_KEY, 'standard')
|
||||
const density: Density = (['compact', 'standard', 'comfortable'] as const).includes(storedDensity) ? storedDensity : 'standard'
|
||||
|
||||
const primaryKey = tableMetaData?.primaryKeyField
|
||||
/** Material's default sort: primary key, descending. */
|
||||
const defaultSort = useMemo<QFilterOrderBy[]>(() => (primaryKey ? [{ fieldName: primaryKey, isAscending: false }] : []), [primaryKey])
|
||||
/**
|
||||
* The sort the list opens with and resets to: the one the table's metadata declares
|
||||
* (`supplementalMetaData.nextUi.defaultSort`), else Material's default, primary key descending.
|
||||
* Keyed by its content, so reloaded metadata that declares the same sort changes nothing.
|
||||
*/
|
||||
const defaultSortKey = JSON.stringify(tableDefaultSort(tableMetaData))
|
||||
const defaultSort = useMemo<QFilterOrderBy[]>(() => JSON.parse(defaultSortKey) as QFilterOrderBy[], [defaultSortKey])
|
||||
/** Quick filters every user sees in basic mode (Material defaultQuickFilterFieldNames, else T1 fields). */
|
||||
const defaultQuickFilterFieldNames = useMemo(() => (tableMetaData ? getDefaultQuickFilterFieldNames(tableMetaData) : []), [tableMetaData])
|
||||
const filterModeKey = `qqq-${tableName}-filter-mode`
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for query key factory
|
||||
// Tests for the query key factory and the global error banner
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { queryKeys } from './query-client'
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { AxiosError, AxiosHeaders, CanceledError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { Toaster, toast } from 'sonner'
|
||||
|
||||
import { HANDLES_OWN_ERRORS, queryClient, queryKeys } from './query-client'
|
||||
|
||||
describe('queryKeys factory', () => {
|
||||
it('all() returns root key', () => {
|
||||
@@ -109,3 +114,192 @@ describe('queryKeys factory', () => {
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* An axios error as the API client rejects with it. Its request carries a token, a body and a
|
||||
* query string, none of which may reach a banner or the console.
|
||||
*
|
||||
* @param request - The request's method and URL (relative to the `/qqq/v1` base).
|
||||
* @param response - The response's status and body; omit for a request that got no response.
|
||||
* @param code - The axios error code.
|
||||
* @returns The error.
|
||||
*/
|
||||
function axiosError(request: { method: string; url: string }, response?: { status: number; data?: unknown }, code = response ? 'ERR_BAD_REQUEST' : 'ERR_NETWORK'): AxiosError {
|
||||
const config = { ...request, baseURL: '/qqq/v1', headers: new AxiosHeaders({ Authorization: 'Bearer secret-token' }), data: '{"name":"Private Person"}' } as InternalAxiosRequestConfig
|
||||
const message = code === 'ECONNABORTED' ? 'timeout of 30000ms exceeded' : response ? `Request failed with status code ${response.status}` : 'Network Error'
|
||||
return new AxiosError(message, code, config, {}, response ? { ...response, data: response.data, statusText: '', headers: {}, config } : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails a query through the shared client, as a component's `useQuery` would.
|
||||
*
|
||||
* @param error - What the query function rejects with.
|
||||
* @param queryKey - The query's key.
|
||||
* @param meta - The query's meta.
|
||||
*/
|
||||
async function failQuery(error: unknown, queryKey: readonly unknown[] = queryKeys.tableRecords('order'), meta?: Record<string, unknown>): Promise<void> {
|
||||
await queryClient.fetchQuery({ queryKey, queryFn: () => Promise.reject(error), retry: false, staleTime: 0, meta }).catch(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails a mutation through the shared client, as a component's `useMutation` would.
|
||||
*
|
||||
* @param error - What the mutation function rejects with.
|
||||
*/
|
||||
async function failMutation(error: unknown): Promise<void> {
|
||||
await queryClient.getMutationCache().build(queryClient, { mutationFn: () => Promise.reject(error) }).execute(undefined).catch(() => undefined)
|
||||
}
|
||||
|
||||
describe('global error banner', () => {
|
||||
const QUERY = { method: 'post', url: '/table/order/query?filter=%7B%22name%22%3A%22Private%20Person%22%7D' }
|
||||
const banner = vi.spyOn(toast, 'error')
|
||||
const warn = vi.spyOn(console, 'warn')
|
||||
|
||||
/**
|
||||
* The banners requested so far.
|
||||
*
|
||||
* @returns Each banner's text and description.
|
||||
*/
|
||||
const banners = () => banner.mock.calls.map(([message, options]) => [message, options?.description])
|
||||
|
||||
/**
|
||||
* The banners on screen.
|
||||
*
|
||||
* @returns Each banner's text.
|
||||
*/
|
||||
const shown = () => Array.from(document.querySelectorAll('[data-sonner-toast]')).map((element) => element.textContent)
|
||||
|
||||
beforeEach(() => {
|
||||
banner.mockClear()
|
||||
warn.mockClear().mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
toast.dismiss()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
it('says the server did not respond, and which request it was, without the query string', async () => {
|
||||
await failQuery(axiosError(QUERY))
|
||||
expect(banners()).toEqual([['No response from the server', 'POST /qqq/v1/table/order/query']])
|
||||
})
|
||||
|
||||
it('says the server took too long when the request timed out', async () => {
|
||||
await failQuery(axiosError({ method: 'get', url: '/jobs' }, undefined, 'ECONNABORTED'))
|
||||
expect(banners()).toEqual([['The server took too long to respond', 'GET /qqq/v1/jobs']])
|
||||
})
|
||||
|
||||
it('says the server did not respond when a fetch fails', async () => {
|
||||
await failQuery(new TypeError('Failed to fetch'), ['qqq', 'realtime', 'subscription-7'])
|
||||
await failQuery(new TypeError('Load failed'), queryKeys.tablePossibleValues('order', 'customerId', 'Private Person'))
|
||||
expect(banners()).toEqual([
|
||||
['No response from the server', 'While loading realtime'],
|
||||
['No response from the server', 'While loading possible values'],
|
||||
])
|
||||
})
|
||||
|
||||
it("shows the server's own message for an unexpected status", async () => {
|
||||
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 409, data: { error: 'This order was changed by someone else.' } }))
|
||||
expect(banners()).toEqual([['This order was changed by someone else.', 'POST /qqq/v1/table/order (HTTP 409)']])
|
||||
})
|
||||
|
||||
it('names the status when the server sent no message, never a non-JSON body', async () => {
|
||||
await failMutation(axiosError({ method: 'put', url: '/table/order/42' }, { status: 413, data: '<html>Request Entity Too Large</html>' }))
|
||||
await failQuery(axiosError(QUERY, { status: 400 }))
|
||||
expect(banners()).toEqual([
|
||||
['Request failed (HTTP 413)', 'PUT /qqq/v1/table/order/{id}'],
|
||||
['Request failed (HTTP 400)', 'POST /qqq/v1/table/order/query'],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the 403, 404 and 5xx wording and adds the request', async () => {
|
||||
await failQuery(axiosError({ method: 'get', url: '/table/order/42' }, { status: 403, data: { error: 'Permission denied.' } }))
|
||||
await failQuery(axiosError({ method: 'get', url: '/table/order/43' }, { status: 403, data: '<html>Forbidden</html>' }))
|
||||
await failQuery(axiosError({ method: 'get', url: '/table/order/42' }, { status: 404 }))
|
||||
await failQuery(axiosError({ method: 'get', url: '/metaData' }, { status: 502 }))
|
||||
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 500 }))
|
||||
expect(banners()).toEqual([
|
||||
['Permission denied.', 'GET /qqq/v1/table/order/{id} (HTTP 403)'],
|
||||
['Permission denied', 'GET /qqq/v1/table/order/{id} (HTTP 403)'],
|
||||
['Resource not found', 'GET /qqq/v1/table/order/{id} (HTTP 404)'],
|
||||
['Server error — retrying…', 'GET /qqq/v1/metaData (HTTP 502)'],
|
||||
['Server error — please try again', 'POST /qqq/v1/table/order (HTTP 500)'],
|
||||
])
|
||||
})
|
||||
|
||||
it('masks every path segment that is not a plain word, and reads an absolute URL by its path', async () => {
|
||||
await failQuery(axiosError({ method: 'get', url: '/jobs/3f2b8c1e-9d4a-4c6b-8e21-5a7d9c0b1f33/progress' }, { status: 409 }))
|
||||
await failQuery(axiosError({ method: 'get', url: '/table/person/private.person%40example.com' }, { status: 409 }))
|
||||
await failQuery(axiosError({ method: 'get', url: 'https://files.example.com/download/abc123def456?signature=secret-token#part' }, { status: 409 }))
|
||||
expect(banners().map(([, description]) => description)).toEqual([
|
||||
'GET /qqq/v1/jobs/{id}/progress',
|
||||
'GET /qqq/v1/table/person/{id}',
|
||||
'GET /download/{id}',
|
||||
])
|
||||
})
|
||||
|
||||
it("shows an application error's own sentence, and only the type of a runtime error", async () => {
|
||||
await failQuery(new Error('Invalid audit response'))
|
||||
await failQuery(new TypeError("Cannot read properties of undefined (reading 'Private Person')"), ['qqq', 'search', 'records', 'Private Person'])
|
||||
await failMutation('not an error object')
|
||||
await failMutation(undefined)
|
||||
expect(banners()).toEqual([
|
||||
['Invalid audit response', 'While loading records'],
|
||||
['The page hit an unexpected error (TypeError)', 'While loading search'],
|
||||
['not an error object', undefined],
|
||||
['The page hit an unexpected error', undefined],
|
||||
])
|
||||
})
|
||||
|
||||
it('shows one banner for a failure that repeats, and another for a different failure', async () => {
|
||||
render(React.createElement(Toaster))
|
||||
|
||||
// a polling query failing on every attempt
|
||||
for (let attempt = 0; attempt < 3; attempt++) await act(() => failQuery(axiosError(QUERY, { status: 400 })))
|
||||
await waitFor(() => expect(shown()).toHaveLength(1))
|
||||
expect(shown()[0]).toContain('Request failed (HTTP 400)')
|
||||
|
||||
// the same status from another endpoint is a different failure; sonner shows banners in
|
||||
// the order they were requested, so by now all three repeats have been applied
|
||||
await act(() => failQuery(axiosError({ method: 'get', url: '/jobs' }, { status: 400 }), queryKeys.jobs()))
|
||||
await waitFor(() => expect(shown()).toHaveLength(2))
|
||||
|
||||
expect(banner).toHaveBeenCalledTimes(4)
|
||||
expect(new Set(banner.mock.calls.map(([, options]) => options?.id)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('shows one banner for a lost connection, whichever requests met it', async () => {
|
||||
render(React.createElement(Toaster))
|
||||
await act(() => failQuery(axiosError(QUERY)))
|
||||
await act(() => failQuery(axiosError({ method: 'get', url: '/jobs' }), queryKeys.jobs()))
|
||||
await act(() => failQuery(axiosError({ method: 'get', url: '/alerts/inbox/summary' }), queryKeys.alertsInbox()))
|
||||
|
||||
// the banner names the request that failed most recently
|
||||
await waitFor(() => expect(shown()[0]).toContain('GET /qqq/v1/alerts/inbox/summary'))
|
||||
expect(shown()).toHaveLength(1)
|
||||
expect(shown()[0]).toContain('No response from the server')
|
||||
})
|
||||
|
||||
it('stays silent for a cancelled request, a 401 and a query that shows its own error', async () => {
|
||||
await failQuery(new CanceledError('canceled'))
|
||||
await failQuery(new DOMException('The operation was aborted.', 'AbortError'))
|
||||
await failQuery(axiosError({ method: 'get', url: '/metaData' }, { status: 401, data: { error: 'Session expired' } }))
|
||||
await failMutation(axiosError({ method: 'post', url: '/table/order' }, { status: 401 }))
|
||||
await failQuery(axiosError(QUERY), queryKeys.tableRecords('order'), HANDLES_OWN_ERRORS)
|
||||
expect(banner).not.toHaveBeenCalled()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs the technical detail once per failure, without message, body, headers or query string', async () => {
|
||||
await failQuery(axiosError(QUERY, { status: 409, data: { error: 'Private Person is locked', record: { name: 'Private Person' } } }))
|
||||
await failQuery(axiosError(QUERY))
|
||||
|
||||
expect(warn.mock.calls).toEqual([
|
||||
['[API] Request failed:', { context: 'query', status: 409, code: 'ERR_BAD_REQUEST', name: 'AxiosError', request: 'POST /qqq/v1/table/order/query', area: 'records' }],
|
||||
['[API] Request failed:', { context: 'query', status: undefined, code: 'ERR_NETWORK', name: 'AxiosError', request: 'POST /qqq/v1/table/order/query', area: 'records' }],
|
||||
])
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toMatch(/Private|secret-token|filter/)
|
||||
expect(JSON.stringify(banners()[1])).not.toMatch(/Private|secret-token|filter/)
|
||||
})
|
||||
})
|
||||
+112
-30
@@ -20,23 +20,107 @@
|
||||
|
||||
import { QueryClient, QueryCache, MutationCache, defaultShouldDehydrateQuery } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { isCancel } from 'axios'
|
||||
import { AxiosError, isCancel } from 'axios'
|
||||
|
||||
import { getErrorStatusCode, getErrorMessage } from '@/lib/utils/error-utils'
|
||||
|
||||
/** JavaScript's own error types (and schema errors): their messages are written for developers. */
|
||||
const RUNTIME_ERROR = /^(Type|Range|Reference|Syntax|Eval|URI|Aggregate|Internal|Zod)Error$/
|
||||
|
||||
/** How a browser words a `fetch` that got no response (Chrome, Firefox, Safari). */
|
||||
const FETCH_NO_RESPONSE = /failed to fetch|networkerror|load failed/i
|
||||
|
||||
/**
|
||||
* Returns a toast message for a query/mutation error based on HTTP status.
|
||||
* The request an error came from, as `METHOD /path`, for a banner and the console. The query
|
||||
* string is dropped, and every path segment that is not a plain word (a record id, a UUID, an
|
||||
* encoded value) becomes `{id}`, so no record data or token is shown and one endpoint has one text.
|
||||
*
|
||||
* @param error - The unknown error thrown by the query or mutation.
|
||||
* @returns The request, or `undefined` when the error carries none.
|
||||
*/
|
||||
function requestLine(error: unknown): string | undefined {
|
||||
const config = error instanceof AxiosError ? error.config : undefined
|
||||
if (!config?.url) return undefined
|
||||
const absolute = /^[a-z][a-z\d+.-]*:\/\//i.test(config.url)
|
||||
try {
|
||||
const { pathname } = new URL(absolute ? config.url : `${config.baseURL ?? ''}${config.url.startsWith('/') ? '' : '/'}${config.url}`, 'http://localhost')
|
||||
const path = pathname.split('/').filter(Boolean).map((segment) => (/^[A-Za-z][A-Za-z_-]{0,39}\d{0,2}$/.test(segment) ? segment : '{id}')).join('/')
|
||||
return `${config.method ? `${config.method.toUpperCase()} ` : ''}/${path}`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own explanation of a failed request, read from the response body only (never
|
||||
* axios' generic "Request failed with status code 400", and never a non-JSON body).
|
||||
*
|
||||
* @param error - The unknown error thrown by the query or mutation.
|
||||
* @returns The message, or an empty string when the server sent none.
|
||||
*/
|
||||
function serverMessage(error: unknown): string {
|
||||
const body: unknown = error instanceof AxiosError ? error.response?.data : error
|
||||
return body && typeof body === 'object' ? getErrorMessage(body, '') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request ended without any response (the connection failed, or the request timed out).
|
||||
*
|
||||
* @param error - The unknown error thrown by the query or mutation.
|
||||
* @returns True for an axios error with no response and for a failed `fetch`.
|
||||
*/
|
||||
function gotNoResponse(error: unknown): boolean {
|
||||
return error instanceof AxiosError ? !error.response : error instanceof TypeError && FETCH_NO_RESPONSE.test(error.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the banner says about a failed query or mutation.
|
||||
*
|
||||
* @param error - The unknown error thrown by the query or mutation.
|
||||
* @param status - Its HTTP status, when it has one.
|
||||
* @param context - `'query'` or `'mutation'` — selects message wording.
|
||||
* @returns The banner's text.
|
||||
*/
|
||||
function failureMessage(error: unknown, status: number | undefined, context: 'query' | 'mutation'): string {
|
||||
if (status === 403) return serverMessage(error) || 'Permission denied'
|
||||
if (status === 404) return 'Resource not found'
|
||||
if (status !== undefined && status >= 500) return context === 'mutation' ? 'Server error — please try again' : 'Server error — retrying…'
|
||||
// Any other status (400, 409, 422, …): the server's own words when it sent any.
|
||||
if (status !== undefined) return serverMessage(error) || `Request failed (HTTP ${status})`
|
||||
|
||||
if (gotNoResponse(error)) {
|
||||
const code = error instanceof AxiosError ? error.code : undefined
|
||||
const timedOut = code === 'ETIMEDOUT' || (code === 'ECONNABORTED' && /timeout/i.test((error as Error).message))
|
||||
return timedOut ? 'The server took too long to respond' : 'No response from the server'
|
||||
}
|
||||
|
||||
// The page failed before or after the request. An error the application raised carries a
|
||||
// sentence written for the user; a runtime error's message is written for developers.
|
||||
const name = error instanceof Error ? error.name : undefined
|
||||
const written = name && RUNTIME_ERROR.test(name) ? '' : getErrorMessage(error, '')
|
||||
return written || `The page hit an unexpected error${name ? ` (${name})` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows one error banner for a failed query or mutation, and logs its technical detail.
|
||||
*
|
||||
* Every banner says what failed: the request (`METHOD /path`, see {@link requestLine}) or, for a
|
||||
* query that failed outside a request, the area it was loading. The banner's id is that text, so
|
||||
* the same failure repeating (a polling query that keeps failing) updates the banner already on
|
||||
* screen instead of adding one. A lost connection is one cause whichever request met it, so it
|
||||
* has one banner, which names the request that failed most recently.
|
||||
*
|
||||
* 401 responses are silently ignored — the axios interceptor already redirects
|
||||
* to login, so surfacing a toast would be redundant and confusing.
|
||||
*
|
||||
* @param error - The unknown error thrown by the query or mutation.
|
||||
* @param context - `'query'` or `'mutation'` — selects message wording.
|
||||
* @param queryKey - The failed query's key; only its namespace (a word from the code) is read.
|
||||
*/
|
||||
function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
|
||||
// A cancelled request (aborted, or discarded because the session ended at logout) has no
|
||||
// result to report.
|
||||
if (isCancel(error)) return
|
||||
function handleQueryError(error: unknown, context: 'query' | 'mutation', queryKey?: readonly unknown[]): void {
|
||||
// A cancelled request (an axios cancel, an aborted `fetch`, or a response discarded because
|
||||
// the session ended at logout) has no result to report.
|
||||
if (isCancel(error) || (error as { name?: unknown } | null)?.name === 'AbortError') return
|
||||
|
||||
const status = getErrorStatusCode(error)
|
||||
|
||||
@@ -45,31 +129,29 @@ function handleQueryError(error: unknown, context: 'query' | 'mutation'): void {
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
toast.error(getErrorMessage(error, 'Permission denied'))
|
||||
return
|
||||
}
|
||||
const message = failureMessage(error, status, context)
|
||||
const request = requestLine(error)
|
||||
const namespace = queryKey?.[0] === 'qqq' ? queryKey[1] : queryKey?.[0]
|
||||
const area = typeof namespace === 'string' && /^[A-Za-z][A-Za-z-]*$/.test(namespace) ? namespace : undefined
|
||||
const http = status !== undefined && !message.includes(`HTTP ${status}`) ? `HTTP ${status}` : undefined
|
||||
// `possibleValues` and `release-notes` read as "possible values" and "release notes".
|
||||
const areaWords = area?.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/-/g, ' ').toLowerCase()
|
||||
const where = request ?? (areaWords ? `While loading ${areaWords}` : undefined)
|
||||
const description = where && http ? `${where} (${http})` : (where ?? http)
|
||||
|
||||
if (status === 404) {
|
||||
toast.error('Resource not found')
|
||||
return
|
||||
}
|
||||
// For a developer: no message, body, header or query string, which can carry record data.
|
||||
const code = (error as { code?: unknown } | null)?.code
|
||||
console.warn('[API] Request failed:', {
|
||||
context,
|
||||
status,
|
||||
code: typeof code === 'string' ? code : undefined,
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
request,
|
||||
area,
|
||||
})
|
||||
|
||||
if (status !== undefined && status >= 500) {
|
||||
if (context === 'mutation') {
|
||||
toast.error('Server error — please try again')
|
||||
} else {
|
||||
toast.error('Server error — retrying…')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Network failure (no status) or unexpected status codes.
|
||||
if (context === 'mutation') {
|
||||
toast.error('Something went wrong')
|
||||
} else {
|
||||
toast.error('Something went wrong')
|
||||
}
|
||||
const sameForEveryRequest = status === undefined && gotNoResponse(error)
|
||||
toast.error(message, { id: `request-failed:${message}:${sameForEveryRequest ? '' : (description ?? '')}`, description })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +216,7 @@ export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
// Queries that render their own error state (records, widgets) opt out with HANDLES_OWN_ERRORS.
|
||||
onError: (error, query) => {
|
||||
if (!handlesOwnErrors(query.meta)) handleQueryError(error, 'query')
|
||||
if (!handlesOwnErrors(query.meta)) handleQueryError(error, 'query', query.queryKey)
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { expect, it } from 'vitest'
|
||||
import { continuationUrl } from './external-navigation'
|
||||
|
||||
const AUTHORIZE =
|
||||
'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&redirect_uri=https%3A%2F%2Fapp.example.test%2Fqqq%2Fhooks%2Fslack%2Foauth%2Fcallback&state=abc.def'
|
||||
|
||||
it('recognizes an answer that is only an absolute https address, and returns it unchanged', () => {
|
||||
expect(continuationUrl(AUTHORIZE)).toBe(AUTHORIZE)
|
||||
expect(continuationUrl(` ${AUTHORIZE}\n`)).toBe(AUTHORIZE)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an ordinary message', 'Slack disconnected.'],
|
||||
['a sentence that contains an address', `Open ${AUTHORIZE}`],
|
||||
['two addresses', `${AUTHORIZE} ${AUTHORIZE}`],
|
||||
['plain http', 'http://slack.com/oauth/v2/authorize'],
|
||||
['a script address', 'javascript:alert(1)'],
|
||||
['a data address', 'data:text/html,<p>x</p>'],
|
||||
['a protocol-relative address', '//slack.com/oauth'],
|
||||
['a local path', '/app/preferences'],
|
||||
['credentials in the address', 'https://user:secret@slack.com/oauth'],
|
||||
['no host', 'https://'],
|
||||
['an empty answer', ''],
|
||||
['no answer', undefined],
|
||||
])('leaves %s as a message', (_name, text) => {
|
||||
expect(continuationUrl(text)).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file external-navigation — leaving the dashboard for another site, such as the consent
|
||||
* page of a service the user is connecting. An address is something to go to, never text to
|
||||
* show: a backend answer that is only an address is recognized here and opened.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The address a backend answer names when the answer is nothing but one absolute `https:`
|
||||
* address: no other words, no whitespace, no credentials in it. A sentence that merely
|
||||
* contains an address is an ordinary message and is not matched.
|
||||
*
|
||||
* @param text - The backend's answer (an action result message, for example).
|
||||
* @returns The address exactly as sent, or `null` when the answer is anything else.
|
||||
*/
|
||||
export function continuationUrl(text: string | null | undefined): string | null {
|
||||
const address = text?.trim()
|
||||
if (!address || /\s/.test(address) || !/^https:\/\//i.test(address)) return null
|
||||
try {
|
||||
const url = new URL(address)
|
||||
return url.protocol === 'https:' && url.hostname && !url.username && !url.password ? address : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the browser to another site in this tab, so the site can return the user here.
|
||||
*
|
||||
* @param url - An absolute address (see {@link continuationUrl}).
|
||||
*/
|
||||
export function leaveTo(url: string): void {
|
||||
window.location.assign(url)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { QTableMetaData } from '@/types'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { declaredDefaultSort, tableDefaultSort } from './query-default-sort'
|
||||
|
||||
const PRIMARY_KEY_DESCENDING = [{ fieldName: 'id', isAscending: false }]
|
||||
|
||||
/**
|
||||
* The people table with a Next UI hint.
|
||||
*
|
||||
* @param nextUi - The hint object.
|
||||
* @param legacy - Put it under the legacy `supplementalTableMetaData` key.
|
||||
* @returns Table metadata.
|
||||
*/
|
||||
function person(nextUi?: unknown, legacy = false): QTableMetaData {
|
||||
const table = structuredClone(qInstance.tables.person)
|
||||
if (nextUi !== undefined) table[legacy ? 'supplementalTableMetaData' : 'supplementalMetaData'] = { nextUi } as Record<string, unknown>
|
||||
return table
|
||||
}
|
||||
|
||||
describe('tableDefaultSort', () => {
|
||||
it('is primary key descending for a table that declares nothing', () => {
|
||||
expect(tableDefaultSort(person())).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
expect(tableDefaultSort(person({ defaultColumns: ['id', 'firstName'] }))).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
expect(declaredDefaultSort(person())).toEqual([])
|
||||
})
|
||||
|
||||
it('is empty before metadata loads and for a table without a primary key', () => {
|
||||
expect(tableDefaultSort(undefined)).toEqual([])
|
||||
expect(tableDefaultSort({ ...person(), primaryKeyField: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('honours a declared sort, in order, with ascending as the direction when none is given', () => {
|
||||
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'modifyDate', isAscending: false }] }))).toEqual([{ fieldName: 'modifyDate', isAscending: false }])
|
||||
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'lastName' }, { fieldName: 'firstName', isAscending: true }, { fieldName: 'id', isAscending: false }] }))).toEqual([
|
||||
{ fieldName: 'lastName', isAscending: true },
|
||||
{ fieldName: 'firstName', isAscending: true },
|
||||
{ fieldName: 'id', isAscending: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the legacy supplemental key, and prefers the current one', () => {
|
||||
expect(tableDefaultSort(person({ defaultSort: [{ fieldName: 'lastName', isAscending: true }] }, true))).toEqual([{ fieldName: 'lastName', isAscending: true }])
|
||||
const both = person({ defaultSort: [{ fieldName: 'firstName', isAscending: true }] })
|
||||
both.supplementalTableMetaData = { nextUi: { defaultSort: [{ fieldName: 'lastName', isAscending: true }] } }
|
||||
expect(tableDefaultSort(both)).toEqual([{ fieldName: 'firstName', isAscending: true }])
|
||||
})
|
||||
|
||||
it('accepts a field of an exposed join', () => {
|
||||
const table = person()
|
||||
const join = table.exposedJoins?.find((candidate) => Object.keys(candidate.joinTable?.fields ?? {}).length > 0)
|
||||
const fieldName = `${join?.joinTable?.name}.${Object.keys(join?.joinTable?.fields ?? {})[0]}`
|
||||
expect(join).toBeTruthy()
|
||||
table.supplementalMetaData = { nextUi: { defaultSort: [{ fieldName, isAscending: false }] } }
|
||||
expect(tableDefaultSort(table)).toEqual([{ fieldName, isAscending: false }])
|
||||
})
|
||||
|
||||
it('drops entries it cannot use and keeps the rest', () => {
|
||||
const table = person({
|
||||
defaultSort: [
|
||||
{ fieldName: 'noSuchField', isAscending: false },
|
||||
{ fieldName: 'lastName', isAscending: 'yes' },
|
||||
{ isAscending: false },
|
||||
'modifyDate',
|
||||
null,
|
||||
['modifyDate', false],
|
||||
{ fieldName: 'modifyDate', isAscending: false },
|
||||
{ fieldName: 'modifyDate', isAscending: true },
|
||||
{ fieldName: 'noSuchJoin.name', isAscending: true },
|
||||
],
|
||||
})
|
||||
expect(tableDefaultSort(table)).toEqual([{ fieldName: 'modifyDate', isAscending: false }])
|
||||
})
|
||||
|
||||
it('does not sort by a field the user may not sort by', () => {
|
||||
const table = person({ defaultSort: [{ fieldName: 'email', isAscending: true }, { fieldName: 'lastName', isAscending: true }] })
|
||||
table.fields.email = { ...table.fields.email, isSortable: false }
|
||||
expect(tableDefaultSort(table)).toEqual([{ fieldName: 'lastName', isAscending: true }])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an empty list', []],
|
||||
['a single object', { fieldName: 'modifyDate', isAscending: false }],
|
||||
['a string', 'modifyDate'],
|
||||
['null', null],
|
||||
['only unusable entries', [{ fieldName: 'noSuchField' }, { fieldName: 'lastName', isAscending: 1 }]],
|
||||
])('falls back to primary key descending for %s', (_what, defaultSort) => {
|
||||
expect(tableDefaultSort(person({ defaultSort }))).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
})
|
||||
|
||||
it('falls back when the hint itself is not an object', () => {
|
||||
expect(tableDefaultSort(person('newest'))).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
expect(tableDefaultSort(person(null))).toEqual(PRIMARY_KEY_DESCENDING)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file query-default-sort — the sort a table's record list opens with.
|
||||
*
|
||||
* A table can declare it, beside its default columns, in the Next UI hint of its supplemental
|
||||
* metadata:
|
||||
*
|
||||
* ```json
|
||||
* "supplementalMetaData": { "nextUi": { "defaultSort": [{ "fieldName": "receivedAt", "isAscending": false }] } }
|
||||
* ```
|
||||
*
|
||||
* Each entry has the shape of an order-by in a query filter (`QFilterOrderBy`). Without a usable
|
||||
* declaration the list sorts by primary key, descending, as it always has.
|
||||
*/
|
||||
|
||||
import type { QFilterOrderBy, QTableMetaData } from '@/types'
|
||||
|
||||
import { resolveField } from './filter-utils'
|
||||
import { canSortField } from './secured-data'
|
||||
|
||||
/**
|
||||
* The sort a table's metadata declares for its record list, validated: an entry is kept when its
|
||||
* `fieldName` names a field of the table (or of an exposed join, as `joinTable.field`) that the
|
||||
* user may sort by, and its `isAscending` is a boolean or absent (ascending, as for an order-by).
|
||||
* A field named twice counts once. Anything else is dropped.
|
||||
*
|
||||
* @param table - Table metadata.
|
||||
* @returns The declared order-bys that can be used; empty when the table declares none.
|
||||
*/
|
||||
export function declaredDefaultSort(table: QTableMetaData): QFilterOrderBy[] {
|
||||
const hint = (table.supplementalMetaData?.nextUi ?? table.supplementalTableMetaData?.nextUi) as { defaultSort?: unknown } | null | undefined
|
||||
const declared = hint && typeof hint === 'object' ? hint.defaultSort : undefined
|
||||
if (!Array.isArray(declared)) return []
|
||||
const orderBys: QFilterOrderBy[] = []
|
||||
for (const entry of declared) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||
const { fieldName, isAscending } = entry as Record<string, unknown>
|
||||
if (typeof fieldName !== 'string' || (isAscending !== undefined && typeof isAscending !== 'boolean')) continue
|
||||
if (orderBys.some((orderBy) => orderBy.fieldName === fieldName)) continue
|
||||
const field = resolveField(table, fieldName)?.field
|
||||
if (!field || !canSortField(field)) continue
|
||||
orderBys.push({ fieldName, isAscending: isAscending ?? true })
|
||||
}
|
||||
return orderBys
|
||||
}
|
||||
|
||||
/**
|
||||
* The sort a table's record list opens with, and returns to on a reset: the one the table
|
||||
* declares, else its primary key, descending (Material's default).
|
||||
*
|
||||
* A person's own choice always comes first: a sort in the address, the sort of a saved view, or
|
||||
* the sort the browser remembers for the table is used instead of this one.
|
||||
*
|
||||
* @param table - Table metadata, when loaded.
|
||||
* @returns The default order-bys; empty only for a table without a primary key.
|
||||
*/
|
||||
export function tableDefaultSort(table: QTableMetaData | undefined): QFilterOrderBy[] {
|
||||
if (!table) return []
|
||||
const declared = declaredDefaultSort(table)
|
||||
if (declared.length > 0) return declared
|
||||
return table.primaryKeyField ? [{ fieldName: table.primaryKeyField, isAscending: false }] : []
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type { QFieldMetaData, QFilterCriteria, QFilterOrderBy, QQueryFilter, QTa
|
||||
import { formatDateTime } from './datetime-utils'
|
||||
import { normalizeFilter, isCriterionComplete, emptyFilter, isFilterExpression, describeExpression, resolveField } from './filter-utils'
|
||||
import { arrangePinnedColumns, effectivePins, getQueryColumns, orderColumns, type ColumnPin, type ColumnPins } from './query-columns'
|
||||
import { tableDefaultSort } from './query-default-sort'
|
||||
|
||||
/** One column entry in a saved view (Material `QQueryColumns.Column`). */
|
||||
export interface SavedViewColumn {
|
||||
@@ -634,9 +635,8 @@ export function reconcileView(table: QTableMetaData, view: RecordQueryView): Rec
|
||||
removedFields.add(orderBy.fieldName)
|
||||
return false
|
||||
})
|
||||
if (savedOrderBys.length > 0 && orderBys.length === 0 && table.primaryKeyField) {
|
||||
orderBys.push({ fieldName: table.primaryKeyField, isAscending: false })
|
||||
}
|
||||
// a view whose every sort field is gone falls back to the table's default sort
|
||||
if (savedOrderBys.length > 0 && orderBys.length === 0) orderBys.push(...tableDefaultSort(table))
|
||||
const quickFilterFieldNames = (view.quickFilterFieldNames ?? []).filter((name) => {
|
||||
if (exists(name)) return true
|
||||
removedFields.add(name)
|
||||
|
||||
@@ -91,6 +91,11 @@ export interface PreferenceSecret {
|
||||
/** Only section is safe to retain in the preferences cache. */
|
||||
export interface PreferenceActionResponse {
|
||||
section: PreferenceSection
|
||||
/**
|
||||
* The outcome, shown once. The UI API has no redirect field, so a message that is nothing but
|
||||
* one absolute `https:` address is where the browser goes next (an OAuth consent page) and is
|
||||
* never shown; see `continuationUrl`.
|
||||
*/
|
||||
message?: string
|
||||
secrets?: PreferenceSecret[]
|
||||
focusRowKey?: string
|
||||
|
||||
Reference in new issue
Block a user