Merge pull request #89 from QRun-IO/feature/notification-center
feat(alerts): notification center - badge, popover, page and banners
This commit was merged in pull request #89.
This commit is contained in:
22 files changed
+2062
-237
No files matched your search
@@ -62,6 +62,11 @@
|
||||
"marker": "command-menu-searching",
|
||||
"gzipKB": 8.1
|
||||
},
|
||||
"notificationCenter": {
|
||||
"$comment": "The notification center (qbit-alerts phase b): the bell popover (import() in AlertsBell on first open), the sticky-notification banners (import() in NotificationBannerSlot when the summary has banners) and the /app/notifications page (next/dynamic in its route). Turbopack bundles the shared parts (severity icons, per-item actions, the optimistic inbox mutation, MarkdownView) into each of the three async chunks, so the budget covers all three; the marker is the per-item read/unread button they share. Measured 15.2 KB gzip across the three (popover 4.3, banners 4.3, page 6.7) on 2026-10-13; budget about 8% above. The shell keeps the badge, poller with backoff, summary normalizer and banner slot: +0.7 KB first load on the /app routes (/app/_/_ 348.5 -> 349.2 KB, inside its 350 KB cap); the new /app/notifications route measures 339.5 KB; total JS 1092.9 -> 1098.0 KB excluding these chunks; CSS unchanged at 23.2 KB.",
|
||||
"marker": "notification-toggle-read",
|
||||
"gzipKB": 16.5
|
||||
},
|
||||
"recordPreview": {
|
||||
"$comment": "The search result record preview (first-tier fields with file thumbnails, chips and dates; quick actions), loaded when a preview first shows: import() in SearchPage. It runs its own two queries (the useTableMetaData and useRecord keys) rather than importing those hooks, which measured +12.4 KB more total JS. Measured 7.1 KB gzip with thumbnails and chips (3.7 KB without); budget about 8% above.",
|
||||
"marker": "search-record-preview",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Notifications page — the notification center's full list (qbit-alerts), with filters
|
||||
* and paging in the URL (`status`, `category`, `severity`, `offset`). A fixed page under `/app`
|
||||
* like `/app/search`, so the static export emits it and the dashboard layout wraps it.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
/** The page loads on demand, so its code stays out of every route's first load. */
|
||||
const NotificationsPage = dynamic(() => import('@/components/alerts/NotificationsPage').then((module) => module.NotificationsPage), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="notifications-page-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Loading notifications…
|
||||
</p>
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders the notifications page at `/app/notifications`.
|
||||
*
|
||||
* @returns The page.
|
||||
*/
|
||||
export default function NotificationsRoute() {
|
||||
return <NotificationsPage />
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import { AssistantProvider, useAssistantController } from '@/components/assistan
|
||||
import { AssistantHeaderButton } from '@/components/assistant/AssistantHeaderButton'
|
||||
import { AssistantMount } from '@/components/assistant/AssistantMount'
|
||||
import { AlertsBell, isAlertsInstalled } from '@/components/alerts/AlertsBell'
|
||||
import { NotificationBannerSlot } from '@/components/alerts/NotificationBannerSlot'
|
||||
|
||||
/**
|
||||
* Inner layout that renders the full dashboard chrome.
|
||||
@@ -349,6 +350,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
|
||||
) : (
|
||||
<>
|
||||
<BannerComponent banners={metaData?.branding?.banners} slot="QFMD_TOP_OF_BODY" className="mb-4 rounded-lg" />
|
||||
<NotificationBannerSlot metaData={metaData} enabled={isAuthenticated} />
|
||||
{children}
|
||||
<CompanyFooter branding={metaData?.branding} />
|
||||
</>
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for the Alerts bell, its popover, and the record panel: metadata gating (no request
|
||||
// without the qbit's app), polling, badge, a11y, mark read, keyboard, and the panel's hidden /
|
||||
// open / watch states.
|
||||
// Tests for the Alerts record panel: metadata gating (no request without the qbit's app) and the
|
||||
// panel's hidden / open / watch states. The bell, popover and banners are in
|
||||
// NotificationCenter.test.tsx.
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -27,9 +27,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
import type { AlertsInboxSummary, AlertsRecordPanel } from '@/types/alerts'
|
||||
import type { AlertsRecordPanel } from '@/types/alerts'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { AlertsBell, isAlertsInstalled } from './AlertsBell'
|
||||
import { isAlertsInstalled } from './AlertsBell'
|
||||
import { AlertsRecordPanelSlot } from './AlertsRecordPanelSlot'
|
||||
|
||||
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
|
||||
@@ -39,17 +39,8 @@ vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
}))
|
||||
|
||||
const SUMMARY: AlertsInboxSummary = {
|
||||
unreadCount: 2,
|
||||
highestUnreadSeverity: 'CRITICAL',
|
||||
alertsHref: '/app/alerts',
|
||||
pollSeconds: 60,
|
||||
items: [
|
||||
{ alertId: 7, title: 'Nightly backup did not run', severity: 'CRITICAL', status: 'OPEN', group: 'Backups', age: '3 h', href: '/app/alerts_alert/7', unread: true },
|
||||
{ alertId: 6, title: '3 CDN purges failed', severity: 'ERROR', status: 'OPEN', group: 'CDN', age: '20 min', href: '/app/alerts_alert/6', unread: true },
|
||||
{ alertId: 2, title: 'Old warning', severity: 'WARNING', status: 'ACKNOWLEDGED', group: null, age: '2 d', href: '/app/alerts_alert/2', unread: false },
|
||||
],
|
||||
}
|
||||
/** The inbox summary (the record panel shows only when it loads). */
|
||||
const SUMMARY = { unreadCount: 0, highestUnreadSeverity: null, notificationsHref: '/app/notifications', items: [], banners: [] }
|
||||
|
||||
const PANEL: AlertsRecordPanel = {
|
||||
visible: true,
|
||||
@@ -86,127 +77,6 @@ describe('isAlertsInstalled', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AlertsBell', () => {
|
||||
beforeEach(() => {
|
||||
router.push.mockReset()
|
||||
})
|
||||
|
||||
it('sends no request and renders nothing when the metadata does not announce Alerts', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json(SUMMARY)
|
||||
}))
|
||||
const without = renderWithClient(<AlertsBell metaData={WITHOUT_ALERTS} />)
|
||||
const loading = renderWithClient(<AlertsBell metaData={undefined} />)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(calls).toBe(0)
|
||||
expect(without.container).toBeEmptyDOMElement()
|
||||
expect(loading.container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders and polls the inbox summary when the metadata announces Alerts', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
|
||||
}))
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
expect(await screen.findByRole('button', { name: 'Alerts, 2 unread' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(calls).toBeGreaterThanOrEqual(3))
|
||||
})
|
||||
|
||||
it('renders nothing when the backend still answers that Alerts is not available (403 or 404)', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
}))
|
||||
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await waitFor(() => expect(calls).toBe(1))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders nothing for a body that is not a summary', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ hello: 'world' })
|
||||
}))
|
||||
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await waitFor(() => expect(calls).toBe(1))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('shows the unread count, red for critical, with an accessible label', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
const bell = await screen.findByRole('button', { name: 'Alerts, 2 unread' })
|
||||
expect(bell).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
|
||||
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
|
||||
})
|
||||
|
||||
it('opens the popover, opens an item (marking it read), and closes on Escape', async () => {
|
||||
let readBody: unknown = null
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
|
||||
http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
|
||||
readBody = await request.json()
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
const bell = await screen.findByRole('button', { name: 'Alerts, 2 unread' })
|
||||
await user.click(bell)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Alerts' })
|
||||
expect(await axe(dialog)).toHaveNoViolations()
|
||||
const items = within(dialog).getAllByRole('link')
|
||||
expect(items[0]).toHaveTextContent('Nightly backup did not run')
|
||||
expect(items[0]).toHaveTextContent('Backups · 3 h ago')
|
||||
await waitFor(() => expect(items[0]).toHaveFocus())
|
||||
|
||||
await user.click(items[0])
|
||||
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/7')
|
||||
await waitFor(() => expect(readBody).toEqual({ alertId: 7 }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(bell)
|
||||
await screen.findByRole('dialog', { name: 'Alerts' })
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(bell).toHaveFocus()
|
||||
})
|
||||
|
||||
it('marks all read and opens the Alerts app', async () => {
|
||||
let readBody: unknown = null
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
|
||||
http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
|
||||
readBody = await request.json()
|
||||
return HttpResponse.json({ updated: 2 })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Alerts, 2 unread' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
|
||||
await waitFor(() => expect(readBody).toEqual({ all: true }))
|
||||
await user.click(screen.getByRole('link', { name: 'Open Alerts' }))
|
||||
expect(router.push).toHaveBeenCalledWith('/app/alerts')
|
||||
})
|
||||
|
||||
it('shows an empty state with no items', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 0, highestUnreadSeverity: null, items: [] })))
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Alerts, none unread' }))
|
||||
expect(await screen.findByText('Nothing needs you right now.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Mark all read' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AlertsRecordPanelSlot', () => {
|
||||
it('sends no request when the metadata does not announce Alerts', async () => {
|
||||
let calls = 0
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AlertsBell — the header bell for the Alerts qbit. Like the QQQ Assistant, it is gated
|
||||
* on instance metadata: unless the metadata lists the qbit's `alerts` app (which QQQ includes
|
||||
* only when qbit-alerts is installed and the user holds `alerts.view`, the same permission its
|
||||
* `/alerts/*` routes require), it renders nothing and sends no request at all. When enabled it
|
||||
* polls the inbox summary every 60 seconds while the tab is visible (TanStack Query pauses
|
||||
* interval refetches in a hidden tab). The popover's code loads on first open, so the shell
|
||||
* stays small.
|
||||
* @file AlertsBell — the header bell of the notification center (qbit-alerts). Like the QQQ
|
||||
* Assistant, it is gated on instance metadata: unless the metadata lists the qbit's `alerts`
|
||||
* app (which QQQ includes only when qbit-alerts is installed and the user holds `alerts.view`,
|
||||
* the same permission its `/alerts/*` routes require), it renders nothing and sends no request
|
||||
* at all. When enabled it polls the inbox summary every `pollSeconds` (60) while the tab is
|
||||
* visible (TanStack Query pauses interval refetches in a hidden tab and refetches when it shows
|
||||
* again), backing off exponentially to 5 minutes after errors. The popover's code loads on
|
||||
* first open, so the shell stays small.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -54,10 +55,46 @@ export function isAlertsInstalled(metaData: QInstance | undefined): boolean {
|
||||
return Boolean(app) && typeof app === 'object'
|
||||
}
|
||||
|
||||
/** The longest wait between polls after errors (5 minutes). */
|
||||
export const MAX_POLL_MS = 300_000
|
||||
|
||||
/** Consecutive failed summary loads (one shared query, so one counter). */
|
||||
let failures = 0
|
||||
|
||||
/**
|
||||
* The summary loader, counting consecutive failures for the backoff.
|
||||
*
|
||||
* @returns The summary.
|
||||
*/
|
||||
async function loadSummary(): Promise<AlertsInboxSummary | null> {
|
||||
try {
|
||||
const summary = await getAlertsInboxSummary()
|
||||
failures = 0
|
||||
return summary
|
||||
} catch (error) {
|
||||
failures++
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The wait before the next poll: `pollSeconds` normally, doubling per consecutive failure up to
|
||||
* {@link MAX_POLL_MS}; no polling once the backend has said Alerts is not for this user.
|
||||
*
|
||||
* @param data - The last summary (null: not available; undefined: none yet).
|
||||
* @param failed - Consecutive failures.
|
||||
* @returns Milliseconds, or false.
|
||||
*/
|
||||
export function pollDelay(data: AlertsInboxSummary | null | undefined, failed: number): number | false {
|
||||
if (data === null && failed === 0) return false
|
||||
const base = (data?.pollSeconds ?? 60) * 1000
|
||||
return failed > 0 ? Math.min(base * 2 ** failed, MAX_POLL_MS) : base
|
||||
}
|
||||
|
||||
/**
|
||||
* The bell's inbox summary (null when the backend still answers that Alerts is unavailable).
|
||||
* Shared with the record panel slot, which shows its panel only when this succeeds. It never
|
||||
* fetches (nor polls) unless `enabled`.
|
||||
* Shared with the record panel slot and the banner slot. It never fetches (nor polls) unless
|
||||
* `enabled`.
|
||||
*
|
||||
* @param enabled - Whether to load it: authenticated and {@link isAlertsInstalled}.
|
||||
* @returns The TanStack Query result.
|
||||
@@ -65,12 +102,13 @@ export function isAlertsInstalled(metaData: QInstance | undefined): boolean {
|
||||
export function useAlertsInbox(enabled: boolean) {
|
||||
return useQuery<AlertsInboxSummary | null>({
|
||||
queryKey: queryKeys.alertsInbox(),
|
||||
queryFn: getAlertsInboxSummary,
|
||||
queryFn: loadSummary,
|
||||
enabled,
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: (query) => (query.state.data ? (query.state.data.pollSeconds ?? 60) * 1000 : false),
|
||||
refetchInterval: (query) => pollDelay(query.state.data, failures),
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: (query) => (query.state.data === null ? false : 'always'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,7 +125,7 @@ export function AlertsBell({ metaData }: { metaData: QInstance | undefined }) {
|
||||
if (!data) return null
|
||||
|
||||
const unread = data.unreadCount
|
||||
const label = unread > 0 ? `Alerts, ${unread} unread` : 'Alerts, none unread'
|
||||
const label = unread > 0 ? `Notifications, ${unread} unread` : 'Notifications, none unread'
|
||||
const critical = data.highestUnreadSeverity === 'CRITICAL'
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -108,7 +146,7 @@ export function AlertsBell({ metaData }: { metaData: QInstance | undefined }) {
|
||||
aria-hidden="true"
|
||||
data-qqq-id="alerts-unread-badge"
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -15,21 +15,21 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file AlertsBellPopover — the bell's popover (loaded on first open): one column of the latest
|
||||
* alerts (severity dot, title, group, age), "Mark all read" and "Open Alerts". Opening an item
|
||||
* marks it read. Focus stays inside while it is open; Escape or a click outside closes it.
|
||||
* @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 { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { markAlertsRead } from '@/lib/api/alerts'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import type { AlertsInboxSummary } from '@/types/alerts'
|
||||
import { SeverityDot } from './SeverityDot'
|
||||
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 {
|
||||
@@ -47,7 +47,6 @@ export interface AlertsBellPopoverProps {
|
||||
*/
|
||||
export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopoverProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
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)
|
||||
@@ -73,7 +72,7 @@ export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopove
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab' || !panel) return
|
||||
const focusable = Array.from(panel.querySelectorAll<HTMLElement>('a,button'))
|
||||
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]
|
||||
@@ -96,18 +95,13 @@ export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopove
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: queryKeys.alertsInbox() })
|
||||
const actions = useInboxActions()
|
||||
|
||||
const open = async (alertId: number, href: string, unread: boolean | undefined) => {
|
||||
const openItem = (item: InboxItem) => {
|
||||
const read = readRequest(item)
|
||||
if (read) actions.mutate(read)
|
||||
onClose()
|
||||
router.push(href)
|
||||
if (unread) {
|
||||
try {
|
||||
await markAlertsRead({ alertId })
|
||||
} finally {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
if (item.href) router.push(item.href)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -115,70 +109,80 @@ export default function AlertsBellPopover({ summary, onClose }: AlertsBellPopove
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label="Alerts"
|
||||
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">Alerts</h2>
|
||||
<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">Nothing needs you right now.</p>
|
||||
<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) => (
|
||||
<li key={item.alertId}>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void open(item.alertId, item.href, item.unread)
|
||||
}}
|
||||
className={`flex items-start gap-3 px-4 py-3 text-sm hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${item.unread ? '' : 'opacity-75'}`}
|
||||
data-qqq-id={`alerts-popover-item-${item.alertId}`}
|
||||
>
|
||||
<SeverityDot severity={item.severity} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block break-words text-foreground ${item.unread ? 'font-semibold' : ''}`}>{item.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{[item.group, item.age === 'just now' ? 'just now' : `${item.age} ago`].filter(Boolean).join(' · ')}
|
||||
{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>
|
||||
</span>
|
||||
{item.unread && <span className="sr-only">(unread)</span>}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
)}
|
||||
<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={async () => {
|
||||
try {
|
||||
await markAlertsRead({ all: true })
|
||||
} finally {
|
||||
refresh()
|
||||
}
|
||||
}}
|
||||
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.alertsHref}
|
||||
href={summary.notificationsHref}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
router.push(summary.alertsHref)
|
||||
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-open-app"
|
||||
data-qqq-id="alerts-view-all"
|
||||
>
|
||||
Open Alerts
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file NotificationBannerSlot — shows sticky notifications (the summary's `banners`) above the
|
||||
* page until dismissed. It shares the bell's summary query, so it sends nothing of its own and
|
||||
* nothing at all unless the metadata announces Alerts; the banner UI loads only when there is
|
||||
* a banner to show.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { isAlertsInstalled, useAlertsInbox } from './AlertsBell'
|
||||
|
||||
const NotificationBanners = lazy(() => import('./NotificationBanners'))
|
||||
|
||||
/**
|
||||
* The banner slot.
|
||||
*
|
||||
* @param props - `metaData`: instance metadata; `enabled`: the user is signed in.
|
||||
* @returns The banners, or null.
|
||||
*/
|
||||
export function NotificationBannerSlot({ metaData, enabled }: { metaData: QInstance | undefined; enabled: boolean }) {
|
||||
const { data } = useAlertsInbox(enabled && isAlertsInstalled(metaData))
|
||||
if (!data?.banners.length) return null
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<NotificationBanners banners={data.banners} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 NotificationBanners — sticky notifications shown above the page (loaded on demand):
|
||||
* severity, title, markdown body (rendered without HTML), an optional link, and Dismiss
|
||||
* (`POST /alerts/inbox/dismiss`), which hides it at once.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useInboxActions } from '@/lib/hooks/use-inbox-actions'
|
||||
import type { InboxItem } from '@/types/alerts'
|
||||
import { MarkdownView } from '@/components/sql-workbench/MarkdownView'
|
||||
import { ItemActions, SeverityIcon, linkKind, severityLabel } from './NotificationParts'
|
||||
|
||||
// the severity's accent as a left border, on the card surface so it reads in light and dark mode
|
||||
const ACCENT: Record<string, string> = { INFO: '#2563eb', SUCCESS: '#16a34a', WARNING: '#d97706', ERROR: '#ef4444', CRITICAL: '#dc2626' }
|
||||
|
||||
/**
|
||||
* The banners.
|
||||
*
|
||||
* @param props - `banners`: the summary's sticky items.
|
||||
* @returns A region with one banner per item.
|
||||
*/
|
||||
export default function NotificationBanners({ banners }: { banners: InboxItem[] }) {
|
||||
const actions = useInboxActions()
|
||||
return (
|
||||
<section aria-label="Notification banners" className="mb-4 space-y-2" data-qqq-id="notification-banners">
|
||||
{banners.map((item) => {
|
||||
const kind = linkKind(item.href)
|
||||
const label = item.linkLabel ?? 'Open'
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
role={item.severity === 'CRITICAL' || item.severity === 'ERROR' ? 'alert' : 'status'}
|
||||
aria-label={`${severityLabel(item.severity)}: ${item.title}`}
|
||||
className="flex items-start gap-3 rounded-lg border border-border bg-card py-3 pl-4 pr-2 text-sm shadow-sm"
|
||||
style={{ borderLeftWidth: '4px', borderLeftColor: ACCENT[item.severity] ?? ACCENT.INFO }}
|
||||
data-qqq-id={`notification-banner-${item.key}`}
|
||||
>
|
||||
<SeverityIcon severity={item.severity} />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="break-words font-semibold text-foreground">{item.title}</p>
|
||||
{item.body && <MarkdownView source={item.body} />}
|
||||
{kind === 'app' && (
|
||||
<Link href={item.href ?? ''} className="inline-block font-medium text-primary underline underline-offset-2">{label}</Link>
|
||||
)}
|
||||
{kind === 'external' && (
|
||||
<a href={item.href ?? ''} target="_blank" rel="noopener noreferrer" className="inline-block font-medium text-primary underline underline-offset-2">{label}</a>
|
||||
)}
|
||||
</div>
|
||||
<ItemActions item={item} onAction={(request) => actions.mutate(request)} dismissOnly />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
* 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 notification center's bell, popover and banner slot: metadata gating (no request
|
||||
// without the qbit's app), polling with backoff and the hidden-tab pause, badge, a11y, per-item
|
||||
// and bulk actions (optimistic, rolled back on failure), and older backends.
|
||||
|
||||
import React from 'react'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
import type { QInstance } from '@/types/metadata'
|
||||
import { AlertsBell, MAX_POLL_MS, pollDelay } from './AlertsBell'
|
||||
import { NotificationBannerSlot } from './NotificationBannerSlot'
|
||||
|
||||
const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => router,
|
||||
usePathname: () => window.location.pathname,
|
||||
useSearchParams: () => new URLSearchParams(window.location.search),
|
||||
}))
|
||||
|
||||
/** The summary as the notification center backend sends it. */
|
||||
const SUMMARY = {
|
||||
unreadCount: 2,
|
||||
highestUnreadSeverity: 'CRITICAL',
|
||||
notificationsHref: '/app/notifications',
|
||||
pollSeconds: 60,
|
||||
items: [
|
||||
{ id: 71, notificationId: 7, title: 'Nightly backup did not run', severity: 'CRITICAL', category: 'ALERT', source: 'Backups', age: '3 h', href: '/app/alerts_alert/7', unread: true },
|
||||
{ id: 61, notificationId: 6, title: 'Release 4.1 is out', severity: 'SUCCESS', category: 'RELEASE', source: 'QQQ', age: '20 min', href: 'https://qrun.io/releases', unread: true },
|
||||
{ id: 21, notificationId: 2, title: 'Old warning', severity: 'WARNING', category: 'SYSTEM', source: null, age: '2 d', href: null, unread: false },
|
||||
],
|
||||
banners: [] as unknown[],
|
||||
}
|
||||
|
||||
/** The summary as qbit-alerts 0.1.0-RC.1 sends it (no `id`, `group` as the source). */
|
||||
const LEGACY_SUMMARY = {
|
||||
unreadCount: 1,
|
||||
highestUnreadSeverity: 'ERROR',
|
||||
alertsHref: '/app/alerts',
|
||||
items: [{ alertId: 6, title: '3 CDN purges failed', severity: 'ERROR', status: 'OPEN', group: 'CDN', age: '20 min', href: '/app/alerts_alert/6', unread: true }],
|
||||
}
|
||||
|
||||
/** Instance metadata announcing qbit-alerts (its `alerts` app, present when the user holds `alerts.view`). */
|
||||
const WITH_ALERTS = { apps: { alerts: { name: 'alerts', label: 'Alerts' } }, tables: {}, processes: {} } as unknown as QInstance
|
||||
|
||||
/** Instance metadata without qbit-alerts (e.g. the QQQ sample app). */
|
||||
const WITHOUT_ALERTS = { apps: { home: { name: 'home', label: 'Home' } }, tables: {}, processes: {} } as unknown as QInstance
|
||||
|
||||
/**
|
||||
* Renders a component with a fresh query client.
|
||||
*
|
||||
* @param ui - The component.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderWithClient(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the bodies posted to an inbox action.
|
||||
*
|
||||
* @param action - `read`, `unread` or `dismiss`.
|
||||
* @returns The bodies, filled as requests arrive.
|
||||
*/
|
||||
function captureAction(action: string): unknown[] {
|
||||
const bodies: unknown[] = []
|
||||
server.use(http.post(`/qqq/v1/alerts/inbox/${action}`, async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
}))
|
||||
return bodies
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits.
|
||||
*
|
||||
* @param ms - Milliseconds.
|
||||
* @returns A promise.
|
||||
*/
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe('pollDelay', () => {
|
||||
it('polls every pollSeconds, backs off exponentially to 5 minutes, and stops when unavailable', () => {
|
||||
const summary = { unreadCount: 0, highestUnreadSeverity: null, items: [], banners: [], notificationsHref: '/app/notifications', pollSeconds: 30 }
|
||||
expect(pollDelay(summary, 0)).toBe(30_000)
|
||||
expect(pollDelay(undefined, 0)).toBe(60_000)
|
||||
expect(pollDelay(summary, 1)).toBe(60_000)
|
||||
expect(pollDelay(summary, 2)).toBe(120_000)
|
||||
expect(pollDelay(undefined, 3)).toBe(MAX_POLL_MS)
|
||||
expect(pollDelay(summary, 10)).toBe(MAX_POLL_MS)
|
||||
expect(pollDelay(null, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AlertsBell', () => {
|
||||
beforeEach(() => {
|
||||
router.push.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
focusManager.setFocused(undefined)
|
||||
})
|
||||
|
||||
it('sends no request and renders nothing when the metadata does not announce Alerts', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/*', () => {
|
||||
calls++
|
||||
return HttpResponse.json(SUMMARY)
|
||||
}))
|
||||
const without = renderWithClient(<AlertsBell metaData={WITHOUT_ALERTS} />)
|
||||
const loading = renderWithClient(<AlertsBell metaData={undefined} />)
|
||||
const banners = renderWithClient(<NotificationBannerSlot metaData={WITHOUT_ALERTS} enabled />)
|
||||
await sleep(100)
|
||||
expect(calls).toBe(0)
|
||||
expect(without.container).toBeEmptyDOMElement()
|
||||
expect(loading.container).toBeEmptyDOMElement()
|
||||
expect(banners.container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders and polls the inbox summary when the metadata announces Alerts', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
|
||||
}))
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(calls).toBeGreaterThanOrEqual(3))
|
||||
})
|
||||
|
||||
it('pauses polling while the tab is hidden and refetches when it shows again', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ ...SUMMARY, pollSeconds: 0.05 })
|
||||
}))
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await screen.findByRole('button', { name: 'Notifications, 2 unread' })
|
||||
act(() => focusManager.setFocused(false))
|
||||
await sleep(100)
|
||||
const hidden = calls
|
||||
await sleep(250)
|
||||
expect(calls).toBe(hidden)
|
||||
act(() => focusManager.setFocused(true))
|
||||
await waitFor(() => expect(calls).toBeGreaterThan(hidden))
|
||||
})
|
||||
|
||||
it('renders nothing when the backend still answers that Alerts is not available (403 or 404)', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
}))
|
||||
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await waitFor(() => expect(calls).toBe(1))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders nothing for a body that is not a summary', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json({ hello: 'world' })
|
||||
}))
|
||||
const { container } = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await waitFor(() => expect(calls).toBe(1))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('shows the unread count, red for critical, capped at 99+, with an accessible label', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const first = renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
|
||||
expect(bell).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByText('2', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-destructive')
|
||||
expect(document.querySelector('[data-qqq-id="alerts-unread-announcer"]')).toHaveAttribute('aria-live', 'polite')
|
||||
first.unmount()
|
||||
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 140, highestUnreadSeverity: 'WARNING' })))
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await screen.findByRole('button', { name: 'Notifications, 140 unread' })
|
||||
expect(screen.getByText('99+', { selector: '[data-qqq-id="alerts-unread-badge"]' })).toHaveClass('bg-primary')
|
||||
})
|
||||
|
||||
it('opens the popover, opens an item (marking it read), and closes on Escape', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const reads = captureAction('read')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
const bell = await screen.findByRole('button', { name: 'Notifications, 2 unread' })
|
||||
await user.click(bell)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
expect(await axe(dialog)).toHaveNoViolations()
|
||||
const items = within(dialog).getAllByRole('listitem')
|
||||
expect(items[0]).toHaveTextContent('Nightly backup did not run')
|
||||
expect(items[0]).toHaveTextContent('Backups · 3 h ago')
|
||||
expect(within(items[0]).getByText('Critical')).toBeInTheDocument()
|
||||
expect(within(items[1]).getByText('Success')).toBeInTheDocument()
|
||||
expect(within(items[1]).getByRole('link')).toHaveAttribute('target', '_blank')
|
||||
expect(within(items[2]).queryByRole('link')).not.toBeInTheDocument()
|
||||
const link = within(items[0]).getByRole('link')
|
||||
await waitFor(() => expect(link).toHaveFocus())
|
||||
|
||||
await user.click(link)
|
||||
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/7')
|
||||
await waitFor(() => expect(reads).toEqual([{ id: 71 }]))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(bell)
|
||||
await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(bell).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps focus inside the popover', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
const first = within(dialog).getAllByRole('link')[0]
|
||||
await waitFor(() => expect(first).toHaveFocus())
|
||||
await user.keyboard('{Shift>}{Tab}{/Shift}')
|
||||
expect(within(dialog).getByRole('link', { name: 'View all' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(first).toHaveFocus()
|
||||
})
|
||||
|
||||
it('marks one read and one unread, and dismisses one', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const reads = captureAction('read')
|
||||
const unreads = captureAction('unread')
|
||||
const dismissals = captureAction('dismiss')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Mark read: Nightly backup did not run' }))
|
||||
await waitFor(() => expect(reads).toEqual([{ id: 71 }]))
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Mark unread: Old warning' }))
|
||||
await waitFor(() => expect(unreads).toEqual([{ id: 21 }]))
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Release 4.1 is out' }))
|
||||
await waitFor(() => expect(dismissals).toEqual([{ id: 61 }]))
|
||||
})
|
||||
|
||||
it('applies an action at once, before the server answers', async () => {
|
||||
let summary = SUMMARY
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
|
||||
http.post('/qqq/v1/alerts/inbox/dismiss', async () => {
|
||||
await sleep(200)
|
||||
summary = { ...SUMMARY, unreadCount: 1, items: SUMMARY.items.slice(1) }
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Dismiss: Nightly backup did not run' }))
|
||||
expect(within(dialog).queryByText('Nightly backup did not run')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Notifications, 1 unread' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rolls back when an action fails', async () => {
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)),
|
||||
http.post('/qqq/v1/alerts/inbox/read', () => new HttpResponse(null, { status: 500 }))
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
|
||||
expect(await screen.findByRole('button', { name: 'Notifications, 2 unread' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks all read and opens the notifications page', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(SUMMARY)))
|
||||
const reads = captureAction('read')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 2 unread' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Mark all read' }))
|
||||
await waitFor(() => expect(reads).toEqual([{ all: true }]))
|
||||
await user.click(screen.getByRole('link', { name: 'View all' }))
|
||||
expect(router.push).toHaveBeenCalledWith('/app/notifications')
|
||||
})
|
||||
|
||||
it('shows older backends without per-item actions, marking read by alert id', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(LEGACY_SUMMARY)))
|
||||
const reads = captureAction('read')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, 1 unread' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Notifications' })
|
||||
expect(dialog).toHaveTextContent('CDN · 20 min ago')
|
||||
expect(within(dialog).queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument()
|
||||
await user.click(within(dialog).getByRole('link', { name: /3 CDN purges failed/ }))
|
||||
await waitFor(() => expect(reads).toEqual([{ alertId: 6 }]))
|
||||
expect(router.push).toHaveBeenCalledWith('/app/alerts_alert/6')
|
||||
})
|
||||
|
||||
it('shows an empty state with no items', async () => {
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json({ ...SUMMARY, unreadCount: 0, highestUnreadSeverity: null, items: [] })))
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<AlertsBell metaData={WITH_ALERTS} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Notifications, none unread' }))
|
||||
expect(await screen.findByText('You have no notifications.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Mark all read' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationBannerSlot', () => {
|
||||
it('shows sticky notifications until dismissed', async () => {
|
||||
const dismissals: unknown[] = []
|
||||
let summary = {
|
||||
...SUMMARY,
|
||||
banners: [{ id: 90, title: 'Maintenance tonight', body: 'The site is **read-only** from 22:00. [Details](/app/status)', severity: 'WARNING', category: 'SYSTEM', href: 'https://status.example.com', linkLabel: 'Status page', unread: true, sticky: true }] as unknown[],
|
||||
}
|
||||
server.use(
|
||||
http.get('/qqq/v1/alerts/inbox/summary', () => HttpResponse.json(summary)),
|
||||
http.post('/qqq/v1/alerts/inbox/dismiss', async ({ request }) => {
|
||||
dismissals.push(await request.json())
|
||||
summary = { ...summary, banners: [] }
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
})
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled />)
|
||||
const banner = await screen.findByRole('status', { name: 'Warning: Maintenance tonight' })
|
||||
expect(await axe(banner)).toHaveNoViolations()
|
||||
expect(within(banner).getByText('read-only').tagName).toBe('STRONG')
|
||||
expect(within(banner).getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/app/status')
|
||||
expect(within(banner).getByRole('link', { name: 'Status page' })).toHaveAttribute('target', '_blank')
|
||||
expect(within(banner).queryByRole('button', { name: /Mark/ })).not.toBeInTheDocument()
|
||||
await user.click(within(banner).getByRole('button', { name: 'Dismiss: Maintenance tonight' }))
|
||||
await waitFor(() => expect(screen.queryByText('Maintenance tonight')).not.toBeInTheDocument())
|
||||
expect(dismissals).toEqual([{ id: 90 }])
|
||||
})
|
||||
|
||||
it('renders nothing without banners or when signed out', async () => {
|
||||
let calls = 0
|
||||
server.use(http.get('/qqq/v1/alerts/inbox/summary', () => {
|
||||
calls++
|
||||
return HttpResponse.json(SUMMARY)
|
||||
}))
|
||||
const none = renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled />)
|
||||
await waitFor(() => expect(calls).toBe(1))
|
||||
expect(none.container).toBeEmptyDOMElement()
|
||||
const signedOut = renderWithClient(<NotificationBannerSlot metaData={WITH_ALERTS} enabled={false} />)
|
||||
await sleep(50)
|
||||
expect(calls).toBe(1)
|
||||
expect(signedOut.container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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 Notification parts shared by the popover, the notifications page and the banners (all
|
||||
* loaded on demand): the severity icon, the relative time, the safe link target, and the
|
||||
* per-item read/unread and dismiss buttons (shown only for items with a recipient `id`).
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, Mail, MailOpen, OctagonAlert, X } from 'lucide-react'
|
||||
|
||||
import type { InboxItem, NotificationSeverity } from '@/types/alerts'
|
||||
import type { InboxActionRequest } from '@/lib/hooks/use-inbox-actions'
|
||||
|
||||
// mid-tone colors that read on light and dark cards; inline so the icons add no CSS
|
||||
const SEVERITY: Record<NotificationSeverity, { label: string; color: string; Icon: typeof Info }> = {
|
||||
INFO: { label: 'Info', color: '#2563eb', Icon: Info },
|
||||
SUCCESS: { label: 'Success', color: '#16a34a', Icon: CheckCircle2 },
|
||||
WARNING: { label: 'Warning', color: '#d97706', Icon: AlertTriangle },
|
||||
ERROR: { label: 'Error', color: '#ef4444', Icon: AlertCircle },
|
||||
CRITICAL: { label: 'Critical', color: '#dc2626', Icon: OctagonAlert },
|
||||
}
|
||||
|
||||
/**
|
||||
* The severity's label ("Critical").
|
||||
*
|
||||
* @param severity - The severity.
|
||||
* @returns Its label.
|
||||
*/
|
||||
export function severityLabel(severity: string): string {
|
||||
return SEVERITY[severity as NotificationSeverity]?.label ?? severity
|
||||
}
|
||||
|
||||
/**
|
||||
* A severity icon with its name for screen readers.
|
||||
*
|
||||
* @param props - The severity.
|
||||
* @returns The icon.
|
||||
*/
|
||||
export function SeverityIcon({ severity }: { severity: NotificationSeverity }) {
|
||||
const { label, color, Icon } = SEVERITY[severity] ?? SEVERITY.INFO
|
||||
return (
|
||||
<span className="flex shrink-0 items-center pt-0.5" data-qqq-id={`notification-severity-${severity}`}>
|
||||
<Icon className="h-4 w-4" style={{ color }} aria-hidden="true" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The relative time of an item: the server's age ("3 h ago"), else from its delivery time.
|
||||
*
|
||||
* @param item - The item.
|
||||
* @param now - The current time (tests).
|
||||
* @returns The text, or empty.
|
||||
*/
|
||||
export function relativeTime(item: Pick<InboxItem, 'age' | 'deliveredAt'>, now: number = Date.now()): string {
|
||||
if (item.age) return item.age === 'just now' ? item.age : `${item.age} ago`
|
||||
const at = item.deliveredAt ? Date.parse(item.deliveredAt) : NaN
|
||||
if (Number.isNaN(at)) return ''
|
||||
const minutes = Math.round((now - at) / 60_000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes} min ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) return `${hours} h ago`
|
||||
return `${Math.round(hours / 24)} d ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies an item's link: a same-origin path starting with a single `/` (client navigation),
|
||||
* https (a new tab), or none (rendered as plain text). Backslashes, whitespace and control
|
||||
* characters are refused outright, since browsers normalize `\` to `/` (`/\evil.com` would
|
||||
* leave the app), as is a second character of `/` or `\`.
|
||||
*
|
||||
* @param href - The link.
|
||||
* @returns The kind.
|
||||
*/
|
||||
export function linkKind(href: string | null | undefined): 'app' | 'external' | null {
|
||||
if (!href || /[\\\s\u0000-\u001f\u007f]/.test(href) || href[1] === '/' || href[1] === '\\') return null
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(href, window.location.origin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (href.startsWith('/')) return url.origin === window.location.origin ? 'app' : null
|
||||
return url.protocol === 'https:' ? 'external' : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The secondary line: source and relative time (and the category on the page).
|
||||
*
|
||||
* @param item - The item.
|
||||
* @param withCategory - Include the category.
|
||||
* @returns The text.
|
||||
*/
|
||||
export function itemMeta(item: InboxItem, withCategory = false): string {
|
||||
const category = withCategory && item.category ? item.category.charAt(0) + item.category.slice(1).toLowerCase() : null
|
||||
return [category, item.source, relativeTime(item)].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** Props for {@link ItemActions}. */
|
||||
export interface ItemActionsProps {
|
||||
/** The item. */
|
||||
item: InboxItem
|
||||
/** Runs an action. */
|
||||
onAction: (request: InboxActionRequest) => void
|
||||
/** Hide the read/unread toggle (banners). */
|
||||
dismissOnly?: boolean
|
||||
}
|
||||
|
||||
const ICON_BUTTON = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
|
||||
/**
|
||||
* Mark read/unread and dismiss for one item; nothing for items without a recipient id (older
|
||||
* backends).
|
||||
*
|
||||
* @param props - Component properties.
|
||||
* @returns The buttons, or null.
|
||||
*/
|
||||
export function ItemActions({ item, onAction, dismissOnly = false }: ItemActionsProps) {
|
||||
const id = item.id
|
||||
if (id === undefined) return null
|
||||
return (
|
||||
<span className="flex shrink-0 items-start gap-0.5">
|
||||
{!dismissOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BUTTON}
|
||||
onClick={() => onAction({ action: item.unread ? 'read' : 'unread', target: { id } })}
|
||||
aria-label={`${item.unread ? 'Mark read' : 'Mark unread'}: ${item.title}`}
|
||||
title={item.unread ? 'Mark read' : 'Mark unread'}
|
||||
data-qqq-id={`notification-toggle-read-${id}`}
|
||||
>
|
||||
{item.unread ? <MailOpen className="h-4 w-4" aria-hidden="true" /> : <Mail className="h-4 w-4" aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BUTTON}
|
||||
onClick={() => onAction({ action: 'dismiss', target: { id } })}
|
||||
aria-label={`Dismiss: ${item.title}`}
|
||||
title="Dismiss"
|
||||
data-qqq-id={`notification-dismiss-${id}`}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The read target for opening an item: its recipient id, else (older backends) its alert id.
|
||||
*
|
||||
* @param item - The item.
|
||||
* @returns The request, or null when it cannot be marked read.
|
||||
*/
|
||||
export function readRequest(item: InboxItem): InboxActionRequest | null {
|
||||
if (!item.unread) return null
|
||||
if (item.id !== undefined) return { action: 'read', target: { id: item.id } }
|
||||
if (item.alertId !== undefined) return { action: 'read', target: { alertId: item.alertId } }
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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 notifications page: metadata gating, filters and paging in the URL, the inbox
|
||||
// query, safe markdown bodies, actions, and empty and error states.
|
||||
|
||||
import React from 'react'
|
||||
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { axe } from 'jest-axe'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
|
||||
const nav = vi.hoisted(() => ({ params: new URLSearchParams(), push: vi.fn(), replace: vi.fn() }))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: nav.push, replace: nav.replace, back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => '/app/notifications',
|
||||
useSearchParams: () => nav.params,
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
|
||||
const meta = vi.hoisted(() => ({ apps: { alerts: { name: 'alerts', label: 'Alerts' } } as Record<string, unknown> }))
|
||||
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn(async () => ({ apps: meta.apps, tables: {}, processes: {} })) }))
|
||||
|
||||
import { NotificationsPage, filterFromParams } from './NotificationsPage'
|
||||
|
||||
/** One inbox item. */
|
||||
function item(id: number, overrides: Record<string, unknown> = {}) {
|
||||
return { id, notificationId: id, title: `Notice ${id}`, severity: 'INFO', category: 'SYSTEM', source: 'QQQ', age: '1 h', unread: false, ...overrides }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the page with a fresh query client.
|
||||
*
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NotificationsPage />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the inbox, recording each request's query.
|
||||
*
|
||||
* @param page - The response.
|
||||
* @returns The recorded queries.
|
||||
*/
|
||||
function serveInbox(page: Record<string, unknown>): URLSearchParams[] {
|
||||
const queries: URLSearchParams[] = []
|
||||
server.use(http.get('/qqq/v1/alerts/inbox', ({ request }) => {
|
||||
queries.push(new URL(request.url).searchParams)
|
||||
return HttpResponse.json(page)
|
||||
}))
|
||||
return queries
|
||||
}
|
||||
|
||||
describe('filterFromParams', () => {
|
||||
it('reads status, category, severity and offset, with safe defaults', () => {
|
||||
expect(filterFromParams(new URLSearchParams('status=unread&category=JOB&severity=ERROR&offset=25'))).toEqual({ status: 'unread', category: 'JOB', severity: 'ERROR', offset: 25, limit: 25 })
|
||||
expect(filterFromParams(new URLSearchParams('status=bogus&offset=-4'))).toEqual({ status: 'all', category: '', severity: '', offset: 0, limit: 25 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationsPage', () => {
|
||||
beforeEach(() => {
|
||||
nav.params = new URLSearchParams()
|
||||
nav.push.mockReset()
|
||||
nav.replace.mockReset()
|
||||
meta.apps = { alerts: { name: 'alerts', label: 'Alerts' } }
|
||||
})
|
||||
|
||||
it('sends no inbox request without the Alerts app', async () => {
|
||||
meta.apps = {}
|
||||
const queries = serveInbox({ items: [], total: 0 })
|
||||
renderPage()
|
||||
expect(await screen.findByText('Notifications are not available.')).toBeInTheDocument()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(queries).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lists notifications with markdown bodies rendered without HTML', async () => {
|
||||
const queries = serveInbox({
|
||||
items: [
|
||||
item(5, { title: 'Export finished', severity: 'SUCCESS', category: 'JOB', body: 'Your **export** is ready. <img src=x onerror=alert(1)>', href: '/app/exports/5', linkLabel: 'Download', unread: true }),
|
||||
item(4, { title: 'Key rotated', severity: 'CRITICAL', category: 'SECURITY', body: '[docs](https://qrun.io/docs) and [bad](javascript:alert(1))' }),
|
||||
],
|
||||
total: 2,
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
})
|
||||
const reads: unknown[] = []
|
||||
server.use(http.post('/qqq/v1/alerts/inbox/read', async ({ request }) => {
|
||||
reads.push(await request.json())
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderPage()
|
||||
const list = await screen.findByRole('list', { name: 'Notifications' })
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
expect(queries[0].get('status')).toBe('all')
|
||||
expect(queries[0].get('offset')).toBe('0')
|
||||
expect(queries[0].get('limit')).toBe('25')
|
||||
expect(queries[0].has('category')).toBe(false)
|
||||
|
||||
const items = within(list).getAllByRole('listitem')
|
||||
expect(items[0]).toHaveTextContent('Job · QQQ · 1 h ago')
|
||||
expect(within(items[0]).getByText('export').tagName).toBe('STRONG')
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(within(items[0]).getByText('(unread)', { exact: false })).toBeInTheDocument()
|
||||
expect(within(items[1]).getByText('Critical')).toBeInTheDocument()
|
||||
expect(within(items[1]).getByRole('link', { name: 'docs' })).toHaveAttribute('href', 'https://qrun.io/docs')
|
||||
expect(within(items[1]).queryByRole('link', { name: 'bad' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1–2 of 2')).toBeInTheDocument()
|
||||
|
||||
await user.click(within(items[0]).getByRole('link', { name: 'Download' }))
|
||||
expect(nav.push).toHaveBeenCalledWith('/app/exports/5')
|
||||
await waitFor(() => expect(reads).toEqual([{ id: 5 }]))
|
||||
})
|
||||
|
||||
it('puts filters in the URL and sends them to the inbox', async () => {
|
||||
nav.params = new URLSearchParams('status=unread&category=SECURITY&severity=ERROR&offset=25')
|
||||
const queries = serveInbox({ items: [], total: 0, offset: 25, limit: 25 })
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
expect(await screen.findByText('No notifications match these filters.')).toBeInTheDocument()
|
||||
expect(queries[0].get('status')).toBe('unread')
|
||||
expect(queries[0].get('category')).toBe('SECURITY')
|
||||
expect(queries[0].get('severity')).toBe('ERROR')
|
||||
expect(queries[0].get('offset')).toBe('25')
|
||||
expect(screen.getByRole('button', { name: 'Unread' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'All' }))
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?category=SECURITY&severity=ERROR', { scroll: false })
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Category' }), 'RELEASE')
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?status=unread&category=RELEASE&severity=ERROR', { scroll: false })
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), '')
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?status=unread&category=SECURITY', { scroll: false })
|
||||
})
|
||||
|
||||
it('pages through the inbox', async () => {
|
||||
nav.params = new URLSearchParams('offset=25')
|
||||
serveInbox({ items: Array.from({ length: 25 }, (_, index) => item(100 - index)), total: 60, offset: 25, limit: 25 })
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
expect(await screen.findByText('26–50 of 60')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications?offset=50', { scroll: false })
|
||||
await user.click(screen.getByRole('button', { name: 'Previous' }))
|
||||
expect(nav.replace).toHaveBeenLastCalledWith('/app/notifications', { scroll: false })
|
||||
})
|
||||
|
||||
it('marks read, unread and all read, and dismisses at once', async () => {
|
||||
serveInbox({ items: [item(1, { unread: true }), item(2)], total: 2, offset: 0, limit: 25 })
|
||||
const bodies: Array<[string, unknown]> = []
|
||||
server.use(http.post('/qqq/v1/alerts/inbox/:action', async ({ request, params }) => {
|
||||
bodies.push([String(params.action), await request.json()])
|
||||
return HttpResponse.json({ updated: 1 })
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
await screen.findByRole('list', { name: 'Notifications' })
|
||||
await user.click(screen.getByRole('button', { name: 'Mark read: Notice 1' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mark unread: Notice 2' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mark all read' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Dismiss: Notice 2' }))
|
||||
await waitFor(() => expect(bodies).toEqual([['read', { id: 1 }], ['unread', { id: 2 }], ['read', { all: true }], ['dismiss', { id: 2 }]]))
|
||||
})
|
||||
|
||||
it('shows older items without actions, and the empty and error states', async () => {
|
||||
serveInbox({ items: [{ alertId: 3, title: 'Legacy alert', severity: 'ERROR', group: 'CDN', age: '2 h', unread: true }], total: 1 })
|
||||
const first = renderPage()
|
||||
const list = await screen.findByRole('list', { name: 'Notifications' })
|
||||
expect(list).toHaveTextContent('CDN · 2 h ago')
|
||||
expect(within(list).queryByRole('button')).not.toBeInTheDocument()
|
||||
first.unmount()
|
||||
|
||||
serveInbox({ items: [], total: 0 })
|
||||
const empty = renderPage()
|
||||
expect(await screen.findByText('You have no notifications.')).toBeInTheDocument()
|
||||
empty.unmount()
|
||||
|
||||
server.use(http.get('/qqq/v1/alerts/inbox', () => new HttpResponse(null, { status: 500 })))
|
||||
renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load notifications.')
|
||||
serveInbox({ items: [item(9)], total: 1 })
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Notice 9')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,8 @@
|
||||
/**
|
||||
* @file MarkdownView — renders a markdown cell's text as React elements (never as HTML):
|
||||
* headings, paragraphs, bullet and numbered lists, fenced code, and inline bold, italic, code
|
||||
* and http(s) links. Anything else is shown as text, so a notebook can never inject markup.
|
||||
* and http(s) or relative app-path links. Anything else is shown as text, so a notebook (or a
|
||||
* notification body) can never inject markup.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
@@ -33,7 +34,7 @@ import React from 'react'
|
||||
*/
|
||||
export function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = []
|
||||
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\((https?:\/\/[^\s)]+)\))/g
|
||||
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\((https?:\/\/[^\s)]+|\/(?![/\\])[^\s)]*)\))/g
|
||||
let last = 0
|
||||
let match: RegExpExecArray | null
|
||||
let index = 0
|
||||
@@ -53,12 +54,13 @@ export function renderInline(text: string, keyPrefix: string): React.ReactNode[]
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>)
|
||||
} else {
|
||||
const label = token.slice(1, token.indexOf(']'))
|
||||
const external = !match[2].startsWith('/')
|
||||
nodes.push(
|
||||
<a
|
||||
key={key}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className="text-primary underline"
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -133,6 +133,14 @@ describe('MarkdownView', () => {
|
||||
const { container } = render(<MarkdownView source="[x](javascript:alert(1))" />)
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('links relative app paths in the same tab, but not protocol-relative URLs', () => {
|
||||
const { container } = render(<MarkdownView source="[open](/app/orders/7) and [bad](//evil.example)" />)
|
||||
const links = container.querySelectorAll('a')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toHaveAttribute('href', '/app/orders/7')
|
||||
expect(links[0]).not.toHaveAttribute('target')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlanTree', () => {
|
||||
|
||||
@@ -20,12 +20,32 @@ import { http, HttpResponse } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
import { getAlertsInboxSummary, getAlertsRecordPanel, markAlertsRead, watchAlertsRecord } from './alerts'
|
||||
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: [], alertsHref: '/app/alerts', pollSeconds: undefined })
|
||||
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 () => {
|
||||
@@ -45,12 +65,42 @@ describe('getAlertsInboxSummary', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('markAlertsRead and watchAlertsRecord', () => {
|
||||
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/read', async ({ request }) => {
|
||||
bodies.push(await request.json())
|
||||
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 }) => {
|
||||
@@ -58,9 +108,11 @@ describe('markAlertsRead and watchAlertsRecord', () => {
|
||||
return HttpResponse.json({ created: true })
|
||||
})
|
||||
)
|
||||
await expect(markAlertsRead({ all: true })).resolves.toBe(3)
|
||||
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([{ all: true }, { tableName: 'blog_post', recordId: 5 }])
|
||||
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 () => {
|
||||
@@ -68,7 +120,7 @@ describe('markAlertsRead and watchAlertsRecord', () => {
|
||||
http.post('/qqq/v1/alerts/inbox/read', () => HttpResponse.json({})),
|
||||
http.post('/qqq/v1/alerts/record-panel/watch', () => HttpResponse.json({ created: false }))
|
||||
)
|
||||
await expect(markAlertsRead({ alertId: 1 })).resolves.toBe(0)
|
||||
await expect(postInboxAction('read', { alertId: 1 })).resolves.toBe(0)
|
||||
await expect(watchAlertsRecord('blog_post', 5)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+98
-14
@@ -15,19 +15,85 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Alerts API — the Alerts qbit's bell (`/alerts/inbox/*`) and record panel
|
||||
* @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.
|
||||
* 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 } from '@/types/alerts'
|
||||
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']
|
||||
|
||||
/**
|
||||
* Loads the bell: unread count, highest unread severity and the latest items.
|
||||
* 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.
|
||||
@@ -42,25 +108,43 @@ export async function getAlertsInboxSummary(): Promise<AlertsInboxSummary | null
|
||||
throw error
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return null
|
||||
const summary = data as Partial<AlertsInboxSummary>
|
||||
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: summary.highestUnreadSeverity ?? null,
|
||||
items: summary.items,
|
||||
alertsHref: typeof summary.alertsHref === 'string' ? summary.alertsHref : '/app/alerts',
|
||||
pollSeconds: summary.pollSeconds,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks one alert's inbox item read, or all of the user's.
|
||||
* Loads one page of the inbox.
|
||||
*
|
||||
* @param target - `{ alertId }` for one item, or `{ all: true }`.
|
||||
* @returns How many items were marked read.
|
||||
* @param filter - Status, category, severity and paging.
|
||||
* @returns The page (an empty page for a body that is not one).
|
||||
*/
|
||||
export async function markAlertsRead(target: { alertId: number } | { all: true }): Promise<number> {
|
||||
const data = await apiClient.post<{ updated?: number }>('/alerts/inbox/read', target)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
// Tests for the optimistic inbox updates and the notification helpers.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AlertsInboxSummary, InboxItem } from '@/types/alerts'
|
||||
import { linkKind, relativeTime } from '@/components/alerts/NotificationParts'
|
||||
import { applyToItems, applyToPage, applyToSummary, targets } from './use-inbox-actions'
|
||||
|
||||
const item = (id: number, unread: boolean, extra: Partial<InboxItem> = {}): InboxItem => ({ key: `n${id}`, id, title: `T${id}`, severity: 'INFO', unread, ...extra })
|
||||
|
||||
const SUMMARY: AlertsInboxSummary = {
|
||||
unreadCount: 5,
|
||||
highestUnreadSeverity: 'ERROR',
|
||||
items: [item(1, true), item(2, true), item(3, false)],
|
||||
banners: [item(2, true, { sticky: true })],
|
||||
notificationsHref: '/app/notifications',
|
||||
}
|
||||
|
||||
describe('targets', () => {
|
||||
it('matches by id, ids, all, or alert id', () => {
|
||||
expect(targets(item(1, true), { id: 1 })).toBe(true)
|
||||
expect(targets(item(1, true), { ids: [2, 1] })).toBe(true)
|
||||
expect(targets({ key: 'a', title: 'x', severity: 'INFO', unread: true }, { ids: [1] })).toBe(false)
|
||||
expect(targets(item(1, true), { all: true })).toBe(true)
|
||||
expect(targets(item(1, true, { alertId: 9 }), { alertId: 9 })).toBe(true)
|
||||
expect(targets(item(1, true), { id: 2 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyToItems', () => {
|
||||
it('reads, unreads and dismisses, counting unread changes', () => {
|
||||
expect(applyToItems(SUMMARY.items, { action: 'read', target: { id: 1 } }).unreadDelta).toBe(-1)
|
||||
expect(applyToItems(SUMMARY.items, { action: 'read', target: { id: 3 } }).unreadDelta).toBe(0)
|
||||
expect(applyToItems(SUMMARY.items, { action: 'unread', target: { id: 3 } }).unreadDelta).toBe(1)
|
||||
const dismissed = applyToItems(SUMMARY.items, { action: 'dismiss', target: { ids: [2, 3] } })
|
||||
expect(dismissed.items.map((each) => each.id)).toEqual([1])
|
||||
expect(dismissed.unreadDelta).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyToSummary', () => {
|
||||
it('adjusts the unread count from the server total, not the visible items', () => {
|
||||
expect(applyToSummary(SUMMARY, { action: 'read', target: { id: 1 } })).toMatchObject({ unreadCount: 4, highestUnreadSeverity: 'ERROR' })
|
||||
expect(applyToSummary(SUMMARY, { action: 'read', target: { all: true } })).toMatchObject({ unreadCount: 0, highestUnreadSeverity: null })
|
||||
const dismissed = applyToSummary(SUMMARY, { action: 'dismiss', target: { id: 2 } })
|
||||
expect(dismissed.banners).toEqual([])
|
||||
expect(dismissed.unreadCount).toBe(4)
|
||||
expect(applyToSummary(SUMMARY, { action: 'read', target: { id: 2 } }).banners).toBe(SUMMARY.banners)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyToPage', () => {
|
||||
it('drops dismissed items from the page and its total', () => {
|
||||
expect(applyToPage({ items: SUMMARY.items, total: 30, offset: 0, limit: 25 }, { action: 'dismiss', target: { id: 1 } })).toMatchObject({ total: 29, items: [{ id: 2 }, { id: 3 }] })
|
||||
expect(applyToPage({ items: SUMMARY.items, total: 3, offset: 0, limit: 25 }, { action: 'read', target: { all: true } }).items.every((each) => !each.unread)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('relativeTime and linkKind', () => {
|
||||
it('prefers the server age, else computes from the delivery time', () => {
|
||||
const now = Date.parse('2026-10-13T12:00:00Z')
|
||||
expect(relativeTime({ age: '3 h' })).toBe('3 h ago')
|
||||
expect(relativeTime({ age: 'just now' })).toBe('just now')
|
||||
expect(relativeTime({ deliveredAt: '2026-10-13T11:59:50Z' }, now)).toBe('just now')
|
||||
expect(relativeTime({ deliveredAt: '2026-10-13T11:45:00Z' }, now)).toBe('15 min ago')
|
||||
expect(relativeTime({ deliveredAt: '2026-10-13T09:00:00Z' }, now)).toBe('3 h ago')
|
||||
expect(relativeTime({ deliveredAt: '2026-10-10T12:00:00Z' }, now)).toBe('3 d ago')
|
||||
expect(relativeTime({ deliveredAt: 'nope' }, now)).toBe('')
|
||||
expect(relativeTime({}, now)).toBe('')
|
||||
})
|
||||
|
||||
it('links only same-origin app paths and https, refusing open redirects', () => {
|
||||
expect(linkKind('/app/x')).toBe('app')
|
||||
expect(linkKind('https://qrun.io/releases')).toBe('external')
|
||||
expect(linkKind('//evil.com')).toBeNull()
|
||||
expect(linkKind('/\\evil.com')).toBeNull()
|
||||
expect(linkKind('\\\\evil.com')).toBeNull()
|
||||
expect(linkKind('/app/x\\y')).toBeNull()
|
||||
expect(linkKind('/app/ x')).toBeNull()
|
||||
expect(linkKind('/app/\tx')).toBeNull()
|
||||
expect(linkKind('javascript:alert(1)')).toBeNull()
|
||||
expect(linkKind('data:text/html,x')).toBeNull()
|
||||
expect(linkKind('http://x')).toBeNull()
|
||||
expect(linkKind('https://')).toBeNull()
|
||||
expect(linkKind(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 useInboxActions — read, unread and dismiss for the notification center, applied
|
||||
* optimistically to the bell's summary and every loaded page of the notifications page, rolled
|
||||
* back (with a toast) on failure, and followed by a refetch of both. Used only by the lazily loaded popover,
|
||||
* page and banner, so it stays out of the first load.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { postInboxAction } from '@/lib/api/alerts'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import type { AlertsInboxSummary, InboxItem, InboxPage, InboxTarget } from '@/types/alerts'
|
||||
|
||||
/** An inbox action. */
|
||||
export type InboxAction = 'read' | 'unread' | 'dismiss'
|
||||
|
||||
/** One action and its target. */
|
||||
export interface InboxActionRequest {
|
||||
/** What to do. */
|
||||
action: InboxAction
|
||||
/** Which items. */
|
||||
target: InboxTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item is the action's target.
|
||||
*
|
||||
* @param item - The item.
|
||||
* @param target - The target.
|
||||
* @returns True when it matches.
|
||||
*/
|
||||
export function targets(item: InboxItem, target: InboxTarget): boolean {
|
||||
if ('all' in target) return true
|
||||
if ('ids' in target) return item.id !== undefined && target.ids.includes(item.id)
|
||||
if ('id' in target) return item.id === target.id
|
||||
return item.alertId === target.alertId
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an action to a list of items.
|
||||
*
|
||||
* @param items - The items.
|
||||
* @param request - The action and its target.
|
||||
* @returns The new items and how the unread count changes.
|
||||
*/
|
||||
export function applyToItems(items: InboxItem[], { action, target }: InboxActionRequest): { items: InboxItem[]; unreadDelta: number } {
|
||||
let unreadDelta = 0
|
||||
const next: InboxItem[] = []
|
||||
for (const item of items) {
|
||||
if (!targets(item, target)) {
|
||||
next.push(item)
|
||||
} else if (action === 'dismiss') {
|
||||
if (item.unread) unreadDelta--
|
||||
} else {
|
||||
const unread = action === 'unread'
|
||||
if (item.unread !== unread) unreadDelta += unread ? 1 : -1
|
||||
next.push({ ...item, unread })
|
||||
}
|
||||
}
|
||||
return { items: next, unreadDelta }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an action to the bell's summary.
|
||||
*
|
||||
* @param summary - The summary.
|
||||
* @param request - The action and its target.
|
||||
* @returns The new summary.
|
||||
*/
|
||||
export function applyToSummary(summary: AlertsInboxSummary, request: InboxActionRequest): AlertsInboxSummary {
|
||||
const { items, unreadDelta } = applyToItems(summary.items, request)
|
||||
const banners = request.action === 'dismiss' ? applyToItems(summary.banners, request).items : summary.banners
|
||||
const allRead = request.action === 'read' && 'all' in request.target
|
||||
const unreadCount = allRead ? 0 : Math.max(0, summary.unreadCount + unreadDelta)
|
||||
return { ...summary, items, banners, unreadCount, highestUnreadSeverity: unreadCount === 0 ? null : summary.highestUnreadSeverity }
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an action to one page of the notifications page.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @param request - The action and its target.
|
||||
* @returns The new page.
|
||||
*/
|
||||
export function applyToPage(page: InboxPage, request: InboxActionRequest): InboxPage {
|
||||
const { items } = applyToItems(page.items, request)
|
||||
return { ...page, items, total: Math.max(0, page.total - (page.items.length - items.length)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The mutation behind every inbox action.
|
||||
*
|
||||
* @returns A TanStack mutation taking an {@link InboxActionRequest}.
|
||||
*/
|
||||
export function useInboxActions() {
|
||||
const queryClient = useQueryClient()
|
||||
const summaryKey = queryKeys.alertsInbox()
|
||||
const pageKey = queryKeys.alertsInboxPage()
|
||||
return useMutation({
|
||||
mutationFn: ({ action, target }: InboxActionRequest) => postInboxAction(action, target),
|
||||
onMutate: async (request: InboxActionRequest) => {
|
||||
await Promise.all([queryClient.cancelQueries({ queryKey: summaryKey }), queryClient.cancelQueries({ queryKey: pageKey })])
|
||||
const summary = queryClient.getQueryData<AlertsInboxSummary | null>(summaryKey)
|
||||
const pages = queryClient.getQueriesData<InboxPage>({ queryKey: pageKey })
|
||||
if (summary) queryClient.setQueryData(summaryKey, applyToSummary(summary, request))
|
||||
for (const [key, page] of pages) if (page) queryClient.setQueryData(key, applyToPage(page, request))
|
||||
return { summary, pages }
|
||||
},
|
||||
onError: (_error, _request, context) => {
|
||||
toast.error('Could not update notifications. Try again.')
|
||||
if (!context) return
|
||||
if (context.summary) queryClient.setQueryData(summaryKey, context.summary)
|
||||
for (const [key, page] of context.pages) queryClient.setQueryData(key, page)
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: summaryKey })
|
||||
void queryClient.invalidateQueries({ queryKey: pageKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -101,6 +101,7 @@ const STATIC_PAGE_LABELS: Record<string, string> = {
|
||||
'/app': 'Dashboard',
|
||||
'/app/developer': 'Developer',
|
||||
'/app/search': 'Search',
|
||||
'/app/notifications': 'Notifications',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -455,6 +455,13 @@ export const queryKeys = {
|
||||
* @returns The inbox summary query key tuple.
|
||||
*/
|
||||
alertsInbox: () => [...queryKeys.alerts(), 'inbox'] as const,
|
||||
/**
|
||||
* Key for the pages of the notifications page (`/alerts/inbox`); with a filter, one page.
|
||||
*
|
||||
* @param filter - The page's filter (omit for every page).
|
||||
* @returns The inbox page query key tuple.
|
||||
*/
|
||||
alertsInboxPage: (filter?: object) => (filter ? ([...queryKeys.alerts(), 'inboxPage', filter] as const) : ([...queryKeys.alerts(), 'inboxPage'] as const)),
|
||||
/**
|
||||
* Key for a record's "Open alerts" panel.
|
||||
*
|
||||
|
||||
+89
-6
@@ -15,12 +15,93 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @file Types for the Alerts qbit's bell and record panel (`/qqq/v1/alerts/*`).
|
||||
* @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. */
|
||||
@@ -43,16 +124,18 @@ export interface AlertItem {
|
||||
unread?: boolean
|
||||
}
|
||||
|
||||
/** `GET /alerts/inbox/summary`: the bell. */
|
||||
/** `GET /alerts/inbox/summary`: the bell, normalized. */
|
||||
export interface AlertsInboxSummary {
|
||||
/** Unread inbox items. */
|
||||
unreadCount: number
|
||||
/** The highest severity among unread items, or null. */
|
||||
highestUnreadSeverity: AlertSeverity | null
|
||||
highestUnreadSeverity: NotificationSeverity | null
|
||||
/** The latest items, newest first (at most 10). */
|
||||
items: AlertItem[]
|
||||
/** The Alerts app path. */
|
||||
alertsHref: string
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// E2E: the notification center (qbit-alerts) against mocked /qqq/v1/alerts/inbox routes: no
|
||||
// request without the Alerts app; bell badge, popover, the /app/notifications page and a sticky
|
||||
// banner, readable at 390 and 1440 px in light and dark mode with no horizontal scrolling, axe,
|
||||
// and the actions. Set QQQ_NOTIFICATIONS_SCREENSHOTS to a directory to also write screenshots.
|
||||
|
||||
import { test, expect, type Page, type Route } from '@playwright/test'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
import { checkA11y } from './a11y-helpers'
|
||||
|
||||
/** Where screenshots go when set (a directory). */
|
||||
const SCREENSHOT_DIR = process.env.QQQ_NOTIFICATIONS_SCREENSHOTS
|
||||
|
||||
const SEVERITIES = ['CRITICAL', 'ERROR', 'WARNING', 'SUCCESS', 'INFO'] as const
|
||||
const CATEGORIES = ['ALERT', 'JOB', 'SECURITY', 'RELEASE', 'SYSTEM', 'SHARE', 'MARKETING'] as const
|
||||
|
||||
/** Thirty inbox items, newest first. */
|
||||
const ITEMS = Array.from({ length: 30 }, (_, index) => ({
|
||||
id: 100 - index,
|
||||
notificationId: 50 - index,
|
||||
title: [
|
||||
'Nightly backup did not run on the primary database cluster',
|
||||
'Order export finished',
|
||||
'Your password was changed',
|
||||
'QQQ 4.1 is available',
|
||||
'Scheduled maintenance on Saturday',
|
||||
][index % 5] + (index >= 5 ? ` (${index})` : ''),
|
||||
body: index % 5 === 0
|
||||
? 'The **nightly** backup job stopped at 02:14.\n\n- Check the [backup job](/app/person)\n- Read the [runbook](https://qrun.io/docs/backups)'
|
||||
: 'Details for this notification, with `code` and a [link](/app/person).',
|
||||
severity: SEVERITIES[index % 5],
|
||||
category: CATEGORIES[index % 7],
|
||||
source: ['Backups', 'Jobs', 'Security', 'Releases', 'System'][index % 5],
|
||||
href: index % 5 === 3 ? 'https://qrun.io/releases/4.1' : '/app/person',
|
||||
linkLabel: index % 5 === 3 ? 'Release notes' : 'Open',
|
||||
deliveredAt: new Date(Date.now() - (index + 1) * 47 * 60_000).toISOString(),
|
||||
age: index === 0 ? 'just now' : `${index * 47} min`,
|
||||
unread: index < 4,
|
||||
sticky: false,
|
||||
}))
|
||||
|
||||
const BANNER = {
|
||||
id: 7,
|
||||
notificationId: 7,
|
||||
title: 'Scheduled maintenance tonight from 22:00 to 23:00 UTC',
|
||||
body: 'The admin will be **read-only** during the window. See the [status page](https://status.qrun.io) for updates.',
|
||||
severity: 'WARNING',
|
||||
category: 'SYSTEM',
|
||||
source: 'System',
|
||||
href: null,
|
||||
linkLabel: null,
|
||||
deliveredAt: new Date().toISOString(),
|
||||
age: '5 min',
|
||||
unread: true,
|
||||
sticky: true,
|
||||
}
|
||||
|
||||
/** Recorded inbox traffic. */
|
||||
interface InboxMock {
|
||||
/** Every request path under /alerts. */
|
||||
requests: string[]
|
||||
/** POST bodies by action. */
|
||||
posts: Array<[string, unknown]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces the Alerts app in the metadata (unless `installed` is false) and answers the inbox.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @param installed - Whether the metadata lists the alerts app.
|
||||
* @returns The recorded traffic.
|
||||
*/
|
||||
async function mockInbox(page: Page, installed = true): Promise<InboxMock> {
|
||||
const mock: InboxMock = { requests: [], posts: [] }
|
||||
const items = ITEMS.map((item) => ({ ...item }))
|
||||
let banners = [BANNER]
|
||||
const json = (route: Route, body: unknown) => route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
await setupApiMocks(page)
|
||||
if (installed) {
|
||||
await page.route(/\/qqq\/v1\/metaData(\?|$)/, (route) => json(route, { ...METADATA, apps: { ...METADATA.apps, alerts: { name: 'alerts', label: 'Alerts', children: [] } } }))
|
||||
}
|
||||
await page.route('**/qqq/v1/alerts/**', async (route) => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = url.pathname.replace('/qqq/v1/alerts', '')
|
||||
mock.requests.push(path)
|
||||
if (path === '/inbox/summary') {
|
||||
const unread = items.filter((item) => item.unread)
|
||||
return json(route, {
|
||||
unreadCount: unread.length,
|
||||
highestUnreadSeverity: unread.some((item) => item.severity === 'CRITICAL') ? 'CRITICAL' : unread[0]?.severity ?? null,
|
||||
items: items.slice(0, 10).map(({ body: _body, ...rest }) => rest),
|
||||
banners,
|
||||
pollSeconds: 60,
|
||||
notificationsHref: '/app/notifications',
|
||||
})
|
||||
}
|
||||
if (path === '/inbox') {
|
||||
const status = url.searchParams.get('status')
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0)
|
||||
const limit = Number(url.searchParams.get('limit') ?? 25)
|
||||
const matching = items.filter((item) => (status !== 'unread' || item.unread)
|
||||
&& (!url.searchParams.get('category') || item.category === url.searchParams.get('category'))
|
||||
&& (!url.searchParams.get('severity') || item.severity === url.searchParams.get('severity')))
|
||||
return json(route, { items: matching.slice(offset, offset + limit), total: matching.length, offset, limit })
|
||||
}
|
||||
const action = path.replace('/inbox/', '')
|
||||
const body = request.postDataJSON() as { id?: number; all?: boolean }
|
||||
mock.posts.push([action, body])
|
||||
for (const item of items) {
|
||||
if (body.all || item.id === body.id) {
|
||||
if (action === 'read') item.unread = false
|
||||
if (action === 'unread') item.unread = true
|
||||
}
|
||||
}
|
||||
if (action === 'dismiss') {
|
||||
banners = banners.filter((banner) => banner.id !== body.id)
|
||||
const index = items.findIndex((item) => item.id === body.id)
|
||||
if (index >= 0) items.splice(index, 1)
|
||||
}
|
||||
return json(route, { updated: 1 })
|
||||
})
|
||||
return mock
|
||||
}
|
||||
|
||||
/**
|
||||
* Elements that scroll horizontally, and whether the document does.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @returns The offenders.
|
||||
*/
|
||||
async function horizontalScroll(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const found: string[] = []
|
||||
const root = document.scrollingElement as HTMLElement
|
||||
if (root.scrollWidth > window.innerWidth + 1) found.push('document')
|
||||
for (const element of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
|
||||
const style = getComputedStyle(element)
|
||||
if (/(auto|scroll)/.test(style.overflowX) && element.scrollWidth > element.clientWidth + 1) found.push(element.dataset.qqqId ?? element.tagName)
|
||||
}
|
||||
return found
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Notification center', () => {
|
||||
test('sends no Alerts request without the Alerts app', async ({ page }) => {
|
||||
const mock = await mockInbox(page, false)
|
||||
await page.goto('/app/person')
|
||||
await expect(page.getByRole('heading', { name: 'People' }).first()).toBeVisible()
|
||||
await page.waitForTimeout(500)
|
||||
expect(mock.requests).toEqual([])
|
||||
await expect(page.locator('[data-qqq-id="button-notifications"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
for (const width of [390, 1440]) {
|
||||
test(`bell, popover, banner and page at ${width} px (${scheme})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 })
|
||||
await page.emulateMedia({ colorScheme: scheme })
|
||||
await page.addInitScript((value) => { try { localStorage.setItem('qqq-dark-mode', String(value === 'dark')) } catch { /* ignore */ } }, scheme)
|
||||
const mock = await mockInbox(page)
|
||||
await page.goto('/app/person')
|
||||
|
||||
const bell = page.getByRole('button', { name: 'Notifications, 4 unread' })
|
||||
await expect(bell).toBeVisible()
|
||||
const banner = page.getByRole('status', { name: /Warning: Scheduled maintenance tonight/ })
|
||||
await expect(banner).toBeVisible()
|
||||
expect(await horizontalScroll(page)).toEqual([])
|
||||
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/banner-${width}-${scheme}.png` })
|
||||
|
||||
await bell.click()
|
||||
const popover = page.getByRole('dialog', { name: 'Notifications' })
|
||||
await expect(popover).toBeVisible()
|
||||
await expect(popover.getByRole('listitem')).toHaveCount(10)
|
||||
const box = await popover.boundingBox()
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width + 1)
|
||||
expect(await horizontalScroll(page)).toEqual([])
|
||||
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/popover-${width}-${scheme}.png` })
|
||||
if (scheme === 'light' && width === 1440) await checkA11y(page)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(popover).toHaveCount(0)
|
||||
|
||||
await page.goto('/app/notifications')
|
||||
const list = page.getByRole('list', { name: 'Notifications' })
|
||||
await expect(list.locator(':scope > li')).toHaveCount(25)
|
||||
await expect(page.getByText('1–25 of 30')).toBeVisible()
|
||||
expect(await horizontalScroll(page)).toEqual([])
|
||||
if (SCREENSHOT_DIR) await page.screenshot({ path: `${SCREENSHOT_DIR}/page-${width}-${scheme}.png`, fullPage: false })
|
||||
if (scheme === 'light' && width === 1440) await checkA11y(page)
|
||||
|
||||
if (scheme === 'light' && width === 1440) {
|
||||
await page.getByRole('button', { name: 'Next' }).click()
|
||||
await expect(page.getByText('26–30 of 30')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Unread', exact: true }).click()
|
||||
await expect(page.getByText('1–4 of 4')).toBeVisible()
|
||||
await page.getByRole('button', { name: /^Mark read: Nightly backup/ }).click()
|
||||
await expect(page.getByRole('button', { name: 'Notifications, 3 unread' })).toBeVisible()
|
||||
await page.getByRole('button', { name: /^Dismiss: Scheduled maintenance tonight/ }).click()
|
||||
await expect(page.getByRole('status', { name: /Warning: Scheduled maintenance tonight/ })).toHaveCount(0)
|
||||
expect(mock.posts).toEqual([['read', { id: 100 }], ['dismiss', { id: 7 }]])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user