feat(preferences): Preferences page, notification settings drawer and section actions

Renders every /qqq/v1/preferences section generically from its metadata:
scalar fields with the standard editors, row lists as a table (cards on
narrow widths), read-only reasons, partial saves, section/list/row actions
with input and confirmation dialogs, and a show-once dialog for one-time
secrets. The user menu opens /app/preferences (device display settings are
its Display section) plus any section marked showInUserMenu; the bell
popover and /app/notifications get a gear that opens the alerts section in
a drawer. Merges main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 5.5 committed 2026-10-13 20:42:59 -05:00
1 parent 35249098bb
commit 1f4a38b9ff
47 files changed
+7682 -3975

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
// polyfills (crypto-browserify -> elliptic, GHSA-848j-6mx2-7j84) that no story needs.
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
staticDirs: ['../public'],
addons: [
'@storybook/addon-docs',
],
+95 -95
View File
@@ -1,95 +1,95 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { execFileSync } from 'node:child_process'
import { appendFileSync, readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
export const AREAS = ['esb', 'navigation', 'performance', 'processes', 'query', 'records', 'security', 'widgets']
export const SMOKE = [
'navigation/landing.spec.ts',
'navigation/navigation.spec.ts',
'records/crud.spec.ts',
'query/query-grid.spec.ts',
'widgets/sample-dashboard.spec.ts',
].map((file) => `tests/acceptance/specs/${file}`)
const sourceAreas = [
[/widget|dashboard|report|chart|pivot/, 'widgets'],
[/query|grid|table|filter|search/, 'query'],
[/record|form|field|input|validation|attachment|preview/, 'records'],
[/process|bulk/, 'processes'],
[/navigation|\bnav\b|sidebar|theme|branding|layout/, 'navigation'],
[/security|auth|login|session|permission/, 'security'],
[/esb/, 'esb'],
[/performance/, 'performance'],
]
/** Unknown production/configuration paths expand coverage instead of silently losing it. */
export function selectSpecs(paths) {
const selected = new Set()
for (const raw of paths) {
const file = raw.replaceAll('\\', '/')
if (file.startsWith('docs/') || /\.md$/i.test(file)) continue
const specArea = file.match(/^tests\/acceptance\/specs\/([^/]+)\//)?.[1]
if (specArea && AREAS.includes(specArea)) {
selected.add(specArea)
continue
}
if (/^(package\.json|pnpm-lock\.yaml|next\.config\.[^/]+)$/.test(file)
|| /^tests\/acceptance\/(playwright\.config\.|support\/|fixture\/|matrix\/|quarantine\.json)/.test(file)
|| /^scripts\/acceptance/.test(file)
|| file === '.github/workflows/acceptance.yml'
|| /^src\/lib\/(api\/|core\/|client|query-client|context\/)/.test(file)) return allSpecs()
if (file.startsWith('src/')) {
const matches = sourceAreas.filter(([pattern]) => pattern.test(file.toLowerCase()))
if (!matches.length) return allSpecs()
for (const [, area] of matches) selected.add(area)
continue
}
// Unit/mocked tests don't change the real backend; smoke still runs.
if (/^(tests\/|\.storybook\/)/.test(file)) continue
return allSpecs()
}
return [...AREAS.filter((area) => selected.has(area)).map((area) => `tests/acceptance/specs/${area}`),
...SMOKE.filter((file) => !selected.has(file.split('/')[3]))]
}
/** Directory filters include new specs automatically. */
function allSpecs() {
return AREAS.map((area) => `tests/acceptance/specs/${area}`)
}
/**
* Build a bounded matrix; each cell owns one browser engine and one backend.
* @param {{ eventName: string, labels?: string[], paths?: string[] }} input
*/
export function planAcceptance({ eventName, labels = [], paths = [] }) {
const full = eventName !== 'pull_request' || labels.includes('full-acceptance')
const counts = full ? { chromium: 3, firefox: 3, webkit: 4, mobile: 4, tablet: 4 } : { chromium: 2, mobile: 2 }
const include = Object.entries(counts).flatMap(([project, total]) => Array.from({ length: total }, (_, index) => ({
project, browser: project === 'mobile' ? 'chromium' : project === 'tablet' ? 'webkit' : project,
shard: index + 1, total,
})))
return { mode: full ? 'full' : 'pr', matrix: { include }, specs: full ? [] : selectSpecs(paths) }
}
// Diff both sides of renames, from the PR merge-base (not the current main tip).
// Event data is passed as argv, never interpolated into a shell command.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
const eventName = process.env.GITHUB_EVENT_NAME
const labels = (event.pull_request?.labels ?? []).map((label) => label.name)
const paths = eventName === 'pull_request' && !labels.includes('full-acceptance')
? execFileSync('git', ['diff', '--name-only', '--no-renames', '-z', `${event.pull_request.base.sha}...${event.pull_request.head.sha}`, '--'], { encoding: 'utf8' }).split('\0').filter(Boolean)
: []
const plan = planAcceptance({ eventName, labels, paths })
for (const [key, value] of Object.entries(plan)) {
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}\n`)
}
console.log(JSON.stringify(plan, null, 2))
}
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { execFileSync } from 'node:child_process'
import { appendFileSync, readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
export const AREAS = ['esb', 'navigation', 'performance', 'processes', 'query', 'records', 'security', 'widgets']
export const SMOKE = [
'navigation/landing.spec.ts',
'navigation/navigation.spec.ts',
'records/crud.spec.ts',
'query/query-grid.spec.ts',
'widgets/sample-dashboard.spec.ts',
].map((file) => `tests/acceptance/specs/${file}`)
const sourceAreas = [
[/widget|dashboard|report|chart|pivot/, 'widgets'],
[/query|grid|table|filter|search/, 'query'],
[/record|form|field|input|validation|attachment|preview/, 'records'],
[/process|bulk/, 'processes'],
[/navigation|\bnav\b|sidebar|theme|branding|layout/, 'navigation'],
[/security|auth|login|session|permission/, 'security'],
[/esb/, 'esb'],
[/performance/, 'performance'],
]
/** Unknown production/configuration paths expand coverage instead of silently losing it. */
export function selectSpecs(paths) {
const selected = new Set()
for (const raw of paths) {
const file = raw.replaceAll('\\', '/')
if (file.startsWith('docs/') || /\.md$/i.test(file)) continue
const specArea = file.match(/^tests\/acceptance\/specs\/([^/]+)\//)?.[1]
if (specArea && AREAS.includes(specArea)) {
selected.add(specArea)
continue
}
if (/^(package\.json|pnpm-lock\.yaml|next\.config\.[^/]+)$/.test(file)
|| /^tests\/acceptance\/(playwright\.config\.|support\/|fixture\/|matrix\/|quarantine\.json)/.test(file)
|| /^scripts\/acceptance/.test(file)
|| file === '.github/workflows/acceptance.yml'
|| /^src\/lib\/(api\/|core\/|client|query-client|context\/)/.test(file)) return allSpecs()
if (file.startsWith('src/')) {
const matches = sourceAreas.filter(([pattern]) => pattern.test(file.toLowerCase()))
if (!matches.length) return allSpecs()
for (const [, area] of matches) selected.add(area)
continue
}
// Unit/mocked tests don't change the real backend; smoke still runs.
if (/^(tests\/|\.storybook\/)/.test(file)) continue
return allSpecs()
}
return [...AREAS.filter((area) => selected.has(area)).map((area) => `tests/acceptance/specs/${area}`),
...SMOKE.filter((file) => !selected.has(file.split('/')[3]))]
}
/** Directory filters include new specs automatically. */
function allSpecs() {
return AREAS.map((area) => `tests/acceptance/specs/${area}`)
}
/**
* Build a bounded matrix; each cell owns one browser engine and one backend.
* @param {{ eventName: string, labels?: string[], paths?: string[] }} input
*/
export function planAcceptance({ eventName, labels = [], paths = [] }) {
const full = eventName !== 'pull_request' || labels.includes('full-acceptance')
const counts = full ? { chromium: 3, firefox: 3, webkit: 4, mobile: 4, tablet: 4 } : { chromium: 2, mobile: 2 }
const include = Object.entries(counts).flatMap(([project, total]) => Array.from({ length: total }, (_, index) => ({
project, browser: project === 'mobile' ? 'chromium' : project === 'tablet' ? 'webkit' : project,
shard: index + 1, total,
})))
return { mode: full ? 'full' : 'pr', matrix: { include }, specs: full ? [] : selectSpecs(paths) }
}
// Diff both sides of renames, from the PR merge-base (not the current main tip).
// Event data is passed as argv, never interpolated into a shell command.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
const eventName = process.env.GITHUB_EVENT_NAME
const labels = (event.pull_request?.labels ?? []).map((label) => label.name)
const paths = eventName === 'pull_request' && !labels.includes('full-acceptance')
? execFileSync('git', ['diff', '--name-only', '--no-renames', '-z', `${event.pull_request.base.sha}...${event.pull_request.head.sha}`, '--'], { encoding: 'utf8' }).split('\0').filter(Boolean)
: []
const plan = planAcceptance({ eventName, labels, paths })
for (const [key, value] of Object.entries(plan)) {
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}\n`)
}
console.log(JSON.stringify(plan, null, 2))
}
@@ -0,0 +1,39 @@
/*
* 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 Preferences page entry point, compatible with the dashboard static export. */
'use client'
import dynamic from 'next/dynamic'
const PreferencesPage = dynamic(
() => import('@/components/preferences/PreferencesPage').then((module) => module.PreferencesPage),
{
ssr: false,
loading: () => (
<p role="status" className="p-6 text-muted-foreground">
Loading preferences…
</p>
),
}
)
/** Renders current-user preferences.
* @returns The dynamically loaded preferences page.
*/
export default function PreferencesRoute() {
return <PreferencesPage />
}
+195 -190
View File
@@ -1,190 +1,195 @@
/*
* 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 AlertsBellPopover — the bell's popover (loaded on first open): the latest
* notifications, newest first (severity icon, title, source, relative time), each with mark
* read/unread and dismiss when the backend sends its recipient id, plus "Mark all read" and
* "View all". Opening an item marks it read. Focus stays inside while it is open; Escape or a
* click outside closes it.
*/
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import type { AlertsInboxSummary, InboxItem } from '@/types/alerts'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest } from './NotificationParts'
/** Props for the popover. */
export interface AlertsBellPopoverProps {
/** The inbox summary to show. */
summary: AlertsInboxSummary
/** Close the popover (focus returns to the bell). */
onClose: () => void
}
/**
* The bell popover.
*
* @param props - Component properties.
* @returns The popover dialog.
*/
export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopoverProps) {
const router = useRouter()
const panelRef = useRef<HTMLDivElement>(null)
// a dropdown under the bell from 640 px; full width under the header below that (inline, so no new CSS)
const [wide, setWide] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(min-width: 640px)').matches)
useEffect(() => {
if (typeof window.matchMedia !== 'function') return
const query = window.matchMedia('(min-width: 640px)')
const onChange = () => setWide(query.matches)
query.addEventListener?.('change', onChange)
return () => query.removeEventListener?.('change', onChange)
}, [])
const placement: React.CSSProperties = wide
? { position: 'absolute', right: 0, top: '100%', marginTop: '0.5rem', width: '24rem', maxHeight: '70vh' }
: { position: 'fixed', left: '0.5rem', right: '0.5rem', top: 'calc(var(--qqq-header-height) + 0.25rem)', maxHeight: '70vh' }
useEffect(() => {
const panel = panelRef.current
panel?.querySelector<HTMLElement>('a,button')?.focus({ preventScroll: true })
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab' || !panel) return
const focusable = Array.from(panel.querySelectorAll<HTMLElement>('a,button:not([disabled])'))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
const onPointer = (event: PointerEvent) => {
if (panel && !panel.contains(event.target as Node) && !(event.target as HTMLElement).closest?.('[data-qqq-id="button-notifications"]')) onClose()
}
document.addEventListener('keydown', onKey)
document.addEventListener('pointerdown', onPointer)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('pointerdown', onPointer)
}
}, [onClose])
const actions = useInboxActions()
const openItem = (item: InboxItem) => {
const read = readRequest(item)
if (read) actions.mutate(read)
onClose()
if (item.href) router.push(item.href)
}
return (
<div
ref={panelRef}
role="dialog"
aria-modal="false"
aria-label="Notifications"
className="z-50 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
style={placement}
data-qqq-id="alerts-popover"
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">Notifications</h2>
<span className="text-xs text-muted-foreground">{summary.unreadCount > 0 ? `${summary.unreadCount} unread` : 'All read'}</span>
</div>
{summary.items.length === 0 ? (
<p className="px-4 py-6 text-sm text-muted-foreground" data-qqq-id="alerts-popover-empty">You have no notifications.</p>
) : (
<ul className="divide-y divide-border">
{summary.items.map((item) => {
const kind = linkKind(item.href)
const title = <span className={`block break-words text-foreground ${item.unread ? 'font-semibold' : ''}`}>{item.title}</span>
const meta = <span className="block text-xs text-muted-foreground">{itemMeta(item)}</span>
const linkClass = 'block min-w-0 flex-1 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:underline'
return (
<li key={item.key} className={`flex items-start gap-3 py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-75'}`} data-qqq-id={`alerts-popover-item-${item.key}`}>
<SeverityIcon severity={item.severity} />
{kind === 'app' ? (
<a
href={item.href ?? undefined}
onClick={(event) => {
event.preventDefault()
openItem(item)
}}
className={linkClass}
>
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</a>
) : kind === 'external' ? (
<a href={item.href ?? undefined} target="_blank" rel="noopener noreferrer" onClick={() => openItem({ ...item, href: null })} className={linkClass}>
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</a>
) : (
<span className="min-w-0 flex-1">
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</span>
)}
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
</li>
)
})}
</ul>
)}
<div className="flex items-center justify-between gap-2 border-t border-border px-4 py-3 text-sm">
<button
type="button"
disabled={summary.unreadCount === 0}
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
className="rounded-md px-2 py-1 text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
data-qqq-id="alerts-mark-all-read"
>
Mark all read
</button>
<a
href={summary.notificationsHref}
onClick={(event) => {
event.preventDefault()
onClose()
router.push(summary.notificationsHref)
}}
className="rounded-md px-2 py-1 font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="alerts-view-all"
>
View all
</a>
</div>
</div>
)
}
/*
* 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 AlertsBellPopover — the bell's popover (loaded on first open): the latest
* notifications, newest first (severity icon, title, source, relative time), each with mark
* read/unread and dismiss when the backend sends its recipient id, plus "Mark all read" and
* "View all". Opening an item marks it read. Focus stays inside while it is open; Escape or a
* click outside closes it.
*/
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { NotificationSettingsButton } from './NotificationSettingsButton'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import type { AlertsInboxSummary, InboxItem } from '@/types/alerts'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest } from './NotificationParts'
/** Props for the popover. */
export interface AlertsBellPopoverProps {
/** The inbox summary to show. */
summary: AlertsInboxSummary
/** Close the popover (focus returns to the bell). */
onClose: () => void
}
/**
* The bell popover.
*
* @param props - Component properties.
* @returns The popover dialog.
*/
export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopoverProps) {
const router = useRouter()
const [settingsOpen, setSettingsOpen] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
// a dropdown under the bell from 640 px; full width under the header below that (inline, so no new CSS)
const [wide, setWide] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(min-width: 640px)').matches)
useEffect(() => {
if (typeof window.matchMedia !== 'function') return
const query = window.matchMedia('(min-width: 640px)')
const onChange = () => setWide(query.matches)
query.addEventListener?.('change', onChange)
return () => query.removeEventListener?.('change', onChange)
}, [])
const placement: React.CSSProperties = wide
? { position: 'absolute', right: 0, top: '100%', marginTop: '0.5rem', width: '24rem', maxHeight: '70vh' }
: { position: 'fixed', left: '0.5rem', right: '0.5rem', top: 'calc(var(--qqq-header-height) + 0.25rem)', maxHeight: '70vh' }
useEffect(() => {
if (settingsOpen) return
const panel = panelRef.current
panel?.querySelector<HTMLElement>('a,button')?.focus({ preventScroll: true })
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab' || !panel) return
const focusable = Array.from(panel.querySelectorAll<HTMLElement>('a,button:not([disabled])'))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
const onPointer = (event: PointerEvent) => {
if (panel && !panel.contains(event.target as Node) && !(event.target as HTMLElement).closest?.('[data-qqq-id="button-notifications"]')) onClose()
}
document.addEventListener('keydown', onKey)
document.addEventListener('pointerdown', onPointer)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('pointerdown', onPointer)
}
}, [onClose, settingsOpen])
const actions = useInboxActions()
const openItem = (item: InboxItem) => {
const read = readRequest(item)
if (read) actions.mutate(read)
onClose()
if (item.href) router.push(item.href)
}
return (
<div
ref={panelRef}
hidden={settingsOpen}
role="dialog"
aria-modal="false"
aria-label="Notifications"
className="z-50 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
style={placement}
data-qqq-id="alerts-popover"
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground">Notifications</h2>
<NotificationSettingsButton sectionName={summary.preferencesSection} onOpenChange={setSettingsOpen} />
<span className="text-xs text-muted-foreground">{summary.unreadCount > 0 ? `${summary.unreadCount} unread` : 'All read'}</span>
</div>
{summary.items.length === 0 ? (
<p className="px-4 py-6 text-sm text-muted-foreground" data-qqq-id="alerts-popover-empty">You have no notifications.</p>
) : (
<ul className="divide-y divide-border">
{summary.items.map((item) => {
const kind = linkKind(item.href)
const title = <span className={`block break-words text-foreground ${item.unread ? 'font-semibold' : ''}`}>{item.title}</span>
const meta = <span className="block text-xs text-muted-foreground">{itemMeta(item)}</span>
const linkClass = 'block min-w-0 flex-1 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring hover:underline'
return (
<li key={item.key} className={`flex items-start gap-3 py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-75'}`} data-qqq-id={`alerts-popover-item-${item.key}`}>
<SeverityIcon severity={item.severity} />
{kind === 'app' ? (
<a
href={item.href ?? undefined}
onClick={(event) => {
event.preventDefault()
openItem(item)
}}
className={linkClass}
>
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</a>
) : kind === 'external' ? (
<a href={item.href ?? undefined} target="_blank" rel="noopener noreferrer" onClick={() => openItem({ ...item, href: null })} className={linkClass}>
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</a>
) : (
<span className="min-w-0 flex-1">
{title}
{meta}
{item.unread && <span className="sr-only">(unread)</span>}
</span>
)}
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
</li>
)
})}
</ul>
)}
<div className="flex items-center justify-between gap-2 border-t border-border px-4 py-3 text-sm">
<button
type="button"
disabled={summary.unreadCount === 0}
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
className="rounded-md px-2 py-1 text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
data-qqq-id="alerts-mark-all-read"
>
Mark all read
</button>
<a
href={summary.notificationsHref}
onClick={(event) => {
event.preventDefault()
onClose()
router.push(summary.notificationsHref)
}}
className="rounded-md px-2 py-1 font-medium text-primary hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="alerts-view-all"
>
View all
</a>
</div>
</div>
)
}
@@ -0,0 +1,125 @@
/*
* 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 { render, 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 { expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import { NotificationSettingsButton } from './NotificationSettingsButton'
import AlertsBellPopover from './AlertsBellPopover'
import { AlertsBell } from './AlertsBell'
import type { QInstance } from '@/types/metadata'
// The drawer and form are lazy chunks; give the first import time on a busy runner.
vi.setConfig({ testTimeout: 15_000 })
const summary = {
unreadCount: 0,
highestUnreadSeverity: null,
notificationsHref: '/app/notifications',
preferencesSection: 'notifications',
pollSeconds: 60,
items: [],
banners: [],
}
const renderQuery = (element: React.ReactNode) =>
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
{element}
</QueryClientProvider>
)
it('does not fetch preferences or show a gear without a summary section', () => {
const request = vi.fn()
server.use(
http.get('/qqq/v1/preferences', () => {
request()
return HttpResponse.json({ sections: [] })
})
)
renderQuery(<NotificationSettingsButton />)
expect(screen.queryByRole('button', { name: 'Notification settings' })).not.toBeInTheDocument()
expect(request).not.toHaveBeenCalled()
})
it('hides the gear when the advertised section is absent', async () => {
const request = vi.fn()
server.use(
http.get('/qqq/v1/preferences', () => {
request()
return HttpResponse.json({ sections: [] })
})
)
renderQuery(<NotificationSettingsButton sectionName="notifications" />)
await waitFor(() => expect(request).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Notification settings' })).not.toBeInTheDocument()
})
it('opens an accessible drawer, traps focus, links to all preferences and returns focus to its gear', async () => {
renderQuery(<NotificationSettingsButton sectionName="notifications" />)
const user = userEvent.setup()
const gear = await screen.findByRole('button', { name: 'Notification settings' })
await user.click(gear)
const drawer = await screen.findByRole('dialog', { name: 'Notifications' }, { timeout: 5000 })
await within(drawer).findByRole('checkbox', { name: 'Email alerts' })
const manage = within(drawer).getByRole('link', { name: 'Manage all preferences' })
expect(manage).toHaveAttribute('href', '/app/preferences?section=notifications')
manage.focus()
await user.tab()
expect(within(drawer).getByRole('button', { name: 'Close settings' })).toHaveFocus()
expect(await axe(drawer)).toHaveNoViolations()
await user.keyboard('{Escape}')
await waitFor(() => expect(gear).toHaveFocus())
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('closes the popover before showing the drawer and restores its gear on close', async () => {
renderQuery(<AlertsBellPopover summary={summary} onClose={vi.fn()} />)
const user = userEvent.setup()
const gear = await screen.findByRole('button', { name: 'Notification settings' })
await user.click(gear)
await waitFor(() =>
expect(document.querySelector('[data-qqq-id="alerts-popover"]')).not.toBeVisible()
)
const drawer = await screen.findByRole('dialog', { name: 'Notifications' }, { timeout: 5000 })
await within(drawer).findByRole('checkbox', { name: 'Email alerts' })
await user.click(within(drawer).getByRole('button', { name: 'Close settings' }))
await waitFor(() => expect(gear).toHaveFocus())
expect(document.querySelector('[data-qqq-id="alerts-popover"]')).toBeVisible()
})
it('fetches preferences lazily when the bell popover opens', async () => {
const request = vi.fn()
server.use(
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
http.get('/qqq/v1/preferences', () => {
request()
return HttpResponse.json({ sections: [] })
})
)
renderQuery(
<AlertsBell metaData={{ apps: { alerts: { name: 'alerts' } } } as unknown as QInstance} />
)
const bell = await screen.findByRole('button', { name: 'Notifications, none unread' })
expect(request).not.toHaveBeenCalled()
await userEvent.setup().click(bell)
await waitFor(() => expect(request).toHaveBeenCalledTimes(1))
})
@@ -0,0 +1,87 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Section-gated notification gear; its drawer and form load only when requested. */
'use client'
import { lazy, Suspense, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Settings } from 'lucide-react'
import { getPreferences } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
const PreferenceSectionDrawer = lazy(
() => import('@/components/preferences/PreferenceSectionDrawer')
)
/** Shows the shortcut only for a section visible to the current user.
* @param props - Summary section name and optional popover visibility callback.
* @returns Gear and lazily loaded drawer, or nothing.
*/
export function NotificationSettingsButton({
sectionName,
onOpenChange,
}: {
sectionName?: string | null
onOpenChange?: (open: boolean) => void
}) {
const query = useQuery({
queryKey: queryKeys.preferencesIndex(),
queryFn: getPreferences,
enabled: Boolean(sectionName),
staleTime: 30_000,
retry: false,
})
const section = query.data?.sections.find((section) => section.name === sectionName)
const [open, setOpen] = useState(false)
const gear = useRef<HTMLButtonElement>(null)
const changeOpen = (next: boolean) => {
onOpenChange?.(next)
setOpen(next)
}
if (!section) return null
return (
<>
<button
ref={gear}
type="button"
aria-label="Notification settings"
aria-haspopup="dialog"
onClick={() => changeOpen(true)}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="notifications-settings-gear"
>
<Settings className="h-5 w-5" aria-hidden="true" />
</button>
{open && (
<Suspense
fallback={
<span role="status" className="sr-only">
Loading settings…
</span>
}
>
<PreferenceSectionDrawer
section={section}
open={open}
onOpenChange={changeOpen}
returnFocusRef={gear}
/>
</Suspense>
)}
</>
)
}
@@ -205,4 +205,26 @@ describe('NotificationsPage', () => {
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Notice 9')).toBeInTheDocument()
})
it('opens notification preferences from the page header and restores focus to its gear', async () => {
serveInbox({ items: [], total: 0 })
renderPage()
const user = userEvent.setup()
const gear = await screen.findByRole('button', { name: 'Notification settings' })
await user.click(gear)
const drawer = await screen.findByRole('dialog', { name: 'Notifications' }, { timeout: 5000 })
await within(drawer).findByRole('checkbox', { name: 'Email alerts' })
expect(within(drawer).getByRole('link', { name: 'Manage all preferences' })).toHaveAttribute('href', '/app/preferences?section=notifications')
await user.keyboard('{Escape}')
await waitFor(() => expect(gear).toHaveFocus())
})
it('hides the header gear when its summary section is not installed', async () => {
serveInbox({ items: [], total: 0 })
server.use(http.get('/qqq/v1/preferences', () => HttpResponse.json({ sections: [] })))
renderPage()
await screen.findByText('You have no notifications.')
expect(screen.queryByRole('button', { name: 'Notification settings' })).not.toBeInTheDocument()
})
})
+278 -272
View File
@@ -1,272 +1,278 @@
/*
* 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 NotificationsPage — the notification center's full list at `/app/notifications`
* (loaded on demand): All/Unread, category and severity filters, paging, markdown bodies
* (rendered without HTML), and the bell's actions. Filters and the offset live in the URL.
* Like the bell, it sends no request unless the instance metadata announces Alerts.
*/
'use client'
import React, { useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { getAlertsInbox } from '@/lib/api/alerts'
import { useQContext } from '@/lib/context/q-context'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { queryKeys } from '@/lib/query-client'
import type { InboxFilter, InboxItem } from '@/types/alerts'
import { NOTIFICATION_CATEGORIES, NOTIFICATION_SEVERITIES } from '@/types/alerts'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
import { isAlertsInstalled } from './AlertsBell'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest, severityLabel } from './NotificationParts'
/** Items per page. */
export const PAGE_SIZE = 25
const SELECT_CLASS = 'rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'
/**
* "SECURITY" → "Security".
*
* @param value - An upper-case name.
* @returns The label.
*/
const titleCase = (value: string) => value.charAt(0) + value.slice(1).toLowerCase()
/**
* Reads the filter from the URL.
*
* @param params - The search params.
* @returns The filter.
*/
export function filterFromParams(params: URLSearchParams): InboxFilter {
const offset = Number.parseInt(params.get('offset') ?? '0', 10)
return {
status: params.get('status') === 'unread' ? 'unread' : 'all',
category: params.get('category') ?? '',
severity: params.get('severity') ?? '',
offset: Number.isFinite(offset) && offset > 0 ? offset : 0,
limit: PAGE_SIZE,
}
}
/**
* The page's URL for a filter (defaults left out).
*
* @param filter - The filter.
* @returns The path with its query.
*/
function pagePath(filter: InboxFilter): string {
const params = new URLSearchParams()
if (filter.status === 'unread') params.set('status', 'unread')
if (filter.category) params.set('category', filter.category)
if (filter.severity) params.set('severity', filter.severity)
if (filter.offset > 0) params.set('offset', String(filter.offset))
const query = params.toString()
return `/app/notifications${query ? `?${query}` : ''}`
}
/**
* The loading indicator.
*
* @returns The indicator.
*/
function Loading() {
return (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading notifications…
</p>
)
}
/**
* The notifications page.
*
* @returns The page.
*/
export function NotificationsPage() {
const { setPageHeader } = useQContext()
useEffect(() => setPageHeader('Notifications'), [setPageHeader])
const { data: metaData, isLoading } = useMetaData()
if (isLoading) return <Loading />
if (!isAlertsInstalled(metaData)) {
return (
<p className="p-6 text-sm text-muted-foreground" data-qqq-id="notifications-unavailable">
Notifications are not available.
</p>
)
}
return <NotificationsList />
}
/**
* The filters, list and paging.
*
* @returns The list.
*/
function NotificationsList() {
const router = useRouter()
const searchParams = useSearchParams()
const filter = filterFromParams(new URLSearchParams(searchParams?.toString() ?? ''))
const navigate = (next: Partial<InboxFilter>) => router.replace(pagePath({ ...filter, offset: 0, ...next }), { scroll: false })
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.alertsInboxPage(filter),
queryFn: () => getAlertsInbox(filter),
placeholderData: keepPreviousData,
retry: false,
})
const actions = useInboxActions()
const first = data && data.total > 0 ? data.offset + 1 : 0
const last = data ? Math.min(data.offset + data.items.length, data.total) : 0
const filtered = filter.status === 'unread' || filter.category !== '' || filter.severity !== ''
/**
* Follows an item's link, marking it read.
*
* @param item - The item.
* @param event - The click.
*/
const follow = (item: InboxItem, event: React.MouseEvent) => {
const read = readRequest(item)
if (read) actions.mutate(read)
if (linkKind(item.href) === 'app' && item.href) {
event.preventDefault()
router.push(item.href)
}
}
return (
<div className="mx-auto w-full max-w-4xl space-y-4" data-qqq-id="notifications-page">
<div className="flex flex-wrap items-center gap-2">
<div role="group" aria-label="Show" className="inline-flex rounded-md border border-input bg-card p-0.5">
{(['all', 'unread'] as const).map((status) => (
<button
key={status}
type="button"
aria-pressed={filter.status === status}
onClick={() => navigate({ status })}
className={`rounded px-3 py-1 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${filter.status === status ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent'}`}
data-qqq-id={`notifications-status-${status}`}
>
{status === 'all' ? 'All' : 'Unread'}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Category
<select value={filter.category} onChange={(event) => navigate({ category: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-category">
<option value="">All</option>
{NOTIFICATION_CATEGORIES.map((category) => (
<option key={category} value={category}>{titleCase(category)}</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Severity
<select value={filter.severity} onChange={(event) => navigate({ severity: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-severity">
<option value="">All</option>
{NOTIFICATION_SEVERITIES.map((severity) => (
<option key={severity} value={severity}>{severityLabel(severity)}</option>
))}
</select>
</label>
<button
type="button"
className={`${BUTTON_CLASS} sm:ml-auto`}
disabled={!data?.items.some((item) => item.unread)}
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
data-qqq-id="notifications-mark-all-read"
>
Mark all read
</button>
</div>
{isLoading ? (
<Loading />
) : isError ? (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">
Could not load notifications.
<button type="button" className={BUTTON_CLASS} onClick={() => void refetch()}>
Retry
</button>
</div>
) : !data || data.items.length === 0 ? (
<p className="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground" data-qqq-id="notifications-empty">
{filtered ? 'No notifications match these filters.' : 'You have no notifications.'}
</p>
) : (
<ul className="space-y-2" aria-label="Notifications">
{data.items.map((item) => {
const kind = linkKind(item.href)
return (
<li
key={item.key}
className={`flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-80'}`}
data-qqq-id={`notifications-item-${item.key}`}
>
<SeverityIcon severity={item.severity} />
<div className="min-w-0 flex-1 space-y-1">
<h2 className={`break-words text-sm text-foreground ${item.unread ? 'font-semibold' : 'font-medium'}`}>
{item.unread && <span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-primary align-middle" aria-hidden="true" />}
{item.title}
{item.unread && <span className="sr-only"> (unread)</span>}
</h2>
<p className="text-xs text-muted-foreground">{itemMeta(item, true)}</p>
{item.body && <MarkdownView source={item.body} />}
{kind && (
<a
href={item.href ?? undefined}
target={kind === 'external' ? '_blank' : undefined}
rel={kind === 'external' ? 'noopener noreferrer' : undefined}
onClick={(event) => follow(item, event)}
className="inline-block font-medium text-primary underline underline-offset-2"
>
{item.linkLabel ?? 'Open'}
</a>
)}
</div>
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
</li>
)
})}
</ul>
)}
{data && data.total > 0 && (
<nav aria-label="Pages" className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{`${first}–${last} of ${data.total}`}</span>
<span className="flex gap-2">
<button type="button" className={BUTTON_CLASS} disabled={filter.offset === 0} onClick={() => navigate({ offset: Math.max(0, filter.offset - PAGE_SIZE) })} data-qqq-id="notifications-previous">
Previous
</button>
<button type="button" className={BUTTON_CLASS} disabled={filter.offset + PAGE_SIZE >= data.total} onClick={() => navigate({ offset: filter.offset + PAGE_SIZE })} data-qqq-id="notifications-next">
Next
</button>
</span>
</nav>
)}
</div>
)
}
/*
* 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 NotificationsPage — the notification center's full list at `/app/notifications`
* (loaded on demand): All/Unread, category and severity filters, paging, markdown bodies
* (rendered without HTML), and the bell's actions. Filters and the offset live in the URL.
* Like the bell, it sends no request unless the instance metadata announces Alerts.
*/
'use client'
import React, { useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { getAlertsInbox } from '@/lib/api/alerts'
import { useQContext } from '@/lib/context/q-context'
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { queryKeys } from '@/lib/query-client'
import type { InboxFilter, InboxItem } from '@/types/alerts'
import { NOTIFICATION_CATEGORIES, NOTIFICATION_SEVERITIES } from '@/types/alerts'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
import { NotificationSettingsButton } from './NotificationSettingsButton'
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
import { ItemActions, SeverityIcon, itemMeta, linkKind, readRequest, severityLabel } from './NotificationParts'
/** Items per page. */
export const PAGE_SIZE = 25
const SELECT_CLASS = 'rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'
/**
* "SECURITY" → "Security".
*
* @param value - An upper-case name.
* @returns The label.
*/
const titleCase = (value: string) => value.charAt(0) + value.slice(1).toLowerCase()
/**
* Reads the filter from the URL.
*
* @param params - The search params.
* @returns The filter.
*/
export function filterFromParams(params: URLSearchParams): InboxFilter {
const offset = Number.parseInt(params.get('offset') ?? '0', 10)
return {
status: params.get('status') === 'unread' ? 'unread' : 'all',
category: params.get('category') ?? '',
severity: params.get('severity') ?? '',
offset: Number.isFinite(offset) && offset > 0 ? offset : 0,
limit: PAGE_SIZE,
}
}
/**
* The page's URL for a filter (defaults left out).
*
* @param filter - The filter.
* @returns The path with its query.
*/
function pagePath(filter: InboxFilter): string {
const params = new URLSearchParams()
if (filter.status === 'unread') params.set('status', 'unread')
if (filter.category) params.set('category', filter.category)
if (filter.severity) params.set('severity', filter.severity)
if (filter.offset > 0) params.set('offset', String(filter.offset))
const query = params.toString()
return `/app/notifications${query ? `?${query}` : ''}`
}
/**
* The loading indicator.
*
* @returns The indicator.
*/
function Loading() {
return (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading notifications…
</p>
)
}
/**
* The notifications page.
*
* @returns The page.
*/
export function NotificationsPage() {
const { setPageHeader } = useQContext()
useEffect(() => setPageHeader('Notifications'), [setPageHeader])
const { data: metaData, isLoading } = useMetaData()
if (isLoading) return <Loading />
if (!isAlertsInstalled(metaData)) {
return (
<p className="p-6 text-sm text-muted-foreground" data-qqq-id="notifications-unavailable">
Notifications are not available.
</p>
)
}
return <NotificationsList />
}
/**
* The filters, list and paging.
*
* @returns The list.
*/
function NotificationsList() {
const { data: summary } = useAlertsInbox(true)
const router = useRouter()
const searchParams = useSearchParams()
const filter = filterFromParams(new URLSearchParams(searchParams?.toString() ?? ''))
const navigate = (next: Partial<InboxFilter>) => router.replace(pagePath({ ...filter, offset: 0, ...next }), { scroll: false })
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.alertsInboxPage(filter),
queryFn: () => getAlertsInbox(filter),
placeholderData: keepPreviousData,
retry: false,
})
const actions = useInboxActions()
const first = data && data.total > 0 ? data.offset + 1 : 0
const last = data ? Math.min(data.offset + data.items.length, data.total) : 0
const filtered = filter.status === 'unread' || filter.category !== '' || filter.severity !== ''
/**
* Follows an item's link, marking it read.
*
* @param item - The item.
* @param event - The click.
*/
const follow = (item: InboxItem, event: React.MouseEvent) => {
const read = readRequest(item)
if (read) actions.mutate(read)
if (linkKind(item.href) === 'app' && item.href) {
event.preventDefault()
router.push(item.href)
}
}
return (
<div className="mx-auto w-full max-w-4xl space-y-4" data-qqq-id="notifications-page">
<div className="flex items-center justify-between gap-2">
<h1 className="text-2xl font-semibold text-foreground">Notifications</h1>
<NotificationSettingsButton sectionName={summary?.preferencesSection} />
</div>
<div className="flex flex-wrap items-center gap-2">
<div role="group" aria-label="Show" className="inline-flex rounded-md border border-input bg-card p-0.5">
{(['all', 'unread'] as const).map((status) => (
<button
key={status}
type="button"
aria-pressed={filter.status === status}
onClick={() => navigate({ status })}
className={`rounded px-3 py-1 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${filter.status === status ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent'}`}
data-qqq-id={`notifications-status-${status}`}
>
{status === 'all' ? 'All' : 'Unread'}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Category
<select value={filter.category} onChange={(event) => navigate({ category: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-category">
<option value="">All</option>
{NOTIFICATION_CATEGORIES.map((category) => (
<option key={category} value={category}>{titleCase(category)}</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
Severity
<select value={filter.severity} onChange={(event) => navigate({ severity: event.target.value })} className={SELECT_CLASS} data-qqq-id="notifications-severity">
<option value="">All</option>
{NOTIFICATION_SEVERITIES.map((severity) => (
<option key={severity} value={severity}>{severityLabel(severity)}</option>
))}
</select>
</label>
<button
type="button"
className={`${BUTTON_CLASS} sm:ml-auto`}
disabled={!data?.items.some((item) => item.unread)}
onClick={() => actions.mutate({ action: 'read', target: { all: true } })}
data-qqq-id="notifications-mark-all-read"
>
Mark all read
</button>
</div>
{isLoading ? (
<Loading />
) : isError ? (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">
Could not load notifications.
<button type="button" className={BUTTON_CLASS} onClick={() => void refetch()}>
Retry
</button>
</div>
) : !data || data.items.length === 0 ? (
<p className="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground" data-qqq-id="notifications-empty">
{filtered ? 'No notifications match these filters.' : 'You have no notifications.'}
</p>
) : (
<ul className="space-y-2" aria-label="Notifications">
{data.items.map((item) => {
const kind = linkKind(item.href)
return (
<li
key={item.key}
className={`flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm ${item.unread ? '' : 'opacity-80'}`}
data-qqq-id={`notifications-item-${item.key}`}
>
<SeverityIcon severity={item.severity} />
<div className="min-w-0 flex-1 space-y-1">
<h2 className={`break-words text-sm text-foreground ${item.unread ? 'font-semibold' : 'font-medium'}`}>
{item.unread && <span className="mr-1.5 inline-block h-2 w-2 rounded-full bg-primary align-middle" aria-hidden="true" />}
{item.title}
{item.unread && <span className="sr-only"> (unread)</span>}
</h2>
<p className="text-xs text-muted-foreground">{itemMeta(item, true)}</p>
{item.body && <MarkdownView source={item.body} />}
{kind && (
<a
href={item.href ?? undefined}
target={kind === 'external' ? '_blank' : undefined}
rel={kind === 'external' ? 'noopener noreferrer' : undefined}
onClick={(event) => follow(item, event)}
className="inline-block font-medium text-primary underline underline-offset-2"
>
{item.linkLabel ?? 'Open'}
</a>
)}
</div>
<ItemActions item={item} onAction={(request) => actions.mutate(request)} />
</li>
)
})}
</ul>
)}
{data && data.total > 0 && (
<nav aria-label="Pages" className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{`${first}–${last} of ${data.total}`}</span>
<span className="flex gap-2">
<button type="button" className={BUTTON_CLASS} disabled={filter.offset === 0} onClick={() => navigate({ offset: Math.max(0, filter.offset - PAGE_SIZE) })} data-qqq-id="notifications-previous">
Previous
</button>
<button type="button" className={BUTTON_CLASS} disabled={filter.offset + PAGE_SIZE >= data.total} onClick={() => navigate({ offset: filter.offset + PAGE_SIZE })} data-qqq-id="notifications-next">
Next
</button>
</span>
</nav>
)}
</div>
)
}
@@ -55,6 +55,8 @@ interface BooleanFieldProps {
required?: boolean
/** `data-qqq-id` attribute forwarded to the toggle button for CSS customization. */
'data-qqq-id'?: string
/** Id of help or read-only text describing the control. */
describedBy?: string
}
/**
@@ -105,6 +107,7 @@ export function BooleanField({
disabled = false,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
}: BooleanFieldProps) {
// Cycle logic depends on whether the field is required
/**
@@ -145,7 +148,7 @@ export function BooleanField({
aria-checked={ariaVal}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
disabled={disabled}
data-qqq-id={dataQqqId}
onClick={() => {
+106 -103
View File
@@ -1,103 +1,106 @@
/*
* 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 DateField — date input form field with label, validation error display, and accessibility attributes.
*/
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
/**
* Props for the {@link DateField} component.
*/
interface DateFieldProps {
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the input. */
label: string
/** Return value of `register(fieldName)` from React Hook Form. */
registration: UseFormRegisterReturn
/** Validation error; when present triggers error styling and an error message. */
error?: FieldError
/** When `true`, the input is non-interactive and visually dimmed. */
disabled?: boolean
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
required?: boolean
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
'data-qqq-id'?: string
/** Id of help text that describes this control. */
describedBy?: string
}
/**
* Renders an accessible date input field with label and validation error display.
*
* Uses `<input type="date">` which provides native date picker UI in supported
* browsers. The value format is `YYYY-MM-DD` as managed by the browser.
*
* @param props - See {@link DateFieldProps}.
* @returns The rendered date input field with label and optional error message.
*/
export function DateField({
id,
label,
registration,
error,
disabled = false,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
}: DateFieldProps) {
return (
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<input
id={id}
type="date"
{...registration}
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
/>
<FieldFooter id={id} error={error?.message} />
</div>
)
}
/*
* 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 DateField — date input form field with label, validation error display, and accessibility attributes.
*/
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
/**
* Props for the {@link DateField} component.
*/
interface DateFieldProps {
/** The HTML `id` for the `<input>` element and its associated `<label>`. */
id: string
/** Native calendar date or time-of-day editor. */
type?: 'date' | 'time'
/** Human-readable field label rendered above the input. */
label: string
/** Return value of `register(fieldName)` from React Hook Form. */
registration: UseFormRegisterReturn
/** Validation error; when present triggers error styling and an error message. */
error?: FieldError
/** When `true`, the input is non-interactive and visually dimmed. */
disabled?: boolean
/** When `true`, an asterisk indicator is shown and `aria-required` is set. */
required?: boolean
/** `data-qqq-id` attribute forwarded to the input for CSS customization. */
'data-qqq-id'?: string
/** Id of help text that describes this control. */
describedBy?: string
}
/**
* Renders an accessible date input field with label and validation error display.
*
* Uses a native date picker (`YYYY-MM-DD`) or time picker (`HH:mm:ss`).
*
* @param props - See {@link DateFieldProps}.
* @returns The rendered date input field with label and optional error message.
*/
export function DateField({
id,
type = 'date',
label,
registration,
error,
disabled = false,
required = false,
'data-qqq-id': dataQqqId,
describedBy,
}: DateFieldProps) {
return (
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<input
id={id}
type={type}
step={type === 'time' ? 1 : undefined}
{...registration}
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
/>
<FieldFooter id={id} error={error?.message} />
</div>
)
}
+124 -121
View File
@@ -1,121 +1,124 @@
/*
* 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 SelectField — native `<select>` field for static option lists, integrated with React Hook Form.
*/
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
import type { QPossibleValue } from '@/types'
/**
* Props for the {@link SelectField} component.
*/
interface SelectFieldProps {
/** The HTML `id` for the `<select>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the select. */
label: string
/** Return value of `register(fieldName)` from React Hook Form. */
registration: UseFormRegisterReturn
/** The list of selectable options rendered as `<option>` elements. */
options: QPossibleValue[]
/** Validation error; when present triggers error styling and an error message. */
error?: FieldError
/** When `true`, the select is non-interactive and visually dimmed. */
disabled?: boolean
/** When `true`, no blank placeholder option is prepended; an asterisk indicator is shown. */
required?: boolean
/** Text for the blank placeholder option prepended when `required` is false; defaults to `"-- Select {label} --"`. */
placeholder?: string
/** `data-qqq-id` attribute forwarded to the select element for CSS customization. */
'data-qqq-id'?: string
}
/**
* Renders a native `<select>` field for a static list of options.
*
* Unlike {@link PossibleValueSelect} (which fetches options asynchronously),
* this component renders a fixed `options` array synchronously, making it
* suitable for inline enum-like value sets that do not need server-side search.
*
* When `required` is false a blank placeholder option is prepended so the
* user can deselect an existing value.
*
* @param props - See {@link SelectFieldProps}.
* @returns The rendered native select field with label and optional error message.
*/
export function SelectField({
id,
label,
registration,
options,
error,
disabled = false,
required = false,
placeholder,
'data-qqq-id': dataQqqId,
}: SelectFieldProps) {
return (
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<select
id={id}
{...registration}
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
>
{!required && (
<option value="">
{placeholder ?? `-- Select ${label} --`}
</option>
)}
{options.map((option) => (
<option key={String(option.id)} value={String(option.id)}>
{option.label}
</option>
))}
</select>
<FieldFooter id={id} error={error?.message} />
</div>
)
}
/*
* 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 SelectField — native `<select>` field for static option lists, integrated with React Hook Form.
*/
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import { FieldFooter } from '../FieldFooter'
import type { QPossibleValue } from '@/types'
/**
* Props for the {@link SelectField} component.
*/
interface SelectFieldProps {
/** The HTML `id` for the `<select>` element and its associated `<label>`. */
id: string
/** Human-readable field label rendered above the select. */
label: string
/** Return value of `register(fieldName)` from React Hook Form. */
registration: UseFormRegisterReturn
/** The list of selectable options rendered as `<option>` elements. */
options: QPossibleValue[]
/** Validation error; when present triggers error styling and an error message. */
error?: FieldError
/** When `true`, the select is non-interactive and visually dimmed. */
disabled?: boolean
/** When `true`, no blank placeholder option is prepended; an asterisk indicator is shown. */
required?: boolean
/** Text for the blank placeholder option prepended when `required` is false; defaults to `"-- Select {label} --"`. */
placeholder?: string
/** `data-qqq-id` attribute forwarded to the select element for CSS customization. */
'data-qqq-id'?: string
/** Id of help or read-only text describing the control. */
describedBy?: string
}
/**
* Renders a native `<select>` field for a static list of options.
*
* Unlike {@link PossibleValueSelect} (which fetches options asynchronously),
* this component renders a fixed `options` array synchronously, making it
* suitable for inline enum-like value sets that do not need server-side search.
*
* When `required` is false a blank placeholder option is prepended so the
* user can deselect an existing value.
*
* @param props - See {@link SelectFieldProps}.
* @returns The rendered native select field with label and optional error message.
*/
export function SelectField({
id,
label,
registration,
options,
error,
disabled = false,
required = false,
placeholder,
'data-qqq-id': dataQqqId,
describedBy,
}: SelectFieldProps) {
return (
<div className="group flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-0.5 text-muted-foreground" aria-hidden="true">*</span>}
</label>
<select
id={id}
{...registration}
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={[error ? `${id}-error` : undefined, describedBy].filter(Boolean).join(' ') || undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
>
{!required && (
<option value="">
{placeholder ?? `-- Select ${label} --`}
</option>
)}
{options.map((option) => (
<option key={String(option.id)} value={String(option.id)}>
{option.label}
</option>
))}
</select>
<FieldFooter id={id} error={error?.message} />
</div>
)
}
@@ -15,17 +15,21 @@
*/
import React from 'react'
import { beforeEach, expect, it, vi } from 'vitest'
import { beforeEach, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from '@/lib/theme/theme-provider'
import { UserPreferencesDialog } from './UserPreferencesDialog'
import { DisplayPreferencesPanel } from './DisplayPreferencesPanel'
beforeEach(() => localStorage.clear())
it('applies the selected appearance immediately, restores it on remount, and resets to light', async () => {
const user = userEvent.setup()
const dialog = <ThemeProvider><UserPreferencesDialog open onOpenChange={vi.fn()} /></ThemeProvider>
const dialog = (
<ThemeProvider>
<DisplayPreferencesPanel />
</ThemeProvider>
)
const first = render(dialog)
expect(screen.getByRole('radio', { name: 'Light' })).toBeChecked()
await user.click(screen.getByRole('radio', { name: 'Dark' }))
@@ -42,9 +46,11 @@ it('applies the selected appearance immediately, restores it on remount, and res
it('explains an application theme restriction and can reset a saved dark preference', async () => {
localStorage.setItem('qqq-dark-mode', 'true')
const user = userEvent.setup()
render(<ThemeProvider initialTheme={{ primaryColor: '#0f766e' }}>
<UserPreferencesDialog open onOpenChange={vi.fn()} />
</ThemeProvider>)
render(
<ThemeProvider initialTheme={{ primaryColor: '#0f766e' }}>
<DisplayPreferencesPanel />
</ThemeProvider>
)
expect(screen.getByRole('radio', { name: 'Light' })).toBeChecked()
expect(screen.getByRole('radio', { name: 'Dark' })).toBeDisabled()
expect(screen.getByText(/application theme requires light mode/i)).toBeVisible()
@@ -0,0 +1,363 @@
/*
* 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 DisplayPreferencesPanel — display preferences saved on this device.
*/
'use client'
import React from 'react'
import { RotateCcw } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { useUserPreferences } from '@/lib/hooks/use-user-preferences'
import { useTheme } from '@/lib/theme/theme-provider'
/** Available records-per-page options displayed as toggle chips. */
const PAGE_SIZE_OPTIONS = [
{ value: 10, label: '10 records' },
{ value: 25, label: '25 records' },
{ value: 50, label: '50 records' },
{ value: 100, label: '100 records' },
] as const
/** Available display-density options shown as radio buttons. */
const DENSITY_OPTIONS = [
{ value: 'compact', label: 'Compact', description: 'Tighter spacing, more data visible' },
{ value: 'standard', label: 'Standard', description: 'Balanced spacing and readability' },
{ value: 'comfortable', label: 'Comfortable', description: 'More spacing, easier scanning' },
] as const
/** Available table view-mode options (grid vs. card). */
const TABLE_VIEW_OPTIONS = [
{ value: 'grid', label: 'Table', description: 'Rows and columns data grid' },
{ value: 'card', label: 'Cards', description: 'Card layout for each record' },
] as const
/** Available record view-mode options (tabs vs. single-page). */
const RECORD_VIEW_OPTIONS = [
{ value: 'tabs', label: 'Tabbed', description: 'Sections organized into tabs' },
{ value: 'list', label: 'Single Page', description: 'All sections on one scrollable page' },
] as const
/** Device-local display controls, saved immediately.
* @returns Display settings and reset action.
*/
export function DisplayPreferencesPanel() {
const { preferences, updatePreference, resetPreferences, defaults } = useUserPreferences()
const { theme, isDarkMode, darkModePreference, setDarkMode } = useTheme()
const isDefault =
!darkModePreference &&
preferences.tableDefaultPageSize === defaults.tableDefaultPageSize &&
preferences.tableDefaultDensity === defaults.tableDefaultDensity &&
preferences.tableDefaultViewMode === defaults.tableDefaultViewMode &&
preferences.recordDefaultViewMode === defaults.recordDefaultViewMode
return (
<div className="space-y-6" data-qqq-id="display-preferences-panel">
<p className="text-sm text-muted-foreground">Saved on this device</p>
<fieldset
disabled={Boolean(theme)}
aria-describedby="appearance-description"
className="space-y-3"
>
<legend className="text-sm font-semibold text-foreground">Appearance</legend>
<div className="flex gap-2">
{(['Light', 'Dark'] as const).map((mode) => (
<label
key={mode}
className={cn(
'flex flex-1 items-center gap-3 rounded-lg border-2 px-4 py-3 text-sm font-medium',
isDarkMode === (mode === 'Dark')
? 'border-primary/40 bg-primary/10'
: 'border-border',
theme ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-accent'
)}
>
<input
type="radio"
name="appearance"
value={mode.toLowerCase()}
checked={isDarkMode === (mode === 'Dark')}
onChange={() => setDarkMode(mode === 'Dark')}
className="h-4 w-4 accent-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
data-qqq-id={`pref-appearance-${mode.toLowerCase()}`}
/>
{mode}
</label>
))}
</div>
<p id="appearance-description" className="text-xs text-muted-foreground">
{theme
? 'This application theme requires light mode. Your saved appearance choice is kept for when the theme is removed.'
: 'Saved automatically for this site in this browser. Other browsers and devices keep their own choice.'}
</p>
</fieldset>
<div className="border-t border-border" />
{/* Table / Query Defaults */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">Table View Defaults</h3>
{/* Records per page */}
<OptionGroup label="Records per page">
<div className="flex flex-wrap gap-2">
{PAGE_SIZE_OPTIONS.map((opt) => (
<ToggleChip
key={opt.value}
selected={preferences.tableDefaultPageSize === opt.value}
onClick={() => updatePreference('tableDefaultPageSize', opt.value)}
data-qqq-id={`pref-pagesize-${opt.value}`}
>
{opt.label}
</ToggleChip>
))}
</div>
</OptionGroup>
{/* Density */}
<OptionGroup label="Display density">
<div className="space-y-1.5">
{DENSITY_OPTIONS.map((opt) => (
<RadioOption
key={opt.value}
selected={preferences.tableDefaultDensity === opt.value}
onClick={() => updatePreference('tableDefaultDensity', opt.value)}
label={opt.label}
description={opt.description}
data-qqq-id={`pref-density-${opt.value}`}
/>
))}
</div>
</OptionGroup>
{/* View mode */}
<OptionGroup label="Default view">
<div className="flex gap-2">
{TABLE_VIEW_OPTIONS.map((opt) => (
<ToggleCard
key={opt.value}
selected={preferences.tableDefaultViewMode === opt.value}
onClick={() => updatePreference('tableDefaultViewMode', opt.value)}
label={opt.label}
description={opt.description}
data-qqq-id={`pref-table-view-${opt.value}`}
/>
))}
</div>
</OptionGroup>
</div>
<div className="border-t border-border" />
{/* Record Screen Defaults */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">Record View Defaults</h3>
<OptionGroup label="Default layout">
<div className="flex gap-2">
{RECORD_VIEW_OPTIONS.map((opt) => (
<ToggleCard
key={opt.value}
selected={preferences.recordDefaultViewMode === opt.value}
onClick={() => updatePreference('recordDefaultViewMode', opt.value)}
label={opt.label}
description={opt.description}
data-qqq-id={`pref-record-view-${opt.value}`}
/>
))}
</div>
</OptionGroup>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-6 py-3">
<button
type="button"
onClick={() => {
resetPreferences()
setDarkMode(false)
}}
disabled={isDefault}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus:outline-none focus:ring-2 focus:ring-ring',
'disabled:opacity-40 disabled:pointer-events-none'
)}
data-qqq-id="button-reset-preferences"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
Reset to Defaults
</button>
</div>
</div>
)
}
// --- Sub-components ---
/**
* Renders a labeled group container for a set of preference controls.
*
* @param props - Component properties.
* @returns A `<div>` with a muted `<label>` header above the slotted children.
*/
function OptionGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">{label}</label>
{children}
</div>
)
}
/**
* A pill-shaped toggle button for selecting a single option from a set.
*
* Applies a filled primary style when selected, and a bordered outline style otherwise.
*
* @param props - Component properties.
* @returns A `<button>` styled as a pill toggle chip.
*/
function ToggleChip({
selected,
onClick,
children,
...rest
}: {
selected: boolean
onClick: () => void
children: React.ReactNode
'data-qqq-id'?: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring',
selected
? 'bg-primary text-primary-foreground shadow-sm'
: 'border border-border bg-card text-foreground hover:bg-accent'
)}
{...rest}
>
{children}
</button>
)
}
/**
* A full-width radio-style option row with a custom circle indicator.
*
* Renders a label and a supporting description string alongside a filled
* circle when selected, and an empty circle otherwise.
*
* @param props - Component properties.
* @returns A `<button>` styled as a radio option row.
*/
function RadioOption({
selected,
onClick,
label,
description,
...rest
}: {
selected: boolean
onClick: () => void
label: string
description: string
'data-qqq-id'?: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring',
selected
? 'bg-primary/10 border border-primary/30'
: 'border border-transparent hover:bg-accent'
)}
{...rest}
>
<div
className={cn(
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 transition-colors',
selected ? 'border-primary' : 'border-muted-foreground/40'
)}
>
{selected && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
<div className="flex-1 min-w-0">
<span
className={cn('text-sm font-medium', selected ? 'text-foreground' : 'text-foreground')}
>
{label}
</span>
<span className="ml-2 text-xs text-muted-foreground">{description}</span>
</div>
</button>
)
}
/**
* A card-style toggle button used for selecting between two mutually exclusive view modes.
*
* Displays a bold label and a muted description. Applies a highlighted border
* with primary tinting when selected, and a neutral border otherwise.
*
* @param props - Component properties.
* @returns A `<button>` styled as a selectable card.
*/
function ToggleCard({
selected,
onClick,
label,
description,
...rest
}: {
selected: boolean
onClick: () => void
label: string
description: string
'data-qqq-id'?: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex-1 rounded-lg px-4 py-3 text-left transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring',
selected
? 'bg-primary/10 border-2 border-primary/40'
: 'border-2 border-border hover:bg-accent'
)}
{...rest}
>
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{description}</div>
</button>
)
}
+25 -6
View File
@@ -32,6 +32,10 @@ vi.mock('next/navigation', () => ({
import Sidebar from './Sidebar'
import { ThemeProvider } from '@/lib/theme/theme-provider'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/node'
import { preferencesFixture } from '@/mocks/fixtures/preferences'
/**
* Renders the sidebar with the same appearance context as the application.
@@ -39,7 +43,8 @@ import { ThemeProvider } from '@/lib/theme/theme-provider'
* @returns Testing Library render result.
*/
function render(ui: React.ReactElement) {
return renderWithoutProviders(ui, { wrapper: ThemeProvider })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return renderWithoutProviders(<QueryClientProvider client={client}>{ui}</QueryClientProvider>, { wrapper: ThemeProvider })
}
import type { SidebarRoute } from '@/lib/hooks/use-routes'
import type { QBrandingMetaData } from '@/types'
@@ -472,15 +477,12 @@ describe('Sidebar', () => {
// ─── User entry (Material: Gravatar with gravatarDefault, "Anonymous") ──────
it('returns preferences focus to the user menu button after the menu item unmounts', async () => {
it('links the user menu Preferences item to the Preferences page', async () => {
const user = userEvent.setup()
render(<Sidebar routes={leafRoutes} userName="Alice" />)
const trigger = screen.getByRole('button', { name: 'Alice' })
await user.click(trigger)
await user.click(screen.getByRole('menuitem', { name: 'Preferences' }))
expect(screen.getByRole('dialog', { name: 'Preferences' })).toBeVisible()
await user.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
expect(screen.getByRole('menuitem', { name: 'Preferences' })).toHaveAttribute('href', '/app/preferences')
})
it('names a user without a name "Anonymous" and shows the initial without a Gravatar setting', () => {
@@ -510,3 +512,20 @@ describe('Sidebar logo link', () => {
expect(plain.container.querySelector('[data-qqq-id="sidebar-logo-link"]')).toHaveAttribute('href', '/app')
})
})
it('loads menu sections lazily and only lists sections that opt in', async () => {
const request = vi.fn()
server.use(http.get('/qqq/v1/preferences', () => {
request()
return HttpResponse.json(preferencesFixture)
}))
render(<Sidebar routes={leafRoutes} userName="Alice" />)
expect(request).not.toHaveBeenCalled()
await userEvent.setup().click(screen.getByRole('button', { name: 'Alice' }))
const item = await screen.findByRole('menuitem', { name: 'Access tokens' })
expect(request).toHaveBeenCalledTimes(1)
expect(item).toHaveAttribute('href', '/app/preferences?section=access-tokens')
expect(item.querySelector('[data-qqq-icon="key"]')).not.toBeNull()
expect(screen.queryByRole('menuitem', { name: 'Regional settings' })).not.toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'Notifications' })).not.toBeInTheDocument()
})
+23 -13
View File
@@ -22,6 +22,9 @@
import React, { useEffect, useState, useRef } from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import { getPreferences } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
import { usePathname } from 'next/navigation'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import {
@@ -39,7 +42,6 @@ import { cn } from '@/lib/utils/cn'
import { navItemQqqId } from '@/lib/utils/qqq-id'
import { gravatarUrl } from '@/lib/utils/gravatar'
import { NAV_WIDTH_CSS } from '@/lib/utils/nav-width'
import { UserPreferencesDialog } from './UserPreferencesDialog'
import BannerComponent from './Banner'
import { MetadataIcon, type MetadataIconKind } from './MetadataIcon'
import { NavResizeHandle } from './NavResizeHandle'
@@ -522,8 +524,8 @@ function SidebarLinkItem({ route, isActive }: SidebarLinkItemProps) {
*
* @param props - Component properties.
* @returns A React fragment containing a `<div>` with the popover menu (above
* the user bar) and the user-info `<button>`, followed by a
* {@link UserPreferencesDialog} portal. The popover is dismissed on outside
* the user bar) and the user-info `<button>`. Preferences links to its page.
* The popover is dismissed on outside
* click or Escape and re-mounts cleanly each time it opens.
*/
function UserFooter({
@@ -538,14 +540,18 @@ function UserFooter({
gravatarDefault?: string
}) {
const [menuOpen, setMenuOpen] = useState(false)
const preferences = useQuery({
queryKey: queryKeys.preferencesIndex(),
queryFn: getPreferences,
enabled: menuOpen,
retry: false,
})
// Material shows the user's Gravatar; here only when the branding declares gravatarDefault, so
// no email hash goes to a third party unless the application opts in. A failed image keeps the initial.
const avatarUrl = gravatarDefault ? gravatarUrl(userEmail, gravatarDefault) : undefined
const [avatarFailed, setAvatarFailed] = useState(false)
// Material names a user without a name "Anonymous"
const displayName = userName || 'Anonymous'
const userButtonRef = useRef<HTMLButtonElement>(null)
const [prefsOpen, setPrefsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
// Close menu on outside click
@@ -584,12 +590,11 @@ function UserFooter({
data-qqq-id="sidebar-user-menu"
>
<div className="py-1">
<button
type="button"
<Link
href="/app/preferences"
role="menuitem"
onClick={() => {
setMenuOpen(false)
setPrefsOpen(true)
}}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-sm font-medium text-foreground',
@@ -600,7 +605,16 @@ function UserFooter({
>
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
Preferences
</button>
</Link>
{preferences.data?.sections.filter((section) => section.showInUserMenu).map((section) => (
<Link key={section.name} href={`/app/preferences?section=${encodeURIComponent(section.name)}`} role="menuitem"
onClick={() => setMenuOpen(false)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:bg-accent"
data-qqq-id={`menu-item-preferences-${section.name}`}>
<MetadataIcon icon={section.icon} kind="section" className="h-4 w-4 text-muted-foreground" />
{section.label}
</Link>
))}
{logout && (
<button
type="button"
@@ -634,7 +648,6 @@ function UserFooter({
)}
aria-haspopup="menu"
aria-expanded={menuOpen}
ref={userButtonRef}
data-qqq-id="sidebar-user-button"
data-qqq-sidenav-item-type="user-profile"
>
@@ -675,9 +688,6 @@ function UserFooter({
)}
</button>
</div>
{/* Preferences dialog */}
<UserPreferencesDialog open={prefsOpen} onOpenChange={setPrefsOpen} returnFocusRef={userButtonRef} />
</>
)
}
@@ -1,428 +0,0 @@
/*
* 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 UserPreferencesDialog — modal dialog for editing user display preferences stored in localStorage.
*/
'use client'
import React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X, RotateCcw } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { useUserPreferences } from '@/lib/hooks/use-user-preferences'
import { useTheme } from '@/lib/theme/theme-provider'
/**
* Props for the UserPreferencesDialog component.
*/
interface UserPreferencesDialogProps {
/** Whether the dialog is currently open. */
open: boolean
/** Called when the open state should change (e.g. after a close action). */
onOpenChange: (open: boolean) => void
/** The persistent user-menu button, since its Preferences item unmounts on selection. */
returnFocusRef?: React.RefObject<HTMLButtonElement | null>
}
/** Available records-per-page options displayed as toggle chips. */
const PAGE_SIZE_OPTIONS = [
{ value: 10, label: '10 records' },
{ value: 25, label: '25 records' },
{ value: 50, label: '50 records' },
{ value: 100, label: '100 records' },
] as const
/** Available display-density options shown as radio buttons. */
const DENSITY_OPTIONS = [
{ value: 'compact', label: 'Compact', description: 'Tighter spacing, more data visible' },
{ value: 'standard', label: 'Standard', description: 'Balanced spacing and readability' },
{ value: 'comfortable', label: 'Comfortable', description: 'More spacing, easier scanning' },
] as const
/** Available table view-mode options (grid vs. card). */
const TABLE_VIEW_OPTIONS = [
{ value: 'grid', label: 'Table', description: 'Rows and columns data grid' },
{ value: 'card', label: 'Cards', description: 'Card layout for each record' },
] as const
/** Available record view-mode options (tabs vs. single-page). */
const RECORD_VIEW_OPTIONS = [
{ value: 'tabs', label: 'Tabbed', description: 'Sections organized into tabs' },
{ value: 'list', label: 'Single Page', description: 'All sections on one scrollable page' },
] as const
/**
* Modal dialog for editing user display preferences persisted in localStorage.
*
* Sections cover: appearance, records-per-page, display density, table view mode, and
* record view mode. A "Reset to Defaults" button is enabled only when at
* least one preference differs from its default value.
*
* @param props - Component properties.
* @returns A Radix Dialog root with an animated overlay and a centered content
* panel (max 85 vh). The content scrolls internally if the viewport is short.
* Changes take effect immediately via `updatePreference`; there is no Save
* button — each change is saved immediately to localStorage.
*/
export function UserPreferencesDialog({ open, onOpenChange, returnFocusRef }: UserPreferencesDialogProps) {
const { preferences, updatePreference, resetPreferences, defaults } = useUserPreferences()
const { theme, isDarkMode, darkModePreference, setDarkMode } = useTheme()
const isDefault = (
!darkModePreference &&
preferences.tableDefaultPageSize === defaults.tableDefaultPageSize &&
preferences.tableDefaultDensity === defaults.tableDefaultDensity &&
preferences.tableDefaultViewMode === defaults.tableDefaultViewMode &&
preferences.recordDefaultViewMode === defaults.recordDefaultViewMode
)
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[var(--qqq-z-overlay,400)] bg-black/50',
'data-[state=open]:animate-in data-[state=open]:fade-in-0',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0'
)}
/>
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[var(--qqq-z-modal,1000)] -translate-x-1/2 -translate-y-1/2',
'w-full max-w-lg max-h-[85vh] flex flex-col',
'rounded-xl border border-border bg-card shadow-lg',
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95'
)}
onCloseAutoFocus={event => {
const target = returnFocusRef?.current
if (target?.isConnected) {
event.preventDefault()
target.focus()
}
}}
aria-describedby={undefined}
data-qqq-id="dialog-user-preferences"
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<DialogPrimitive.Title className="text-lg font-semibold tracking-tight text-foreground">
Preferences
</DialogPrimitive.Title>
<DialogPrimitive.Close
className={cn(
'rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-accent',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
<fieldset disabled={Boolean(theme)} aria-describedby="appearance-description" className="space-y-3">
<legend className="text-sm font-semibold text-foreground">Appearance</legend>
<div className="flex gap-2">
{(['Light', 'Dark'] as const).map((mode) => (
<label key={mode} className={cn(
'flex flex-1 items-center gap-3 rounded-lg border-2 px-4 py-3 text-sm font-medium',
isDarkMode === (mode === 'Dark') ? 'border-primary/40 bg-primary/10' : 'border-border',
theme ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-accent'
)}>
<input
type="radio"
name="appearance"
value={mode.toLowerCase()}
checked={isDarkMode === (mode === 'Dark')}
onChange={() => setDarkMode(mode === 'Dark')}
className="h-4 w-4 accent-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
data-qqq-id={`pref-appearance-${mode.toLowerCase()}`}
/>
{mode}
</label>
))}
</div>
<p id="appearance-description" className="text-xs text-muted-foreground">
{theme
? 'This application theme requires light mode. Your saved appearance choice is kept for when the theme is removed.'
: 'Saved automatically for this site in this browser. Other browsers and devices keep their own choice.'}
</p>
</fieldset>
<div className="border-t border-border" />
{/* Table / Query Defaults */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">Table View Defaults</h3>
{/* Records per page */}
<OptionGroup label="Records per page">
<div className="flex flex-wrap gap-2">
{PAGE_SIZE_OPTIONS.map((opt) => (
<ToggleChip
key={opt.value}
selected={preferences.tableDefaultPageSize === opt.value}
onClick={() => updatePreference('tableDefaultPageSize', opt.value)}
data-qqq-id={`pref-pagesize-${opt.value}`}
>
{opt.label}
</ToggleChip>
))}
</div>
</OptionGroup>
{/* Density */}
<OptionGroup label="Display density">
<div className="space-y-1.5">
{DENSITY_OPTIONS.map((opt) => (
<RadioOption
key={opt.value}
selected={preferences.tableDefaultDensity === opt.value}
onClick={() => updatePreference('tableDefaultDensity', opt.value)}
label={opt.label}
description={opt.description}
data-qqq-id={`pref-density-${opt.value}`}
/>
))}
</div>
</OptionGroup>
{/* View mode */}
<OptionGroup label="Default view">
<div className="flex gap-2">
{TABLE_VIEW_OPTIONS.map((opt) => (
<ToggleCard
key={opt.value}
selected={preferences.tableDefaultViewMode === opt.value}
onClick={() => updatePreference('tableDefaultViewMode', opt.value)}
label={opt.label}
description={opt.description}
data-qqq-id={`pref-table-view-${opt.value}`}
/>
))}
</div>
</OptionGroup>
</div>
<div className="border-t border-border" />
{/* Record Screen Defaults */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">Record View Defaults</h3>
<OptionGroup label="Default layout">
<div className="flex gap-2">
{RECORD_VIEW_OPTIONS.map((opt) => (
<ToggleCard
key={opt.value}
selected={preferences.recordDefaultViewMode === opt.value}
onClick={() => updatePreference('recordDefaultViewMode', opt.value)}
label={opt.label}
description={opt.description}
data-qqq-id={`pref-record-view-${opt.value}`}
/>
))}
</div>
</OptionGroup>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-6 py-3">
<button
type="button"
onClick={() => { resetPreferences(); setDarkMode(false) }}
disabled={isDefault}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus:outline-none focus:ring-2 focus:ring-ring',
'disabled:opacity-40 disabled:pointer-events-none'
)}
data-qqq-id="button-reset-preferences"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
Reset to Defaults
</button>
<DialogPrimitive.Close
className={cn(
'rounded-lg bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1'
)}
data-qqq-id="button-close-preferences"
>
Done
</DialogPrimitive.Close>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
// --- Sub-components ---
/**
* Renders a labeled group container for a set of preference controls.
*
* @param props - Component properties.
* @returns A `<div>` with a muted `<label>` header above the slotted children.
*/
function OptionGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">{label}</label>
{children}
</div>
)
}
/**
* A pill-shaped toggle button for selecting a single option from a set.
*
* Applies a filled primary style when selected, and a bordered outline style otherwise.
*
* @param props - Component properties.
* @returns A `<button>` styled as a pill toggle chip.
*/
function ToggleChip({
selected,
onClick,
children,
...rest
}: {
selected: boolean
onClick: () => void
children: React.ReactNode
'data-qqq-id'?: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring',
selected
? 'bg-primary text-primary-foreground shadow-sm'
: 'border border-border bg-card text-foreground hover:bg-accent'
)}
{...rest}
>
{children}
</button>
)
}
/**
* A full-width radio-style option row with a custom circle indicator.
*
* Renders a label and a supporting description string alongside a filled
* circle when selected, and an empty circle otherwise.
*
* @param props - Component properties.
* @returns A `<button>` styled as a radio option row.
*/
function RadioOption({
selected,
onClick,
label,
description,
...rest
}: {
selected: boolean
onClick: () => void
label: string
description: string
'data-qqq-id'?: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring',
selected
? 'bg-primary/10 border border-primary/30'
: 'border border-transparent hover:bg-accent'
)}
{...rest}
>
<div
className={cn(
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 transition-colors',
selected ? 'border-primary' : 'border-muted-foreground/40'
)}
>
{selected && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
<div className="flex-1 min-w-0">
<span className={cn('text-sm font-medium', selected ? 'text-foreground' : 'text-foreground')}>
{label}
</span>
<span className="ml-2 text-xs text-muted-foreground">{description}</span>
</div>
</button>
)
}
/**
* A card-style toggle button used for selecting between two mutually exclusive view modes.
*
* Displays a bold label and a muted description. Applies a highlighted border
* with primary tinting when selected, and a neutral border otherwise.
*
* @param props - Component properties.
* @returns A `<button>` styled as a selectable card.
*/
function ToggleCard({
selected,
onClick,
label,
description,
...rest
}: {
selected: boolean
onClick: () => void
label: string
description: string
'data-qqq-id'?: string
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex-1 rounded-lg px-4 py-3 text-left transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring',
selected
? 'bg-primary/10 border-2 border-primary/40'
: 'border-2 border-border hover:bg-accent'
)}
{...rest}
>
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{description}</div>
</button>
)
}
@@ -0,0 +1,389 @@
/*
* 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 { fireEvent, render, 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 { beforeEach, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import { preferencesFixture } from '@/mocks/fixtures/preferences'
import type { PreferenceSection } from '@/types/preferences'
import { queryKeys } from '@/lib/query-client'
import { toast } from 'sonner'
import { PreferenceSectionForm } from './PreferenceSectionForm'
const layout = vi.hoisted(() => ({ width: 500 }))
vi.mock('@/lib/hooks/use-container-width', () => ({
useContainerWidth: () => [undefined, layout.width],
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
beforeEach(() => {
layout.width = 500
})
const tokens = preferencesFixture.sections.find((section) => section.name === 'access-tokens')!
function renderForm(section: PreferenceSection = structuredClone(tokens)) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
client.setQueryData(queryKeys.preferencesIndex(), { sections: [section] })
return {
client,
...render(
<QueryClientProvider client={client}>
<PreferenceSectionForm section={section} />
</QueryClientProvider>
),
}
}
it.each([500, 1000])(
'renders actions at their scope and filters row actions at width %s',
async (width) => {
layout.width = width
const section = structuredClone(tokens)
section.actions!.push({
name: 'refresh',
label: 'Refresh tokens',
scope: 'LIST',
listName: 'tokens',
order: 1,
})
section.actions!.push({
name: 'other',
label: 'Other list action',
scope: 'ROW',
listName: 'elsewhere',
})
section.lists[0].rows = [
{ ...section.lists[0].rows[0], key: 'all', label: 'All', actionNames: null },
{ ...section.lists[0].rows[0], key: 'some', label: 'Some', actionNames: ['revoke'] },
{ ...section.lists[0].rows[0], key: 'none', label: 'None', actionNames: [] },
]
const received = vi.fn()
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/refresh', async ({ request }) => {
received(await request.json())
return HttpResponse.json({ section, message: 'Refreshed' })
})
)
renderForm(section)
const user = userEvent.setup()
expect(screen.getByRole('button', { name: 'Create token' }).closest('header')).not.toBeNull()
const list = screen.getByRole('region', { name: 'Tokens' })
await user.click(within(list).getByRole('button', { name: 'Refresh tokens' }))
await waitFor(() => expect(received).toHaveBeenCalledWith({}))
await user.click(screen.getByRole('button', { name: 'Actions for All' }))
expect(screen.getByRole('menuitem', { name: 'Rotate token' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Revoke token' })).toHaveClass('text-destructive')
expect(screen.queryByRole('menuitem', { name: 'Other list action' })).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'Actions for Some' }))
expect(screen.queryByRole('menuitem', { name: 'Rotate token' })).not.toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Revoke token' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Actions for None' })).not.toBeInTheDocument()
}
)
it('keeps input validation in the dialog, shows secrets once, copies, gates dismissal and clears on Done', async () => {
const received = vi.fn()
let attempt = 0
const saved = structuredClone(tokens)
saved.lists[0].rows = []
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/create', async ({ request }) => {
received(await request.json())
if (attempt++ === 0)
return HttpResponse.json(
{ errors: ['Description: choose a different name', 'Expires at: must be in the future'] },
{ status: 400 }
)
return HttpResponse.json({
section: saved,
message: 'Created successfully',
secrets: [
{
label: 'Access token',
value: 'test-only-secret',
masked: 'test-…cret',
usageText: 'curl example.test\n# test-only-secret',
},
],
})
})
)
const { client } = renderForm()
const user = userEvent.setup()
const clipboard = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
const trigger = screen.getByRole('button', { name: 'Create token' })
await user.click(trigger)
const dialog = screen.getByRole('dialog', { name: 'Create token' })
expect(dialog.contains(document.activeElement)).toBe(true)
await user.type(within(dialog).getByLabelText('Description', { exact: false }), 'Script')
fireEvent.change(within(dialog).getByLabelText('Expires at', { exact: false }), {
target: { value: '2027-01-01T12:00' },
})
await user.click(within(dialog).getByRole('button', { name: 'Create token' }))
const error = await within(dialog).findByRole('alert')
expect(error).toHaveTextContent('Description: choose a different name')
expect(error).toHaveTextContent('Expires at: must be in the future')
expect(error).toHaveFocus()
expect(within(dialog).getByLabelText('Description', { exact: false })).toHaveValue('Script')
expect(received).toHaveBeenCalledWith({
values: { description: 'Script', expiresAt: expect.stringMatching(/Z$/) },
})
expect(await axe(dialog)).toHaveNoViolations()
await user.click(within(dialog).getByRole('button', { name: 'Create token' }))
const secret = await screen.findByRole('dialog', { name: 'Show once' })
expect(secret.contains(document.activeElement)).toBe(true)
expect(secret).toHaveTextContent("Copy it now. You won't be able to see it again.")
expect(within(secret).getByRole('textbox', { name: 'Access token' })).toHaveAttribute('readonly')
expect(secret).toHaveTextContent('test-…cret')
expect(within(secret).getByRole('button', { name: 'Done' })).toBeDisabled()
await user.keyboard('{Escape}')
fireEvent.pointerDown(document.querySelector('[data-qqq-id="preference-action-overlay"]')!, {
pointerType: 'mouse',
button: 0,
})
expect(screen.getByRole('dialog', { name: 'Show once' })).toBeInTheDocument()
await user.click(within(secret).getByRole('button', { name: 'Copy Access token' }))
expect(clipboard).toHaveBeenCalledWith('test-only-secret')
await user.click(within(secret).getByRole('button', { name: 'Copy Access token usage' }))
expect(clipboard).toHaveBeenCalledWith('curl example.test\n# test-only-secret')
expect(await axe(secret)).toHaveNoViolations()
expect(
JSON.stringify(
client
.getQueryCache()
.getAll()
.map((query) => query.state.data)
)
).not.toContain('test-only-secret')
expect(client.getMutationCache().getAll()).toHaveLength(0)
await user.click(within(secret).getByRole('checkbox', { name: "I've copied it" }))
await user.click(within(secret).getByRole('button', { name: 'Done' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByDisplayValue('test-only-secret')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(screen.getByText('No items available.')).toBeInTheDocument()
expect(client.getQueryData(queryKeys.preferenceSection(tokens.name))).toEqual(saved)
expect(toast.success).toHaveBeenCalledWith('Created successfully')
await user.click(trigger)
expect(screen.queryByRole('dialog', { name: 'Show once' })).not.toBeInTheDocument()
})
it('confirms destructive row actions before POST and returns focus to the row menu', async () => {
const received = vi.fn()
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/revoke', async ({ request }) => {
received(await request.json())
return HttpResponse.json({ section: tokens, message: 'Revoked', secrets: [] })
})
)
renderForm()
const user = userEvent.setup()
const trigger = screen.getByRole('button', { name: 'Actions for Development script' })
await user.click(trigger)
await user.click(screen.getByRole('menuitem', { name: 'Revoke token' }))
const dialog = screen.getByRole('dialog', { name: 'Revoke token' })
expect(dialog.contains(document.activeElement)).toBe(true)
expect(dialog).toHaveTextContent('Revoke this token? It will stop working immediately.')
expect(received).not.toHaveBeenCalled()
const confirm = within(dialog).getByRole('button', { name: 'Revoke token' })
expect(confirm).toHaveClass('bg-destructive')
await user.click(confirm)
await waitFor(() =>
expect(received).toHaveBeenCalledWith({ rowKey: 'example-token', values: {} })
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(trigger).toHaveFocus()
})
it('selects the secret and usage text when clipboard access fails', async () => {
renderForm()
const user = userEvent.setup()
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied'))
await user.click(screen.getByRole('button', { name: 'Actions for Development script' }))
await user.click(screen.getByRole('menuitem', { name: 'Rotate token' }))
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Rotate token' }))
const secret = await screen.findByRole('dialog', { name: 'Show once' })
await user.click(within(secret).getByRole('button', { name: 'Copy Access token' }))
const input = within(secret).getByRole('textbox', { name: 'Access token' }) as HTMLInputElement
expect(input).toHaveFocus()
expect(input.selectionEnd! - input.selectionStart!).toBe(input.value.length)
await user.click(within(secret).getByRole('button', { name: 'Copy Access token usage' }))
expect(window.getSelection()?.toString()).toContain('curl')
})
it('disables every scope while dirty and locks editors while an action is running', async () => {
const section = structuredClone(tokens)
section.fields = [preferencesFixture.sections[1].fields[0]]
section.values = { timeZone: 'UTC' }
section.actions!.push({
name: 'refresh',
label: 'Refresh tokens',
scope: 'LIST',
listName: 'tokens',
})
let finish!: () => void
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/refresh', async () => {
await new Promise<void>((resolve) => {
finish = resolve
})
return HttpResponse.json({ section })
})
)
renderForm(section)
const user = userEvent.setup()
fireEvent.change(screen.getByLabelText('Time zone'), { target: { value: 'Europe/London' } })
for (const name of ['Create token', 'Refresh tokens', 'Actions for Development script'])
expect(screen.getByRole('button', { name })).toBeDisabled()
expect(screen.getByText('Save or discard your changes first')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Discard' }))
await user.click(screen.getByRole('button', { name: 'Refresh tokens' }))
await waitFor(() => expect(finish).toBeDefined())
expect(screen.getByLabelText('Time zone')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Create token' })).toBeDisabled()
finish()
await waitFor(() => expect(screen.getByLabelText('Time zone')).toBeEnabled())
})
it('supports keyboard row menus, cancellation, and focus return without invoking the action', async () => {
const received = vi.fn()
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/revoke', () => {
received()
return HttpResponse.json({ section: tokens })
})
)
renderForm()
const user = userEvent.setup()
const trigger = screen.getByRole('button', { name: 'Actions for Development script' })
trigger.focus()
await user.keyboard('{Enter}')
const revoke = await screen.findByRole('menuitem', { name: 'Revoke token' })
revoke.focus()
await user.keyboard('{Enter}')
const dialog = await screen.findByRole('dialog', { name: 'Revoke token' })
expect(dialog.contains(document.activeElement)).toBe(true)
await user.tab()
expect(dialog.contains(document.activeElement)).toBe(true)
await user.tab()
expect(dialog.contains(document.activeElement)).toBe(true)
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(received).not.toHaveBeenCalled()
})
it('uses inline enum metadata, typed defaults, and action order without hardcoded token inputs', async () => {
const section = structuredClone(tokens)
section.possibleValues = {
modes: [
{ id: 'daily', label: 'Daily' },
{ id: 'weekly', label: 'Weekly' },
],
}
section.actions = [
{ name: 'later', label: 'Later', scope: 'SECTION', order: 20 },
{
name: 'configure',
label: 'Configure',
scope: 'SECTION',
order: 1,
inputFields: [
{
...preferencesFixture.sections[1].fields[0],
name: 'mode',
label: 'Mode',
possibleValueSourceName: 'modes',
defaultValue: 'daily',
},
{
...preferencesFixture.sections[1].fields[0],
name: 'count',
label: 'Count',
type: 'INTEGER',
defaultValue: 3,
},
],
},
]
const received = vi.fn()
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/configure', async ({ request }) => {
received(await request.json())
return HttpResponse.json({ section })
})
)
renderForm(section)
const header = screen.getByRole('button', { name: 'Configure' }).closest('header')!
expect(
within(header)
.getAllByRole('button')
.map((button) => button.textContent)
).toEqual(['Configure', 'Later'])
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Configure' }))
const dialog = screen.getByRole('dialog', { name: 'Configure' })
expect(within(dialog).getByRole('combobox', { name: 'Mode' })).toHaveValue('daily')
await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Mode' }), 'weekly')
await user.click(within(dialog).getByRole('button', { name: 'Configure' }))
await waitFor(() =>
expect(received).toHaveBeenCalledWith({ values: { mode: 'weekly', count: 3 } })
)
})
it('shows each returned secret and restores focus to the returned row after acknowledgement', async () => {
server.use(
http.post('/qqq/v1/preferences/access-tokens/actions/rotate', () =>
HttpResponse.json({
section: tokens,
focusRowKey: 'example-token',
secrets: [
{ label: 'Primary token', value: 'primary-test-only', masked: 'primary-…only' },
{
label: 'Recovery token',
value: 'recovery-test-only',
masked: 'recovery-…only',
usageText: 'Example recovery usage',
},
],
})
)
)
const { container } = renderForm()
const user = userEvent.setup()
const persist = vi.spyOn(window.localStorage, 'setItem')
await user.click(screen.getByRole('button', { name: 'Actions for Development script' }))
await user.click(screen.getByRole('menuitem', { name: 'Rotate token' }))
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Rotate token' }))
const dialog = await screen.findByRole('dialog', { name: 'Show once' })
expect(within(dialog).getByRole('textbox', { name: 'Primary token' })).toHaveValue('primary-test-only')
expect(within(dialog).getByRole('textbox', { name: 'Recovery token' })).toHaveValue('recovery-test-only')
expect(
within(dialog).getByRole('button', { name: 'Copy Recovery token usage' })
).toBeInTheDocument()
expect(persist).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('checkbox', { name: "I've copied it" }))
await user.click(within(dialog).getByRole('button', { name: 'Done' }))
expect(container.querySelector('[data-preference-row-key="example-token"]')).toHaveFocus()
expect(screen.queryByDisplayValue('primary-test-only')).not.toBeInTheDocument()
expect(screen.queryByDisplayValue('recovery-test-only')).not.toBeInTheDocument()
persist.mockRestore()
})
@@ -0,0 +1,514 @@
/*
* 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 Scoped preference actions and ephemeral, acknowledged one-time secrets. */
'use client'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form'
import * as Dialog from '@radix-ui/react-dialog'
import * as Menu from '@radix-ui/react-dropdown-menu'
import { MoreHorizontal } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type {
PreferenceAction,
PreferenceRow,
PreferenceSection,
PreferenceSecret,
} from '@/types/preferences'
import { runPreferenceAction } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
import { BUTTON_CLASS, buttonClass } from '@/components/ui/controls'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { PreferenceField } from './PreferenceField'
import { preferenceDefaults, preferenceSlots, wireValue } from './preference-values'
import { preferenceMessages } from './preference-messages'
/** Shared focus-trapped action dialog; all dismissal is controlled by its owner.
* @param props - Accessible title, description, body and dismissal policy.
* @returns A portalled modal.
*/
function ActionDialog({
title,
description,
locked,
onClose,
onReturnFocus,
children,
}: {
title: string
description: string
locked: boolean
onClose: () => void
onReturnFocus: () => void
children: ReactNode
}) {
return (
<Dialog.Root
open
onOpenChange={(open) => {
if (!open && !locked) onClose()
}}
>
<Dialog.Portal>
<Dialog.Overlay
data-qqq-id="preference-action-overlay"
className="fixed inset-0 z-[1100] bg-foreground/40"
/>
<Dialog.Content
className="fixed left-1/2 top-1/2 z-[1101] max-h-[90dvh] w-[calc(100%_-_2rem)] max-w-xl -translate-x-1/2 -translate-y-1/2 space-y-4 overflow-y-auto rounded-lg border border-border bg-card p-6 text-foreground shadow-xl"
onEscapeKeyDown={(event) => {
if (locked) event.preventDefault()
}}
onInteractOutside={(event) => {
if (locked) event.preventDefault()
}}
onCloseAutoFocus={(event) => {
event.preventDefault()
onReturnFocus()
}}
>
<Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
<Dialog.Description className="whitespace-pre-wrap text-sm text-muted-foreground">
{description}
</Dialog.Description>
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
/** Action inputs use exactly the section editors and wire conversions.
* @param props - Selected action, execution state and callbacks.
* @returns Inputs and explicit confirmation/submit controls.
*/
function ActionInputs({
section,
action,
pending,
errors,
onRun,
onClose,
onReturnFocus,
}: {
section: PreferenceSection
action: PreferenceAction
pending: boolean
errors: string[]
onRun: (values: Record<string, unknown>) => void
onClose: () => void
onReturnFocus: () => void
}) {
const prefix = useId()
const slots = preferenceSlots({
...section,
fields: action.inputFields ?? [],
lists: [],
values: {},
})
const form = useForm<Record<string, unknown>>({ defaultValues: preferenceDefaults(slots) })
const errorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (errors.length) errorRef.current?.focus()
}, [errors])
return (
<ActionDialog
title={action.label}
description={action.description || 'Review and run this action.'}
locked={pending}
onClose={onClose}
onReturnFocus={onReturnFocus}
>
<form
className="space-y-4"
onSubmit={(event) => {
// This portal is inside the section form's React tree; do not submit that form.
event.stopPropagation()
void form.handleSubmit((values) =>
onRun(
Object.fromEntries(
slots
.filter((slot) => !slot.readOnly && !slot.field.isHidden)
.map((slot) => [slot.field.name, wireValue(slot, values[slot.name])])
)
)
)(event)
}}
>
{errors.length > 0 && (
<div ref={errorRef} role="alert" tabIndex={-1} className="text-sm text-destructive">
<ul>
{errors.map((message, i) => (
<li key={i}>{message}</li>
))}
</ul>
</div>
)}
{slots.map((slot) => (
<PreferenceField
key={slot.name}
slot={slot}
section={section}
form={form}
prefix={prefix}
disabled={pending}
/>
))}
{action.confirmationText && (
<p className={action.isDestructive ? 'text-destructive' : ''}>
{action.confirmationText}
</p>
)}
<div className="flex gap-3">
<button type="button" className={BUTTON_CLASS} disabled={pending} onClick={onClose}>
Cancel
</button>
<button
type="submit"
className={buttonClass(action.isDestructive ? 'danger' : 'primary')}
disabled={pending}
>
{pending ? 'Running…' : action.label}
</button>
</div>
</form>
</ActionDialog>
)
}
/** Copy a value, selecting its visible text when clipboard access is unavailable.
* @param props - Text and accessible copy label; usage renders as a code block.
* @returns Read-only text with a copy affordance and status.
*/
function CopyValue({
label,
value,
usage = false,
}: {
label: string
value: string
usage?: boolean
}) {
const input = useRef<HTMLInputElement>(null)
const code = useRef<HTMLElement>(null)
const [status, setStatus] = useState('')
const copy = async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable')
await navigator.clipboard.writeText(value)
setStatus('Copied')
} catch {
if (input.current) {
input.current.focus()
input.current.select()
}
if (code.current) {
code.current.focus()
const range = document.createRange()
range.selectNodeContents(code.current)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
setStatus('Text selected. Press Ctrl+C or Command+C to copy.')
}
}
return (
<div className="min-w-0 space-y-2">
{usage ? (
<pre role="region" aria-label={label} className="overflow-x-auto rounded bg-muted p-3">
<code ref={code} tabIndex={0}>
{value}
</code>
</pre>
) : (
<input
ref={input}
aria-label={label}
readOnly
value={value}
className="w-full rounded border border-input bg-background p-2 font-mono"
/>
)}
<button
type="button"
className={BUTTON_CLASS}
aria-label={`Copy ${label}`}
onClick={() => void copy()}
>
Copy
</button>
<span role="status" className="ml-2 text-sm text-muted-foreground">
{status}
</span>
</div>
)
}
/** Only local component state owns the returned secrets; unmount clears all copy fields.
* @param props - One-time values and explicit dismissal callbacks.
* @returns An acknowledgement-gated secret dialog.
*/
function SecretDialog({
secrets,
onClose,
onReturnFocus,
}: {
secrets: PreferenceSecret[]
onClose: () => void
onReturnFocus: () => void
}) {
const [acknowledged, setAcknowledged] = useState(false)
return (
<ActionDialog
title="Show once"
description="Copy it now. You won't be able to see it again."
locked={!acknowledged}
onClose={onClose}
onReturnFocus={onReturnFocus}
>
{secrets.map((secret, i) => (
<section key={i} aria-label={secret.label} className="space-y-3">
<h3 className="font-semibold">{secret.label}</h3>
<CopyValue label={secret.label} value={secret.value} />
{secret.masked && (
<p className="font-mono text-sm text-muted-foreground">{secret.masked}</p>
)}
{secret.usageText && (
<CopyValue label={`${secret.label} usage`} value={secret.usageText} usage />
)}
</section>
))}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={acknowledged}
onChange={(event) => setAcknowledged(event.target.checked)}
/>
I&apos;ve copied it
</label>
<button type="button" className={BUTTON_CLASS} disabled={!acknowledged} onClick={onClose}>
Done
</button>
</ActionDialog>
)
}
/** Keeps action results out of TanStack's mutation cache and serializes section editing.
* @param props - Current metadata, edit locks, and safe section replacement callback.
* @returns Scoped controls, dialogs and editor lock state.
*/
export function usePreferenceActions({
section,
dirty,
saving,
onSaved,
}: {
section: PreferenceSection
dirty: boolean
saving: boolean
onSaved: (section: PreferenceSection) => void
}) {
const client = useQueryClient()
const [selected, setSelected] = useState<{
action: PreferenceAction
row?: PreferenceRow
} | null>(null)
const [pending, setPending] = useState(false)
const [errors, setErrors] = useState<string[]>([])
const [secrets, setSecrets] = useState<PreferenceSecret[]>([])
const inFlight = useRef(false)
const mounted = useRef(true)
const returnFocus = useRef<HTMLElement | null>(null)
const focusFallback = useRef<HTMLElement | null>(null)
const focusRowKey = useRef<string | undefined>(undefined)
const showingSecrets = useRef(false)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
const restoreFocus = () => {
// Moving from inputs to secrets must keep focus in the new modal.
if (showingSecrets.current) return
const row =
focusRowKey.current &&
Array.from(
focusFallback.current?.querySelectorAll<HTMLElement>('[data-preference-row-key]') ?? []
).find((element) => element.dataset.preferenceRowKey === focusRowKey.current)
if (row) row.focus()
else if (returnFocus.current?.isConnected) returnFocus.current.focus()
else focusFallback.current?.focus()
}
const active = pending || Boolean(selected) || secrets.length > 0
const disabled = dirty || saving || active
const actions = [...(section.actions ?? [])].sort(
(a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label)
)
const run = async (
action: PreferenceAction,
row?: PreferenceRow,
values?: Record<string, unknown>
) => {
if (dirty || saving || inFlight.current) return
inFlight.current = true
setPending(true)
setErrors([])
try {
await client.cancelQueries({ queryKey: queryKeys.preferences() })
const result = await runPreferenceAction(section.name, action.name, {
...(row ? { rowKey: row.key } : {}),
...(values ? { values } : {}),
})
if (!mounted.current) return
focusRowKey.current = result.focusRowKey
showingSecrets.current = Boolean(result.secrets?.length)
onSaved(result.section)
setSelected(null)
if (result.message) toast.success(result.message)
setSecrets(result.secrets ?? [])
} catch (error) {
if (mounted.current) setErrors(preferenceMessages(error))
} finally {
inFlight.current = false
if (mounted.current) setPending(false)
}
}
const start = (action: PreferenceAction, row?: PreferenceRow) => {
if (disabled) return
returnFocus.current = document.activeElement as HTMLElement
focusFallback.current = returnFocus.current?.closest('form') ?? focusFallback.current
focusRowKey.current = undefined
setErrors([])
if (action.inputFields?.length || action.confirmationText) setSelected({ action, row })
else void run(action, row)
}
const controls = (scope: PreferenceAction['scope'], listName?: string, row?: PreferenceRow) => {
const allowed = actions.filter(
(action) =>
action.scope === scope &&
(scope === 'SECTION' || action.listName === listName) &&
(!row || row.actionNames == null || row.actionNames.includes(action.name))
)
if (!allowed.length) return null
if (scope === 'ROW')
return (
<Menu.Root>
<Menu.Trigger asChild>
<button
type="button"
disabled={disabled}
className={BUTTON_CLASS}
aria-label={`Actions for ${row?.label}`}
title={dirty ? 'Save or discard your changes first' : undefined}
onPointerDown={(event) => {
returnFocus.current = event.currentTarget
focusFallback.current = event.currentTarget.closest('form')
}}
onFocus={(event) => {
returnFocus.current = event.currentTarget
focusFallback.current = event.currentTarget.closest('form')
}}
>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
className="z-[1102] min-w-40 rounded border border-border bg-popover p-1 shadow-lg"
onCloseAutoFocus={(event) => {
if (selected) event.preventDefault()
}}
>
{allowed.map((action) => (
<Menu.Item
key={action.name}
className={`flex cursor-pointer items-center gap-2 rounded px-3 py-2 text-sm outline-none focus:bg-accent ${action.isDestructive ? 'text-destructive' : 'text-foreground'}`}
onSelect={() => {
const trigger = returnFocus.current
start(action, row)
returnFocus.current = trigger
}}
>
<MetadataIcon icon={action.icon} kind="process" className="h-4 w-4" />
{action.label}
</Menu.Item>
))}
</Menu.Content>
</Menu.Portal>
</Menu.Root>
)
return (
<div className="flex flex-wrap gap-2">
{allowed.map((action) => (
<button
key={action.name}
type="button"
disabled={disabled}
title={dirty ? 'Save or discard your changes first' : action.description || undefined}
className={buttonClass(action.isDestructive ? 'destructive' : 'secondary')}
onClick={() => start(action)}
>
<MetadataIcon icon={action.icon} kind="process" className="h-4 w-4" />
{action.label}
</button>
))}
</div>
)
}
return {
active,
controls,
dialogs: (
<>
{!selected && errors.length > 0 && (
<div role="alert" className="text-destructive">
{errors.map((message, i) => (
<p key={i}>{message}</p>
))}
</div>
)}
{selected && (
<ActionInputs
section={section}
action={selected.action}
pending={pending}
errors={errors}
onRun={(values) => void run(selected.action, selected.row, values)}
onClose={() => {
setSelected(null)
setErrors([])
}}
onReturnFocus={restoreFocus}
/>
)}
{secrets.length > 0 && (
<SecretDialog
secrets={secrets}
onClose={() => {
showingSecrets.current = false
setSecrets([])
}}
onReturnFocus={restoreFocus}
/>
)}
</>
),
}
}
@@ -0,0 +1,126 @@
/*
* 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 Preference metadata adapter for the existing form field editors. */
'use client'
import type { UseFormReturn } from 'react-hook-form'
import { LockKeyhole } from 'lucide-react'
import type { PreferenceSection } from '@/types/preferences'
import { cn } from '@/lib/utils/cn'
import { BooleanField } from '@/components/forms/field-types/BooleanField'
import { DateField } from '@/components/forms/field-types/DateField'
import { DateTimeField } from '@/components/forms/field-types/DateTimeField'
import { NumberField } from '@/components/forms/field-types/NumberField'
import { SelectField } from '@/components/forms/field-types/SelectField'
import { TextAreaField } from '@/components/forms/field-types/TextAreaField'
import { TextField } from '@/components/forms/field-types/TextField'
import { HelpContent } from '@/components/records/HelpContent'
import type { PreferenceSlot } from './preference-values'
/** Adapts a scalar or list cell without fetching possible values.
* @param props - Slot metadata, enum sources and shared form state.
* @returns A labelled field with help and any read-only reason.
*/
export function PreferenceField({
slot,
section,
form,
prefix,
disabled,
hideLabel = false,
}: {
slot: PreferenceSlot
section: PreferenceSection
form: UseFormReturn<Record<string, unknown>>
prefix: string
disabled: boolean
/** In a table cell the column header names the field: keep the label for assistive tech only. */
hideLabel?: boolean
}) {
const { field } = slot
if (field.isHidden) return null
const id = `${prefix}-${slot.name}`
const help = field.helpContents?.filter((item) => item.content)
const reason = slot.readOnly ? slot.reason || 'Read only' : undefined
const props = {
id,
label: field.label,
required: field.isRequired,
disabled: disabled || slot.readOnly,
registration: form.register(slot.name),
describedBy:
[help?.length ? `${id}-help` : '', reason ? `${id}-reason` : ''].filter(Boolean).join(' ') ||
undefined,
'data-qqq-id': `preferences-field-${section.name}-${slot.list ? `${slot.list}-${slot.row}-` : ''}${field.name}`,
}
const options = field.possibleValueSourceName
? (section.possibleValues[field.possibleValueSourceName] ?? [])
: field.inlinePossibleValueSource?.enumValues
let editor
if (options) editor = <SelectField {...props} options={options} />
else
switch (field.type) {
case 'BOOLEAN':
editor = <BooleanField {...props} name={slot.name} control={form.control} />
break
case 'INTEGER':
case 'DECIMAL':
editor = (
<NumberField
{...props}
step={field.type === 'INTEGER' ? 1 : 'any'}
minValue={field.minValue}
maxValue={field.maxValue}
/>
)
break
case 'TEXT':
editor = <TextAreaField {...props} maxLength={field.maxLength} />
break
case 'DATE':
case 'TIME':
editor = <DateField {...props} type={field.type === 'TIME' ? 'time' : 'date'} />
break
case 'DATE_TIME':
editor = <DateTimeField {...props} />
break
default:
editor = <TextField {...props} maxLength={field.maxLength} />
}
return (
<div className={cn('min-w-0 space-y-1 break-words', hideLabel && '[&_label]:sr-only')}>
{editor}
{help && (
<div id={`${id}-help`} className="text-xs text-muted-foreground">
{help.map((item, i) => (
<HelpContent key={i} helpContent={item} />
))}
</div>
)}
{reason && (
<p
id={`${id}-reason`}
className="flex items-start gap-1 text-xs text-muted-foreground"
title={reason}
>
<LockKeyhole className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
{reason}
</p>
)}
</div>
)
}
@@ -0,0 +1,114 @@
/*
* 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 Lazy, focus-trapped preference sheet shared by notification shortcuts. */
'use client'
import type { RefObject } from 'react'
import Link from 'next/link'
import * as Dialog from '@radix-ui/react-dialog'
import { useQuery } from '@tanstack/react-query'
import { X } from 'lucide-react'
import type { PreferenceSection } from '@/types/preferences'
import { getPreferenceSection } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { PreferenceSectionForm } from './PreferenceSectionForm'
/** A right-side sheet on larger screens and full-screen sheet on phones.
* @param props - Known section, open state, and persistent gear focus target.
* @returns Modal section editor.
*/
export default function PreferenceSectionDrawer({
section,
open,
onOpenChange,
returnFocusRef,
}: {
section: PreferenceSection
open: boolean
onOpenChange: (open: boolean) => void
returnFocusRef: RefObject<HTMLButtonElement | null>
}) {
const query = useQuery({
queryKey: queryKeys.preferenceSection(section.name),
queryFn: () => getPreferenceSection(section.name),
enabled: open,
retry: false,
})
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-[var(--qqq-z-overlay,400)] bg-foreground/40" />
<Dialog.Content
className="fixed inset-y-0 right-0 z-[var(--qqq-z-modal,1000)] flex h-dvh w-full flex-col border-l border-border bg-card text-foreground shadow-xl sm:max-w-3xl"
onCloseAutoFocus={(event) => {
event.preventDefault()
returnFocusRef.current?.focus({ preventScroll: true })
}}
data-qqq-id="preferences-drawer"
>
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-border p-4">
<div>
<Dialog.Title className="text-lg font-semibold">{section.label}</Dialog.Title>
<Dialog.Description className="text-sm text-muted-foreground">
{section.description || 'Manage your preferences for this section.'}
</Dialog.Description>
</div>
<Dialog.Close
aria-label="Close settings"
className={BUTTON_CLASS}
data-qqq-id="preferences-drawer-close"
>
<X className="h-4 w-4" aria-hidden="true" />
</Dialog.Close>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
{query.isLoading ? (
<p role="status" className="animate-pulse rounded-lg bg-muted p-6">
Loading preferences…
</p>
) : query.isError ? (
<div role="alert" className="space-y-3 text-destructive">
<p>Could not load this preference section.</p>
<button
type="button"
onClick={() => void query.refetch()}
className={BUTTON_CLASS}
data-qqq-id="preferences-drawer-retry"
>
Retry
</button>
</div>
) : (
query.data && <PreferenceSectionForm key={query.data.name} section={query.data} hideHeading />
)}
</div>
<div className="shrink-0 border-t border-border p-4">
<Link
href={`/app/preferences?section=${encodeURIComponent(section.name)}`}
onClick={() => onOpenChange(false)}
className="text-sm font-medium text-primary underline underline-offset-2"
data-qqq-id="preferences-manage-all"
>
Manage all preferences
</Link>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
@@ -0,0 +1,57 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Interactive provider actions, backed by the same development MSW handlers. */
import { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner'
import { preferencesFixture } from '@/mocks/fixtures/preferences'
import { resetPreferencesMock } from '@/mocks/handlers/preferences'
import { PreferenceSectionForm } from './PreferenceSectionForm'
function Example({ width = 1000 }: { width?: number }) {
const [client] = useState(() => new QueryClient())
return (
<QueryClientProvider client={client}>
<div style={{ width, maxWidth: '100%' }} className="p-6">
<PreferenceSectionForm
section={structuredClone(
preferencesFixture.sections.find((section) => section.name === 'access-tokens')!
)}
/>
</div>
<Toaster />
</QueryClientProvider>
)
}
const meta = {
title: 'Preferences/Access tokens',
component: Example,
parameters: { layout: 'fullscreen' },
loaders: [
async () => {
const { worker } = await import('@/mocks/browser')
await worker.start({ quiet: true, onUnhandledRequest: 'bypass' })
resetPreferencesMock()
return {}
},
],
} satisfies Meta<typeof Example>
export default meta
type Story = StoryObj<typeof meta>
export const Table: Story = { args: { width: 1000 } }
export const Cards: Story = { args: { width: 400 } }
@@ -0,0 +1,267 @@
/*
* 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 { act, fireEvent, render, 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 { beforeEach, expect, it, vi } from 'vitest'
import { axe } from 'jest-axe'
import { server } from '@/mocks/node'
import { preferencesFixture } from '@/mocks/fixtures/preferences'
import type { PreferenceSection } from '@/types/preferences'
import type { QFieldMetaData } from '@/types/metadata'
import { PreferenceSectionForm } from './PreferenceSectionForm'
import { queryKeys } from '@/lib/query-client'
const layout = vi.hoisted(() => ({ width: 500 }))
vi.mock('@/lib/hooks/use-container-width', () => ({
useContainerWidth: () => [undefined, layout.width],
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
beforeEach(() => {
layout.width = 500
})
const notifications = preferencesFixture.sections[0]
const renderForm = (section = notifications) => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return {
client,
...render(
<QueryClientProvider client={client}>
<PreferenceSectionForm section={section} />
</QueryClientProvider>
),
}
}
it('renders all primitive editors, help, required markers, and inline enum options', async () => {
const types: QFieldMetaData['type'][] = [
'BOOLEAN',
'INTEGER',
'DECIMAL',
'STRING',
'TEXT',
'DATE',
'TIME',
'DATE_TIME',
]
const section: PreferenceSection = {
...notifications,
lists: [],
values: {},
fields: types.map((type) => ({
...notifications.fields[0],
name: type,
label: type,
type,
isRequired: true,
helpContents: [{ content: 'Example help', format: 'TEXT' }],
})),
}
renderForm(section)
expect(screen.getByRole('checkbox', { name: 'BOOLEAN' })).toHaveAttribute('aria-required', 'true')
for (const [name, type] of [
['INTEGER', 'number'],
['DECIMAL', 'number'],
['STRING', 'text'],
['DATE', 'date'],
['TIME', 'time'],
['DATE_TIME', 'datetime-local'],
]) {
expect(screen.getByLabelText(new RegExp(`^${name}\\*?$`))).toHaveAttribute('type', type)
}
expect(screen.getByLabelText(/^TEXT\*?$/).tagName).toBe('TEXTAREA')
expect(screen.getAllByText('Example help')).toHaveLength(8)
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled()
})
it('uses a table at md container width and cards below it, retaining edits across resize', async () => {
layout.width = 900
const view = renderForm()
expect(screen.getByRole('table', { name: 'Categories' })).toBeInTheDocument()
const system = screen.getByRole('row', { name: /System/ })
await userEvent.setup().click(within(system).getByRole('checkbox', { name: 'In-app' }))
layout.width = 500
view.rerender(
<QueryClientProvider client={view.client}>
<PreferenceSectionForm section={notifications} />
</QueryClientProvider>
)
expect(screen.queryByRole('table')).not.toBeInTheDocument()
expect(
within(screen.getByRole('group', { name: 'System' })).getByRole('checkbox', { name: 'In-app' })
).toHaveAttribute('aria-checked', 'false')
})
it('locks provider read-only cells and exposes their reason to assistive technology', async () => {
const { container } = renderForm()
const security = screen.getByRole('group', { name: 'Security' })
const checkbox = within(security).getByRole('checkbox', { name: 'In-app' })
expect(checkbox).toBeDisabled()
expect(checkbox).toHaveAccessibleDescription("Security notices can't be muted")
expect(await axe(container)).toHaveNoViolations()
})
it('sends only changed scalar and row fields, with typed numeric enum IDs, then refreshes queries', async () => {
const bodies: unknown[] = []
server.use(
http.patch('/qqq/v1/preferences/notifications', async ({ request }) => {
bodies.push(await request.json())
const saved = structuredClone(notifications)
saved.values.digestHour = 12
saved.lists[0].rows[0].values.emailMode = 'DAILY_DIGEST'
return HttpResponse.json(saved)
})
)
const { client } = renderForm()
const invalidate = vi.spyOn(client, 'invalidateQueries')
const user = userEvent.setup()
await user.selectOptions(screen.getByRole('combobox', { name: 'Daily digest hour' }), '12')
const system = within(screen.getByRole('group', { name: 'System' }))
expect(system.getByRole('option', { name: 'Daily digest' })).toHaveValue('DAILY_DIGEST')
await user.selectOptions(system.getByRole('combobox', { name: 'Email delivery' }), 'DAILY_DIGEST')
await user.click(screen.getByRole('button', { name: 'Save changes' }))
await waitFor(() =>
expect(bodies).toEqual([
{
values: { digestHour: 12 },
lists: { categories: [{ key: 'SYSTEM', values: { emailMode: 'DAILY_DIGEST' } }] },
},
])
)
await waitFor(() => expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled())
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.preferences() })
expect(screen.getByLabelText('Daily digest hour')).toHaveValue('12')
})
it('keeps edits on a 400, shows all server messages, and discards back to saved values', async () => {
server.use(
http.patch('/qqq/v1/preferences/notifications', () =>
HttpResponse.json(
{ errors: ['Time zone is invalid', 'Digest hour is outside the allowed range'] },
{ status: 400 }
)
)
)
renderForm()
const user = userEvent.setup()
await user.clear(screen.getByLabelText('Time zone'))
await user.type(screen.getByLabelText('Time zone'), 'invalid')
await user.click(screen.getByRole('button', { name: 'Save changes' }))
const summary = await screen.findByRole('alert')
expect(summary).toHaveTextContent('Time zone is invalid')
expect(summary).toHaveTextContent('Digest hour is outside the allowed range')
expect(screen.getByLabelText('Time zone')).toHaveValue('invalid')
await user.click(screen.getByRole('button', { name: 'Discard' }))
expect(screen.getByLabelText('Time zone')).toHaveValue('America/Chicago')
expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('preserves dirty edits during background refetches', async () => {
const view = renderForm()
fireEvent.change(screen.getByLabelText('Time zone'), { target: { value: 'Europe/London' } })
const incoming = structuredClone(notifications)
incoming.values.timeZone = 'UTC'
await act(async () =>
view.rerender(
<QueryClientProvider client={view.client}>
<PreferenceSectionForm section={incoming} />
</QueryClientProvider>
)
)
expect(screen.getByLabelText('Time zone')).toHaveValue('Europe/London')
})
it('submits primitive editor changes as typed values using only metadata names', async () => {
const types: QFieldMetaData['type'][] = [
'INTEGER',
'DECIMAL',
'STRING',
'TEXT',
'DATE',
'TIME',
'DATE_TIME',
]
const section: PreferenceSection = {
...notifications,
name: 'custom',
lists: [],
values: {},
fields: types.map((type, i) => ({
...notifications.fields[0],
name: `setting${i}`,
label: type,
type,
})),
}
let body: unknown
server.use(
http.patch('/qqq/v1/preferences/custom', async ({ request }) => {
body = await request.json()
return HttpResponse.json(section)
})
)
renderForm(section)
for (const [label, value] of [
['INTEGER', '7'],
['DECIMAL', '1.25'],
['STRING', 'Example'],
['TEXT', 'Longer text'],
['DATE', '2026-10-15'],
['TIME', '09:30'],
['DATE_TIME', '2026-10-15T10:00'],
])
fireEvent.change(screen.getByLabelText(label, { exact: true }), { target: { value } })
await userEvent.setup().click(screen.getByRole('button', { name: 'Save changes' }))
await waitFor(() =>
expect(body).toEqual({
values: {
setting0: 7,
setting1: 1.25,
setting2: 'Example',
setting3: 'Longer text',
setting4: '2026-10-15',
setting5: '09:30:00',
setting6: expect.stringMatching(/Z$/),
},
})
)
})
it('displays the backend BasicErrorResponseV1 error string without losing edits', async () => {
server.use(
http.patch('/qqq/v1/preferences/notifications', () =>
HttpResponse.json(
{
error: 'Invalid time zone. Digest hour must be between 0 and 23.',
},
{ status: 400 }
)
)
)
renderForm()
fireEvent.change(screen.getByLabelText('Time zone'), { target: { value: 'invalid' } })
await userEvent.setup().click(screen.getByRole('button', { name: 'Save changes' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Invalid time zone. Digest hour must be between 0 and 23.'
)
expect(screen.getByLabelText('Time zone')).toHaveValue('invalid')
})
@@ -0,0 +1,282 @@
/*
* 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 Generic scalar/list preference editor with partial writes and retained validation errors. */
'use client'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { PreferenceSection, PreferencesResponse } from '@/types/preferences'
import { patchPreferenceSection } from '@/lib/api/preferences'
import { queryKeys } from '@/lib/query-client'
import { useContainerWidth } from '@/lib/hooks/use-container-width'
import { useMediaQuery } from '@/lib/hooks/use-media-query'
import { cn } from '@/lib/utils/cn'
import { BUTTON_CLASS } from '@/components/ui/controls'
import { PreferenceField } from './PreferenceField'
import { usePreferenceActions } from './PreferenceActions'
import { preferenceMessages } from './preference-messages'
import { preferenceDefaults, preferencePatch, preferenceSlots } from './preference-values'
/** Renders one provider section, with one scroll owner supplied by the page or drawer.
* @param props - Section metadata and current values.
* @returns Metadata-driven preference form.
*/
export function PreferenceSectionForm({
section,
hideHeading = false,
}: {
section: PreferenceSection
/** Omit the section title and description when a container (the drawer) already shows them. */
hideHeading?: boolean
}) {
const client = useQueryClient()
const prefix = useId()
const [containerRef, width] = useContainerWidth<HTMLFormElement>()
const desktop = useMediaQuery('(min-width: 768px)')
const table = width ? width >= 768 : desktop
const [snapshot, setSnapshot] = useState(section)
const slots = useMemo(() => preferenceSlots(snapshot), [snapshot])
const defaults = useMemo(() => preferenceDefaults(slots), [slots])
const form = useForm<Record<string, unknown>>({ defaultValues: defaults })
const {
reset,
control,
formState: { isDirty },
} = form
const values = useWatch({ control })
const patch = preferencePatch(slots, values)
const dirty = Boolean(patch.values || patch.lists)
const mutation = useMutation({
mutationFn: () => patchPreferenceSection(snapshot.name, patch),
onSuccess: (saved) => {
setSnapshot(saved)
reset(preferenceDefaults(preferenceSlots(saved)))
client.setQueryData(queryKeys.preferenceSection(saved.name), saved)
void client.invalidateQueries({ queryKey: queryKeys.preferences() })
void client.invalidateQueries({ queryKey: queryKeys.alerts() })
toast.success('Preferences saved')
},
})
const actions = usePreferenceActions({
section: snapshot,
dirty,
saving: mutation.isPending,
onSaved: (saved) => {
setSnapshot(saved)
reset(preferenceDefaults(preferenceSlots(saved)))
client.setQueryData(queryKeys.preferenceSection(saved.name), saved)
client.setQueryData<PreferencesResponse>(queryKeys.preferencesIndex(), (current) =>
current
? {
...current,
sections: current.sections.map((item) => (item.name === saved.name ? saved : item)),
}
: current
)
},
})
const previousSection = useRef(section)
// A background refetch must not discard unsaved edits.
useEffect(() => {
if (previousSection.current === section) return
previousSection.current = section
if (!isDirty && !actions.active) {
setSnapshot(section)
reset(preferenceDefaults(preferenceSlots(section)))
}
}, [section, reset, isDirty, actions.active])
const cell = (name: string, hideLabel = false) => {
const slot = slots.find((slot) => slot.name === name)!
return (
<PreferenceField
slot={slot}
section={snapshot}
form={form}
prefix={prefix}
disabled={mutation.isPending || actions.active}
hideLabel={hideLabel}
/>
)
}
return (
<form
ref={containerRef}
tabIndex={-1}
onSubmit={form.handleSubmit(() => {
if (dirty && !actions.active) mutation.mutate()
})}
className="min-w-0 space-y-6 [&_input]:min-w-0 [&_input]:max-w-full [&_select]:min-w-0 [&_select]:max-w-full"
data-qqq-id={`preferences-form-${snapshot.name}`}
>
<header className={cn('flex flex-wrap items-center justify-between gap-3', hideHeading && 'justify-end')}>
{!hideHeading && <h2 className="text-lg font-semibold">{snapshot.label}</h2>}
{actions.controls('SECTION')}
</header>
{!hideHeading && snapshot.description && (
<p className="text-sm text-muted-foreground">{snapshot.description}</p>
)}
{dirty && Boolean(snapshot.actions?.length) && (
<p role="status" className="text-sm text-muted-foreground">
Save or discard your changes first
</p>
)}
{actions.dialogs}
{mutation.isError && (
<div
role="alert"
className="rounded-lg border border-destructive bg-destructive/10 p-4 text-sm text-destructive"
>
<p className="font-semibold">Could not save changes</p>
<ul className="list-inside list-disc">
{preferenceMessages(mutation.error).map((message, i) => (
<li key={i}>{message}</li>
))}
</ul>
</div>
)}
<div className="grid min-w-0 gap-5 md:grid-cols-2">
{snapshot.fields.map(
(field, i) => !field.isHidden && <div key={field.name}>{cell(`scalar${i}`)}</div>
)}
</div>
{snapshot.lists.map((list, li) => (
<section key={list.name} aria-label={list.label} className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="font-semibold text-foreground">{list.label}</h3>
{actions.controls('LIST', list.name)}
</div>
{list.description && <p className="text-sm text-muted-foreground">{list.description}</p>}
{!list.rows.length ? (
<p className="text-sm text-muted-foreground">No items available.</p>
) : table ? (
<table
className="w-full table-fixed border-collapse text-left text-sm"
data-qqq-id={`preferences-list-table-${list.name}`}
>
<caption className="sr-only">{list.label}</caption>
<thead>
<tr>
<th scope="col" className="break-words p-2">
{list.rowLabel}
</th>
{list.fields
.filter((field) => !field.isHidden)
.map((field) => (
<th scope="col" className="break-words p-2" key={field.name}>
{field.label}
</th>
))}
{snapshot.actions?.some(
(action) => action.scope === 'ROW' && action.listName === list.name
) && (
<th scope="col" className="w-20 p-2">
Actions
</th>
)}
</tr>
</thead>
<tbody>
{list.rows.map((row, ri) => (
<tr
key={row.key}
tabIndex={-1}
data-preference-row-key={row.key}
className="border-t border-border"
>
<th scope="row" className="break-words p-2 align-top font-medium">
{row.label}
{row.description && (
<p className="text-xs font-normal text-muted-foreground">
{row.description}
</p>
)}
</th>
{list.fields.map(
(field, fi) =>
!field.isHidden && (
<td key={field.name} className="p-2 align-top">
{cell(`list${li}row${ri}field${fi}`, true)}
</td>
)
)}
{snapshot.actions?.some(
(action) => action.scope === 'ROW' && action.listName === list.name
) && (
<td className="p-2 align-top">{actions.controls('ROW', list.name, row)}</td>
)}
</tr>
))}
</tbody>
</table>
) : (
<div className="space-y-3" data-qqq-id={`preferences-list-cards-${list.name}`}>
{list.rows.map((row, ri) => (
<fieldset
key={row.key}
tabIndex={-1}
data-preference-row-key={row.key}
className="min-w-0 rounded-lg border border-border p-4"
>
<legend className="px-1 font-medium">{row.label}</legend>
{actions.controls('ROW', list.name, row)}
{row.description && (
<p className="mb-3 text-sm text-muted-foreground">{row.description}</p>
)}
<div className="grid min-w-0 gap-4 sm:grid-cols-2">
{list.fields.map(
(field, fi) =>
!field.isHidden && (
<div key={field.name}>{cell(`list${li}row${ri}field${fi}`)}</div>
)
)}
</div>
</fieldset>
))}
</div>
)}
</section>
))}
{!snapshot.fields.length && !snapshot.lists.length && (
<p className="text-muted-foreground">No preferences available in this section.</p>
)}
<div className="flex flex-wrap gap-3 border-t border-border pt-4">
<button
type="submit"
className={BUTTON_CLASS}
disabled={!dirty || mutation.isPending || actions.active}
data-qqq-id="preferences-save"
>
{mutation.isPending ? 'Saving…' : 'Save changes'}
</button>
<button
type="button"
className={BUTTON_CLASS}
disabled={!dirty || mutation.isPending || actions.active}
onClick={() => {
reset(defaults)
mutation.reset()
}}
data-qqq-id="preferences-discard"
>
Discard
</button>
</div>
</form>
)
}
@@ -0,0 +1,109 @@
/*
* 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 { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, 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'
const state = vi.hoisted(() => ({
search: '',
wide: true,
replace: vi.fn(),
setPageHeader: vi.fn(),
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: state.replace }),
useSearchParams: () => new URLSearchParams(state.search),
}))
vi.mock('@/lib/context/q-context', () => ({
useQContext: () => ({ setPageHeader: state.setPageHeader }),
}))
vi.mock('@/lib/hooks/use-media-query', () => ({ useMediaQuery: () => state.wide }))
beforeEach(() => {
state.search = ''
state.wide = true
vi.clearAllMocks()
})
const renderPage = () =>
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ThemeProvider>
<PreferencesPage />
</ThemeProvider>
</QueryClientProvider>
)
it('lists Display first, then every registered section with descriptions, and navigates via URL', async () => {
const { container } = renderPage()
await screen.findByRole('button', { name: /Regional settings/ })
const nav = screen.getByRole('navigation', { name: 'Preference sections' })
expect(nav.querySelectorAll('button')[0]).toHaveTextContent('Display')
expect(screen.getByText('Saved on this device', { exact: true })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Notifications What reaches/ })).toBeInTheDocument()
await userEvent.setup().click(screen.getByRole('button', { name: /Regional settings/ }))
expect(state.replace).toHaveBeenCalledWith('/app/preferences?section=regional', { scroll: false })
expect(await axe(container)).toHaveNoViolations()
})
it('opens a deep-linked section and renders a compact select on narrow screens', async () => {
state.search = 'section=regional'
state.wide = false
renderPage()
await screen.findByRole('heading', { name: 'Regional settings' })
expect(screen.getByRole('combobox', { name: 'Section' })).toHaveValue('regional')
expect(screen.getByRole('option', { name: 'Display' })).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Week starts on' })).toHaveValue('SUNDAY')
await userEvent
.setup()
.selectOptions(screen.getByRole('combobox', { name: 'Section' }), '__display')
expect(state.replace).toHaveBeenCalledWith('/app/preferences', { scroll: false })
})
it('explains an unavailable deep link', async () => {
state.search = 'section=missing'
renderPage()
await screen.findByText('This preference section is not available.')
})
it('keeps Display available when there are no provider sections', async () => {
server.use(http.get('/qqq/v1/preferences', () => HttpResponse.json({ sections: [] })))
renderPage()
await screen.findByText('No additional preferences are available.')
expect(screen.getByRole('heading', { name: 'Display' })).toBeInTheDocument()
})
it('offers retry on errors and keeps local display settings accessible', async () => {
server.use(
http.get('/qqq/v1/preferences', () =>
HttpResponse.json({ error: 'Unavailable' }, { status: 503 })
)
)
renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load preferences.')
expect(screen.getByRole('radio', { name: 'Light' })).toBeInTheDocument()
server.use(http.get('/qqq/v1/preferences', () => HttpResponse.json({ sections: [] })))
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
})
@@ -0,0 +1,181 @@
/*
* 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 Preferences route content, with device display settings and server-owned sections. */
'use client'
import { useEffect } 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 { BUTTON_CLASS } from '@/components/ui/controls'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { DisplayPreferencesPanel } from '@/components/layout/DisplayPreferencesPanel'
import { PreferenceSectionForm } from './PreferenceSectionForm'
/** Loading placeholders shared by the page and section drawer.
* @returns Accessible skeleton status.
*/
export function PreferencesSkeleton() {
return (
<div role="status" aria-busy="true" className="space-y-4 p-4" data-qqq-id="preferences-loading">
<span className="sr-only">Loading preferences…</span>
{[0, 1, 2].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
)
}
/** Every registered section and a client-only Display section, selected through the URL.
* @returns Preferences navigation and selected section.
*/
export function PreferencesPage() {
const { setPageHeader } = useQContext()
useEffect(() => setPageHeader('Preferences'), [setPageHeader])
const router = useRouter()
const params = useSearchParams()
const wide = useMediaQuery('(min-width: 1024px)')
const query = useQuery({
queryKey: queryKeys.preferencesIndex(),
queryFn: getPreferences,
retry: false,
})
// A client-only key cannot collide with the backend's [a-z] section names.
const requested = params.get('section')
const selected =
requested === 'display' && !query.data?.sections.some((section) => section.name === 'display')
? '__display'
: (requested ?? '__display')
const sections = [
{
name: '__display',
label: 'Display',
description: 'Appearance and default views. Saved on this device.',
icon: { name: 'display_settings' },
},
...(query.data?.sections ?? []),
]
const section = query.data?.sections.find((section) => section.name === selected)
const navigate = (name: string) =>
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>
<div className="grid min-w-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
<nav aria-label="Preference sections" className="min-w-0">
{wide ? (
<ul className="space-y-2">
{sections.map((item) => (
<li key={item.name}>
<button
type="button"
onClick={() => navigate(item.name)}
aria-current={selected === item.name ? 'page' : undefined}
className={`flex w-full items-start gap-3 rounded-lg p-3 text-left focus-visible:ring-2 focus-visible:ring-ring ${selected === item.name ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent'}`}
data-qqq-id={`preferences-section-nav-${item.name === '__display' ? 'display' : item.name}`}
>
<MetadataIcon
icon={item.icon}
kind="section"
className="mt-1 h-5 w-5 shrink-0"
/>
<span className="min-w-0 break-words">
<span className="block font-medium">{item.label}</span>
<span className="block text-sm text-muted-foreground">
{item.description}
</span>
</span>
</button>
</li>
))}
</ul>
) : (
<label className="block space-y-2 text-sm font-medium">
<span>Section</span>
<select
value={selected}
onChange={(event) => navigate(event.target.value)}
className="w-full min-w-0 rounded-md border border-input bg-background p-3 text-foreground"
data-qqq-id="preferences-section-select"
>
{!sections.some((item) => item.name === selected) && (
<option value={selected}>Unavailable section</option>
)}
{sections.map((item) => (
<option
key={item.name}
value={item.name}
data-qqq-id={`preferences-section-nav-${item.name === '__display' ? 'display' : item.name}`}
>
{item.label}
</option>
))}
</select>
</label>
)}
</nav>
<div className="min-w-0 space-y-4 rounded-lg border border-border bg-card p-4 sm:p-6">
{query.isError && (
<div role="alert" className="space-y-2 text-sm text-destructive">
<p>Could not load preferences.</p>
<button
type="button"
onClick={() => void query.refetch()}
className={BUTTON_CLASS}
data-qqq-id="preferences-retry"
>
Retry
</button>
</div>
)}
{selected === '__display' ? (
<>
<h2 className="text-lg font-semibold">Display</h2>
<DisplayPreferencesPanel />
</>
) : query.isLoading ? (
<PreferencesSkeleton />
) : section ? (
<>
<PreferenceSectionForm key={section.name} section={section} />
</>
) : (
!query.isError && (
<p role="status" className="text-muted-foreground">
This preference section is not available.
</p>
)
)}
{query.isLoading && selected === '__display' && <PreferencesSkeleton />}
{query.data?.sections.length === 0 && (
<p className="text-sm text-muted-foreground">
No additional preferences are available.
</p>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,39 @@
/*
* 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 Plain-English validation messages for preference writes and actions. */
import { isAxiosError } from 'axios'
import { getErrorMessage } from '@/lib/utils/error-utils'
/** All backend validation messages, retaining a useful fallback for other failures.
* @param error - Failed preference write response.
* @returns Messages for the error summary.
*/
export function preferenceMessages(error: unknown): string[] {
if (isAxiosError(error)) {
const body: unknown = error.response?.data
if (body && typeof body === 'object') {
for (const key of ['messages', 'errors'] as const) {
const value = (body as Record<string, unknown>)[key]
if (Array.isArray(value)) {
const texts = value.filter((item): item is string => typeof item === 'string')
if (texts.length) return texts
}
}
}
}
return [getErrorMessage(error, 'Could not save preferences. Please try again.')]
}
@@ -0,0 +1,51 @@
/*
* 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 { preferencesFixture } from '@/mocks/fixtures/preferences'
import { preferenceDefaults, preferencePatch, preferenceSlots } from './preference-values'
it('never submits hidden or locked fields and safely identifies punctuation-bearing row keys', () => {
const section = structuredClone(preferencesFixture.sections[0])
section.fields[2].isHidden = true
const slots = preferenceSlots(section)
const values = preferenceDefaults(slots)
values.scalar2 = 'UTC'
values.list0row1field0 = false
values.list1row0field0 = false
expect(preferencePatch(slots, values)).toEqual({
lists: { subscriptions: [{ key: 'order:42', values: { enabled: false } }] },
})
})
it('retains unchanged timestamp precision and sends UTC instants and explicit null when edited', () => {
const section = structuredClone(preferencesFixture.sections[0])
section.values.pausedUntil = '2026-11-01T07:30:00.123Z'
const slots = preferenceSlots(section)
const values = preferenceDefaults(slots)
expect(preferencePatch(slots, values)).toEqual({})
values.scalar0 = '2026-12-01T10:00:00'
expect(preferencePatch(slots, values).values?.pausedUntil).toMatch(/Z$/)
values.scalar0 = ''
expect(preferencePatch(slots, values)).toEqual({ values: { pausedUntil: null } })
})
it('does not send changes after reverting a numeric enum selection to its original ID', () => {
const slots = preferenceSlots(preferencesFixture.sections[0])
const values = preferenceDefaults(slots)
values.scalar1 = '9'
expect(preferencePatch(slots, values)).toEqual({})
})
@@ -0,0 +1,128 @@
/*
* 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 Mapping metadata into flat form slots and partial preference patches. */
import type { QFieldMetaData } from '@/types/metadata'
import type { PreferenceSection, PreferenceSectionPatch } from '@/types/preferences'
import { fromLocalDateTimeInput, toLocalDateTimeInput } from '@/lib/utils/datetime-utils'
/** One form slot, independent of punctuation in provider row keys or field names. */
export interface PreferenceSlot {
name: string
field: QFieldMetaData
value: unknown
list?: string
row?: string
readOnly: boolean
reason?: string | null
}
/** Flattens section fields and list cells into stable React Hook Form names.
* @param section - Section snapshot.
* @returns Editable and read-only slots.
*/
export function preferenceSlots(section: PreferenceSection): PreferenceSlot[] {
const slots: PreferenceSlot[] = section.fields.map((field, i) => ({
name: `scalar${i}`,
field,
value: Object.prototype.hasOwnProperty.call(section.values, field.name)
? section.values[field.name]
: (field.defaultValue ?? null),
readOnly: field.isEditable === false,
}))
section.lists.forEach((list, li) =>
list.rows.forEach((row, ri) =>
list.fields.forEach((field, fi) => {
slots.push({
name: `list${li}row${ri}field${fi}`,
field,
list: list.name,
row: row.key,
value: Object.prototype.hasOwnProperty.call(row.values, field.name)
? row.values[field.name]
: (field.defaultValue ?? null),
readOnly: field.isEditable === false || Boolean(row.readOnlyFields?.includes(field.name)),
reason: row.readOnlyReason,
})
})
)
)
return slots
}
/** Converts wire values to editor values, retaining booleans for the toggle.
* @param slots - Flattened metadata.
* @returns Initial form values.
*/
export function preferenceDefaults(slots: PreferenceSlot[]): Record<string, unknown> {
return Object.fromEntries(
slots.map(({ name, field, value }) => [
name,
field.type === 'DATE_TIME'
? toLocalDateTimeInput(value)
: field.type === 'BOOLEAN'
? value
: value == null
? ''
: String(value),
])
)
}
/** Coerces an edited control value back to its wire type.
* @param slot - Metadata and original value.
* @param value - Editor value.
* @returns Typed JSON value.
*/
export function wireValue(slot: PreferenceSlot, value: unknown): unknown {
if (value === '' || value == null) return null
if (slot.field.type === 'INTEGER' || slot.field.type === 'DECIMAL') return Number(value)
if (slot.field.type === 'DATE_TIME') return fromLocalDateTimeInput(String(value), slot.value)
if (slot.field.type === 'TIME' && /^\d{2}:\d{2}$/.test(String(value))) return `${value}:00`
return value
}
/** Builds a minimal patch; hidden/read-only slots are never submitted.
* @param slots - Snapshot used to start editing.
* @param values - Current control values.
* @returns Only changed fields, grouped by provider row key.
*/
export function preferencePatch(
slots: PreferenceSlot[],
values: Record<string, unknown>
): PreferenceSectionPatch {
const patch: PreferenceSectionPatch = {}
const defaults = preferenceDefaults(slots)
for (const slot of slots) {
if (slot.readOnly || slot.field.isHidden) continue
const value = wireValue(slot, values[slot.name])
if (Object.is(value, wireValue(slot, defaults[slot.name]))) continue
if (slot.list !== undefined && slot.row !== undefined) {
patch.lists ??= {}
const rows = (patch.lists[slot.list] ??= [])
let row = rows.find((row) => row.key === slot.row)
if (!row) {
row = { key: slot.row, values: {} }
rows.push(row)
}
row.values[slot.field.name] = value
} else {
patch.values ??= {}
patch.values[slot.field.name] = value
}
}
return patch
}
+150 -148
View File
@@ -1,148 +1,150 @@
/*
* 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.
*/
// Tests for the Alerts API client
import { http, HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { server } from '@/mocks/node'
import { getAlertsInbox, getAlertsInboxSummary, getAlertsRecordPanel, normalizeInboxItem, postInboxAction, watchAlertsRecord } from './alerts'
describe('getAlertsInboxSummary', () => {
it('returns the summary, with defaults filled in', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ unreadCount: 1, items: [] })))
await expect(getAlertsInboxSummary()).resolves.toEqual({ unreadCount: 1, highestUnreadSeverity: null, items: [], banners: [], notificationsHref: '/app/notifications', pollSeconds: undefined })
})
it('normalizes items and banners, and keeps an app notificationsHref', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({
unreadCount: 1,
highestUnreadSeverity: 'SUCCESS',
notificationsHref: '/app/inbox',
pollSeconds: 30,
items: [{ id: 3, title: 'Hi', severity: 'SUCCESS', category: 'JOB', source: 'Jobs', unread: true }, { title: '' }, null],
banners: [{ id: 4, title: 'Down tonight', severity: 'WARNING', body: '**soon**', sticky: true }],
})))
const summary = await getAlertsInboxSummary()
expect(summary?.highestUnreadSeverity).toBe('SUCCESS')
expect(summary?.notificationsHref).toBe('/app/inbox')
expect(summary?.pollSeconds).toBe(30)
expect(summary?.items).toEqual([expect.objectContaining({ key: 'n3', id: 3, title: 'Hi', category: 'JOB', source: 'Jobs', unread: true })])
expect(summary?.banners).toEqual([expect.objectContaining({ key: 'n4', body: '**soon**', sticky: true, unread: false })])
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ unreadCount: 0, items: [], notificationsHref: 'https://evil.example', highestUnreadSeverity: 'BOGUS' })))
await expect(getAlertsInboxSummary()).resolves.toMatchObject({ notificationsHref: '/app/notifications', highestUnreadSeverity: null })
})
it('returns null for 403, 404, and non-summary bodies', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => new HttpResponse(null, { status: 403 })))
await expect(getAlertsInboxSummary()).resolves.toBeNull()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => new HttpResponse(null, { status: 404 })))
await expect(getAlertsInboxSummary()).resolves.toBeNull()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json([1, 2])))
await expect(getAlertsInboxSummary()).resolves.toBeNull()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ unreadCount: 'x', items: [] })))
await expect(getAlertsInboxSummary()).resolves.toBeNull()
})
it('throws other errors', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => new HttpResponse(null, { status: 500 })))
await expect(getAlertsInboxSummary()).rejects.toBeTruthy()
})
})
describe('normalizeInboxItem', () => {
it('reads older backends: alertId, group and raisedAt', () => {
expect(normalizeInboxItem({ alertId: 6, title: 'Old', severity: 'ERROR', group: 'CDN', raisedAt: '2026-10-01T00:00:00Z', status: 'OPEN' }, 0)).toEqual({
key: 'a6', id: undefined, notificationId: undefined, alertId: 6, title: 'Old', body: null, severity: 'ERROR', category: null, source: 'CDN',
href: null, linkLabel: null, deliveredAt: '2026-10-01T00:00:00Z', age: null, unread: false, sticky: false,
})
expect(normalizeInboxItem({ title: 'Bare', severity: 'NOPE' }, 2)).toMatchObject({ key: 'i2', severity: 'INFO' })
expect(normalizeInboxItem('x', 0)).toBeNull()
})
})
describe('getAlertsInbox', () => {
it('sends the filter and normalizes the page', async () => {
let query: URLSearchParams | null = null
server.use(http.get('/qqq/v1/alerts/inbox', ({ request }) => {
query = new URL(request.url).searchParams
return HttpResponse.json({ items: [{ id: 1, title: 'A', severity: 'INFO' }], total: 40, offset: 25, limit: 25 })
}))
await expect(getAlertsInbox({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: 25, limit: 25 })).resolves.toMatchObject({ total: 40, offset: 25, limit: 25, items: [{ id: 1 }] })
expect(Object.fromEntries(query!)).toEqual({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: '25', limit: '25' })
})
it('fills paging from the filter for a sparse body', async () => {
server.use(http.get('/qqq/v1/alerts/inbox', () => HttpResponse.json({ items: [{ id: 1, title: 'A' }] })))
await expect(getAlertsInbox({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })).resolves.toMatchObject({ total: 1, offset: 0, limit: 25 })
server.use(http.get('/qqq/v1/alerts/inbox', () => HttpResponse.json(null)))
await expect(getAlertsInbox({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })).resolves.toMatchObject({ items: [], total: 0 })
})
})
describe('postInboxAction and watchAlertsRecord', () => {
it('posts JSON bodies and returns the counts', async () => {
const bodies: unknown[] = []
server.use(
http.post('/qqq/v1/alerts/inbox/:action', async ({ request, params }) => {
bodies.push([params.action, await request.json()])
return HttpResponse.json({ updated: 3 })
}),
http.post('/qqq/v1/alerts/record-panel/watch', async ({ request }) => {
bodies.push(await request.json())
return HttpResponse.json({ created: true })
})
)
await expect(postInboxAction('read', { all: true })).resolves.toBe(3)
await expect(postInboxAction('unread', { id: 4 })).resolves.toBe(3)
await expect(postInboxAction('dismiss', { ids: [4, 5] })).resolves.toBe(3)
await expect(watchAlertsRecord('blog_post', 5)).resolves.toBe(true)
expect(bodies).toEqual([['read', { all: true }], ['unread', { id: 4 }], ['dismiss', { ids: [4, 5] }], { tableName: 'blog_post', recordId: 5 }])
})
it('treats odd bodies as zero / false', async () => {
server.use(
http.post('/qqq/v1/alerts/inbox/read', () => HttpResponse.json({})),
http.post('/qqq/v1/alerts/record-panel/watch', () => HttpResponse.json({ created: false }))
)
await expect(postInboxAction('read', { alertId: 1 })).resolves.toBe(0)
await expect(watchAlertsRecord('blog_post', 5)).resolves.toBe(false)
})
})
describe('getAlertsRecordPanel', () => {
it('passes the table and record and returns the panel', async () => {
server.use(http.get('/qqq/v1/alerts/record-panel', ({ request }) => {
const url = new URL(request.url)
return HttpResponse.json({ visible: url.searchParams.get('tableName') === 'blog_post' && url.searchParams.get('recordId') === '7', open: [] })
}))
await expect(getAlertsRecordPanel('blog_post', 7)).resolves.toMatchObject({ visible: true })
})
it('returns null for 400, 403, 404, and non-panel bodies; throws others', async () => {
for(const status of [400, 403, 404])
{
server.use(http.get('/qqq/v1/alerts/record-panel', () => new HttpResponse(null, { status })))
await expect(getAlertsRecordPanel('t', 1)).resolves.toBeNull()
}
server.use(http.get('/qqq/v1/alerts/record-panel', () => HttpResponse.json({ nope: true })))
await expect(getAlertsRecordPanel('t', 1)).resolves.toBeNull()
server.use(http.get('/qqq/v1/alerts/record-panel', () => new HttpResponse(null, { status: 500 })))
await expect(getAlertsRecordPanel('t', 1)).rejects.toBeTruthy()
})
})
/*
* 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.
*/
// Tests for the Alerts API client
import { http, HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { server } from '@/mocks/node'
import { getAlertsInbox, getAlertsInboxSummary, getAlertsRecordPanel, normalizeInboxItem, postInboxAction, watchAlertsRecord } from './alerts'
describe('getAlertsInboxSummary', () => {
it('returns the summary, with defaults filled in', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ unreadCount: 1, items: [] })))
await expect(getAlertsInboxSummary()).resolves.toEqual({ unreadCount: 1, highestUnreadSeverity: null, items: [], banners: [], notificationsHref: '/app/notifications', preferencesSection: null, pollSeconds: undefined })
})
it('normalizes items and banners, and keeps an app notificationsHref', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({
unreadCount: 1,
highestUnreadSeverity: 'SUCCESS',
notificationsHref: '/app/inbox',
preferencesSection: 'notifications',
pollSeconds: 30,
items: [{ id: 3, title: 'Hi', severity: 'SUCCESS', category: 'JOB', source: 'Jobs', unread: true }, { title: '' }, null],
banners: [{ id: 4, title: 'Down tonight', severity: 'WARNING', body: '**soon**', sticky: true }],
})))
const summary = await getAlertsInboxSummary()
expect(summary?.highestUnreadSeverity).toBe('SUCCESS')
expect(summary?.notificationsHref).toBe('/app/inbox')
expect(summary?.pollSeconds).toBe(30)
expect(summary?.preferencesSection).toBe('notifications')
expect(summary?.items).toEqual([expect.objectContaining({ key: 'n3', id: 3, title: 'Hi', category: 'JOB', source: 'Jobs', unread: true })])
expect(summary?.banners).toEqual([expect.objectContaining({ key: 'n4', body: '**soon**', sticky: true, unread: false })])
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ unreadCount: 0, items: [], notificationsHref: 'https://evil.example', highestUnreadSeverity: 'BOGUS' })))
await expect(getAlertsInboxSummary()).resolves.toMatchObject({ notificationsHref: '/app/notifications', highestUnreadSeverity: null })
})
it('returns null for 403, 404, and non-summary bodies', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => new HttpResponse(null, { status: 403 })))
await expect(getAlertsInboxSummary()).resolves.toBeNull()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => new HttpResponse(null, { status: 404 })))
await expect(getAlertsInboxSummary()).resolves.toBeNull()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json([1, 2])))
await expect(getAlertsInboxSummary()).resolves.toBeNull()
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ unreadCount: 'x', items: [] })))
await expect(getAlertsInboxSummary()).resolves.toBeNull()
})
it('throws other errors', async () => {
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => new HttpResponse(null, { status: 500 })))
await expect(getAlertsInboxSummary()).rejects.toBeTruthy()
})
})
describe('normalizeInboxItem', () => {
it('reads older backends: alertId, group and raisedAt', () => {
expect(normalizeInboxItem({ alertId: 6, title: 'Old', severity: 'ERROR', group: 'CDN', raisedAt: '2026-10-01T00:00:00Z', status: 'OPEN' }, 0)).toEqual({
key: 'a6', id: undefined, notificationId: undefined, alertId: 6, title: 'Old', body: null, severity: 'ERROR', category: null, source: 'CDN',
href: null, linkLabel: null, deliveredAt: '2026-10-01T00:00:00Z', age: null, unread: false, sticky: false,
})
expect(normalizeInboxItem({ title: 'Bare', severity: 'NOPE' }, 2)).toMatchObject({ key: 'i2', severity: 'INFO' })
expect(normalizeInboxItem('x', 0)).toBeNull()
})
})
describe('getAlertsInbox', () => {
it('sends the filter and normalizes the page', async () => {
let query: URLSearchParams | null = null
server.use(http.get('/qqq/v1/alerts/inbox', ({ request }) => {
query = new URL(request.url).searchParams
return HttpResponse.json({ items: [{ id: 1, title: 'A', severity: 'INFO' }], total: 40, offset: 25, limit: 25 })
}))
await expect(getAlertsInbox({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: 25, limit: 25 })).resolves.toMatchObject({ total: 40, offset: 25, limit: 25, items: [{ id: 1 }] })
expect(Object.fromEntries(query!)).toEqual({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: '25', limit: '25' })
})
it('fills paging from the filter for a sparse body', async () => {
server.use(http.get('/qqq/v1/alerts/inbox', () => HttpResponse.json({ items: [{ id: 1, title: 'A' }] })))
await expect(getAlertsInbox({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })).resolves.toMatchObject({ total: 1, offset: 0, limit: 25 })
server.use(http.get('/qqq/v1/alerts/inbox', () => HttpResponse.json(null)))
await expect(getAlertsInbox({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })).resolves.toMatchObject({ items: [], total: 0 })
})
})
describe('postInboxAction and watchAlertsRecord', () => {
it('posts JSON bodies and returns the counts', async () => {
const bodies: unknown[] = []
server.use(
http.post('/qqq/v1/alerts/inbox/:action', async ({ request, params }) => {
bodies.push([params.action, await request.json()])
return HttpResponse.json({ updated: 3 })
}),
http.post('/qqq/v1/alerts/record-panel/watch', async ({ request }) => {
bodies.push(await request.json())
return HttpResponse.json({ created: true })
})
)
await expect(postInboxAction('read', { all: true })).resolves.toBe(3)
await expect(postInboxAction('unread', { id: 4 })).resolves.toBe(3)
await expect(postInboxAction('dismiss', { ids: [4, 5] })).resolves.toBe(3)
await expect(watchAlertsRecord('blog_post', 5)).resolves.toBe(true)
expect(bodies).toEqual([['read', { all: true }], ['unread', { id: 4 }], ['dismiss', { ids: [4, 5] }], { tableName: 'blog_post', recordId: 5 }])
})
it('treats odd bodies as zero / false', async () => {
server.use(
http.post('/qqq/v1/alerts/inbox/read', () => HttpResponse.json({})),
http.post('/qqq/v1/alerts/record-panel/watch', () => HttpResponse.json({ created: false }))
)
await expect(postInboxAction('read', { alertId: 1 })).resolves.toBe(0)
await expect(watchAlertsRecord('blog_post', 5)).resolves.toBe(false)
})
})
describe('getAlertsRecordPanel', () => {
it('passes the table and record and returns the panel', async () => {
server.use(http.get('/qqq/v1/alerts/record-panel', ({ request }) => {
const url = new URL(request.url)
return HttpResponse.json({ visible: url.searchParams.get('tableName') === 'blog_post' && url.searchParams.get('recordId') === '7', open: [] })
}))
await expect(getAlertsRecordPanel('blog_post', 7)).resolves.toMatchObject({ visible: true })
})
it('returns null for 400, 403, 404, and non-panel bodies; throws others', async () => {
for(const status of [400, 403, 404])
{
server.use(http.get('/qqq/v1/alerts/record-panel', () => new HttpResponse(null, { status })))
await expect(getAlertsRecordPanel('t', 1)).resolves.toBeNull()
}
server.use(http.get('/qqq/v1/alerts/record-panel', () => HttpResponse.json({ nope: true })))
await expect(getAlertsRecordPanel('t', 1)).resolves.toBeNull()
server.use(http.get('/qqq/v1/alerts/record-panel', () => new HttpResponse(null, { status: 500 })))
await expect(getAlertsRecordPanel('t', 1)).rejects.toBeTruthy()
})
})
+183 -182
View File
@@ -1,182 +1,183 @@
/*
* 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 Alerts API — the Alerts qbit's notification center (`/alerts/inbox*`) and record panel
* (`/alerts/record-panel*`). Callers only request these when the instance metadata lists the
* qbit's `alerts` app (see `isAlertsInstalled`). As a fallback, 403 and 404 from the inbox
* summary (or a body that is not a summary, e.g. a root SPA's not-found page) still mean Alerts
* is not for this user, so the bell and panel stay hidden. Items are normalized so older
* backends (qbit-alerts 0.1.0-RC.1: `alertId`, `group`, no `id`) still render.
*/
import type { AlertsInboxSummary, AlertsRecordPanel, InboxFilter, InboxItem, InboxPage, InboxTarget, NotificationSeverity } from '@/types/alerts'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import apiClient from './client'
/** The notifications page, unless the backend names another app path. */
export const NOTIFICATIONS_PATH = '/app/notifications'
const SEVERITIES = ['INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL']
/**
* A finite number, or undefined.
*
* @param value - Any value.
* @returns The number, or undefined.
*/
const num = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
/**
* A non-empty string, or null.
*
* @param value - Any value.
* @returns The string, or null.
*/
const str = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
/**
* Normalizes one item of either backend shape; null when it has no title.
*
* @param raw - The item as sent.
* @param index - Its position (the fallback key).
* @returns The item, or null.
*/
export function normalizeInboxItem(raw: unknown, index: number): InboxItem | null {
if (!raw || typeof raw !== 'object') return null
const item = raw as Record<string, unknown>
const title = str(item.title)
if (!title) return null
const id = num(item.id)
const alertId = num(item.alertId)
const severity = SEVERITIES.includes(item.severity as string) ? (item.severity as NotificationSeverity) : 'INFO'
return {
key: id !== undefined ? `n${id}` : alertId !== undefined ? `a${alertId}` : `i${index}`,
id,
notificationId: num(item.notificationId),
alertId,
title,
body: str(item.body),
severity,
category: str(item.category),
source: str(item.source) ?? str(item.group),
href: str(item.href),
linkLabel: str(item.linkLabel),
deliveredAt: str(item.deliveredAt) ?? str(item.raisedAt),
age: str(item.age),
unread: item.unread === true,
sticky: item.sticky === true,
}
}
/**
* Normalizes a list of items, dropping any without a title.
*
* @param raw - The list as sent.
* @returns The items.
*/
function normalizeItems(raw: unknown): InboxItem[] {
return Array.isArray(raw) ? raw.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null) : []
}
/**
* Loads the bell: unread count, highest unread severity, the latest items and the banners.
*
* @returns The summary, or `null` when Alerts is not available to this user.
* @throws The request error for any other failure.
*/
export async function getAlertsInboxSummary(): Promise<AlertsInboxSummary | null> {
let data: unknown
try {
data = await apiClient.get<unknown>('/alerts/inbox/summary')
} catch (error) {
const status = getErrorStatusCode(error)
if (status === 403 || status === 404) return null
throw error
}
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
const summary = data as Record<string, unknown>
if (typeof summary.unreadCount !== 'number' || !Array.isArray(summary.items)) return null
const href = str(summary.notificationsHref)
return {
unreadCount: summary.unreadCount,
highestUnreadSeverity: SEVERITIES.includes(summary.highestUnreadSeverity as string) ? (summary.highestUnreadSeverity as NotificationSeverity) : null,
items: normalizeItems(summary.items),
banners: normalizeItems(summary.banners),
notificationsHref: href && href.startsWith('/app/') ? href : NOTIFICATIONS_PATH,
pollSeconds: num(summary.pollSeconds),
}
}
/**
* Loads one page of the inbox.
*
* @param filter - Status, category, severity and paging.
* @returns The page (an empty page for a body that is not one).
*/
export async function getAlertsInbox(filter: InboxFilter): Promise<InboxPage> {
const params: Record<string, string> = { status: filter.status, offset: String(filter.offset), limit: String(filter.limit) }
if (filter.category) params.category = filter.category
if (filter.severity) params.severity = filter.severity
const data = await apiClient.get<Record<string, unknown> | null>('/alerts/inbox', { params })
const items = normalizeItems(data?.items)
return { items, total: num(data?.total) ?? items.length, offset: num(data?.offset) ?? filter.offset, limit: num(data?.limit) ?? filter.limit }
}
/**
* Posts an inbox action.
*
* @param action - `read`, `unread` or `dismiss`.
* @param target - `{id}`, `{ids}`, `{all: true}`, or (read only, older backends) `{alertId}`.
* @returns How many items changed.
*/
export async function postInboxAction(action: 'read' | 'unread' | 'dismiss', target: InboxTarget): Promise<number> {
const data = await apiClient.post<{ updated?: number }>(`/alerts/inbox/${action}`, target)
return typeof data?.updated === 'number' ? data.updated : 0
}
/**
* Loads the "Open alerts" panel for a record.
*
* @param tableName - The record's table.
* @param recordId - The record's primary key.
* @returns The panel, or `null` when Alerts is not available (or not for this table).
* @throws The request error for any other failure.
*/
export async function getAlertsRecordPanel(tableName: string, recordId: string | number): Promise<AlertsRecordPanel | null> {
let data: unknown
try {
data = await apiClient.get<unknown>('/alerts/record-panel', { params: { tableName, recordId: String(recordId) } })
} catch (error) {
const status = getErrorStatusCode(error)
if (status === 400 || status === 403 || status === 404) return null
throw error
}
if (!data || typeof data !== 'object' || Array.isArray(data) || typeof (data as { visible?: unknown }).visible !== 'boolean') return null
return data as AlertsRecordPanel
}
/**
* "Watch alerts for this record": subscribes the user to the record's alerts.
*
* @param tableName - The record's table.
* @param recordId - The record's primary key.
* @returns Whether a new subscription was created (false when already watching).
*/
export async function watchAlertsRecord(tableName: string, recordId: string | number): Promise<boolean> {
const data = await apiClient.post<{ created?: boolean }>('/alerts/record-panel/watch', { tableName, recordId })
return data?.created === true
}
/*
* 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 Alerts API — the Alerts qbit's notification center (`/alerts/inbox*`) and record panel
* (`/alerts/record-panel*`). Callers only request these when the instance metadata lists the
* qbit's `alerts` app (see `isAlertsInstalled`). As a fallback, 403 and 404 from the inbox
* summary (or a body that is not a summary, e.g. a root SPA's not-found page) still mean Alerts
* is not for this user, so the bell and panel stay hidden. Items are normalized so older
* backends (qbit-alerts 0.1.0-RC.1: `alertId`, `group`, no `id`) still render.
*/
import type { AlertsInboxSummary, AlertsRecordPanel, InboxFilter, InboxItem, InboxPage, InboxTarget, NotificationSeverity } from '@/types/alerts'
import { getErrorStatusCode } from '@/lib/utils/error-utils'
import apiClient from './client'
/** The notifications page, unless the backend names another app path. */
export const NOTIFICATIONS_PATH = '/app/notifications'
const SEVERITIES = ['INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL']
/**
* A finite number, or undefined.
*
* @param value - Any value.
* @returns The number, or undefined.
*/
const num = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
/**
* A non-empty string, or null.
*
* @param value - Any value.
* @returns The string, or null.
*/
const str = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
/**
* Normalizes one item of either backend shape; null when it has no title.
*
* @param raw - The item as sent.
* @param index - Its position (the fallback key).
* @returns The item, or null.
*/
export function normalizeInboxItem(raw: unknown, index: number): InboxItem | null {
if (!raw || typeof raw !== 'object') return null
const item = raw as Record<string, unknown>
const title = str(item.title)
if (!title) return null
const id = num(item.id)
const alertId = num(item.alertId)
const severity = SEVERITIES.includes(item.severity as string) ? (item.severity as NotificationSeverity) : 'INFO'
return {
key: id !== undefined ? `n${id}` : alertId !== undefined ? `a${alertId}` : `i${index}`,
id,
notificationId: num(item.notificationId),
alertId,
title,
body: str(item.body),
severity,
category: str(item.category),
source: str(item.source) ?? str(item.group),
href: str(item.href),
linkLabel: str(item.linkLabel),
deliveredAt: str(item.deliveredAt) ?? str(item.raisedAt),
age: str(item.age),
unread: item.unread === true,
sticky: item.sticky === true,
}
}
/**
* Normalizes a list of items, dropping any without a title.
*
* @param raw - The list as sent.
* @returns The items.
*/
function normalizeItems(raw: unknown): InboxItem[] {
return Array.isArray(raw) ? raw.map(normalizeInboxItem).filter((item): item is InboxItem => item !== null) : []
}
/**
* Loads the bell: unread count, highest unread severity, the latest items and the banners.
*
* @returns The summary, or `null` when Alerts is not available to this user.
* @throws The request error for any other failure.
*/
export async function getAlertsInboxSummary(): Promise<AlertsInboxSummary | null> {
let data: unknown
try {
data = await apiClient.get<unknown>('/alerts/inbox/summary')
} catch (error) {
const status = getErrorStatusCode(error)
if (status === 403 || status === 404) return null
throw error
}
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
const summary = data as Record<string, unknown>
if (typeof summary.unreadCount !== 'number' || !Array.isArray(summary.items)) return null
const href = str(summary.notificationsHref)
return {
unreadCount: summary.unreadCount,
highestUnreadSeverity: SEVERITIES.includes(summary.highestUnreadSeverity as string) ? (summary.highestUnreadSeverity as NotificationSeverity) : null,
items: normalizeItems(summary.items),
banners: normalizeItems(summary.banners),
notificationsHref: href && href.startsWith('/app/') ? href : NOTIFICATIONS_PATH,
preferencesSection: str(summary.preferencesSection),
pollSeconds: num(summary.pollSeconds),
}
}
/**
* Loads one page of the inbox.
*
* @param filter - Status, category, severity and paging.
* @returns The page (an empty page for a body that is not one).
*/
export async function getAlertsInbox(filter: InboxFilter): Promise<InboxPage> {
const params: Record<string, string> = { status: filter.status, offset: String(filter.offset), limit: String(filter.limit) }
if (filter.category) params.category = filter.category
if (filter.severity) params.severity = filter.severity
const data = await apiClient.get<Record<string, unknown> | null>('/alerts/inbox', { params })
const items = normalizeItems(data?.items)
return { items, total: num(data?.total) ?? items.length, offset: num(data?.offset) ?? filter.offset, limit: num(data?.limit) ?? filter.limit }
}
/**
* Posts an inbox action.
*
* @param action - `read`, `unread` or `dismiss`.
* @param target - `{id}`, `{ids}`, `{all: true}`, or (read only, older backends) `{alertId}`.
* @returns How many items changed.
*/
export async function postInboxAction(action: 'read' | 'unread' | 'dismiss', target: InboxTarget): Promise<number> {
const data = await apiClient.post<{ updated?: number }>(`/alerts/inbox/${action}`, target)
return typeof data?.updated === 'number' ? data.updated : 0
}
/**
* Loads the "Open alerts" panel for a record.
*
* @param tableName - The record's table.
* @param recordId - The record's primary key.
* @returns The panel, or `null` when Alerts is not available (or not for this table).
* @throws The request error for any other failure.
*/
export async function getAlertsRecordPanel(tableName: string, recordId: string | number): Promise<AlertsRecordPanel | null> {
let data: unknown
try {
data = await apiClient.get<unknown>('/alerts/record-panel', { params: { tableName, recordId: String(recordId) } })
} catch (error) {
const status = getErrorStatusCode(error)
if (status === 400 || status === 403 || status === 404) return null
throw error
}
if (!data || typeof data !== 'object' || Array.isArray(data) || typeof (data as { visible?: unknown }).visible !== 'boolean') return null
return data as AlertsRecordPanel
}
/**
* "Watch alerts for this record": subscribes the user to the record's alerts.
*
* @param tableName - The record's table.
* @param recordId - The record's primary key.
* @returns Whether a new subscription was created (false when already watching).
*/
export async function watchAlertsRecord(tableName: string, recordId: string | number): Promise<boolean> {
const data = await apiClient.post<{ created?: boolean }>('/alerts/record-panel/watch', { tableName, recordId })
return data?.created === true
}
+482 -1
View File
@@ -6875,6 +6875,315 @@
}
},
"type": "object"
},
"PreferenceActionRequestV1": {
"additionalProperties": false,
"description": "Only rowKey and values are accepted. Identity always comes from the authenticated session.",
"properties": {
"rowKey": {
"description": "Required for ROW actions: a key loaded for this user with the action allowed. Forbidden for SECTION and LIST actions.",
"type": "string"
},
"values": {
"additionalProperties": {},
"description": "Action input fields. Validated and coerced like PATCH fields; unknown fields are rejected.",
"type": "object"
}
},
"type": "object"
},
"PreferenceActionResponseV1": {
"description": "Action result and reloaded section. Secrets are shown once and must not be stored by clients. Response uses Cache-Control: no-store.",
"properties": {
"section": {
"$ref": "#/components/schemas/PreferenceSectionV1"
},
"message": {
"description": "User-facing outcome message.",
"type": "string"
},
"secrets": {
"description": "One-time secrets for immediate display; clients must not store them.",
"items": {
"$ref": "#/components/schemas/PreferenceSecretV1"
},
"type": "array"
},
"focusRowKey": {
"description": "Optional row key to focus after reloading the section.",
"type": "string"
}
},
"type": "object"
},
"PreferenceActionV1": {
"properties": {
"name": {
"description": "Stable action name used in URLs.",
"type": "string"
},
"label": {
"description": "Human-readable action label.",
"type": "string"
},
"icon": {
"$ref": "#/components/schemas/Icon"
},
"description": {
"description": "Explanation for clients to display.",
"type": "string"
},
"scope": {
"description": "Where to display this action. ROW actions require a loaded row key.",
"enum": [
"SECTION",
"LIST",
"ROW"
],
"type": "string"
},
"listName": {
"description": "List targeted by LIST or ROW actions; null for SECTION.",
"nullable": true,
"type": "string"
},
"confirmationText": {
"description": "When set, clients must confirm with this text before running the action.",
"nullable": true,
"type": "string"
},
"isDestructive": {
"description": "Whether clients should present this as a destructive action.",
"type": "boolean"
},
"inputFields": {
"description": "Action inputs, using preference field validation and coercion.",
"items": {
"$ref": "#/components/schemas/FieldMetaData"
},
"type": "array"
},
"order": {
"description": "Display order, ascending then label; defaults to 100.",
"type": "integer"
}
},
"type": "object"
},
"PreferenceListV1": {
"description": "A list of fixed preference rows, rendered with the same typed field editors as scalar preferences.",
"properties": {
"name": {
"description": "List name used in PATCH lists.",
"type": "string"
},
"label": {
"description": "Human-readable list title.",
"type": "string"
},
"description": {
"description": "Explanation of this list.",
"type": "string"
},
"rowLabel": {
"description": "Label for one row, such as Category.",
"type": "string"
},
"fields": {
"description": "Editable columns and their validation rules.",
"items": {
"$ref": "#/components/schemas/FieldMetaData"
},
"type": "array"
},
"rows": {
"description": "Fixed rows available to this user.",
"items": {
"$ref": "#/components/schemas/PreferenceRowV1"
},
"type": "array"
}
},
"type": "object"
},
"PreferenceRowV1": {
"description": "A provider-owned row. Clients change row membership only through registered actions.",
"properties": {
"actionNames": {
"description": "Null allows every ROW action of this list; empty allows none; otherwise only the named actions are allowed.",
"items": {
"type": "string"
},
"nullable": true,
"type": "array"
},
"key": {
"description": "Stable provider row key to send when patching.",
"type": "string"
},
"label": {
"description": "Human-readable row label.",
"type": "string"
},
"description": {
"description": "Explanation of this row.",
"type": "string"
},
"values": {
"additionalProperties": {},
"description": "Field name to typed value. Omitted fields retain their existing values; explicit null clears an optional field.",
"type": "object"
},
"readOnlyFields": {
"description": "Field names that cannot be changed for this row.",
"items": {
"type": "string"
},
"type": "array"
},
"readOnlyReason": {
"description": "Explain why the indicated fields are read-only.",
"type": "string"
}
},
"type": "object"
},
"PreferenceSecretV1": {
"properties": {
"label": {
"description": "Human-readable secret label.",
"type": "string"
},
"value": {
"description": "Shown once. Clients must not store, log, cache, or send this secret to analytics.",
"type": "string"
},
"masked": {
"description": "Masked representation, for example qqq_…7f3a.",
"type": "string"
},
"usageText": {
"description": "Usage guidance such as a curl example; may contain newlines.",
"type": "string"
}
},
"type": "object"
},
"PreferenceSectionPatchRequestV1": {
"additionalProperties": false,
"description": "Partial update for the signed-in user only. Unknown properties, fields, lists and rows, including userId, are rejected with 400. All validation errors are returned together; omitted fields and rows are unchanged.",
"properties": {
"values": {
"additionalProperties": {},
"description": "Changed scalar fields only. Explicit null clears an optional field. Types, required, maxLength, ENUM membership and editability are validated.",
"type": "object"
},
"lists": {
"additionalProperties": {
"items": {
"additionalProperties": false,
"properties": {
"key": {
"description": "Required fixed row key from the section response. No new rows can be created.",
"type": "string"
},
"values": {
"additionalProperties": {},
"description": "Changed row fields only. Read-only fields are rejected.",
"type": "object"
}
},
"type": "object"
},
"type": "array"
},
"description": "List name to changed fixed rows. No add or remove operation is supported.",
"type": "object"
}
},
"type": "object"
},
"PreferenceSectionV1": {
"description": "One visible section with the signed-in user's current preferences.",
"properties": {
"showInUserMenu": {
"description": "Add a user-menu entry using the section label and icon to open this section; defaults to false.",
"type": "boolean"
},
"name": {
"description": "Stable section name used in URLs.",
"type": "string"
},
"label": {
"description": "Human-readable section title.",
"type": "string"
},
"description": {
"description": "Explanation for clients to display.",
"type": "string"
},
"appName": {
"description": "Optional owning application name.",
"type": "string"
},
"icon": {
"$ref": "#/components/schemas/Icon"
},
"order": {
"description": "Display order, ascending then label; defaults to 100.",
"type": "integer"
},
"fields": {
"description": "Scalar field definitions with editability, type, defaults and validation.",
"items": {
"$ref": "#/components/schemas/FieldMetaData"
},
"type": "array"
},
"values": {
"additionalProperties": {},
"description": "Field name to typed value. Omitted fields retain their existing values; explicit null clears an optional field.",
"type": "object"
},
"actions": {
"description": "Registered section, list and row actions, ordered by order then label.",
"items": {
"$ref": "#/components/schemas/PreferenceActionV1"
},
"type": "array"
},
"lists": {
"description": "Optional fixed-row preference lists.",
"items": {
"$ref": "#/components/schemas/PreferenceListV1"
},
"type": "array"
},
"possibleValues": {
"additionalProperties": {
"items": {
"$ref": "#/components/schemas/PossibleValueOption"
},
"type": "array"
},
"description": "Every ENUM possible-value source referenced by scalar, list or action input fields, keyed by source name.",
"type": "object"
}
},
"type": "object"
},
"PreferencesResponseV1": {
"description": "Visible sections in display order, with only the signed-in user's values.",
"properties": {
"sections": {
"description": "Installed sections the session may access; an empty list is valid.",
"items": {
"$ref": "#/components/schemas/PreferenceSectionV1"
},
"type": "array"
}
},
"type": "object"
}
},
"securitySchemes": {
@@ -10416,6 +10725,178 @@
"Widgets"
]
}
},
"/qqq/v1/preferences": {
"get": {
"description": "Preferences always belong to the authenticated session user. No user identifier is accepted. Unknown or hidden sections return 404; unauthenticated requests return 401. PATCH changes only supplied fields and fixed rows; invalid changes return 400 with all messages before anything is stored. Writes are audited as the user and client channel.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreferencesResponseV1"
}
}
},
"description": "Current preferences for the session user"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "List visible sections and the signed-in user's preferences",
"tags": [
"General"
]
}
},
"/qqq/v1/preferences/{sectionName}": {
"get": {
"description": "Preferences always belong to the authenticated session user. No user identifier is accepted. Unknown or hidden sections return 404; unauthenticated requests return 401. PATCH changes only supplied fields and fixed rows; invalid changes return 400 with all messages before anything is stored. Writes are audited as the user and client channel.",
"parameters": [
{
"description": "Registered visible preference section name, from GET /preferences.",
"in": "path",
"name": "sectionName",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreferenceSectionV1"
}
}
},
"description": "Current preferences for the session user"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "Read one visible section for the signed-in user",
"tags": [
"General"
]
},
"patch": {
"description": "Preferences always belong to the authenticated session user. No user identifier is accepted. Unknown or hidden sections return 404; unauthenticated requests return 401. PATCH changes only supplied fields and fixed rows; invalid changes return 400 with all messages before anything is stored. Writes are audited as the user and client channel.",
"parameters": [
{
"description": "Registered visible preference section name, from GET /preferences.",
"in": "path",
"name": "sectionName",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreferenceSectionPatchRequestV1"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreferenceSectionV1"
}
}
},
"description": "Current preferences for the session user"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "Partially update the signed-in user's preferences",
"tags": [
"General"
]
}
},
"/qqq/v1/preferences/{sectionName}/actions/{actionName}": {
"post": {
"description": "Runs a registered action for the authenticated session user. ROW actions target only rows loaded for that user and allowed by actionNames. Unknown actions or hidden sections return 404; invalid inputs return 400; unauthenticated requests return 401. Clients must confirm confirmationText before running. Secrets are shown once and must not be stored, logged, cached, or sent to analytics by clients. Responses use Cache-Control: no-store.",
"parameters": [
{
"description": "Registered visible preference section name, from GET /preferences.",
"in": "path",
"name": "sectionName",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Registered action name from the visible section.",
"in": "path",
"name": "actionName",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreferenceActionRequestV1"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PreferenceActionResponseV1"
}
}
},
"description": "Current preferences for the session user"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "Run an action on the signed-in user's preferences",
"tags": [
"General"
]
}
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
/*
* 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getPreferences,
getPreferenceSection,
patchPreferenceSection,
runPreferenceAction,
} from './preferences'
import apiClient from './client'
import { preferencesFixture } from '@/mocks/fixtures/preferences'
vi.mock('./client', () => ({ default: { get: vi.fn(), patch: vi.fn(), post: vi.fn() } }))
beforeEach(() => vi.clearAllMocks())
describe('preferences API', () => {
it('POSTs encoded action names and the exact row/input body', async () => {
const result = { section: preferencesFixture.sections[0], secrets: [] }
const body = { rowKey: 'row:1', values: { description: 'Script' } }
vi.mocked(apiClient.post).mockResolvedValue(result)
expect(await runPreferenceAction('a/b', 'rotate/now', body)).toBe(result)
expect(apiClient.post).toHaveBeenCalledWith('/preferences/a%2Fb/actions/rotate%2Fnow', body)
await runPreferenceAction('tokens', 'refresh', {})
expect(apiClient.post).toHaveBeenLastCalledWith('/preferences/tokens/actions/refresh', {})
})
it('loads the section index through the shared client', async () => {
vi.mocked(apiClient.get).mockResolvedValue(preferencesFixture)
expect(await getPreferences()).toBe(preferencesFixture)
expect(apiClient.get).toHaveBeenCalledWith('/preferences')
})
it('encodes section names when loading', async () => {
vi.mocked(apiClient.get).mockResolvedValue(preferencesFixture.sections[0])
expect(await getPreferenceSection('a/b')).toBe(preferencesFixture.sections[0])
expect(apiClient.get).toHaveBeenCalledWith('/preferences/a%2Fb')
})
it('sends the exact partial PATCH body and returns the reloaded section', async () => {
const patch = {
values: { digestHour: 12 },
lists: { categories: [{ key: 'SYSTEM', values: { inApp: false } }] },
}
vi.mocked(apiClient.patch).mockResolvedValue(preferencesFixture.sections[0])
expect(await patchPreferenceSection('a/b', patch)).toBe(preferencesFixture.sections[0])
expect(apiClient.patch).toHaveBeenCalledWith('/preferences/a%2Fb', patch)
})
})
+69
View File
@@ -0,0 +1,69 @@
/*
* 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 Current-user preferences through the shared authenticated UI API client. */
import type {
PreferencesResponse,
PreferenceSection,
PreferenceSectionPatch,
PreferenceActionRequest,
PreferenceActionResponse,
} from '@/types/preferences'
import apiClient from './client'
/** Loads every visible preference section.
* @returns Ordered sections with metadata and values.
*/
export function getPreferences(): Promise<PreferencesResponse> {
return apiClient.get<PreferencesResponse>('/preferences')
}
/** Loads one visible section.
* @param name - Section name from metadata.
* @returns The section with current values.
*/
export function getPreferenceSection(name: string): Promise<PreferenceSection> {
return apiClient.get<PreferenceSection>(`/preferences/${encodeURIComponent(name)}`)
}
/** Saves a partial change to the signed-in user's section.
* @param name - Section name from metadata.
* @param patch - Only the changed fields and rows.
* @returns The reloaded section.
*/
export function patchPreferenceSection(
name: string,
patch: PreferenceSectionPatch
): Promise<PreferenceSection> {
return apiClient.patch<PreferenceSection>(`/preferences/${encodeURIComponent(name)}`, patch)
}
/** Runs a registered action without retaining its one-time result.
* @param section - Visible section name.
* @param action - Registered action name.
* @param request - Optional row key and action input values.
* @returns Reloaded section and ephemeral action result.
*/
export function runPreferenceAction(
section: string,
action: string,
request: PreferenceActionRequest
): Promise<PreferenceActionResponse> {
return apiClient.post<PreferenceActionResponse>(
`/preferences/${encodeURIComponent(section)}/actions/${encodeURIComponent(action)}`,
request
)
}
+599 -586
View File
File diff suppressed because it is too large. Load diff
+214
View File
@@ -0,0 +1,214 @@
/*
* 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 Realistic provider-owned notification and core-store regional preferences. */
import type { QFieldMetaData } from '@/types/metadata'
import type { PreferencesResponse } from '@/types/preferences'
/** Creates ordinary field metadata for preference fixtures.
* @param name - Field name.
* @param label - Display label.
* @param type - Primitive field type.
* @param source - Inline section enum source name.
* @returns Complete metadata.
*/
const field = (
name: string,
label: string,
type: QFieldMetaData['type'],
source?: string
): QFieldMetaData => ({
name,
label,
type,
isEditable: true,
isRequired: type === 'BOOLEAN',
isHeavy: false,
isHidden: false,
adornments: [],
possibleValueSourceName: source,
})
const options = (...ids: string[]) =>
ids.map((id) => ({
id,
label: id
.toLowerCase()
.replace(/_/g, ' ')
.replace(/^./, (s) => s.toUpperCase()),
}))
export const preferencesFixture: PreferencesResponse = {
sections: [
{
name: 'notifications',
label: 'Notifications',
description: 'What reaches you in the app and by email.',
icon: { name: 'notifications' },
order: 20,
fields: [
field('pausedUntil', 'Pause until', 'DATE_TIME'),
field('digestHour', 'Daily digest hour', 'INTEGER', 'digestHours'),
field('timeZone', 'Time zone', 'STRING'),
field('alertEmailEnabled', 'Email alerts', 'BOOLEAN'),
field('alertInAppEnabled', 'In-app alerts', 'BOOLEAN'),
],
values: {
pausedUntil: null,
digestHour: 9,
timeZone: 'America/Chicago',
alertEmailEnabled: true,
alertInAppEnabled: true,
},
possibleValues: {
digestHours: Array.from({ length: 24 }, (_, id) => ({
id,
label: `${String(id).padStart(2, '0')}:00`,
})),
severities: options('INFO', 'WARNING', 'ERROR', 'CRITICAL'),
emailModes: options('OFF', 'IMMEDIATE', 'DAILY_DIGEST'),
deliveryModes: options('IN_APP', 'EMAIL', 'BOTH'),
},
lists: [
{
name: 'categories',
label: 'Categories',
rowLabel: 'Category',
description: 'Choose how each kind of notification reaches you.',
fields: [
field('inApp', 'In-app', 'BOOLEAN'),
field('inAppMinSeverity', 'Minimum in-app severity', 'STRING', 'severities'),
field('emailMode', 'Email delivery', 'STRING', 'emailModes'),
field('emailMinSeverity', 'Minimum email severity', 'STRING', 'severities'),
],
rows: ['SYSTEM', 'SECURITY', 'PRODUCT', 'ACCOUNT'].map((key) => ({
key,
label: key.charAt(0) + key.slice(1).toLowerCase(),
values: {
inApp: true,
inAppMinSeverity: 'INFO',
emailMode: 'IMMEDIATE',
emailMinSeverity: 'WARNING',
},
readOnlyFields: key === 'SECURITY' ? ['inApp'] : [],
readOnlyReason: key === 'SECURITY' ? "Security notices can't be muted" : null,
})),
},
{
name: 'subscriptions',
label: 'Subscriptions',
rowLabel: 'Subscription',
fields: [
field('enabled', 'Enabled', 'BOOLEAN'),
field('minSeverity', 'Minimum severity', 'STRING', 'severities'),
field('deliveryMode', 'Delivery mode', 'STRING', 'deliveryModes'),
],
rows: [
{
key: 'order:42',
label: 'Order processing',
description: 'Updates to orders you follow.',
values: { enabled: true, minSeverity: 'WARNING', deliveryMode: 'BOTH' },
readOnlyFields: [],
},
],
},
],
},
{
name: 'regional',
label: 'Regional settings',
description: 'Your time zone, date format and calendar.',
icon: { name: 'language' },
order: 30,
fields: [
field('timeZone', 'Time zone', 'STRING'),
field('dateFormat', 'Date format', 'STRING', 'dateFormats'),
field('weekStartsOn', 'Week starts on', 'STRING', 'weekDays'),
],
values: { timeZone: 'America/Chicago', dateFormat: 'MM/dd/yyyy', weekStartsOn: 'SUNDAY' },
lists: [],
possibleValues: {
dateFormats: ['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy-MM-dd'].map((id) => ({ id, label: id })),
weekDays: options('SUNDAY', 'MONDAY'),
},
},
{
name: 'access-tokens',
label: 'Access tokens',
description: 'Manage tokens for your scripts and integrations.',
icon: { name: 'key' },
order: 40,
showInUserMenu: true,
fields: [],
values: {},
possibleValues: {},
lists: [
{
name: 'tokens',
label: 'Tokens',
rowLabel: 'Token',
fields: [
field('description', 'Description', 'STRING'),
field('expiresAt', 'Expires at', 'DATE_TIME'),
field('lastUsedAt', 'Last used at', 'DATE_TIME'),
].map((item) => ({ ...item, isEditable: false })),
rows: [
{
key: 'example-token',
label: 'Development script',
values: {
description: 'Development script',
expiresAt: '2027-01-01T00:00:00Z',
lastUsedAt: '2026-10-12T12:00:00Z',
},
actionNames: ['rotate', 'revoke'],
},
],
},
],
actions: [
{
name: 'create',
label: 'Create token',
scope: 'SECTION',
order: 10,
icon: { name: 'add' },
inputFields: [
{ ...field('description', 'Description', 'STRING'), isRequired: true, maxLength: 100 },
{ ...field('expiresAt', 'Expires at', 'DATE_TIME'), isRequired: true },
],
},
{
name: 'rotate',
label: 'Rotate token',
scope: 'ROW',
listName: 'tokens',
order: 20,
confirmationText: 'Rotate this token? The old token will stop working immediately.',
},
{
name: 'revoke',
label: 'Revoke token',
scope: 'ROW',
listName: 'tokens',
order: 30,
isDestructive: true,
confirmationText: 'Revoke this token? It will stop working immediately.',
},
],
},
],
}
+32
View File
@@ -0,0 +1,32 @@
/*
* 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 Alerts summary mock advertises its registered preference section. */
import { http, HttpResponse } from 'msw'
export const alertsHandlers = [
http.get('/qqq/v1/alerts/inbox/summary', () =>
HttpResponse.json({
unreadCount: 0,
highestUnreadSeverity: null,
notificationsHref: '/app/notifications',
preferencesSection: 'notifications',
pollSeconds: 60,
items: [],
banners: [],
})
),
]
+44 -40
View File
@@ -1,40 +1,44 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Combines all MSW handler arrays into a single export for use by browser and node setups.
*/
import { authHandlers } from './auth'
import { metadataHandlers } from './metadata'
import { tableHandlers } from './tables'
import { possibleValuesHandlers } from './possible-values'
import { processHandlers } from './processes'
import { widgetHandlers } from './widgets'
import { esbHandlers } from './esb'
import { sqlWorkbenchHandlers } from './sql-workbench'
export const handlers = [
...authHandlers,
...metadataHandlers,
// Possible values must come before table handlers to avoid route conflicts
...possibleValuesHandlers,
...tableHandlers,
...processHandlers,
...widgetHandlers,
...esbHandlers,
...sqlWorkbenchHandlers,
]
/*
* 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 Combines all MSW handler arrays into a single export for use by browser and node setups.
*/
import { authHandlers } from './auth'
import { metadataHandlers } from './metadata'
import { tableHandlers } from './tables'
import { possibleValuesHandlers } from './possible-values'
import { processHandlers } from './processes'
import { widgetHandlers } from './widgets'
import { esbHandlers } from './esb'
import { sqlWorkbenchHandlers } from './sql-workbench'
import { preferencesHandlers } from './preferences'
import { alertsHandlers } from './alerts'
export const handlers = [
...preferencesHandlers,
...alertsHandlers,
...authHandlers,
...metadataHandlers,
// Possible values must come before table handlers to avoid route conflicts
...possibleValuesHandlers,
...tableHandlers,
...processHandlers,
...widgetHandlers,
...esbHandlers,
...sqlWorkbenchHandlers,
]
+135
View File
@@ -0,0 +1,135 @@
/*
* 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 Stateful MSW preferences endpoints; rejects unknown or locked writes atomically. */
import { http, HttpResponse } from 'msw'
import type { PreferenceSectionPatch, PreferenceActionRequest } from '@/types/preferences'
import { preferencesFixture } from '../fixtures/preferences'
let state = structuredClone(preferencesFixture)
/** Restores preference mock values between tests. */
export function resetPreferencesMock() {
state = structuredClone(preferencesFixture)
}
export const preferencesHandlers = [
http.post('/qqq/v1/preferences/:sectionName/actions/:actionName', async ({ params, request }) => {
const section = state.sections.find((item) => item.name === params.sectionName)
const action = section?.actions?.find((item) => item.name === params.actionName)
if (!section || !action)
return HttpResponse.json({ error: 'Action not found' }, { status: 404 })
const body = (await request.json()) as PreferenceActionRequest
const errors = Object.keys(body)
.filter((key) => key !== 'rowKey' && key !== 'values')
.map((key) => `Unexpected property: ${key}`)
const list = section.lists.find((item) => item.name === action.listName) ?? section.lists[0]
const row = list?.rows.find((item) => item.key === body.rowKey)
if (
action.scope === 'ROW' &&
(!row || (row.actionNames != null && !row.actionNames.includes(action.name)))
)
errors.push("This action isn't available for this token.")
if (action.scope !== 'ROW' && body.rowKey !== undefined)
errors.push('Unexpected property: rowKey')
for (const field of action.inputFields ?? []) {
if (field.isRequired && !body.values?.[field.name]) errors.push(`${field.label}: is required`)
}
if (errors.length) return HttpResponse.json({ errors }, { status: 400 })
if (action.name === 'revoke') {
list.rows = list.rows.filter((item) => item.key !== body.rowKey)
return HttpResponse.json(
{ section, message: 'Token revoked.', secrets: [] },
{ headers: { 'Cache-Control': 'no-store' } }
)
}
const key = row?.key ?? crypto.randomUUID()
if (action.name === 'create')
list.rows.push({
key,
label: String(body.values?.description),
values: { ...body.values, lastUsedAt: null },
actionNames: ['rotate', 'revoke'],
})
// Demo-only value is generated per response and never added to mock preference state.
const value = `demo_only_${crypto.randomUUID()}`
return HttpResponse.json(
{
section,
message: 'Token ready.',
focusRowKey: key,
secrets: [
{
label: 'Access token',
value,
masked: `demo_only_…${value.slice(-4)}`,
usageText: `curl -H "Authorization: Bearer ${value}" https://example.test/qqq/v1/metaData`,
},
],
},
{ headers: { 'Cache-Control': 'no-store' } }
)
}),
http.get('/qqq/v1/preferences', () => HttpResponse.json(state)),
http.get('/qqq/v1/preferences/:sectionName', ({ params }) => {
const section = state.sections.find((section) => section.name === params.sectionName)
return section
? HttpResponse.json(section)
: HttpResponse.json({ error: 'Preference section not found' }, { status: 404 })
}),
http.patch('/qqq/v1/preferences/:sectionName', async ({ params, request }) => {
const section = state.sections.find((section) => section.name === params.sectionName)
if (!section)
return HttpResponse.json({ error: 'Preference section not found' }, { status: 404 })
const patch = (await request.json()) as PreferenceSectionPatch
const errors: string[] = Object.keys(patch)
.filter((key) => key !== 'values' && key !== 'lists')
.map((key) => `Unknown property: ${key}`)
for (const name of Object.keys(patch.values ?? {})) {
if (!section.fields.some((field) => field.name === name && field.isEditable))
errors.push(`Unknown or read-only field: ${name}`)
}
for (const [name, rows] of Object.entries(patch.lists ?? {})) {
const list = section.lists.find((list) => list.name === name)
if (!list) {
errors.push(`Unknown list: ${name}`)
continue
}
for (const change of rows) {
const row = list.rows.find((row) => row.key === change.key)
if (!row) {
errors.push(`Unknown row: ${change.key}`)
continue
}
for (const fieldName of Object.keys(change.values)) {
if (row.readOnlyFields?.includes(fieldName))
errors.push(row.readOnlyReason || `${fieldName} is read only`)
else if (!list.fields.some((field) => field.name === fieldName && field.isEditable))
errors.push(`Unknown or read-only field: ${fieldName}`)
}
}
}
if (errors.length)
return HttpResponse.json({ error: errors.join('\n'), errors }, { status: 400 })
Object.assign(section.values, patch.values)
for (const [name, rows] of Object.entries(patch.lists ?? {})) {
const list = section.lists.find((list) => list.name === name)!
for (const change of rows)
Object.assign(list.rows.find((row) => row.key === change.key)!.values, change.values)
}
return HttpResponse.json(section)
}),
]
+161 -159
View File
@@ -1,159 +1,161 @@
/*
* 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 Types for the Alerts qbit's notification center (bell, popover, `/app/notifications`,
* banners) and record panel (`/qqq/v1/alerts/*`).
*/
/** An alert's severity, least to most severe. */
export type AlertSeverity = 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'
/** A notification's severity: the alert severities plus SUCCESS. */
export type NotificationSeverity = 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR' | 'CRITICAL'
/** The notification categories the page filters on. */
export const NOTIFICATION_CATEGORIES = ['SYSTEM', 'JOB', 'SECURITY', 'RELEASE', 'MARKETING', 'SHARE', 'ALERT'] as const
/** A notification category. */
export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number]
/** The notification severities, least to most severe. */
export const NOTIFICATION_SEVERITIES: readonly NotificationSeverity[] = ['INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL']
/**
* One inbox item, normalized from either backend shape: the notification center
* (`{id, notificationId, title, body, severity, category, source, href, linkLabel, deliveredAt,
* age, unread, sticky}`) or qbit-alerts 0.1.0-RC.1 (`{alertId, title, severity, status, group,
* age, href, unread}`). Without `id` and `alertId` the item has no per-item actions.
*/
export interface InboxItem {
/** Stable React key. */
key: string
/** The recipient row id (the target of read, unread and dismiss). */
id?: number
/** The notification's id. */
notificationId?: number
/** Older backends: the alert id (`{alertId}` still marks it read). */
alertId?: number
/** Title. */
title: string
/** Markdown body (the page and banners only). */
body?: string | null
/** Severity. */
severity: NotificationSeverity
/** Category. */
category?: string | null
/** Who sent it (older backends: the alert group). */
source?: string | null
/** Where it links: a relative app path or https. */
href?: string | null
/** The link's label. */
linkLabel?: string | null
/** When it was delivered (ISO). */
deliveredAt?: string | null
/** Relative age from the server ("3 h"). */
age?: string | null
/** Not read yet. */
unread: boolean
/** A banner until dismissed. */
sticky?: boolean
}
/** `GET /alerts/inbox`: one page of the inbox. */
export interface InboxPage {
/** The items, newest first. */
items: InboxItem[]
/** Matching items in all. */
total: number
/** The page's offset. */
offset: number
/** The page size. */
limit: number
}
/** The `/alerts/inbox` filters. */
export interface InboxFilter {
/** `all` or `unread`. */
status: 'all' | 'unread'
/** A category, or empty for every category. */
category: string
/** A severity, or empty for every severity. */
severity: string
/** Offset of the first item. */
offset: number
/** Page size. */
limit: number
}
/** The target of read, unread and dismiss. */
export type InboxTarget = { id: number } | { ids: number[] } | { all: true } | { alertId: number }
/** One alert as the bell and the record panel show it. */
export interface AlertItem {
/** The alert record's id. */
alertId: number
/** External-safe title. */
title: string
/** Severity. */
severity: AlertSeverity
/** OPEN, ACKNOWLEDGED, SNOOZED or RESOLVED. */
status: string
/** The alert group's label, when the user can read groups. */
group?: string | null
/** Relative age ("3 h"). */
age: string
/** When it was first raised (ISO). */
raisedAt?: string | null
/** The alert record's path in the app. */
href: string
/** Inbox items only: not read yet. */
unread?: boolean
}
/** `GET /alerts/inbox/summary`: the bell, normalized. */
export interface AlertsInboxSummary {
/** Unread inbox items. */
unreadCount: number
/** The highest severity among unread items, or null. */
highestUnreadSeverity: NotificationSeverity | null
/** The latest items, newest first (at most 10). */
items: InboxItem[]
/** Sticky, undismissed, unexpired items shown as banners. */
banners: InboxItem[]
/** The notifications page path ("View all"). */
notificationsHref: string
/** How often to poll (seconds). */
pollSeconds?: number
}
/** `GET /alerts/record-panel`: the "Open alerts" panel for a record. */
export interface AlertsRecordPanel {
/** False when there is nothing to show (the panel is hidden). */
visible: boolean
/** Open alerts that reference the record. */
openCount: number
/** The first few open alerts, most severe first. */
open: AlertItem[]
/** Alerts about the record resolved in the last 30 days. */
resolvedLast30Days: number
/** The filtered Alerts list for every alert about the record. */
viewAllHref: string | null
/** Whether the user may acknowledge and snooze. */
canManage: boolean
/** Whether the user already watches this record. */
watching: boolean
}
/*
* 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 Types for the Alerts qbit's notification center (bell, popover, `/app/notifications`,
* banners) and record panel (`/qqq/v1/alerts/*`).
*/
/** An alert's severity, least to most severe. */
export type AlertSeverity = 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'
/** A notification's severity: the alert severities plus SUCCESS. */
export type NotificationSeverity = 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR' | 'CRITICAL'
/** The notification categories the page filters on. */
export const NOTIFICATION_CATEGORIES = ['SYSTEM', 'JOB', 'SECURITY', 'RELEASE', 'MARKETING', 'SHARE', 'ALERT'] as const
/** A notification category. */
export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number]
/** The notification severities, least to most severe. */
export const NOTIFICATION_SEVERITIES: readonly NotificationSeverity[] = ['INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL']
/**
* One inbox item, normalized from either backend shape: the notification center
* (`{id, notificationId, title, body, severity, category, source, href, linkLabel, deliveredAt,
* age, unread, sticky}`) or qbit-alerts 0.1.0-RC.1 (`{alertId, title, severity, status, group,
* age, href, unread}`). Without `id` and `alertId` the item has no per-item actions.
*/
export interface InboxItem {
/** Stable React key. */
key: string
/** The recipient row id (the target of read, unread and dismiss). */
id?: number
/** The notification's id. */
notificationId?: number
/** Older backends: the alert id (`{alertId}` still marks it read). */
alertId?: number
/** Title. */
title: string
/** Markdown body (the page and banners only). */
body?: string | null
/** Severity. */
severity: NotificationSeverity
/** Category. */
category?: string | null
/** Who sent it (older backends: the alert group). */
source?: string | null
/** Where it links: a relative app path or https. */
href?: string | null
/** The link's label. */
linkLabel?: string | null
/** When it was delivered (ISO). */
deliveredAt?: string | null
/** Relative age from the server ("3 h"). */
age?: string | null
/** Not read yet. */
unread: boolean
/** A banner until dismissed. */
sticky?: boolean
}
/** `GET /alerts/inbox`: one page of the inbox. */
export interface InboxPage {
/** The items, newest first. */
items: InboxItem[]
/** Matching items in all. */
total: number
/** The page's offset. */
offset: number
/** The page size. */
limit: number
}
/** The `/alerts/inbox` filters. */
export interface InboxFilter {
/** `all` or `unread`. */
status: 'all' | 'unread'
/** A category, or empty for every category. */
category: string
/** A severity, or empty for every severity. */
severity: string
/** Offset of the first item. */
offset: number
/** Page size. */
limit: number
}
/** The target of read, unread and dismiss. */
export type InboxTarget = { id: number } | { ids: number[] } | { all: true } | { alertId: number }
/** One alert as the bell and the record panel show it. */
export interface AlertItem {
/** The alert record's id. */
alertId: number
/** External-safe title. */
title: string
/** Severity. */
severity: AlertSeverity
/** OPEN, ACKNOWLEDGED, SNOOZED or RESOLVED. */
status: string
/** The alert group's label, when the user can read groups. */
group?: string | null
/** Relative age ("3 h"). */
age: string
/** When it was first raised (ISO). */
raisedAt?: string | null
/** The alert record's path in the app. */
href: string
/** Inbox items only: not read yet. */
unread?: boolean
}
/** `GET /alerts/inbox/summary`: the bell, normalized. */
export interface AlertsInboxSummary {
/** Unread inbox items. */
unreadCount: number
/** The highest severity among unread items, or null. */
highestUnreadSeverity: NotificationSeverity | null
/** The latest items, newest first (at most 10). */
items: InboxItem[]
/** Sticky, undismissed, unexpired items shown as banners. */
banners: InboxItem[]
/** The notifications page path ("View all"). */
notificationsHref: string
/** Registered section offered by the notification settings shortcut. */
preferencesSection?: string | null
/** How often to poll (seconds). */
pollSeconds?: number
}
/** `GET /alerts/record-panel`: the "Open alerts" panel for a record. */
export interface AlertsRecordPanel {
/** False when there is nothing to show (the panel is hidden). */
visible: boolean
/** Open alerts that reference the record. */
openCount: number
/** The first few open alerts, most severe first. */
open: AlertItem[]
/** Alerts about the record resolved in the last 30 days. */
resolvedLast30Days: number
/** The filtered Alerts list for every alert about the record. */
viewAllHref: string | null
/** Whether the user may acknowledge and snooze. */
canManage: boolean
/** Whether the user already watches this record. */
watching: boolean
}
+103
View File
@@ -0,0 +1,103 @@
/*
* 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 The signed-in user's metadata-driven preference sections (UI API v1). */
import type { QFieldMetaData, QIcon } from './metadata'
import type { QPossibleValue } from './records'
/** All visible sections, in backend order. */
export interface PreferencesResponse {
sections: PreferenceSection[]
}
/** A provider's section, including current values and inline enum sources. */
export interface PreferenceSection {
name: string
label: string
icon?: QIcon | null
description?: string | null
order?: number
appName?: string | null
showInUserMenu?: boolean
actions?: PreferenceAction[]
fields: QFieldMetaData[]
values: Record<string, unknown>
lists: PreferenceList[]
possibleValues: Record<string, QPossibleValue[]>
}
/** Fixed rows supplied by a section provider. */
export interface PreferenceList {
name: string
label: string
description?: string | null
rowLabel: string
fields: QFieldMetaData[]
rows: PreferenceRow[]
}
/** One independently editable row; editability may differ from other rows. */
export interface PreferenceRow {
key: string
label: string
description?: string | null
values: Record<string, unknown>
readOnlyFields?: string[]
readOnlyReason?: string | null
actionNames?: string[] | null
}
/** A provider action, rendered at its declared scope. */
export interface PreferenceAction {
name: string
label: string
icon?: QIcon | null
description?: string | null
scope: 'SECTION' | 'LIST' | 'ROW'
listName?: string | null
confirmationText?: string | null
isDestructive?: boolean
inputFields?: QFieldMetaData[]
order?: number
}
/** Identity and list selection always come from the server metadata/session. */
export interface PreferenceActionRequest {
rowKey?: string
values?: Record<string, unknown>
}
/** Ephemeral display data: never persist or put in a query/mutation cache. */
export interface PreferenceSecret {
label: string
value: string
masked?: string
usageText?: string
}
/** Only section is safe to retain in the preferences cache. */
export interface PreferenceActionResponse {
section: PreferenceSection
message?: string
secrets?: PreferenceSecret[]
focusRowKey?: string
}
/** Only changed scalar values and changed fields of explicitly named rows. */
export interface PreferenceSectionPatch {
values?: Record<string, unknown>
lists?: Record<string, Array<{ key: string; values: Record<string, unknown> }>>
}
+312 -302
View File
@@ -1,302 +1,312 @@
# Real-backend acceptance (QRun-IO/qqq#649)
Executable acceptance for every supported QQQ UI feature, against the QRun-owned sample
application and the production Next build. Mocked unit and e2e tests complement this
suite; they never replace it.
## CI: build → shards → gate
`.github/workflows/acceptance.yml` plans coverage while one `build` job builds QQQ
from `QQQ_REF`, the static export, dashboard JAR and sample fat JAR. Its artifact
contains `out/`, the sample JAR (with its versioned filename) and the matching
`qqq-middleware-api` JAR. Shards download these and run
`node scripts/acceptance.mjs --skip-build --shard=i/n`. Each shard compiles the owned
Java fixture at startup against those two JARs and runs its own backend with
**one worker and zero retries**. No Maven or Next build runs in a test job.
| Mode | Projects and shard counts | Specs |
| --- | --- | --- |
| Ordinary PR | Chromium 2, mobile 2 | Changed areas plus fixed smoke |
| Full: main, `v*` tags, manual dispatch | Chromium 3, Firefox 3, WebKit 4, mobile 4, tablet 4 | All specs, subject to the existing touch tags and quarantine |
| PR with `full-acceptance` label | Same as full | Same as full |
Adding or removing the label replans the PR. `scripts/acceptance-select.mjs` owns
the path mapping and smoke list: landing, navigation, record CRUD, query grid and
sample dashboard. A changed acceptance spec/helper selects its whole area. Source
paths select matching widget, query, record/form, process, navigation/theme,
security/auth or ESB areas. Shared infrastructure (including API/client code,
fixtures, support, matrix definitions, dependencies and build configuration) and
unmapped production paths select every area on Chromium and mobile. Docs-only PRs
run smoke. Deleted/renamed paths are included using a merge-base diff.
`QQQ_ACCEPTANCE_BLOB=1` selects the blob reporter and defers the per-run gate.
CI sets `QQQ_ACCEPTANCE_BLOB_NAME` to a unique project/shard ZIP filename.
`QQQ_ACCEPTANCE_SPEC_PATHS` is a JSON array of spec/directory paths used by CI;
leave both unset for the usual local JSON/HTML reports and gate. The final
**`acceptance gate`** job always runs, merges every shard's blob into
`test-results/acceptance/report.json` and `html/`, then runs `acceptance-gate.mjs`.
PR mode uses `--partial`; full mode requires all non-quarantined matrix rows.
Build/plan/shard failures, skipped shards or missing blobs fail this job even if a partial report
looks green. Configure branch protection to require this single acceptance signal.
Merged reports, blobs (including failure attachments) and `gate.json` are retained
in the `acceptance` artifact for 30 days; per-shard blobs are retained too.
The target is ordinary PRs under 30 minutes; hosted timings still need measurement.
pnpm and browser binaries are cached; only the required browser engine is installed
(mobile uses Chromium, tablet uses WebKit), with OS dependencies installed every run.
QQQ's installed Maven modules are cached by resolved source commit SHA. Every Maven
command has four attempts with 15/45/120-second backoff plus HTTP transport retries.
## Quarantine
`WID-029` is quarantined under [issue #93](https://github.com/QRun-IO/qqq-frontend-next/issues/93).
Its tests retain their assertions and touch tags and add `@quarantine`. Normal runs
exclude that tag; `QQQ_ACCEPTANCE_QUARANTINE=only` runs only those tests (intersected
with the project's touch tags). Full CI also runs them in a separate, non-blocking
Chromium `quarantine` job with its own `acceptance-quarantine` artifact.
`tests/acceptance/quarantine.json` records each ID, issue link and reason. The gate
lists entries and test outcomes in `gate.json`, counts rows in `summary.quarantined`,
and excludes them from required desktop/touch coverage. Unknown IDs and missing
issue links still fail the gate. A quarantine pass does not close the issue: remove
both the registry entry and test tags once the fix is verified.
```bash
QQQ_ACCEPTANCE_QUARANTINE=only QQQ_ACCEPTANCE_BROWSERS=chromium \
node scripts/acceptance.mjs --skip-build
```
## RC1 priority: workflow depth in Chromium
For RC1, prioritize complete real-application workflows in Chromium before expanding
browser and viewport coverage. Exercise normal entry paths, defaults, validation,
editing and cancellation, saving and reopening, changing dependent selections,
permissions, failure recovery, and the resulting records or downloaded files.
Check persisted values independently through the owned backend where applicable.
Scenario counts and passing page-load checks are not evidence that every workflow works.
Use the default Chromium project for focused diagnosis and the full Chromium acceptance
gate for the candidate. Preserve existing cross-browser tests and record failures;
additional browser and responsive work follows the workflow depth pass.
## Run
```bash
# Install the QQQ Maven modules, build the sample jar, and point at it:
# mvn -DskipTests -Djacoco.skip=true install # from the qqq repo root
# mvn -DskipTests -Djacoco.skip=true -Dcheckstyle.skip=true package # from qqq/qqq-sample-project
export QQQ_SAMPLE_JAR=/path/to/qqq-sample-project-<version>-jar-with-dependencies.jar
pnpm test:acceptance # static export + full suite + gate
node scripts/acceptance.mjs --skip-build specs/records # reuse out/, filtered (partial gate)
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet pnpm test:acceptance
QQQ_ACCEPTANCE_MODE=standalone pnpm test:acceptance # container-image build instead
```
- **javalin mode (default).** The static export (`pnpm build:export`, `out/`) goes on the
classpath as `next-dashboard/`, ahead of the sample jar. `QApplicationJavalinServer` then
serves it at `/`, which is exactly what a fresh application gets.
- **standalone mode.** Tests the Node standalone build through the container's
`qqq-server.mjs` entrypoint. Builds twice from clean `.next` directories: a staged
authentication-variant build baked to the security backend, then the main build
baked to the shared backend. Next rewrites are build-time values; changing only a
server environment variable cannot retarget them. The variant validates its actual
route manifest and marker before startup, disables Java dashboard serving, and
restarts its owned Node server when authentication metadata changes. Javalin mode
still uses its export and does not start this second Node server.
- **API-versioned report setup.** The acceptance server loads the matching
`qqq-middleware-api` JAR from `~/.m2/repository/com/kingsrook/qqq/qqq-middleware-api/<version>/`.
Set `QQQ_MIDDLEWARE_API_JAR` to another path when Maven uses a different local repository.
The fixture registers one API-aware v1 version so WID-072 uses real metadata, query and count routes.
- **Ports.** Set them with `QQQ_ACCEPTANCE_BACKEND_PORT` (default 18765) and
`QQQ_ACCEPTANCE_FRONTEND_PORT` (default 13765). The sample's embedded Artemis broker uses
`QQQ_ACCEPTANCE_ESB_PORT` (default 61616, passed as `qqq.sample.esb.port`). Use distinct
HTTP and broker ports for concurrent runs. The security variant uses
`QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT` (default backend HTTP port + 10),
`QQQ_ACCEPTANCE_SECURITY_ESB_PORT` (default main ESB port + 1, normally 61617),
`QQQ_ACCEPTANCE_SECURITY_IDP_PORT` (default frontend HTTP port + 10), and
`QQQ_ACCEPTANCE_SECURITY_FRONTEND_PORT` (standalone browser origin, default frontend
HTTP port + 20). Browser redirects use the mode-specific UI origin; direct backend
control, SQL and permission/replay requests retain the security backend origin.
Its broker setting is passed as `qqq.sample.esb.port`; explicit variant
`options.properties` retain precedence over these defaults.
- **Results.** Output lands in `test-results/acceptance/`: `report.json`, `gate.json` (with
per-project counts in `byProject`), the HTML report, and a trace, video and screenshot for
each failure.
- **Transport evidence.** CI enables `QQQ_ACCEPTANCE_TRANSPORT_TRACE=1`; set it locally
to retain `QQQ_TRANSPORT` events in the acceptance server log for documents, static
chunks and versioned API requests. Events contain request/connection IDs, paths without
queries, status, write byte counts and failure classes; they omit headers, cookies and
bodies. A completed server write does not prove browser receipt or rendering. A write
can fail even when final completion reports HTTP 200, so inspect `firstWriteFailure`
separately. This observer is acceptance-only and adds synchronous logging overhead;
leave the variable unset for a control run with the original handler tree. Native and
ordinary server logs can still contain session data; keep raw evidence private.
- **Document/XHR evidence (opt-in).** Set `QQQ_ACCEPTANCE_BROWSER_TRACE=1` locally to
attach `browser-boundary.json` through the diagnostics fixture. CI uses `navigation`
mode only for the exact NAV023 unique-key and NAV056 application-theme-override cases
on WebKit/tablet; other cases, including SEC043, do not opt in through that mode.
By default, only `/app/person[/]`, `/app/person/key[/]`, `/app/person/2[/]`, and
`/app/person.bulkEdit[/]` documents on configured UI/API origins are observed. To also
capture dashboard documents at exactly `/app` and `/app/`, set
`QQQ_ACCEPTANCE_BROWSER_TRACE_SESSION=1` alongside `QQQ_ACCEPTANCE_BROWSER_TRACE=1`
locally. The session flag alone installs nothing and does not select additional tests;
other dashboard paths are not included. Requests are exact method/path matches:
GET person metadata, person record 2 and person.bulkEdit process metadata;
POST `/qqq/v1/manageSession`, querySavedView init, person query/count and person.bulkEdit init.
GET `/qqq/v1/manageSession` is excluded. The helper emits
fixed aliases, document UUID/sequence/clocks, native XHR send return/throw, status and
terminal events. It never emits URLs, query/hash values, headers, credentials, payloads,
error messages or stacks, and adds no request headers or interception. Disabled mode
installs no binding or init script. One listener set follows each reused XHR generation.
Native OPENED reentry or reuse before the recorder observes the prior `loadend`
emits one `xhr-incomplete` marker and suppresses attribution for that instance for
its remaining lifetime. This includes overlapping requests and earlier application
event listeners; no endpoint, generation or attempt is guessed. Excluded requests
retain only completion state so their old events cannot impersonate later selected
reuse; no excluded target data is emitted. Native calls and outcomes are preserved.
Each document is limited to 512 events plus an overflow marker and 256 selected XHRs;
Node retains at most 4096 validated events with invalid/drop counters. Missing delivery,
overflow or missing `ready` means incomplete evidence; zero counters do not prove
complete delivery. Lifecycle phase is the last
observed pagehide/pageshow state, not proof of browser-internal teardown; no unload or
beforeunload hooks are added. Snapshotting uses Node memory even after a page fails.
Existing diagnostics, assertions and timeouts are unchanged. RSC fetches and document
navigation are not XHRs: correlate existing Playwright/server/native traces instead.
Send-return disproves a synchronous throw for that invocation; a pageError alone does
not prove one. XHR load observes completion in JS, not UI rendering; successful server
writes still do not prove receipt. Binding delivery has nonzero overhead and can be
lost during destruction. Retain source/export/runner provenance and raw traces privately;
passing instrumented cells do not attribute historical failures or establish a fix.
- **Browsers.** `QQQ_ACCEPTANCE_BROWSERS` picks the Playwright projects (default `chromium`).
The documented matrix is `chromium,firefox,webkit,mobile,tablet`; results and the exact commands
are in `docs/acceptance/browser-matrix.md`.
## Phone scope (`mobile` project)
The `mobile` project (Pixel 7: 412 px, touch) runs only tests tagged **`@mobile`** in their
title (`grep: /@mobile/` in `playwright.config.ts`). Below 768 px lists are card lists and
record actions live in an action sheet, so specs that assert desktop layout (grid columns,
resizing, grid keyboard navigation, desktop menus) are not phone scenarios and are not run
there. Skipping them with `test.skip` is not allowed: skips fail the gate.
- Tag a test `@mobile` when it proves phone behavior: navigation drawer, card list, record
view, forms, process runs, dialogs, sign-in/out. It must also pass on the desktop projects,
so use layout-neutral helpers (`listCell`, `listRows`, `navigation` in
`specs/security/support/ui.ts`, `recordList` in `specs/navigation/nav-helpers.ts`), or
pin a phone viewport with `test.use({ viewport: { width: 412, height: 839 }, hasTouch: true })`.
- Every phone-relevant row keeps at least one passing `@mobile` test; the gate fails a
configured project that ran no tests.
## Rules
- **Tag every test with its matrix ID(s)** in the title, for example
`test('[REC-004] edit persists after refresh', ...)`. The gate fails on:
- a required row with no test, or any failing, skipped or flaky test for it (retries are 0);
- an unknown ID, or a test without an ID;
- `required: false` without an `approval` note.
`required: false` needs James's explicit approval. Never loosen an expectation or delete
a scenario to go green.
- **Use `test` and `expect` from `support/fixtures`.** Each test gets a freshly reset
database, its own mock session, and a persona (`test.use({ persona: 'viewer' })`).
- Personas are `admin`, `viewer` (reads only, no processes), `noPets`, `noProcesses`,
`noApps` and `expired` (401 with the cookie cleared). ESB personas are `noPersonRead`
(all permissions except `person.read`), `noEsbView`, `noEsbOperate`, `noEsbDelete`,
and `noSyncPerson` (each removes only its named access permission). The sample sharing demo is enabled;
choose its identity with `test.use({ user: 'bob' })` (alice by default).
- `backend.sql(select)` reads the owned H2 database directly. Use it to verify persisted
values independently of the UI and API.
- `backend.api` calls the backend over HTTP as the same session. Use it to prove
server-side enforcement.
- **Include the `diagnostics` fixture in every test.** It fails the test on page errors,
console errors, failed or ≥400 application requests, and Content-Security-Policy
violations (every frame forwards `securitypolicyviolation` events; they are listed as
`cspViolations` in `diagnostics.json`). The dashboard is served with a strict policy
(QRun-IO/qqq#695, SEC-038 to SEC-040), so a feature that needs another origin must get it
from metadata or the application's `withNextDashboardSecurityHeadersCustomizer` hook
(see `WidgetsFixtures.allowFakeService`), never from a loosened test. Negative scenarios
whitelist their expected failures with `diagnostics.allow('/data/person/99 404')`. Requests that
a navigation cancels are not failures: `ERR_ABORTED`/`NS_BINDING_ABORTED`/cancelled, and
WebKit's "… due to access control checks." for a same-origin Next.js route prefetch or
RSC payload reported within a second of a document navigation (listed under
`interruptedFetches` in the attached `diagnostics.json`). Firefox legacy-font diagnostics
are classified there only when the exact cancellation status matches a same-origin font
request from an earlier document near navigation (aborted, or HTTP 200 before decode was cancelled), and the final document successfully
loads and decodes that font. Every unmatched or failed-decode diagnostic still fails.
- **v1 only.** The `diagnostics` fixture also fails a test whose page calls an unversioned API
route of a QQQ server (`/data`, `/processes`, `/widget`, `/possibleValues`, `/download`,
`/reports`, `/metaData`, `/manageSession`, `/logout` outside `/qqq/v1`; QRun-IO/qqq#699).
`allow()` does not waive it. Node-side `backend.api` calls may still exercise legacy routes.
- **Transient feedback.** Arm `expectToastDuringAction` before the triggering action when
asserting a toast. It observes the visible exact text and notification count inside the
browser (plus header clearance when requested), so driver/trace delays cannot outlast the
notification before observation begins. Assert persistent form and database state afterwards.
- **Assert real behavior.** Check exact values, labels, counts and persisted rows. A 200
response or a visible container is not acceptance.
- **Fixtures.** Each area owns `fixture/<Area>Fixtures.java`: `define()` adds metadata;
`prime()` creates and seeds tables. `prime()` reruns after every reset and must be
idempotent (DROP then CREATE). Use synthetic QRun-owned data only.
- **Matrix.** Each area owns `matrix/<area>.json` and its ID prefixes. A row has
`id, feature, source[], fixture, scenarios[], negative[], issues[], required`.
`docs/acceptance/feature-matrix.md` is generated from these files.
## Pinned Linux WebKit crash fix
The test runner is temporarily pinned to `@playwright/test@1.64.0-alpha-2026-10-01`: its WebKit 2369 bundle contains libsoup 3.6.6. The previous 1.58.2/WebKit 2248 bundle contains 3.6.5 and reproduced heap corruption during navigation; the same browser using the fixed library passed 110 targeted cases, and the official replacement passed 72 repeated WebKit/tablet cases against the native Linux fixture. The 110-case comparison also corrected an unrelated iframe forwarding problem; the reproduced heap crash occurred outside that iframe workflow. See [QQQ#904](https://github.com/QRun-IO/qqq/issues/904) and [upstream#42803](https://github.com/microsoft/playwright/issues/42803). This is test infrastructure, not a browser shipped with Next UI. Replace the dated pin with a stable release containing the fix after running the acceptance gate ([QQQ#973](https://github.com/QRun-IO/qqq/issues/973)); do not downgrade to the affected stable 1.63 merely to remove the prerelease suffix.
## ESB (QRun-IO/qqq#986)
The sample JAR must include its embedded Artemis broker, `personEvents` topic,
`syncPerson` subscriber, ESB app/widget, all nine management processes, and the ESB
module's real v1 routes. `EsbFixtures` requires that production wiring and fails startup
if it is absent; it never registers replacements. Its application step runs the sample subscriber, then writes an owned H2
receipt of the processed event; the failure control makes that step fail to exercise
real dead-lettering and replay. Production API responses and broker operations are
never mocked. All control routes remain on the loopback-only acceptance server.
Before each test, reset stops consumers, verifies they stopped, resumes then drains the owned
subscription and dead-letter queue, closes pooled connections, clears counters and
reseeds H2, then waits for both the subscriber and control channel to be ready.
Teardown stops and drains even when the test fails. The readiness route returns 200
only after initial fixture reset completes. The sample owns broker startup/shutdown.
The pause scenario reads actual Artemis consumer, delivery and message counts through
an owned support route: a PAUSED runner can still finish its outstanding receive,
so publishing waits until its broker consumer has closed. No fixed sleep or client
prefetch override is used.
```bash
# After building the sample JAR and production export; set QQQ_SAMPLE_JAR and
# QQQ_MIDDLEWARE_API_JAR as described above. Reserve both ports before starting.
QQQ_ACCEPTANCE_BACKEND_PORT=18961 QQQ_ACCEPTANCE_ESB_PORT=18962 \
QQQ_ACCEPTANCE_BROWSERS=chromium \
node scripts/acceptance.mjs --skip-build specs/esb --max-failures=1
```
`ESB-001`–`ESB-010` are required matrix rows. The sample has no management URL, so
these tests verify real trigger pause/resume/restart, topic-subscription browsing,
dead-letter replay, application/process/table permissions and unsupported-capability
hiding. Replay confirmation explicitly reports the unavailable broker count.
Successful broker-level purge/delete/move/pause and a numeric dead-letter count need
a broker management endpoint; this fixture does not claim to prove those operations.
The ESB table-probe blanket 404 diagnostic exemption is removed: unexpected failed
requests fail acceptance, and negative cases allow only their specific denial.
## Standalone security qualification (#734)
Preserve the original SEC-034..036 table-authentication requirements alongside the
header/enforcement rows SEC-038/040 and both SEC-039 provider-metadata cases:
```bash
QQQ_ACCEPTANCE_MODE=standalone \
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet \
node scripts/acceptance.mjs --grep '\[SEC-(034|035|036|038|039|040)\]'
```
This bounded selection currently collects 43 cases (nine per desktop, eight per
phone/tablet; the Auth0 SEC-039 case is desktop-only). It uses the existing partial
matrix gate, zero retries and strict diagnostics. A passing Javalin run cannot
qualify this mode. Retain the actual Node response origins, both build identities,
frontend/backend source and artifact hashes, runtime versions, original failures
and raw reports. Never use a stale static export to make standalone variants pass.
The original loaded-branding assertion also remains required; missing standalone
assets must be investigated, not skipped. Local discovery or unit success is not
native qualification or issue closeout.
# Real-backend acceptance (QRun-IO/qqq#649)
Executable acceptance for every supported QQQ UI feature, against the QRun-owned sample
application and the production Next build. Mocked unit and e2e tests complement this
suite; they never replace it.
## CI: build → shards → gate
`.github/workflows/acceptance.yml` plans coverage while one `build` job builds QQQ
from `QQQ_REF`, the static export, dashboard JAR and sample fat JAR. Its artifact
contains `out/`, the sample JAR (with its versioned filename) and the matching
`qqq-middleware-api` JAR. Shards download these and run
`node scripts/acceptance.mjs --skip-build --shard=i/n`. Each shard compiles the owned
Java fixture at startup against those two JARs and runs its own backend with
**one worker and zero retries**. No Maven or Next build runs in a test job.
| Mode | Projects and shard counts | Specs |
| --- | --- | --- |
| Ordinary PR | Chromium 2, mobile 2 | Changed areas plus fixed smoke |
| Full: main, `v*` tags, manual dispatch | Chromium 3, Firefox 3, WebKit 4, mobile 4, tablet 4 | All specs, subject to the existing touch tags and quarantine |
| PR with `full-acceptance` label | Same as full | Same as full |
Adding or removing the label replans the PR. `scripts/acceptance-select.mjs` owns
the path mapping and smoke list: landing, navigation, record CRUD, query grid and
sample dashboard. A changed acceptance spec/helper selects its whole area. Source
paths select matching widget, query, record/form, process, navigation/theme,
security/auth or ESB areas. Shared infrastructure (including API/client code,
fixtures, support, matrix definitions, dependencies and build configuration) and
unmapped production paths select every area on Chromium and mobile. Docs-only PRs
run smoke. Deleted/renamed paths are included using a merge-base diff.
`QQQ_ACCEPTANCE_BLOB=1` selects the blob reporter and defers the per-run gate.
CI sets `QQQ_ACCEPTANCE_BLOB_NAME` to a unique project/shard ZIP filename.
`QQQ_ACCEPTANCE_SPEC_PATHS` is a JSON array of spec/directory paths used by CI;
leave both unset for the usual local JSON/HTML reports and gate. The final
**`acceptance gate`** job always runs, merges every shard's blob into
`test-results/acceptance/report.json` and `html/`, then runs `acceptance-gate.mjs`.
PR mode uses `--partial`; full mode requires all non-quarantined matrix rows.
Build/plan/shard failures, skipped shards or missing blobs fail this job even if a partial report
looks green. Configure branch protection to require this single acceptance signal.
Merged reports, blobs (including failure attachments) and `gate.json` are retained
in the `acceptance` artifact for 30 days; per-shard blobs are retained too.
The target is ordinary PRs under 30 minutes; hosted timings still need measurement.
pnpm and browser binaries are cached; only the required browser engine is installed
(mobile uses Chromium, tablet uses WebKit), with OS dependencies installed every run.
QQQ's installed Maven modules are cached by resolved source commit SHA. Every Maven
command has four attempts with 15/45/120-second backoff plus HTTP transport retries.
## Quarantine
`WID-029` is quarantined under [issue #93](https://github.com/QRun-IO/qqq-frontend-next/issues/93).
Its tests retain their assertions and touch tags and add `@quarantine`. Normal runs
exclude that tag; `QQQ_ACCEPTANCE_QUARANTINE=only` runs only those tests (intersected
with the project's touch tags). Full CI also runs them in a separate, non-blocking
Chromium `quarantine` job with its own `acceptance-quarantine` artifact.
`tests/acceptance/quarantine.json` records each ID, issue link and reason. The gate
lists entries and test outcomes in `gate.json`, counts rows in `summary.quarantined`,
and excludes them from required desktop/touch coverage. Unknown IDs and missing
issue links still fail the gate. A quarantine pass does not close the issue: remove
both the registry entry and test tags once the fix is verified.
```bash
QQQ_ACCEPTANCE_QUARANTINE=only QQQ_ACCEPTANCE_BROWSERS=chromium \
node scripts/acceptance.mjs --skip-build
```
## RC1 priority: workflow depth in Chromium
For RC1, prioritize complete real-application workflows in Chromium before expanding
browser and viewport coverage. Exercise normal entry paths, defaults, validation,
editing and cancellation, saving and reopening, changing dependent selections,
permissions, failure recovery, and the resulting records or downloaded files.
Check persisted values independently through the owned backend where applicable.
Scenario counts and passing page-load checks are not evidence that every workflow works.
Use the default Chromium project for focused diagnosis and the full Chromium acceptance
gate for the candidate. Preserve existing cross-browser tests and record failures;
additional browser and responsive work follows the workflow depth pass.
## Run
```bash
# Install the QQQ Maven modules, build the sample jar, and point at it:
# mvn -DskipTests -Djacoco.skip=true install # from the qqq repo root
# mvn -DskipTests -Djacoco.skip=true -Dcheckstyle.skip=true package # from qqq/qqq-sample-project
export QQQ_SAMPLE_JAR=/path/to/qqq-sample-project-<version>-jar-with-dependencies.jar
pnpm test:acceptance # static export + full suite + gate
node scripts/acceptance.mjs --skip-build specs/records # reuse out/, filtered (partial gate)
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet pnpm test:acceptance
QQQ_ACCEPTANCE_MODE=standalone pnpm test:acceptance # container-image build instead
```
- **javalin mode (default).** The static export (`pnpm build:export`, `out/`) goes on the
classpath as `next-dashboard/`, ahead of the sample jar. `QApplicationJavalinServer` then
serves it at `/`, which is exactly what a fresh application gets.
- **standalone mode.** Tests the Node standalone build through the container's
`qqq-server.mjs` entrypoint. Builds twice from clean `.next` directories: a staged
authentication-variant build baked to the security backend, then the main build
baked to the shared backend. Next rewrites are build-time values; changing only a
server environment variable cannot retarget them. The variant validates its actual
route manifest and marker before startup, disables Java dashboard serving, and
restarts its owned Node server when authentication metadata changes. Javalin mode
still uses its export and does not start this second Node server.
- **API-versioned report setup.** The acceptance server loads the matching
`qqq-middleware-api` JAR from `~/.m2/repository/com/kingsrook/qqq/qqq-middleware-api/<version>/`.
Set `QQQ_MIDDLEWARE_API_JAR` to another path when Maven uses a different local repository.
The fixture registers one API-aware v1 version so WID-072 uses real metadata, query and count routes.
- **Ports.** Set them with `QQQ_ACCEPTANCE_BACKEND_PORT` (default 18765) and
`QQQ_ACCEPTANCE_FRONTEND_PORT` (default 13765). The sample's embedded Artemis broker uses
`QQQ_ACCEPTANCE_ESB_PORT` (default 61616, passed as `qqq.sample.esb.port`). Use distinct
HTTP and broker ports for concurrent runs. The security variant uses
`QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT` (default backend HTTP port + 10),
`QQQ_ACCEPTANCE_SECURITY_ESB_PORT` (default main ESB port + 1, normally 61617),
`QQQ_ACCEPTANCE_SECURITY_IDP_PORT` (default frontend HTTP port + 10), and
`QQQ_ACCEPTANCE_SECURITY_FRONTEND_PORT` (standalone browser origin, default frontend
HTTP port + 20). Browser redirects use the mode-specific UI origin; direct backend
control, SQL and permission/replay requests retain the security backend origin.
Its broker setting is passed as `qqq.sample.esb.port`; explicit variant
`options.properties` retain precedence over these defaults.
- **Results.** Output lands in `test-results/acceptance/`: `report.json`, `gate.json` (with
per-project counts in `byProject`), the HTML report, and a trace, video and screenshot for
each failure.
- **Transport evidence.** CI enables `QQQ_ACCEPTANCE_TRANSPORT_TRACE=1`; set it locally
to retain `QQQ_TRANSPORT` events in the acceptance server log for documents, static
chunks and versioned API requests. Events contain request/connection IDs, paths without
queries, status, write byte counts and failure classes; they omit headers, cookies and
bodies. A completed server write does not prove browser receipt or rendering. A write
can fail even when final completion reports HTTP 200, so inspect `firstWriteFailure`
separately. This observer is acceptance-only and adds synchronous logging overhead;
leave the variable unset for a control run with the original handler tree. Native and
ordinary server logs can still contain session data; keep raw evidence private.
- **Document/XHR evidence (opt-in).** Set `QQQ_ACCEPTANCE_BROWSER_TRACE=1` locally to
attach `browser-boundary.json` through the diagnostics fixture. CI uses `navigation`
mode only for the exact NAV023 unique-key and NAV056 application-theme-override cases
on WebKit/tablet; other cases, including SEC043, do not opt in through that mode.
By default, only `/app/person[/]`, `/app/person/key[/]`, `/app/person/2[/]`, and
`/app/person.bulkEdit[/]` documents on configured UI/API origins are observed. To also
capture dashboard documents at exactly `/app` and `/app/`, set
`QQQ_ACCEPTANCE_BROWSER_TRACE_SESSION=1` alongside `QQQ_ACCEPTANCE_BROWSER_TRACE=1`
locally. The session flag alone installs nothing and does not select additional tests;
other dashboard paths are not included. Requests are exact method/path matches:
GET person metadata, person record 2 and person.bulkEdit process metadata;
POST `/qqq/v1/manageSession`, querySavedView init, person query/count and person.bulkEdit init.
GET `/qqq/v1/manageSession` is excluded. The helper emits
fixed aliases, document UUID/sequence/clocks, native XHR send return/throw, status and
terminal events. It never emits URLs, query/hash values, headers, credentials, payloads,
error messages or stacks, and adds no request headers or interception. Disabled mode
installs no binding or init script. One listener set follows each reused XHR generation.
Native OPENED reentry or reuse before the recorder observes the prior `loadend`
emits one `xhr-incomplete` marker and suppresses attribution for that instance for
its remaining lifetime. This includes overlapping requests and earlier application
event listeners; no endpoint, generation or attempt is guessed. Excluded requests
retain only completion state so their old events cannot impersonate later selected
reuse; no excluded target data is emitted. Native calls and outcomes are preserved.
Each document is limited to 512 events plus an overflow marker and 256 selected XHRs;
Node retains at most 4096 validated events with invalid/drop counters. Missing delivery,
overflow or missing `ready` means incomplete evidence; zero counters do not prove
complete delivery. Lifecycle phase is the last
observed pagehide/pageshow state, not proof of browser-internal teardown; no unload or
beforeunload hooks are added. Snapshotting uses Node memory even after a page fails.
Existing diagnostics, assertions and timeouts are unchanged. RSC fetches and document
navigation are not XHRs: correlate existing Playwright/server/native traces instead.
Send-return disproves a synchronous throw for that invocation; a pageError alone does
not prove one. XHR load observes completion in JS, not UI rendering; successful server
writes still do not prove receipt. Binding delivery has nonzero overhead and can be
lost during destruction. Retain source/export/runner provenance and raw traces privately;
passing instrumented cells do not attribute historical failures or establish a fix.
- **Browsers.** `QQQ_ACCEPTANCE_BROWSERS` picks the Playwright projects (default `chromium`).
The documented matrix is `chromium,firefox,webkit,mobile,tablet`; results and the exact commands
are in `docs/acceptance/browser-matrix.md`.
## Phone scope (`mobile` project)
The `mobile` project (Pixel 7: 412 px, touch) runs only tests tagged **`@mobile`** in their
title (`grep: /@mobile/` in `playwright.config.ts`). Below 768 px lists are card lists and
record actions live in an action sheet, so specs that assert desktop layout (grid columns,
resizing, grid keyboard navigation, desktop menus) are not phone scenarios and are not run
there. Skipping them with `test.skip` is not allowed: skips fail the gate.
- Tag a test `@mobile` when it proves phone behavior: navigation drawer, card list, record
view, forms, process runs, dialogs, sign-in/out. It must also pass on the desktop projects,
so use layout-neutral helpers (`listCell`, `listRows`, `navigation` in
`specs/security/support/ui.ts`, `recordList` in `specs/navigation/nav-helpers.ts`), or
pin a phone viewport with `test.use({ viewport: { width: 412, height: 839 }, hasTouch: true })`.
- Every phone-relevant row keeps at least one passing `@mobile` test; the gate fails a
configured project that ran no tests.
## Rules
- **Tag every test with its matrix ID(s)** in the title, for example
`test('[REC-004] edit persists after refresh', ...)`. The gate fails on:
- a required row with no test, or any failing, skipped or flaky test for it (retries are 0);
- an unknown ID, or a test without an ID;
- `required: false` without an `approval` note.
`required: false` needs James's explicit approval. Never loosen an expectation or delete
a scenario to go green.
- **Use `test` and `expect` from `support/fixtures`.** Each test gets a freshly reset
database, its own mock session, and a persona (`test.use({ persona: 'viewer' })`).
- Personas are `admin`, `viewer` (reads only, no processes), `noPets`, `noProcesses`,
`noApps` and `expired` (401 with the cookie cleared). ESB personas are `noPersonRead`
(all permissions except `person.read`), `noEsbView`, `noEsbOperate`, `noEsbDelete`,
and `noSyncPerson` (each removes only its named access permission). The sample sharing demo is enabled;
choose its identity with `test.use({ user: 'bob' })` (alice by default).
- `backend.sql(select)` reads the owned H2 database directly. Use it to verify persisted
values independently of the UI and API.
- `backend.api` calls the backend over HTTP as the same session. Use it to prove
server-side enforcement.
- **Include the `diagnostics` fixture in every test.** It fails the test on page errors,
console errors, failed or ≥400 application requests, and Content-Security-Policy
violations (every frame forwards `securitypolicyviolation` events; they are listed as
`cspViolations` in `diagnostics.json`). The dashboard is served with a strict policy
(QRun-IO/qqq#695, SEC-038 to SEC-040), so a feature that needs another origin must get it
from metadata or the application's `withNextDashboardSecurityHeadersCustomizer` hook
(see `WidgetsFixtures.allowFakeService`), never from a loosened test. Negative scenarios
whitelist their expected failures with `diagnostics.allow('/data/person/99 404')`. Requests that
a navigation cancels are not failures: `ERR_ABORTED`/`NS_BINDING_ABORTED`/cancelled, and
WebKit's "… due to access control checks." for a same-origin Next.js route prefetch or
RSC payload reported within a second of a document navigation (listed under
`interruptedFetches` in the attached `diagnostics.json`). Firefox legacy-font diagnostics
are classified there only when the exact cancellation status matches a same-origin font
request from an earlier document near navigation (aborted, or HTTP 200 before decode was cancelled), and the final document successfully
loads and decodes that font. Every unmatched or failed-decode diagnostic still fails.
- **v1 only.** The `diagnostics` fixture also fails a test whose page calls an unversioned API
route of a QQQ server (`/data`, `/processes`, `/widget`, `/possibleValues`, `/download`,
`/reports`, `/metaData`, `/manageSession`, `/logout` outside `/qqq/v1`; QRun-IO/qqq#699).
`allow()` does not waive it. Node-side `backend.api` calls may still exercise legacy routes.
- **Transient feedback.** Arm `expectToastDuringAction` before the triggering action when
asserting a toast. It observes the visible exact text and notification count inside the
browser (plus header clearance when requested), so driver/trace delays cannot outlast the
notification before observation begins. Assert persistent form and database state afterwards.
- **Assert real behavior.** Check exact values, labels, counts and persisted rows. A 200
response or a visible container is not acceptance.
- **Fixtures.** Each area owns `fixture/<Area>Fixtures.java`: `define()` adds metadata;
`prime()` creates and seeds tables. `prime()` reruns after every reset and must be
idempotent (DROP then CREATE). Use synthetic QRun-owned data only.
- **Matrix.** Each area owns `matrix/<area>.json` and its ID prefixes. A row has
`id, feature, source[], fixture, scenarios[], negative[], issues[], required`.
`docs/acceptance/feature-matrix.md` is generated from these files.
## Pinned Linux WebKit crash fix
The test runner is temporarily pinned to `@playwright/test@1.64.0-alpha-2026-10-01`: its WebKit 2369 bundle contains libsoup 3.6.6. The previous 1.58.2/WebKit 2248 bundle contains 3.6.5 and reproduced heap corruption during navigation; the same browser using the fixed library passed 110 targeted cases, and the official replacement passed 72 repeated WebKit/tablet cases against the native Linux fixture. The 110-case comparison also corrected an unrelated iframe forwarding problem; the reproduced heap crash occurred outside that iframe workflow. See [QQQ#904](https://github.com/QRun-IO/qqq/issues/904) and [upstream#42803](https://github.com/microsoft/playwright/issues/42803). This is test infrastructure, not a browser shipped with Next UI. Replace the dated pin with a stable release containing the fix after running the acceptance gate ([QQQ#973](https://github.com/QRun-IO/qqq/issues/973)); do not downgrade to the affected stable 1.63 merely to remove the prerelease suffix.
## ESB (QRun-IO/qqq#986)
The sample JAR must include its embedded Artemis broker, `personEvents` topic,
`syncPerson` subscriber, ESB app/widget, all nine management processes, and the ESB
module's real v1 routes. `EsbFixtures` requires that production wiring and fails startup
if it is absent; it never registers replacements. Its application step runs the sample subscriber, then writes an owned H2
receipt of the processed event; the failure control makes that step fail to exercise
real dead-lettering and replay. Production API responses and broker operations are
never mocked. All control routes remain on the loopback-only acceptance server.
Before each test, reset stops consumers, verifies they stopped, resumes then drains the owned
subscription and dead-letter queue, closes pooled connections, clears counters and
reseeds H2, then waits for both the subscriber and control channel to be ready.
Teardown stops and drains even when the test fails. The readiness route returns 200
only after initial fixture reset completes. The sample owns broker startup/shutdown.
The pause scenario reads actual Artemis consumer, delivery and message counts through
an owned support route: a PAUSED runner can still finish its outstanding receive,
so publishing waits until its broker consumer has closed. No fixed sleep or client
prefetch override is used.
```bash
# After building the sample JAR and production export; set QQQ_SAMPLE_JAR and
# QQQ_MIDDLEWARE_API_JAR as described above. Reserve both ports before starting.
QQQ_ACCEPTANCE_BACKEND_PORT=18961 QQQ_ACCEPTANCE_ESB_PORT=18962 \
QQQ_ACCEPTANCE_BROWSERS=chromium \
node scripts/acceptance.mjs --skip-build specs/esb --max-failures=1
```
`ESB-001`–`ESB-010` are required matrix rows. The sample has no management URL, so
these tests verify real trigger pause/resume/restart, topic-subscription browsing,
dead-letter replay, application/process/table permissions and unsupported-capability
hiding. Replay confirmation explicitly reports the unavailable broker count.
Successful broker-level purge/delete/move/pause and a numeric dead-letter count need
a broker management endpoint; this fixture does not claim to prove those operations.
The ESB table-probe blanket 404 diagnostic exemption is removed: unexpected failed
requests fail acceptance, and negative cases allow only their specific denial.
## Standalone security qualification (#734)
Preserve the original SEC-034..036 table-authentication requirements alongside the
header/enforcement rows SEC-038/040 and both SEC-039 provider-metadata cases:
```bash
QQQ_ACCEPTANCE_MODE=standalone \
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet \
node scripts/acceptance.mjs --grep '\[SEC-(034|035|036|038|039|040)\]'
```
This bounded selection currently collects 43 cases (nine per desktop, eight per
phone/tablet; the Auth0 SEC-039 case is desktop-only). It uses the existing partial
matrix gate, zero retries and strict diagnostics. A passing Javalin run cannot
qualify this mode. Retain the actual Node response origins, both build identities,
frontend/backend source and artifact hashes, runtime versions, original failures
and raw reports. Never use a stale static export to make standalone variants pass.
The original loaded-branding assertion also remains required; missing standalone
assets must be investigated, not skipped. Local discovery or unit success is not
native qualification or issue closeout.
## User preferences (NAV-057)
`specs/preferences.spec.ts` uses the sample backend's `regional` section (label
`Regional settings`, scalar `timeZone`, `dateFormat`, `weekStartsOn`) and core
key-value persistence. It does not register metadata or mock the preferences API.
Build the backend revision that includes `docs/USER-PREFERENCES.md` and its sample
registration before running this spec. It exercises user-menu navigation, saving
an alternate week start, reload persistence, a deep link, overflow and accessibility.
The acceptance selector includes this root-level spec with the navigation area.
+106 -106
View File
@@ -1,106 +1,106 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { existsSync, readdirSync } from 'node:fs'
import { expect, it } from 'vitest'
import { AREAS, SMOKE, planAcceptance, selectSpecs } from '../../../scripts/acceptance-select.mjs'
const allAreas = AREAS.map((area: string) => `tests/acceptance/specs/${area}`)
it('keeps smoke paths and the area inventory aligned with the real suite', () => {
expect(SMOKE).toHaveLength(5)
for (const file of SMOKE) expect(existsSync(file), file).toBe(true)
expect(readdirSync('tests/acceptance/specs', { withFileTypes: true })
.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()).toEqual(AREAS)
})
it('runs smoke for docs-only changes, unit-only changes and an empty diff', () => {
expect(selectSpecs(['README.md', 'docs/acceptance/browser-matrix.md', 'tests/acceptance/README.md', '.github/README.md'])).toEqual(SMOKE)
expect(selectSpecs(['tests/a11y/components.test.tsx'])).toEqual(SMOKE)
expect(selectSpecs([])).toEqual(SMOKE)
})
it.each(AREAS)('selects the %s area for a changed acceptance spec or helper', (area) => {
const selected = selectSpecs([`tests/acceptance/specs/${area}/example.spec.ts`])
expect(selected).toContain(`tests/acceptance/specs/${area}`)
expect(selected).not.toContain(`tests/acceptance/specs/${area}/example.spec.ts`)
for (const file of SMOKE) expect(selected.some((path: string) => file === path || file.startsWith(`${path}/`))).toBe(true)
})
it.each([
['src/components/widgets/Chart.tsx', 'widgets'],
['src/components/query/DataGrid.tsx', 'query'],
['src/types/table.ts', 'query'],
['src/components/records/RecordView.tsx', 'records'],
['src/components/forms/TextField.tsx', 'records'],
['src/types/processes.ts', 'processes'],
['src/components/navigation/Nav.tsx', 'navigation'],
['src/components/Sidebar.tsx', 'navigation'],
['src/lib/theme/tokens.ts', 'navigation'],
['src/components/auth/Login.tsx', 'security'],
['src/components/esb/Queue.tsx', 'esb'],
])('maps %s to %s while retaining smoke', (file, area) => {
const selected = selectSpecs([file])
expect(selected).toContain(`tests/acceptance/specs/${area}`)
expect(selected).not.toEqual(allAreas)
for (const smoke of SMOKE) expect(selected.some((path: string) => smoke === path || smoke.startsWith(`${path}/`))).toBe(true)
})
it.each([
'package.json', 'pnpm-lock.yaml', 'next.config.ts', 'next.config.mjs', 'playwright.config.ts',
'tests/acceptance/playwright.config.ts', 'tests/acceptance/support/fixtures.ts',
'tests/acceptance/fixture/AcceptanceSampleServer.java', 'tests/acceptance/matrix/records.json',
'tests/acceptance/quarantine.json', 'scripts/acceptance-select.mjs', 'scripts/acceptance-backend.mjs',
'.github/workflows/acceptance.yml', 'src/lib/api/client.ts', 'src/lib/api/widgets.ts',
'src/lib/query-client.ts', 'src/lib/core/new-code.ts', 'src/lib/context/q-context.tsx',
'src/components/ui/button.tsx', 'src/styles/globals.css', 'pom.xml',
])('selects all areas for shared infrastructure or unmapped production code: %s', (file) => {
expect(selectSpecs([file])).toEqual(allAreas)
})
it('unions renamed paths and normalizes Windows paths without duplicating smoke', () => {
const selected = selectSpecs(['tests/acceptance/specs/records/old.spec.ts', 'tests\\acceptance\\specs\\widgets\\new.spec.ts'])
expect(selected).toContain('tests/acceptance/specs/records')
expect(selected).toContain('tests/acceptance/specs/widgets')
expect(selected).not.toContain('tests/acceptance/specs/records/crud.spec.ts')
expect(new Set(selected).size).toBe(selected.length)
})
it('uses two isolated shards each for Chromium and mobile on ordinary PRs', () => {
const plan = planAcceptance({ eventName: 'pull_request', paths: ['README.md'] })
expect(plan.mode).toBe('pr')
expect(plan.specs).toEqual(SMOKE)
expect(plan.matrix.include).toEqual([
{ project: 'chromium', browser: 'chromium', shard: 1, total: 2 },
{ project: 'chromium', browser: 'chromium', shard: 2, total: 2 },
{ project: 'mobile', browser: 'chromium', shard: 1, total: 2 },
{ project: 'mobile', browser: 'chromium', shard: 2, total: 2 },
])
})
it.each([
{ eventName: 'push' },
{ eventName: 'workflow_dispatch' },
{ eventName: 'pull_request', labels: ['full-acceptance'] },
])('uses every project and spec in full mode: %j', (event) => {
const plan = planAcceptance(event)
expect(plan.mode).toBe('full')
expect(plan.specs).toEqual([])
expect(plan.matrix.include).toHaveLength(18)
for (const [project, count] of Object.entries({ chromium: 3, firefox: 3, webkit: 4, mobile: 4, tablet: 4 })) {
const cells = plan.matrix.include.filter((cell: { project: string }) => cell.project === project)
expect(cells.map((cell: { shard: number }) => cell.shard)).toEqual(Array.from({ length: count }, (_, i) => i + 1))
expect(cells.every((cell: { browser: string; total: number }) => cell.total === count && cell.browser === (project === 'mobile' ? 'chromium' : project === 'tablet' ? 'webkit' : project))).toBe(true)
}
})
it('shared infrastructure still uses PR browsers unless the exact full label is set', () => {
const plan = planAcceptance({ eventName: 'pull_request', labels: ['full-acceptance-later'], paths: ['package.json'] })
expect(plan.mode).toBe('pr')
expect(plan.specs).toEqual(allAreas)
expect(plan.matrix.include).toHaveLength(4)
})
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { existsSync, readdirSync } from 'node:fs'
import { expect, it } from 'vitest'
import { AREAS, SMOKE, planAcceptance, selectSpecs } from '../../../scripts/acceptance-select.mjs'
const allAreas = AREAS.map((area: string) => `tests/acceptance/specs/${area}`)
it('keeps smoke paths and the area inventory aligned with the real suite', () => {
expect(SMOKE).toHaveLength(5)
for (const file of SMOKE) expect(existsSync(file), file).toBe(true)
expect(readdirSync('tests/acceptance/specs', { withFileTypes: true })
.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()).toEqual(AREAS)
})
it('runs smoke for docs-only changes, unit-only changes and an empty diff', () => {
expect(selectSpecs(['README.md', 'docs/acceptance/browser-matrix.md', 'tests/acceptance/README.md', '.github/README.md'])).toEqual(SMOKE)
expect(selectSpecs(['tests/a11y/components.test.tsx'])).toEqual(SMOKE)
expect(selectSpecs([])).toEqual(SMOKE)
})
it.each(AREAS)('selects the %s area for a changed acceptance spec or helper', (area) => {
const selected = selectSpecs([`tests/acceptance/specs/${area}/example.spec.ts`])
expect(selected).toContain(`tests/acceptance/specs/${area}`)
expect(selected).not.toContain(`tests/acceptance/specs/${area}/example.spec.ts`)
for (const file of SMOKE) expect(selected.some((path: string) => file === path || file.startsWith(`${path}/`))).toBe(true)
})
it.each([
['src/components/widgets/Chart.tsx', 'widgets'],
['src/components/query/DataGrid.tsx', 'query'],
['src/types/table.ts', 'query'],
['src/components/records/RecordView.tsx', 'records'],
['src/components/forms/TextField.tsx', 'records'],
['src/types/processes.ts', 'processes'],
['src/components/navigation/Nav.tsx', 'navigation'],
['src/components/Sidebar.tsx', 'navigation'],
['src/lib/theme/tokens.ts', 'navigation'],
['src/components/auth/Login.tsx', 'security'],
['src/components/esb/Queue.tsx', 'esb'],
])('maps %s to %s while retaining smoke', (file, area) => {
const selected = selectSpecs([file])
expect(selected).toContain(`tests/acceptance/specs/${area}`)
expect(selected).not.toEqual(allAreas)
for (const smoke of SMOKE) expect(selected.some((path: string) => smoke === path || smoke.startsWith(`${path}/`))).toBe(true)
})
it.each([
'package.json', 'pnpm-lock.yaml', 'next.config.ts', 'next.config.mjs', 'playwright.config.ts',
'tests/acceptance/playwright.config.ts', 'tests/acceptance/support/fixtures.ts',
'tests/acceptance/fixture/AcceptanceSampleServer.java', 'tests/acceptance/matrix/records.json',
'tests/acceptance/quarantine.json', 'scripts/acceptance-select.mjs', 'scripts/acceptance-backend.mjs',
'.github/workflows/acceptance.yml', 'src/lib/api/client.ts', 'src/lib/api/widgets.ts',
'src/lib/query-client.ts', 'src/lib/core/new-code.ts', 'src/lib/context/q-context.tsx',
'src/components/ui/button.tsx', 'src/styles/globals.css', 'pom.xml',
])('selects all areas for shared infrastructure or unmapped production code: %s', (file) => {
expect(selectSpecs([file])).toEqual(allAreas)
})
it('unions renamed paths and normalizes Windows paths without duplicating smoke', () => {
const selected = selectSpecs(['tests/acceptance/specs/records/old.spec.ts', 'tests\\acceptance\\specs\\widgets\\new.spec.ts'])
expect(selected).toContain('tests/acceptance/specs/records')
expect(selected).toContain('tests/acceptance/specs/widgets')
expect(selected).not.toContain('tests/acceptance/specs/records/crud.spec.ts')
expect(new Set(selected).size).toBe(selected.length)
})
it('uses two isolated shards each for Chromium and mobile on ordinary PRs', () => {
const plan = planAcceptance({ eventName: 'pull_request', paths: ['README.md'] })
expect(plan.mode).toBe('pr')
expect(plan.specs).toEqual(SMOKE)
expect(plan.matrix.include).toEqual([
{ project: 'chromium', browser: 'chromium', shard: 1, total: 2 },
{ project: 'chromium', browser: 'chromium', shard: 2, total: 2 },
{ project: 'mobile', browser: 'chromium', shard: 1, total: 2 },
{ project: 'mobile', browser: 'chromium', shard: 2, total: 2 },
])
})
it.each([
{ eventName: 'push' },
{ eventName: 'workflow_dispatch' },
{ eventName: 'pull_request', labels: ['full-acceptance'] },
])('uses every project and spec in full mode: %j', (event) => {
const plan = planAcceptance(event)
expect(plan.mode).toBe('full')
expect(plan.specs).toEqual([])
expect(plan.matrix.include).toHaveLength(18)
for (const [project, count] of Object.entries({ chromium: 3, firefox: 3, webkit: 4, mobile: 4, tablet: 4 })) {
const cells = plan.matrix.include.filter((cell: { project: string }) => cell.project === project)
expect(cells.map((cell: { shard: number }) => cell.shard)).toEqual(Array.from({ length: count }, (_, i) => i + 1))
expect(cells.every((cell: { browser: string; total: number }) => cell.total === count && cell.browser === (project === 'mobile' ? 'chromium' : project === 'tablet' ? 'webkit' : project))).toBe(true)
}
})
it('shared infrastructure still uses PR browsers unless the exact full label is set', () => {
const plan = planAcceptance({ eventName: 'pull_request', labels: ['full-acceptance-later'], paths: ['package.json'] })
expect(plan.mode).toBe('pr')
expect(plan.specs).toEqual(allAreas)
expect(plan.matrix.include).toHaveLength(4)
})
File diff suppressed because it is too large. Load diff
+161 -166
View File
@@ -1,166 +1,161 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import type { Locator, Page } from '@playwright/test'
import { expect, open, test } from '../../support/fixtures'
import { expectNoHorizontalScroll, expectTouchReady, expectTouchTargets, undersizedTargets } from '../../support/touch'
import { waitForSampleDashboard } from './sample-dashboard-navigation'
import { tabKey } from '../security/support/ui'
import { appNavigation, expectBreadcrumbs, recordCollection, waitForShell } from './nav-helpers'
/** Whether keyboard focus is inside `scope`. */
async function focusInside(scope: Locator): Promise<boolean> {
return scope.evaluate((element) => element.contains(document.activeElement))
}
/** A short description of the focused element, to tell Tab stops apart. */
async function focusedControl(page: Page): Promise<string> {
return page.evaluate(() => {
const active = document.activeElement
return active ? `${active.tagName} ${active.getAttribute('data-qqq-id') ?? ''} ${active.getAttribute('aria-label') ?? active.textContent?.trim() ?? ''}` : ''
})
}
test.describe('mobile navigation', () => {
test.use({ viewport: { width: 412, height: 839 }, hasTouch: true })
test('[NAV-026] the header menu opens the navigation drawer, which navigates and closes @mobile', async ({ page, backend, diagnostics }) => {
await open(page, '/app/SampleWidgetsDashboard')
await waitForShell(page)
const trail = page.getByRole('navigation', { name: 'Breadcrumb' })
const current = trail.locator('[aria-current="page"]')
await expect(current).toHaveText('Sample Widgets Dashboard')
await expect.poll(async () => {
const navBox = await trail.boundingBox()
const titleBox = await current.boundingBox()
if (!navBox || !titleBox) return 0
return Math.min(navBox.x + navBox.width, titleBox.x + titleBox.width) - Math.max(navBox.x, titleBox.x)
}).toBeGreaterThan(100)
await waitForSampleDashboard(page)
await open(page, '/app')
await waitForShell(page)
const drawer = page.locator('[data-qqq-id="sidebar-mobile-drawer"]')
await expect(drawer).toHaveCount(0)
await expect(page.locator('[data-qqq-id="sidebar-desktop"]')).toBeHidden()
await page.getByRole('button', { name: 'Open navigation menu' }).click()
await expect(drawer).toBeVisible()
await expectTouchReady(page, drawer)
const nav = drawer.getByRole('navigation', { name: 'App navigation' })
await nav.getByRole('button', { name: 'Expand Miscellaneous' }).click()
await nav.getByRole('link', { name: 'Carrier', exact: true }).click()
await expect(page).toHaveURL(/\/app\/carrier\/?$/)
await expect(drawer).toHaveCount(0)
await expect(page).toHaveTitle('Carrier | Miscellaneous | QQQ Sample')
await expect(page.getByRole('navigation', { name: 'Breadcrumb' }).locator('[aria-current="page"]')).toHaveText('Carrier')
await page.getByRole('button', { name: 'Open navigation menu' }).click()
await expect(drawer.getByRole('link', { name: 'Carrier', exact: true })).toHaveAttribute('aria-current', 'page')
await drawer.getByRole('button', { name: 'Close navigation' }).click()
await expect(drawer).toHaveCount(0)
await expect(page).toHaveURL(/\/app\/carrier\/?$/)
// A tap on the backdrop closes the drawer too
await page.getByRole('button', { name: 'Open navigation menu' }).click()
await expect(drawer).toBeVisible()
await page.locator('[data-qqq-id="sidebar-mobile-backdrop"]').click({ position: { x: 380, y: 400 } })
await expect(drawer).toHaveCount(0)
})
test('[NAV-037] the drawer opens from the keyboard, keeps Tab inside and gives focus back to the menu button @mobile', async ({ page, backend, diagnostics }) => {
await open(page, '/app/miscellaneous')
await waitForShell(page)
const menu = page.getByRole('button', { name: 'Open navigation menu' })
const drawer = page.locator('[data-qqq-id="sidebar-mobile-drawer"]')
const tab = tabKey(page)
await expect(menu).toHaveAttribute('aria-expanded', 'false')
// Enter on the focused menu button opens the drawer as a modal dialog and moves focus into it
await menu.focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('dialog', { name: 'Navigation' })).toBeVisible()
await expect(drawer).toBeVisible()
expect(await focusInside(drawer), await focusedControl(page)).toBe(true)
await expectTouchReady(page, drawer)
// Tab and Shift+Tab reach every control of the drawer and never the page behind it (Chromium and
// Firefox wrap around at the ends; WebKit's Option+Tab stops at the first and last control)
const controls = await drawer.locator('a[href]:visible, button:visible').evaluateAll((elements) => elements.map((element) =>
`${element.tagName} ${element.getAttribute('data-qqq-id') ?? ''} ${element.getAttribute('aria-label') ?? element.textContent?.trim() ?? ''}`))
const visited = new Set<string>()
for (const key of [tab, `Shift+${tab}`]) {
for (let press = 0; press < controls.length + 3; press++) {
await page.keyboard.press(key)
expect(await focusInside(drawer), `${key} ${press + 1} left the drawer for ${await focusedControl(page)}`).toBe(true)
visited.add(await focusedControl(page))
}
}
expect(controls.filter((control) => !visited.has(control)), 'drawer controls the keyboard never reached').toEqual([])
// Escape closes it and returns focus to the menu button
await page.keyboard.press('Escape')
await expect(drawer).toHaveCount(0)
await expect(menu).toBeFocused()
await expect(menu).toHaveAttribute('aria-expanded', 'false')
// The close button, used from the keyboard, returns focus as well
await page.keyboard.press('Enter')
await expect(drawer).toBeVisible()
await drawer.getByRole('button', { name: 'Close navigation' }).focus()
await page.keyboard.press('Enter')
await expect(drawer).toHaveCount(0)
await expect(menu).toBeFocused()
// Choosing a link navigates, closes the drawer and returns focus to the menu button
await page.keyboard.press('Enter')
await expect(drawer).toBeVisible()
await drawer.getByRole('link', { name: 'Carrier', exact: true }).focus()
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/\/app\/carrier\/?$/)
await expect(drawer).toHaveCount(0)
await expect(menu).toBeFocused()
await expect(recordCollection(page, 'Carrier')).toBeVisible()
await expectBreadcrumbs(page, ['Miscellaneous', 'Carrier'])
})
})
test('[NAV-038] the shell is touch-ready on phones and tablets: skip link, header controls and user menu @mobile', async ({ page, backend, diagnostics }) => {
await open(page, '/app/navDeepItem')
await waitForShell(page)
await expectBreadcrumbs(page, ['Nav Level One', 'Nav Level Two', 'Nav Level Three', 'Nav Deep Item'])
await expectNoHorizontalScroll(page)
await expectTouchTargets(page.locator('[data-qqq-id="header"]'))
// The skip link is the first Tab stop; once visible it is a full-size target
await page.keyboard.press(tabKey(page))
const skip = page.locator('[data-qqq-id="skip-to-content"]')
await expect(skip).toBeFocused()
await expect(skip).toHaveText('Skip to main content')
expect((await undersizedTargets(page)).filter((target) => target.includes('skip-to-content'))).toEqual([])
// The navigation (tablet sidebar or phone drawer) and its user menu
const nav = await appNavigation(page)
await expectTouchTargets(nav)
const sidebar = page.locator('[data-qqq-id="sidebar"]:visible')
await sidebar.locator('[data-qqq-id="sidebar-user-button"]').click()
const menu = sidebar.getByRole('menu')
await expect(menu.getByRole('menuitem')).toHaveText(['Preferences', 'Log Out'])
await expectTouchTargets(sidebar)
await menu.getByRole('menuitem', { name: 'Preferences', exact: true }).click()
const preferences = page.getByRole('dialog', { name: 'Preferences', exact: true })
await expect(preferences).toBeVisible()
const pageSize = preferences.locator('[data-qqq-id="pref-pagesize-25"]')
await expect.poll(() => pageSize.evaluate(element => {
const bounds = element.getBoundingClientRect()
return element.contains(document.elementFromPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2))
}), { message: 'Preferences must appear above the navigation drawer' }).toBe(true)
await pageSize.click()
await preferences.getByRole('button', { name: 'Done', exact: true }).click()
await expect(preferences).toBeHidden()
await expect(sidebar.locator('[data-qqq-id="sidebar-user-button"]')).toBeFocused()
})
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import type { Locator, Page } from '@playwright/test'
import { expect, open, test } from '../../support/fixtures'
import { expectNoHorizontalScroll, expectTouchReady, expectTouchTargets, undersizedTargets } from '../../support/touch'
import { waitForSampleDashboard } from './sample-dashboard-navigation'
import { tabKey } from '../security/support/ui'
import { appNavigation, expectBreadcrumbs, recordCollection, waitForShell } from './nav-helpers'
/** Whether keyboard focus is inside `scope`. */
async function focusInside(scope: Locator): Promise<boolean> {
return scope.evaluate((element) => element.contains(document.activeElement))
}
/** A short description of the focused element, to tell Tab stops apart. */
async function focusedControl(page: Page): Promise<string> {
return page.evaluate(() => {
const active = document.activeElement
return active ? `${active.tagName} ${active.getAttribute('data-qqq-id') ?? ''} ${active.getAttribute('aria-label') ?? active.textContent?.trim() ?? ''}` : ''
})
}
test.describe('mobile navigation', () => {
test.use({ viewport: { width: 412, height: 839 }, hasTouch: true })
test('[NAV-026] the header menu opens the navigation drawer, which navigates and closes @mobile', async ({ page, backend, diagnostics }) => {
await open(page, '/app/SampleWidgetsDashboard')
await waitForShell(page)
const trail = page.getByRole('navigation', { name: 'Breadcrumb' })
const current = trail.locator('[aria-current="page"]')
await expect(current).toHaveText('Sample Widgets Dashboard')
await expect.poll(async () => {
const navBox = await trail.boundingBox()
const titleBox = await current.boundingBox()
if (!navBox || !titleBox) return 0
return Math.min(navBox.x + navBox.width, titleBox.x + titleBox.width) - Math.max(navBox.x, titleBox.x)
}).toBeGreaterThan(100)
await waitForSampleDashboard(page)
await open(page, '/app')
await waitForShell(page)
const drawer = page.locator('[data-qqq-id="sidebar-mobile-drawer"]')
await expect(drawer).toHaveCount(0)
await expect(page.locator('[data-qqq-id="sidebar-desktop"]')).toBeHidden()
await page.getByRole('button', { name: 'Open navigation menu' }).click()
await expect(drawer).toBeVisible()
await expectTouchReady(page, drawer)
const nav = drawer.getByRole('navigation', { name: 'App navigation' })
await nav.getByRole('button', { name: 'Expand Miscellaneous' }).click()
await nav.getByRole('link', { name: 'Carrier', exact: true }).click()
await expect(page).toHaveURL(/\/app\/carrier\/?$/)
await expect(drawer).toHaveCount(0)
await expect(page).toHaveTitle('Carrier | Miscellaneous | QQQ Sample')
await expect(page.getByRole('navigation', { name: 'Breadcrumb' }).locator('[aria-current="page"]')).toHaveText('Carrier')
await page.getByRole('button', { name: 'Open navigation menu' }).click()
await expect(drawer.getByRole('link', { name: 'Carrier', exact: true })).toHaveAttribute('aria-current', 'page')
await drawer.getByRole('button', { name: 'Close navigation' }).click()
await expect(drawer).toHaveCount(0)
await expect(page).toHaveURL(/\/app\/carrier\/?$/)
// A tap on the backdrop closes the drawer too
await page.getByRole('button', { name: 'Open navigation menu' }).click()
await expect(drawer).toBeVisible()
await page.locator('[data-qqq-id="sidebar-mobile-backdrop"]').click({ position: { x: 380, y: 400 } })
await expect(drawer).toHaveCount(0)
})
test('[NAV-037] the drawer opens from the keyboard, keeps Tab inside and gives focus back to the menu button @mobile', async ({ page, backend, diagnostics }) => {
await open(page, '/app/miscellaneous')
await waitForShell(page)
const menu = page.getByRole('button', { name: 'Open navigation menu' })
const drawer = page.locator('[data-qqq-id="sidebar-mobile-drawer"]')
const tab = tabKey(page)
await expect(menu).toHaveAttribute('aria-expanded', 'false')
// Enter on the focused menu button opens the drawer as a modal dialog and moves focus into it
await menu.focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('dialog', { name: 'Navigation' })).toBeVisible()
await expect(drawer).toBeVisible()
expect(await focusInside(drawer), await focusedControl(page)).toBe(true)
await expectTouchReady(page, drawer)
// Tab and Shift+Tab reach every control of the drawer and never the page behind it (Chromium and
// Firefox wrap around at the ends; WebKit's Option+Tab stops at the first and last control)
const controls = await drawer.locator('a[href]:visible, button:visible').evaluateAll((elements) => elements.map((element) =>
`${element.tagName} ${element.getAttribute('data-qqq-id') ?? ''} ${element.getAttribute('aria-label') ?? element.textContent?.trim() ?? ''}`))
const visited = new Set<string>()
for (const key of [tab, `Shift+${tab}`]) {
for (let press = 0; press < controls.length + 3; press++) {
await page.keyboard.press(key)
expect(await focusInside(drawer), `${key} ${press + 1} left the drawer for ${await focusedControl(page)}`).toBe(true)
visited.add(await focusedControl(page))
}
}
expect(controls.filter((control) => !visited.has(control)), 'drawer controls the keyboard never reached').toEqual([])
// Escape closes it and returns focus to the menu button
await page.keyboard.press('Escape')
await expect(drawer).toHaveCount(0)
await expect(menu).toBeFocused()
await expect(menu).toHaveAttribute('aria-expanded', 'false')
// The close button, used from the keyboard, returns focus as well
await page.keyboard.press('Enter')
await expect(drawer).toBeVisible()
await drawer.getByRole('button', { name: 'Close navigation' }).focus()
await page.keyboard.press('Enter')
await expect(drawer).toHaveCount(0)
await expect(menu).toBeFocused()
// Choosing a link navigates, closes the drawer and returns focus to the menu button
await page.keyboard.press('Enter')
await expect(drawer).toBeVisible()
await drawer.getByRole('link', { name: 'Carrier', exact: true }).focus()
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/\/app\/carrier\/?$/)
await expect(drawer).toHaveCount(0)
await expect(menu).toBeFocused()
await expect(recordCollection(page, 'Carrier')).toBeVisible()
await expectBreadcrumbs(page, ['Miscellaneous', 'Carrier'])
})
})
test('[NAV-038] the shell is touch-ready on phones and tablets: skip link, header controls and user menu @mobile', async ({ page, backend, diagnostics }) => {
await open(page, '/app/navDeepItem')
await waitForShell(page)
await expectBreadcrumbs(page, ['Nav Level One', 'Nav Level Two', 'Nav Level Three', 'Nav Deep Item'])
await expectNoHorizontalScroll(page)
await expectTouchTargets(page.locator('[data-qqq-id="header"]'))
// The skip link is the first Tab stop; once visible it is a full-size target
await page.keyboard.press(tabKey(page))
const skip = page.locator('[data-qqq-id="skip-to-content"]')
await expect(skip).toBeFocused()
await expect(skip).toHaveText('Skip to main content')
expect((await undersizedTargets(page)).filter((target) => target.includes('skip-to-content'))).toEqual([])
// The navigation (tablet sidebar or phone drawer) and its user menu
const nav = await appNavigation(page)
await expectTouchTargets(nav)
const sidebar = page.locator('[data-qqq-id="sidebar"]:visible')
await sidebar.locator('[data-qqq-id="sidebar-user-button"]').click()
const menu = sidebar.getByRole('menu')
await expect(menu.getByRole('menuitem')).toHaveText(['Preferences', 'Log Out'])
await expectTouchTargets(sidebar)
await menu.getByRole('menuitem', { name: 'Preferences', exact: true }).click()
const preferences = page.locator('[data-qqq-id="preferences-page"]')
await expect(preferences).toBeVisible()
const pageSize = preferences.locator('[data-qqq-id="pref-pagesize-25"]')
await expect(page.locator('[data-qqq-id="sidebar-mobile-drawer"]')).toHaveCount(0)
await pageSize.click()
await expectNoHorizontalScroll(page)
})
@@ -0,0 +1,48 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import { expect, open, test } from '../../support/fixtures'
import { expectNoHorizontalScroll } from '../../support/touch'
import { appNavigation } from './nav-helpers'
import { checkA11y } from '../../../e2e/a11y-helpers'
test('[NAV-057] regional preferences save from the user menu and persist after reload @mobile', async ({ page, backend, diagnostics }) => {
void backend; void diagnostics
await open(page, '/app/person')
await appNavigation(page)
await page.locator('[data-qqq-id="sidebar"]:visible [data-qqq-id="sidebar-user-button"]').click()
await page.getByRole('menuitem', { name: 'Preferences', exact: true }).click()
await expect(page).toHaveURL(/\/app\/preferences\/?$/)
await expect(page.getByRole('heading', { name: 'Display', exact: true })).toBeVisible()
const select = page.locator('[data-qqq-id="preferences-section-select"]')
if (await select.isVisible()) await select.selectOption('regional')
else await page.locator('[data-qqq-id="preferences-section-nav-regional"]').click()
await expect(page.getByRole('heading', { name: 'Regional settings', exact: true })).toBeVisible()
const weekStart = page.getByRole('combobox', { name: /week starts on/i })
const current = await weekStart.inputValue()
const next = await weekStart.locator('option').evaluateAll((options, current) => options.map((option) => (option as HTMLOptionElement).value).find((value) => value !== '' && value !== current), current)
expect(next, 'The sample regional enum must offer at least two week starts').toBeTruthy()
await weekStart.selectOption(next!)
const saved = page.waitForResponse((response) => response.url().endsWith('/qqq/v1/preferences/regional') && response.request().method() === 'PATCH')
await page.getByRole('button', { name: 'Save changes', exact: true }).click()
expect((await saved).status()).toBe(200)
await expect(page.getByRole('button', { name: 'Save changes', exact: true })).toBeDisabled()
await page.reload()
await expect(weekStart).toHaveValue(next!)
await expectNoHorizontalScroll(page)
await checkA11y(page)
})
test('[NAV-057] a regional section deep link selects and renders the sample backend metadata @mobile', async ({ page, backend, diagnostics }) => {
void backend; void diagnostics
await open(page, '/app/preferences?section=regional')
await expect(page.getByRole('heading', { name: 'Regional settings', exact: true })).toBeVisible()
await expect(page.getByRole('textbox', { name: /time zone/i })).toBeVisible()
await expect(page.getByRole('combobox', { name: /date format/i })).toBeVisible()
await expect(page.getByRole('combobox', { name: /week starts on/i })).toBeVisible()
await expectNoHorizontalScroll(page)
})
+192 -193
View File
@@ -1,193 +1,192 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import AxeBuilder from '@axe-core/playwright'
import type { Page } from '@playwright/test'
import { appNavigation, expectRecordsLoaded } from './nav-helpers'
import { openReadyPivotEditor } from './pivot-readiness'
import { expect, open, test } from '../../support/fixtures'
// Theme metadata is instance-wide; every scenario needs the backend reset before navigation.
test.beforeEach(async ({ backend }) => { void backend })
for (const mode of ['light', 'dark'] as const) {
test(`[NAV-056] branded report links and controls remain readable in ${mode} mode @mobile`, async ({ page, diagnostics }, testInfo) => {
void diagnostics
await page.emulateMedia({ colorScheme: mode === 'dark' ? 'light' : 'dark' })
await page.addInitScript((dark) => localStorage.setItem('qqq-dark-mode', String(dark)), mode === 'dark')
await open(page, '/app/savedReport/1/edit')
await page.getByRole('button', { name: 'Edit Filters and Columns' }).click()
const editor = page.getByRole('dialog', { name: 'Edit Filters and Columns' })
await expect(editor).toBeVisible()
await expect(editor.getByRole('button', { name: 'OK', exact: true })).toHaveCSS('background-color', 'rgb(29, 78, 216)')
const link = editor.getByRole('link', { name: 'Open in new window' })
if (mode === 'light') await expect(link).toHaveCSS('color', 'rgb(29, 78, 216)')
for (const hovered of [false, true]) {
if (hovered) await link.hover()
const results = await new AxeBuilder({ page }).include('[data-qqq-id="filter-editor-reportSetupWidget"]')
.withRules(['color-contrast']).analyze()
expect(results.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
}
})
}
for (const mode of ['light', 'dark', 'application'] as const) {
test(`[NAV-056] pivot validation remains readable in ${mode} mode @mobile`, async ({ page, backend, diagnostics }) => {
void diagnostics
if (mode === 'application') await backend.enableTheme()
await page.addInitScript((dark) => localStorage.setItem('qqq-dark-mode', String(dark)), mode === 'dark')
await open(page, '/app/savedReport/102/edit')
await expect(page.locator('html')).toHaveAttribute('data-theme', mode === 'dark' ? 'dark' : 'light')
if (mode === 'application') {
await expect.poll(() => page.locator('html').evaluate(root => getComputedStyle(root).getPropertyValue('--color-destructive').trim())).toBe('#f97316')
}
await openReadyPivotEditor(page)
await page.locator('[data-qqq-id="pivot-editor-add-row"]').click()
await page.locator('[data-qqq-id="pivot-editor-ok"]').click()
await expect(page.locator('[data-qqq-id="pivot-editor-error"]')).toContainText('Missing value in 1 field.')
const results = await new AxeBuilder({ page }).include('[data-qqq-id="pivot-editor-error"]')
.withRules(['color-contrast']).analyze()
expect(results.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
expect(results.incomplete).toEqual([])
})
}
test('[NAV-056] the unbranded dark default has readable action buttons @mobile', async ({ page, diagnostics }) => {
void diagnostics
await page.addInitScript(() => localStorage.setItem('qqq-dark-mode', 'true'))
await page.route((url) => url.pathname === '/qqq/v1/metaData', async (route) => {
const response = await route.fetch()
const metadata = await response.json()
delete metadata.branding
await route.fulfill({ response, json: metadata })
})
await open(page, '/app/savedReport/1/edit')
await page.getByRole('button', { name: 'Edit Filters and Columns' }).click()
const results = await new AxeBuilder({ page }).include('[data-qqq-id="filter-editor-reportSetupWidget"]')
.withRules(['color-contrast']).analyze()
expect(results.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
})
/** A real v1 response must carry the theme all the way into the rendered shell. */
test('[NAV-056] Material theme metadata drives CSS tokens, custom CSS and the branded header @mobile', async ({ page, backend, diagnostics }) => {
void diagnostics
await backend.enableTheme()
const response = await backend.api.get('/qqq/v1/metaData')
expect(response.status()).toBe(200)
const metaData = await response.json() as { supplementalInstanceMetaData?: { materialDashboardTheme?: Record<string, unknown> } }
expect(metaData.supplementalInstanceMetaData?.materialDashboardTheme).toMatchObject({
primaryColor: '#0f766e',
sidebarBackgroundColor: '#1f2937',
brandedHeaderEnabled: true,
brandedHeaderTagline: 'Owned acceptance theme',
})
await open(page, '/app/person')
const bar = page.locator('[data-qqq-id="branded-header-bar"]')
await expect(bar).toBeVisible()
await expect(bar).toHaveCSS('background-color', 'rgb(18, 52, 86)')
const tagline = page.locator('[data-qqq-id="branded-header-tagline"]')
await expect(tagline).toHaveText('Owned acceptance theme')
await expect(tagline).toHaveCSS('letter-spacing', '2px')
await expect(page.locator('body')).toHaveClass(/qqq-themed/)
await expect.poll(() => page.locator('html').evaluate((root) =>
getComputedStyle(root).getPropertyValue('--qqq-primary-color').trim())).toBe('#0f766e')
})
/** Opens the same user preferences from either the sidebar or the phone drawer. */
async function openPreferences(page: Page) {
await appNavigation(page)
await page.locator('[data-qqq-id="sidebar"]:visible [data-qqq-id="sidebar-user-button"]').click()
await page.getByRole('menuitem', { name: 'Preferences', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Preferences', exact: true })
await expect(dialog).toBeVisible()
return dialog
}
test('[NAV-056] appearance preferences apply immediately and survive a reload @mobile', async ({ page, diagnostics }) => {
void diagnostics
await page.emulateMedia({ colorScheme: 'dark' })
await open(page, '/app/person')
let dialog = await openPreferences(page)
const light = dialog.getByRole('radio', { name: 'Light', exact: true })
await expect(light).toBeChecked()
await light.focus()
await page.keyboard.press('ArrowRight')
await expect(dialog.getByRole('radio', { name: 'Dark', exact: true })).toBeChecked()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
for (const mode of ['dark', 'light'] as const) {
await dialog.getByRole('radio', { name: mode === 'dark' ? 'Dark' : 'Light', exact: true }).check()
await expect(page.locator('html')).toHaveAttribute('data-theme', mode)
// Audit the selected palette after the existing control color transitions settle.
await dialog.evaluate(async element => {
await Promise.all(element.getAnimations({ subtree: true }).map(animation => animation.finished.catch(() => {})))
})
const results = await new AxeBuilder({ page }).include('[data-qqq-id="dialog-user-preferences"]')
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()
expect(results.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
}
await dialog.getByRole('radio', { name: 'Dark', exact: true }).check()
await dialog.getByRole('button', { name: 'Done', exact: true }).click()
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
dialog = await openPreferences(page)
await expect(dialog.getByRole('radio', { name: 'Dark', exact: true })).toBeChecked()
await dialog.getByRole('button', { name: 'Reset to Defaults' }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
})
test('[NAV-056] appearance preferences explain an application theme override @mobile', async ({ page, backend, diagnostics }) => {
void diagnostics
await backend.enableTheme()
await open(page, '/app/person')
await expect(page.locator('body')).toHaveClass(/qqq-themed/)
await page.evaluate(() => localStorage.setItem('qqq-dark-mode', 'true'))
// The reload must not race the table's initial reads (WebKit page errors, QRun-IO/qqq#985).
await expectRecordsLoaded(page, 'Person')
await page.reload()
const dialog = await openPreferences(page)
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await expect(dialog.getByRole('radio', { name: 'Light', exact: true })).toBeChecked()
await expect(dialog.getByRole('radio', { name: 'Dark', exact: true })).toBeDisabled()
await expect(dialog.getByText(/application theme requires light mode/i)).toBeVisible()
expect(await page.evaluate(() => localStorage.getItem('qqq-dark-mode'))).toBe('true')
await dialog.getByRole('button', { name: 'Reset to Defaults' }).click()
expect(await page.evaluate(() => localStorage.getItem('qqq-dark-mode'))).toBe('false')
})
for (const mode of ['light', 'dark'] as const) {
test(`[NAV-056] API playground text and hovered controls remain readable in ${mode} mode @mobile`, async ({ page, diagnostics }, testInfo) => {
void diagnostics
await page.addInitScript((dark) => localStorage.setItem('qqq-dark-mode', String(dark)), mode === 'dark')
await open(page, '/app/person/dev')
const docs = page.locator('rapi-doc')
await expect(docs.getByText('Expand all', { exact: true })).toBeVisible()
// RapiDoc animates button colors; audit each rendered state after transitions settle.
const settlePalette = () => docs.evaluate(async element => {
await (element as HTMLElement & { updateComplete: Promise<boolean> }).updateComplete
await Promise.all((element.shadowRoot?.getAnimations() ?? []).map(animation => animation.finished.catch(() => {})))
})
for (const hovered of [false, true]) {
if (hovered) await docs.getByRole('button', { name: 'Download OpenAPI spec', exact: true }).hover()
await settlePalette()
const result = await new AxeBuilder({ page }).include('rapi-doc').withRules(['color-contrast']).analyze()
expect(result.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
}
await settlePalette()
await testInfo.attach(`api-playground-${mode}`, { body: await page.screenshot(), contentType: 'image/png' })
const dialog = await openPreferences(page)
const oppositeMode = mode === 'dark' ? 'Light' : 'Dark'
await dialog.getByRole('radio', { name: oppositeMode, exact: true }).check()
await dialog.getByRole('button', { name: 'Done', exact: true }).click()
await expect(docs).toHaveJSProperty('theme', oppositeMode.toLowerCase())
await settlePalette()
const changed = await new AxeBuilder({ page }).include('rapi-doc').withRules(['color-contrast']).analyze()
expect(changed.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
})
}
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
import AxeBuilder from '@axe-core/playwright'
import type { Page } from '@playwright/test'
import { appNavigation, expectRecordsLoaded } from './nav-helpers'
import { openReadyPivotEditor } from './pivot-readiness'
import { expect, open, test } from '../../support/fixtures'
// Theme metadata is instance-wide; every scenario needs the backend reset before navigation.
test.beforeEach(async ({ backend }) => { void backend })
for (const mode of ['light', 'dark'] as const) {
test(`[NAV-056] branded report links and controls remain readable in ${mode} mode @mobile`, async ({ page, diagnostics }, testInfo) => {
void diagnostics
await page.emulateMedia({ colorScheme: mode === 'dark' ? 'light' : 'dark' })
await page.addInitScript((dark) => localStorage.setItem('qqq-dark-mode', String(dark)), mode === 'dark')
await open(page, '/app/savedReport/1/edit')
await page.getByRole('button', { name: 'Edit Filters and Columns' }).click()
const editor = page.getByRole('dialog', { name: 'Edit Filters and Columns' })
await expect(editor).toBeVisible()
await expect(editor.getByRole('button', { name: 'OK', exact: true })).toHaveCSS('background-color', 'rgb(29, 78, 216)')
const link = editor.getByRole('link', { name: 'Open in new window' })
if (mode === 'light') await expect(link).toHaveCSS('color', 'rgb(29, 78, 216)')
for (const hovered of [false, true]) {
if (hovered) await link.hover()
const results = await new AxeBuilder({ page }).include('[data-qqq-id="filter-editor-reportSetupWidget"]')
.withRules(['color-contrast']).analyze()
expect(results.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
}
})
}
for (const mode of ['light', 'dark', 'application'] as const) {
test(`[NAV-056] pivot validation remains readable in ${mode} mode @mobile`, async ({ page, backend, diagnostics }) => {
void diagnostics
if (mode === 'application') await backend.enableTheme()
await page.addInitScript((dark) => localStorage.setItem('qqq-dark-mode', String(dark)), mode === 'dark')
await open(page, '/app/savedReport/102/edit')
await expect(page.locator('html')).toHaveAttribute('data-theme', mode === 'dark' ? 'dark' : 'light')
if (mode === 'application') {
await expect.poll(() => page.locator('html').evaluate(root => getComputedStyle(root).getPropertyValue('--color-destructive').trim())).toBe('#f97316')
}
await openReadyPivotEditor(page)
await page.locator('[data-qqq-id="pivot-editor-add-row"]').click()
await page.locator('[data-qqq-id="pivot-editor-ok"]').click()
await expect(page.locator('[data-qqq-id="pivot-editor-error"]')).toContainText('Missing value in 1 field.')
const results = await new AxeBuilder({ page }).include('[data-qqq-id="pivot-editor-error"]')
.withRules(['color-contrast']).analyze()
expect(results.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
expect(results.incomplete).toEqual([])
})
}
test('[NAV-056] the unbranded dark default has readable action buttons @mobile', async ({ page, diagnostics }) => {
void diagnostics
await page.addInitScript(() => localStorage.setItem('qqq-dark-mode', 'true'))
await page.route((url) => url.pathname === '/qqq/v1/metaData', async (route) => {
const response = await route.fetch()
const metadata = await response.json()
delete metadata.branding
await route.fulfill({ response, json: metadata })
})
await open(page, '/app/savedReport/1/edit')
await page.getByRole('button', { name: 'Edit Filters and Columns' }).click()
const results = await new AxeBuilder({ page }).include('[data-qqq-id="filter-editor-reportSetupWidget"]')
.withRules(['color-contrast']).analyze()
expect(results.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
})
/** A real v1 response must carry the theme all the way into the rendered shell. */
test('[NAV-056] Material theme metadata drives CSS tokens, custom CSS and the branded header @mobile', async ({ page, backend, diagnostics }) => {
void diagnostics
await backend.enableTheme()
const response = await backend.api.get('/qqq/v1/metaData')
expect(response.status()).toBe(200)
const metaData = await response.json() as { supplementalInstanceMetaData?: { materialDashboardTheme?: Record<string, unknown> } }
expect(metaData.supplementalInstanceMetaData?.materialDashboardTheme).toMatchObject({
primaryColor: '#0f766e',
sidebarBackgroundColor: '#1f2937',
brandedHeaderEnabled: true,
brandedHeaderTagline: 'Owned acceptance theme',
})
await open(page, '/app/person')
const bar = page.locator('[data-qqq-id="branded-header-bar"]')
await expect(bar).toBeVisible()
await expect(bar).toHaveCSS('background-color', 'rgb(18, 52, 86)')
const tagline = page.locator('[data-qqq-id="branded-header-tagline"]')
await expect(tagline).toHaveText('Owned acceptance theme')
await expect(tagline).toHaveCSS('letter-spacing', '2px')
await expect(page.locator('body')).toHaveClass(/qqq-themed/)
await expect.poll(() => page.locator('html').evaluate((root) =>
getComputedStyle(root).getPropertyValue('--qqq-primary-color').trim())).toBe('#0f766e')
})
/** Opens the same user preferences from either the sidebar or the phone drawer. */
async function openPreferences(page: Page) {
await appNavigation(page)
await page.locator('[data-qqq-id="sidebar"]:visible [data-qqq-id="sidebar-user-button"]').click()
await page.getByRole('menuitem', { name: 'Preferences', exact: true }).click()
const dialog = page.locator('[data-qqq-id="preferences-page"]')
await expect(dialog).toBeVisible()
return dialog
}
test('[NAV-056] appearance preferences apply immediately and survive a reload @mobile', async ({ page, diagnostics }) => {
void diagnostics
await page.emulateMedia({ colorScheme: 'dark' })
await open(page, '/app/person')
let dialog = await openPreferences(page)
const light = dialog.getByRole('radio', { name: 'Light', exact: true })
await expect(light).toBeChecked()
await light.focus()
await page.keyboard.press('ArrowRight')
await expect(dialog.getByRole('radio', { name: 'Dark', exact: true })).toBeChecked()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
for (const mode of ['dark', 'light'] as const) {
await dialog.getByRole('radio', { name: mode === 'dark' ? 'Dark' : 'Light', exact: true }).check()
await expect(page.locator('html')).toHaveAttribute('data-theme', mode)
// Audit the selected palette after the existing control color transitions settle.
await dialog.evaluate(async element => {
await Promise.all(element.getAnimations({ subtree: true }).map(animation => animation.finished.catch(() => {})))
})
const results = await new AxeBuilder({ page }).include('[data-qqq-id="preferences-page"]')
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()
expect(results.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
}
await dialog.getByRole('radio', { name: 'Dark', exact: true }).check()
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
dialog = await openPreferences(page)
await expect(dialog.getByRole('radio', { name: 'Dark', exact: true })).toBeChecked()
await dialog.getByRole('button', { name: 'Reset to Defaults' }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
})
test('[NAV-056] appearance preferences explain an application theme override @mobile', async ({ page, backend, diagnostics }) => {
void diagnostics
await backend.enableTheme()
await open(page, '/app/person')
await expect(page.locator('body')).toHaveClass(/qqq-themed/)
await page.evaluate(() => localStorage.setItem('qqq-dark-mode', 'true'))
// The reload must not race the table's initial reads (WebKit page errors, QRun-IO/qqq#985).
await expectRecordsLoaded(page, 'Person')
await page.reload()
const dialog = await openPreferences(page)
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
await expect(dialog.getByRole('radio', { name: 'Light', exact: true })).toBeChecked()
await expect(dialog.getByRole('radio', { name: 'Dark', exact: true })).toBeDisabled()
await expect(dialog.getByText(/application theme requires light mode/i)).toBeVisible()
expect(await page.evaluate(() => localStorage.getItem('qqq-dark-mode'))).toBe('true')
await dialog.getByRole('button', { name: 'Reset to Defaults' }).click()
expect(await page.evaluate(() => localStorage.getItem('qqq-dark-mode'))).toBe('false')
})
for (const mode of ['light', 'dark'] as const) {
test(`[NAV-056] API playground text and hovered controls remain readable in ${mode} mode @mobile`, async ({ page, diagnostics }, testInfo) => {
void diagnostics
await page.addInitScript((dark) => localStorage.setItem('qqq-dark-mode', String(dark)), mode === 'dark')
await open(page, '/app/person/dev')
const docs = page.locator('rapi-doc')
await expect(docs.getByText('Expand all', { exact: true })).toBeVisible()
// RapiDoc animates button colors; audit each rendered state after transitions settle.
const settlePalette = () => docs.evaluate(async element => {
await (element as HTMLElement & { updateComplete: Promise<boolean> }).updateComplete
await Promise.all((element.shadowRoot?.getAnimations() ?? []).map(animation => animation.finished.catch(() => {})))
})
for (const hovered of [false, true]) {
if (hovered) await docs.getByRole('button', { name: 'Download OpenAPI spec', exact: true }).hover()
await settlePalette()
const result = await new AxeBuilder({ page }).include('rapi-doc').withRules(['color-contrast']).analyze()
expect(result.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
}
await settlePalette()
await testInfo.attach(`api-playground-${mode}`, { body: await page.screenshot(), contentType: 'image/png' })
const dialog = await openPreferences(page)
const oppositeMode = mode === 'dark' ? 'Light' : 'Dark'
await dialog.getByRole('radio', { name: oppositeMode, exact: true }).check()
await open(page, '/app/person/dev')
await expect(docs).toHaveJSProperty('theme', oppositeMode.toLowerCase())
await settlePalette()
const changed = await new AxeBuilder({ page }).include('rapi-doc').withRules(['color-contrast']).analyze()
expect(changed.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }))).toEqual([])
})
}
+71 -70
View File
@@ -1,70 +1,71 @@
// Vitest test setup
import '@testing-library/jest-dom'
import { configureAxe, toHaveNoViolations } from 'jest-axe'
import { vi, beforeAll, afterEach, afterAll, expect } from 'vitest'
import { server } from '@/mocks/node'
// Extend Vitest's expect with jest-axe accessibility matchers
expect.extend(toHaveNoViolations)
// Configure axe globally — color-contrast is tested manually to avoid false positives
// in jsdom which cannot accurately compute CSS-derived contrast ratios
configureAxe({ rules: { 'color-contrast': { enabled: false } } })
// Start MSW node server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
// Reset any request handlers added during a test
afterEach(() => server.resetHandlers())
// Clean up after all tests are done
afterAll(() => server.close())
// Mock Next.js router
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
redirect: vi.fn(),
}))
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (index: number) => Object.keys(store)[index] ?? null,
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
})
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Vitest test setup
import '@testing-library/jest-dom'
import { configureAxe, toHaveNoViolations } from 'jest-axe'
import { vi, beforeAll, afterEach, afterAll, expect } from 'vitest'
import { resetPreferencesMock } from '@/mocks/handlers/preferences'
import { server } from '@/mocks/node'
// Extend Vitest's expect with jest-axe accessibility matchers
expect.extend(toHaveNoViolations)
// Configure axe globally — color-contrast is tested manually to avoid false positives
// in jsdom which cannot accurately compute CSS-derived contrast ratios
configureAxe({ rules: { 'color-contrast': { enabled: false } } })
// Start MSW node server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
// Reset any request handlers added during a test
afterEach(() => { server.resetHandlers(); resetPreferencesMock() })
// Clean up after all tests are done
afterAll(() => server.close())
// Mock Next.js router
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
redirect: vi.fn(),
}))
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (index: number) => Object.keys(store)[index] ?? null,
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
})
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})