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:
1 parent
35249098bb
commit
1f4a38b9ff
47 files changed
+7682
-3975
No files matched your search
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+12
-6
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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'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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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(),
|
||||
})),
|
||||
})
|
||||
Reference in new issue
Block a user