Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
787a227602
|
||
|
|
30f4f9d586
|
||
|
|
24bbe4a038
|
||
|
|
8903f272c4
|
||
|
|
2345039911
|
||
|
|
4347924658
|
||
|
|
523f59cf38
|
No files matched your search
+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 |
|
||||
@@ -16,8 +16,16 @@
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -35,7 +35,7 @@ import { getAlertsInboxSummary } from '@/lib/api/alerts'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import type { AlertsInboxSummary } from '@/types/alerts'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
|
||||
|
||||
const AlertsBellPopover = lazy(() => import('./AlertsBellPopover'))
|
||||
|
||||
@@ -132,16 +132,18 @@ export function AlertsBell(_props: { metaData: QInstance | undefined }) {
|
||||
aria-expanded={open}
|
||||
data-qqq-id="button-notifications"
|
||||
>
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
className={`absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
|
||||
aria-hidden="true"
|
||||
data-qqq-id="alerts-unread-badge"
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
<HeaderIconAnchor>
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
className={`${HEADER_BADGE_CLASS} ${critical ? 'bg-destructive text-white' : 'bg-primary text-primary-foreground'}`}
|
||||
aria-hidden="true"
|
||||
data-qqq-id="alerts-unread-badge"
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</HeaderIconAnchor>
|
||||
</button>
|
||||
<span className="sr-only" aria-live="polite" data-qqq-id="alerts-unread-announcer">{label}</span>
|
||||
{open && (
|
||||
|
||||
@@ -198,7 +198,12 @@ describe('AlertsBell', () => {
|
||||
expect(bell).toHaveAttribute('aria-expanded', 'false')
|
||||
// severity is in the name, not only in the badge color
|
||||
expect(bell).toHaveAccessibleName('Notifications, 2 unread, critical')
|
||||
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
|
||||
const badge = screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })
|
||||
expect(badge).toHaveClass('bg-destructive')
|
||||
// the badge is positioned against the icon (the header's shared anchor), inside the button
|
||||
expect(badge.parentElement).toHaveAttribute('data-qqq-id', 'header-icon-anchor')
|
||||
expect(badge.parentElement).toHaveClass('relative')
|
||||
expect(badge.parentElement?.parentElement).toBe(bell)
|
||||
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
|
||||
first.unmount()
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// The header's Ask button: its busy dot and unread badge stay on the sparkles icon in every state.
|
||||
// jsdom has no layout, so these tests assert what decides the layout: which element is each
|
||||
// indicator's containing block, what gives that element its size, and the indicator's offsets.
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HEADER_ICON_ANCHOR_CLASS, HEADER_STATUS_DOT_CLASS } from '@/components/layout/HeaderMenu'
|
||||
import { assistantKeys, type AssistantController } from './assistant-context'
|
||||
import { AssistantHeaderButton } from './AssistantHeaderButton'
|
||||
import { AssistantProvider } from './AssistantProvider'
|
||||
|
||||
const ANCHOR = '[data-qqq-id="header-icon-anchor"]'
|
||||
const BUSY = '[data-qqq-id="assistant-busy"]'
|
||||
const UNREAD = '[data-qqq-id="assistant-unread-badge"]'
|
||||
|
||||
/**
|
||||
* The class tokens of an element (an SVG element's `className` is not a string).
|
||||
*
|
||||
* @param element - The element.
|
||||
* @returns Its classes.
|
||||
*/
|
||||
function classes(element: Element): string[] {
|
||||
return (element.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* The containing block of an absolutely positioned element: its nearest positioned ancestor.
|
||||
*
|
||||
* @param element - The absolutely positioned element.
|
||||
* @returns The ancestor its offsets are measured from, or null (the viewport).
|
||||
*/
|
||||
function containingBlock(element: Element): Element | null {
|
||||
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
||||
if (classes(parent).some((name) => ['relative', 'absolute', 'fixed', 'sticky'].includes(name))) return parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The children that give an element its size: every child that is not absolutely positioned.
|
||||
*
|
||||
* @param element - The element.
|
||||
* @returns Its in-flow children.
|
||||
*/
|
||||
function inFlowChildren(element: Element): Element[] {
|
||||
return Array.from(element.children).filter((child) => !classes(child).includes('absolute'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the button in a given state.
|
||||
*
|
||||
* @param state - The state.
|
||||
* @param state.busy - Whether a reply is being written.
|
||||
* @param state.unreadCount - Unread replies.
|
||||
* @param state.open - Whether the panel is open.
|
||||
* @param state.enabled - Whether the assistant is available.
|
||||
* @returns The button, or null when it renders nothing.
|
||||
*/
|
||||
function renderButton({ busy = false, unreadCount = 0, open = false, enabled = true }: { busy?: boolean; unreadCount?: number; open?: boolean; enabled?: boolean }): HTMLElement | null {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
client.setQueryData(assistantKeys.busy(), busy)
|
||||
const controller: AssistantController = {
|
||||
config: null,
|
||||
enabled,
|
||||
open,
|
||||
setOpen: vi.fn(),
|
||||
toggle: vi.fn(),
|
||||
activeConversationId: null,
|
||||
setActiveConversationId: vi.fn(),
|
||||
unreadCount,
|
||||
openWith: vi.fn(),
|
||||
pendingRequest: null,
|
||||
consumeRequest: vi.fn(),
|
||||
triggerRef: React.createRef<HTMLButtonElement>(),
|
||||
}
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AssistantProvider value={controller}>
|
||||
<header><AssistantHeaderButton /></header>
|
||||
</AssistantProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
return screen.queryByRole('button', { name: /^Ask the assistant/ })
|
||||
}
|
||||
|
||||
describe('AssistantHeaderButton', () => {
|
||||
it('renders nothing when the assistant is unavailable', () => {
|
||||
expect(renderButton({ enabled: false, busy: true, unreadCount: 2 })).toBeNull()
|
||||
})
|
||||
|
||||
it('idle: the icon anchor holds only the icon, and the label sits beside it', () => {
|
||||
const button = renderButton({})!
|
||||
const anchor = button.querySelector(ANCHOR)!
|
||||
expect(anchor.parentElement).toBe(button)
|
||||
expect(Array.from(anchor.children).map((child) => child.tagName.toLowerCase())).toEqual(['svg'])
|
||||
expect(button.querySelector(BUSY)).toBeNull()
|
||||
expect(button.querySelector(UNREAD)).toBeNull()
|
||||
const label = screen.getByText('Ask')
|
||||
expect(label.parentElement).toBe(button)
|
||||
expect(anchor.contains(label)).toBe(false)
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
const states = [
|
||||
{ name: 'a reply being written', busy: true, unreadCount: 0 },
|
||||
{ name: 'unread replies', busy: false, unreadCount: 3 },
|
||||
{ name: 'a reply being written and unread replies', busy: true, unreadCount: 12 },
|
||||
]
|
||||
const cases = states.flatMap((state) => [{ ...state, open: false, panel: 'closed' }, { ...state, open: true, panel: 'open' }])
|
||||
|
||||
it.each(cases)('$name, panel $panel: every indicator is positioned against the icon, never the button', ({ busy, unreadCount, open }) => {
|
||||
const button = renderButton({ busy, unreadCount, open })!
|
||||
expect(button).toHaveAttribute('aria-pressed', String(open))
|
||||
const anchor = button.querySelector(ANCHOR)!
|
||||
const indicators = Array.from(button.querySelectorAll(`${BUSY}, ${UNREAD}`))
|
||||
expect(indicators).toHaveLength((busy ? 1 : 0) + (unreadCount > 0 ? 1 : 0))
|
||||
for (const indicator of indicators) {
|
||||
expect(classes(indicator)).toContain('absolute')
|
||||
// the button is positioned too (its focus ring and the Jobs ring need that), so only an
|
||||
// indicator inside the anchor is measured from the icon
|
||||
expect(containingBlock(indicator)).toBe(anchor)
|
||||
expect(containingBlock(indicator)).not.toBe(button)
|
||||
}
|
||||
// the anchor is exactly the icon: nothing else in flow, so the label and the button's
|
||||
// padding cannot move an indicator
|
||||
expect(classes(anchor)).toContain('relative')
|
||||
expect(inFlowChildren(anchor).map((child) => child.tagName.toLowerCase())).toEqual(['svg'])
|
||||
expect(anchor.contains(screen.getByText('Ask'))).toBe(false)
|
||||
})
|
||||
|
||||
it('places the dot on the icon\'s bottom-right corner and the badge over its top-right corner', () => {
|
||||
const button = renderButton({ busy: true, unreadCount: 3 })!
|
||||
const dot = screen.getByRole('status', { name: 'Assistant is answering' })
|
||||
expect(dot).toBe(button.querySelector(BUSY))
|
||||
expect(classes(dot)).toEqual(expect.arrayContaining(['-bottom-0.5', '-right-0.5', 'h-2', 'w-2', 'rounded-full']))
|
||||
const badge = button.querySelector(UNREAD)!
|
||||
expect(badge).toHaveTextContent('3')
|
||||
expect(classes(badge)).toEqual(expect.arrayContaining(['-right-2', '-top-2', 'h-4', 'min-w-4']))
|
||||
// the offsets of the button's own corner (the defect: past the label, under its baseline)
|
||||
for (const indicator of [dot, badge]) {
|
||||
for (const offset of ['bottom-0', 'right-0', 'right-0.5', 'top-0.5']) expect(classes(indicator)).not.toContain(offset)
|
||||
}
|
||||
})
|
||||
|
||||
it('caps the badge at 9+ and names the unread count', () => {
|
||||
const button = renderButton({ unreadCount: 12 })!
|
||||
expect(button.querySelector(UNREAD)).toHaveTextContent('9+')
|
||||
expect(button).toHaveAccessibleName('Ask the assistant, 12 unread (Ctrl+J or ⌘J)')
|
||||
})
|
||||
|
||||
it('shares one anchor and one badge geometry with the bell and Jobs', () => {
|
||||
expect(HEADER_ICON_ANCHOR_CLASS.split(' ')).toEqual(expect.arrayContaining(['relative', 'shrink-0']))
|
||||
expect(HEADER_BADGE_CLASS.split(' ')).toEqual(expect.arrayContaining(['absolute', '-right-2', '-top-2']))
|
||||
expect(HEADER_STATUS_DOT_CLASS.split(' ')).toEqual(expect.arrayContaining(['absolute', '-bottom-0.5', '-right-0.5']))
|
||||
// an 8 px offset from a 20 px icon stays inside the 10 px padding of the 40 px button
|
||||
expect(HEADER_BUTTON_CLASS.split(' ')).toEqual(expect.arrayContaining(['h-10', 'min-w-10', 'px-2.5']))
|
||||
})
|
||||
})
|
||||
@@ -25,13 +25,14 @@ import React from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HEADER_STATUS_DOT_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { assistantKeys, useAssistant } from './assistant-context'
|
||||
|
||||
/**
|
||||
* Toggles the assistant panel ("Ask"); the badge counts unread replies. The sparkles icon alone is
|
||||
* ambiguous, so from 1440 px it carries the text label "Ask" (its tooltip says so below that).
|
||||
* Toggles the assistant panel ("Ask"); the badge counts unread replies and the dot shows a reply
|
||||
* being written. The sparkles icon alone is ambiguous, so from 1440 px it carries the text label
|
||||
* "Ask" (its tooltip says so below that).
|
||||
*
|
||||
* @returns The button, or null when the assistant is unavailable.
|
||||
*/
|
||||
@@ -50,24 +51,23 @@ export function AssistantHeaderButton() {
|
||||
event.currentTarget.focus({ preventScroll: true })
|
||||
toggle()
|
||||
}}
|
||||
className={cn(HEADER_BUTTON_CLASS, 'gap-1.5')}
|
||||
className={cn(HEADER_BUTTON_CLASS, 'gap-2')}
|
||||
aria-label={label}
|
||||
title="Ask the assistant (Ctrl+J or ⌘J)"
|
||||
aria-pressed={open}
|
||||
data-qqq-id="button-assistant"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" aria-hidden="true" />
|
||||
{/* the indicators sit on the icon: the button's corner is past the label from 1440 px */}
|
||||
<HeaderIconAnchor>
|
||||
<Sparkles className="h-5 w-5" aria-hidden="true" />
|
||||
{busy && <span className={cn(HEADER_STATUS_DOT_CLASS, 'bg-primary motion-safe:animate-pulse')} role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
|
||||
{unreadCount > 0 && (
|
||||
<span className={cn(HEADER_BADGE_CLASS, 'bg-primary text-primary-foreground')} aria-hidden="true" data-qqq-id="assistant-unread-badge">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</HeaderIconAnchor>
|
||||
<span className="hidden text-sm font-medium min-[1440px]:inline" aria-hidden="true">Ask</span>
|
||||
{busy && <span className="absolute bottom-0 right-0 h-2 w-2 rounded-full bg-primary motion-safe:animate-pulse" role="status" aria-label="Assistant is answering" data-qqq-id="assistant-busy" />}
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
data-qqq-id="assistant-unread-badge"
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -106,6 +106,12 @@ describe('JobsHeaderButton and JobsPopover', () => {
|
||||
it('shows an active badge, both lists, keyboard trap and restores focus on Escape', async () => {
|
||||
renderConnected(<JobsHeaderButton />)
|
||||
const button = await screen.findByRole('button', { name: 'Jobs, 1 active' })
|
||||
// the badge is positioned against the icon (the header's shared anchor), inside the button
|
||||
const badge = button.querySelector('[data-qqq-id="jobs-active-badge"]')
|
||||
expect(badge).toHaveTextContent('1')
|
||||
expect(badge?.parentElement).toHaveAttribute('data-qqq-id', 'header-icon-anchor')
|
||||
expect(badge?.parentElement).toHaveClass('relative')
|
||||
expect(badge?.parentElement?.parentElement).toBe(button)
|
||||
fireEvent.click(button)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Jobs' })
|
||||
expect(await screen.findByText('Import')).toBeInTheDocument()
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
'use client'
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BriefcaseBusiness } from 'lucide-react'
|
||||
import { HEADER_BUTTON_CLASS } from '@/components/layout/HeaderMenu'
|
||||
import { HEADER_BADGE_CLASS, HEADER_BUTTON_CLASS, HeaderIconAnchor } from '@/components/layout/HeaderMenu'
|
||||
import { useJobsInstalled, useMyJobs } from '@/lib/hooks/use-my-jobs'
|
||||
import './jobs.css'
|
||||
|
||||
@@ -76,16 +76,18 @@ export function JobsHeaderButton() {
|
||||
className="qqq-job-ring absolute inset-1 rounded-full border border-primary"
|
||||
/>
|
||||
)}
|
||||
<BriefcaseBusiness aria-hidden="true" className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
|
||||
data-qqq-id="jobs-active-badge"
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
<HeaderIconAnchor>
|
||||
<BriefcaseBusiness aria-hidden="true" className="h-5 w-5" />
|
||||
{count > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${HEADER_BADGE_CLASS} bg-primary text-primary-foreground`}
|
||||
data-qqq-id="jobs-active-badge"
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</HeaderIconAnchor>
|
||||
</button>
|
||||
<span aria-live="polite" className="sr-only" data-qqq-id="jobs-announcer">
|
||||
{announcement}
|
||||
|
||||
@@ -33,6 +33,34 @@ export const HEADER_BUTTON_CLASS = cn(
|
||||
'hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)
|
||||
|
||||
/**
|
||||
* The positioning context of a header button's indicators: a box exactly the size of its icon.
|
||||
* The button's own box is the hit area (padding, and a text label from 1440 px on Ask) with no
|
||||
* visible edge at rest, so an indicator placed against it drifts away from the icon.
|
||||
*/
|
||||
export const HEADER_ICON_ANCHOR_CLASS = 'relative flex shrink-0'
|
||||
|
||||
/**
|
||||
* A count badge over the icon's top-right corner (unread, active). On a 40 px icon-only button this
|
||||
* is 2 px inside the button's top-right corner. Add the fill and text colors.
|
||||
*/
|
||||
export const HEADER_BADGE_CLASS = 'absolute -right-2 -top-2 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none'
|
||||
|
||||
/** A status dot on the icon's bottom-right corner (activity). Add the fill color. */
|
||||
export const HEADER_STATUS_DOT_CLASS = 'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full'
|
||||
|
||||
/**
|
||||
* A header button's icon together with its indicators ({@link HEADER_BADGE_CLASS},
|
||||
* {@link HEADER_STATUS_DOT_CLASS}), which stay on the icon whatever else the button holds.
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @param props.children - The icon, then its indicators.
|
||||
* @returns The anchor.
|
||||
*/
|
||||
export function HeaderIconAnchor({ children }: { children: React.ReactNode }) {
|
||||
return <span className={HEADER_ICON_ANCHOR_CLASS} data-qqq-id="header-icon-anchor">{children}</span>
|
||||
}
|
||||
|
||||
/** A menu row: full width, 44 px tall on touch. */
|
||||
export const HEADER_MENU_ITEM_CLASS = cn(
|
||||
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-foreground pointer-coarse:min-h-11',
|
||||
|
||||
@@ -32,8 +32,15 @@ vi.mock('@/lib/hooks/use-container-width', () => ({
|
||||
useContainerWidth: () => [undefined, layout.width],
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
|
||||
const navigation = vi.hoisted(() => ({ leaveTo: vi.fn() }))
|
||||
vi.mock('@/lib/utils/external-navigation', async (original) => ({
|
||||
...(await original<typeof import('@/lib/utils/external-navigation')>()),
|
||||
leaveTo: navigation.leaveTo,
|
||||
}))
|
||||
beforeEach(() => {
|
||||
layout.width = 500
|
||||
navigation.leaveTo.mockClear()
|
||||
vi.mocked(toast.success).mockClear()
|
||||
})
|
||||
const tokens = preferencesFixture.sections.find((section) => section.name === 'access-tokens')!
|
||||
function renderForm(section: PreferenceSection = structuredClone(tokens)) {
|
||||
@@ -387,3 +394,36 @@ it('shows each returned secret and restores focus to the returned row after ackn
|
||||
expect(screen.queryByDisplayValue('recovery-test-only')).not.toBeInTheDocument()
|
||||
persist.mockRestore()
|
||||
})
|
||||
|
||||
it('sends the browser to an address-only result and never shows the address', async () => {
|
||||
const authorize =
|
||||
'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&redirect_uri=https%3A%2F%2Fapp.example.test%2Fqqq%2Fhooks%2Fslack%2Foauth%2Fcallback&state=abc.def'
|
||||
const section = structuredClone(tokens)
|
||||
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
|
||||
server.use(
|
||||
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
|
||||
HttpResponse.json({ section, message: authorize })
|
||||
)
|
||||
)
|
||||
renderForm(section)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
|
||||
await waitFor(() => expect(navigation.leaveTo).toHaveBeenCalledWith(authorize))
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/slack\.com/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still shows a result that only mentions an address as a message', async () => {
|
||||
const section = structuredClone(tokens)
|
||||
section.actions!.push({ name: 'connect', label: 'Connect service', scope: 'SECTION' })
|
||||
server.use(
|
||||
http.post('/qqq/v1/preferences/access-tokens/actions/connect', () =>
|
||||
HttpResponse.json({ section, message: 'Connected. See https://example.test/help for more.' })
|
||||
)
|
||||
)
|
||||
renderForm(section)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect service' }))
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith('Connected. See https://example.test/help for more.')
|
||||
)
|
||||
expect(navigation.leaveTo).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from '@/types/preferences'
|
||||
import { runPreferenceAction } from '@/lib/api/preferences'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { continuationUrl, leaveTo } from '@/lib/utils/external-navigation'
|
||||
import { BUTTON_CLASS, buttonClass } from '@/components/ui/controls'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { PreferenceField } from './PreferenceField'
|
||||
@@ -381,7 +382,11 @@ export function usePreferenceActions({
|
||||
showingSecrets.current = Boolean(result.secrets?.length)
|
||||
onSaved(result.section)
|
||||
setSelected(null)
|
||||
if (result.message) toast.success(result.message)
|
||||
// An answer that is only an address (the consent page of a service being connected) is
|
||||
// where the browser goes next. It is never shown: nobody can use an address in a toast.
|
||||
const destination = continuationUrl(result.message)
|
||||
if (destination) leaveTo(destination)
|
||||
else if (result.message) toast.success(result.message)
|
||||
setSecrets(result.secrets ?? [])
|
||||
} catch (error) {
|
||||
if (mounted.current) setErrors(preferenceMessages(error))
|
||||
|
||||
@@ -22,7 +22,7 @@ import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
import { server } from '@/mocks/node'
|
||||
import { ThemeProvider } from '@/lib/theme/theme-provider'
|
||||
import { PreferencesPage } from './PreferencesPage'
|
||||
import { PreferencesPage, sectionOutcome } from './PreferencesPage'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
search: '',
|
||||
@@ -107,3 +107,42 @@ it('offers retry on errors and keeps local display settings accessible', async (
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('says once that a flow that returned to a section succeeded, and takes the outcome off the address', async () => {
|
||||
state.search = 'section=regional&result=connected'
|
||||
renderPage()
|
||||
await screen.findByRole('heading', { name: 'Regional settings' })
|
||||
const outcome = screen.getByText('Regional settings: connected.')
|
||||
expect(outcome).toHaveAttribute('role', 'status')
|
||||
expect(outcome).toHaveAttribute('data-qqq-id', 'preferences-section-outcome')
|
||||
expect(state.replace).toHaveBeenCalledWith('/app/preferences?section=regional', { scroll: false })
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /Notifications What reaches/ }))
|
||||
expect(screen.queryByText('Regional settings: connected.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the reason a flow that returned to a section failed', async () => {
|
||||
state.search = 'section=regional&error=authorization_denied'
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Regional settings: authorization denied.'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads only fixed outcome codes from the address, never a sentence', () => {
|
||||
const read = (search: string) => sectionOutcome(new URLSearchParams(search))
|
||||
expect(read('section=slack&result=connected')).toEqual({
|
||||
section: 'slack',
|
||||
failed: false,
|
||||
words: 'connected',
|
||||
})
|
||||
expect(read('section=slack&error=sign_in_required&result=connected')).toEqual({
|
||||
section: 'slack',
|
||||
failed: true,
|
||||
words: 'sign in required',
|
||||
})
|
||||
expect(read('section=slack')).toBeNull()
|
||||
expect(read('result=connected')).toBeNull()
|
||||
expect(read('section=slack&error=Call+555-0100+now')).toBeNull()
|
||||
expect(read('section=slack&error=%3Cb%3Ex%3C%2Fb%3E')).toBeNull()
|
||||
expect(read(`section=slack&error=${'a_'.repeat(20)}a`)).toBeNull()
|
||||
})
|
||||
@@ -17,13 +17,14 @@
|
||||
/** @file Preferences route content, with device display settings and server-owned sections. */
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getPreferences } from '@/lib/api/preferences'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { BUTTON_CLASS } from '@/components/ui/controls'
|
||||
import { MetadataIcon } from '@/components/layout/MetadataIcon'
|
||||
import { DisplayPreferencesPanel } from '@/components/layout/DisplayPreferencesPanel'
|
||||
@@ -43,6 +44,30 @@ export function PreferencesSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
/** What a flow that left the dashboard reports when it returns to a section. */
|
||||
export interface SectionOutcome {
|
||||
section: string
|
||||
failed: boolean
|
||||
/** The provider's outcome code as words: `authorization_denied` reads "authorization denied". */
|
||||
words: string
|
||||
}
|
||||
|
||||
/** Reads the outcome a flow that left the dashboard (a consent page of a connected service)
|
||||
* puts on the address when it returns: `?section=<name>&result=<code>` when it succeeded,
|
||||
* `&error=<code>` when it did not. The code is one of the provider's fixed lowercase names
|
||||
* (`connected`, `authorization_denied`). It is shown as words after the section's own label;
|
||||
* anything else on the address is ignored, so a link cannot put its own sentence on the page.
|
||||
* @param params - The page's query string.
|
||||
* @returns The outcome, or `null` when the address reports none.
|
||||
*/
|
||||
export function sectionOutcome(params: URLSearchParams): SectionOutcome | null {
|
||||
const section = params.get('section')
|
||||
const error = params.get('error')
|
||||
const code = error ?? params.get('result')
|
||||
if (!section || !code || !/^[a-z]+(?:_[a-z]+){0,7}$/.test(code)) return null
|
||||
return { section, failed: error !== null, words: code.replace(/_/g, ' ') }
|
||||
}
|
||||
|
||||
/** Every registered section and a client-only Display section, selected through the URL.
|
||||
* @returns Preferences navigation and selected section.
|
||||
*/
|
||||
@@ -59,6 +84,13 @@ export function PreferencesPage() {
|
||||
})
|
||||
// A client-only key cannot collide with the backend's [a-z] section names.
|
||||
const requested = params.get('section')
|
||||
const [outcome, setOutcome] = useState(() => sectionOutcome(params))
|
||||
const reported = params.has('result') || params.has('error')
|
||||
// An outcome is reported once: off the address it goes, so a reload does not repeat it.
|
||||
useEffect(() => {
|
||||
if (reported && requested)
|
||||
router.replace(`/app/preferences?section=${encodeURIComponent(requested)}`, { scroll: false })
|
||||
}, [reported, requested, router])
|
||||
const selected =
|
||||
requested === 'display' && !query.data?.sections.some((section) => section.name === 'display')
|
||||
? '__display'
|
||||
@@ -73,13 +105,15 @@ export function PreferencesPage() {
|
||||
...(query.data?.sections ?? []),
|
||||
]
|
||||
const section = query.data?.sections.find((section) => section.name === selected)
|
||||
const navigate = (name: string) =>
|
||||
const navigate = (name: string) => {
|
||||
setOutcome(null)
|
||||
router.replace(
|
||||
name === '__display'
|
||||
? '/app/preferences'
|
||||
: `/app/preferences?section=${encodeURIComponent(name)}`,
|
||||
{ scroll: false }
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl space-y-6" data-qqq-id="preferences-page">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Preferences</h1>
|
||||
@@ -159,6 +193,20 @@ export function PreferencesPage() {
|
||||
<PreferencesSkeleton />
|
||||
) : section ? (
|
||||
<>
|
||||
{outcome?.section === section.name && (
|
||||
<p
|
||||
role={outcome.failed ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
outcome.failed
|
||||
? 'border-destructive text-destructive'
|
||||
: 'border-border bg-muted text-foreground'
|
||||
)}
|
||||
data-qqq-id="preferences-section-outcome"
|
||||
>
|
||||
{`${section.label}: ${outcome.words}.`}
|
||||
</p>
|
||||
)}
|
||||
<PreferenceSectionForm key={section.name} section={section} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -56,9 +56,10 @@ export function HtmlComponent({ index }: HtmlComponentProps) {
|
||||
const raw = values[`${step.name}.html`]
|
||||
const html = useMemo(() => (typeof raw === 'string' && raw ? sanitizeProcessHtml(raw) : ''), [raw])
|
||||
if (!html) return null
|
||||
// links read as links: without it they inherit the text color and have no underline
|
||||
return (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-foreground"
|
||||
className="prose prose-sm max-w-none text-foreground [&_a]:text-link [&_a]:underline"
|
||||
data-qqq-id={`process-html-${index}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
@@ -205,6 +205,24 @@ describe('ProcessSummaryLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTML component', () => {
|
||||
it('shows a link the backend put on the last screen as a link that opens in this tab', () => {
|
||||
const address = 'https://slack.com/oauth/v2/authorize?client_id=1.2&scope=&user_scope=chat%3Awrite&state=abc.def'
|
||||
const step: QFrontendStepMetaData = { name: 'result', label: 'Continue to Slack', components: [{ type: 'HTML' }] }
|
||||
renderStep(step, {
|
||||
noMoreSteps: true,
|
||||
'result.html': `<p>Slack will ask you to approve the connection.</p><p><a href="${address.replace(/&/g, '&')}" style="display:inline-block;padding:8px 16px;background:#4A154B;color:#ffffff">Continue to Slack</a></p><p>This link expires in 10 minutes.</p>`,
|
||||
})
|
||||
const link = screen.getByRole('link', { name: 'Continue to Slack' })
|
||||
expect(link).toHaveAttribute('href', address)
|
||||
expect(link).not.toHaveAttribute('target')
|
||||
expect(link.getAttribute('style')).toContain('background')
|
||||
expect(link.closest('[data-qqq-id="process-html-0"]')).toHaveClass('[&_a]:text-link', '[&_a]:underline')
|
||||
expect(screen.getByText('This link expires in 10 minutes.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Return' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('process helpers', () => {
|
||||
it('sanitizes HTML but keeps CSV and text template downloads', () => {
|
||||
const html = sanitizeProcessHtml('<a href="data:text/csv;base64,QQ==" download="t.csv">t</a><a href="data:text/html,<b>x</b>">h</a><img src="x" onerror="alert(1)"><script>alert(1)</script>')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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