feat(ui/alerts): NotificationBell with Page Visibility poll pause
Adds a header bell component linking to /alerts/inbox with an unread-count badge for the selected environment. Polling pauses when the tab is hidden via TanStack Query's refetchIntervalInBackground:false (already set on useUnreadCount); the new usePageVisible hook gives components a re-renders-on-visibility-change signal for future defense-in-depth. Plan-prose deviation: the plan assumed UnreadCountResponse carries a bySeverity map for per-severity badge coloring, but the backend DTO only exposes a scalar `count`. The bell reads `data?.count` and renders a single var(--error) tint; a TODO references spec §13 for future per-severity work that would require expanding the DTO. Tests: usePageVisible toggles on visibilitychange events; NotificationBell renders the bell with no badge at count=0 and shows "3" at count=3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
ui/src/components/NotificationBell.module.css
Normal file
27
ui/src/components/NotificationBell.module.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.bell {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.bell:hover { background: var(--hover-bg); }
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--error);
|
||||||
|
color: var(--bg);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
47
ui/src/components/NotificationBell.test.tsx
Normal file
47
ui/src/components/NotificationBell.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useEnvironmentStore } from '../api/environment-store';
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({ api: { GET: vi.fn() } }));
|
||||||
|
|
||||||
|
import { api as apiClient } from '../api/client';
|
||||||
|
import { NotificationBell } from './NotificationBell';
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NotificationBell', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useEnvironmentStore.setState({ environment: 'dev' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bell with no badge when zero unread', async () => {
|
||||||
|
(apiClient.GET as any).mockResolvedValue({
|
||||||
|
data: { count: 0 },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
render(<NotificationBell />, { wrapper });
|
||||||
|
expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
|
||||||
|
// Badge is only rendered when count > 0; no numeric text should appear.
|
||||||
|
expect(screen.queryByText(/^\d+$/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows unread count badge when unread alerts exist', async () => {
|
||||||
|
(apiClient.GET as any).mockResolvedValue({
|
||||||
|
data: { count: 3 },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
render(<NotificationBell />, { wrapper });
|
||||||
|
expect(await screen.findByText('3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
ui/src/components/NotificationBell.tsx
Normal file
51
ui/src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import { useUnreadCount } from '../api/queries/alerts';
|
||||||
|
import { useSelectedEnv } from '../api/queries/alertMeta';
|
||||||
|
import { usePageVisible } from '../hooks/usePageVisible';
|
||||||
|
import css from './NotificationBell.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global notification bell shown in the layout header. Links to the alerts
|
||||||
|
* inbox and renders a badge with the unread-alert count for the currently
|
||||||
|
* selected environment.
|
||||||
|
*
|
||||||
|
* Polling is driven by `useUnreadCount` (30s interval, paused in background
|
||||||
|
* via TanStack Query's `refetchIntervalInBackground: false`). The
|
||||||
|
* `usePageVisible` hook is retained as a defense-in-depth signal so future
|
||||||
|
* UI behavior (e.g. animations, live-region updates) can key off visibility
|
||||||
|
* without re-wiring the polling logic.
|
||||||
|
*
|
||||||
|
* TODO (spec §13): per-severity badge coloring — the backend
|
||||||
|
* `UnreadCountResponse` currently exposes only a scalar `count` field. To
|
||||||
|
* colour the badge by max unread severity (CRITICAL → error, WARNING →
|
||||||
|
* amber, INFO → muted) the DTO must grow a `bySeverity` map; deferred to a
|
||||||
|
* future task. Until then the badge uses a single `var(--error)` tint.
|
||||||
|
*/
|
||||||
|
export function NotificationBell() {
|
||||||
|
const env = useSelectedEnv();
|
||||||
|
// Subscribe to visibility so the component re-renders on tab focus, even
|
||||||
|
// though the polling pause itself is handled inside useUnreadCount.
|
||||||
|
usePageVisible();
|
||||||
|
const { data } = useUnreadCount();
|
||||||
|
|
||||||
|
const count = data?.count ?? 0;
|
||||||
|
|
||||||
|
if (!env) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/alerts/inbox"
|
||||||
|
role="button"
|
||||||
|
aria-label={`Notifications (${count} unread)`}
|
||||||
|
className={css.bell}
|
||||||
|
>
|
||||||
|
<Bell size={16} />
|
||||||
|
{count > 0 && (
|
||||||
|
<span className={css.badge} aria-hidden>
|
||||||
|
{count > 99 ? '99+' : count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
ui/src/hooks/usePageVisible.test.ts
Normal file
30
ui/src/hooks/usePageVisible.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { usePageVisible } from './usePageVisible';
|
||||||
|
|
||||||
|
describe('usePageVisible', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
value: 'visible',
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when visible, false when hidden', () => {
|
||||||
|
const { result } = renderHook(() => usePageVisible());
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
ui/src/hooks/usePageVisible.ts
Normal file
22
ui/src/hooks/usePageVisible.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks Page Visibility API state for the current document.
|
||||||
|
*
|
||||||
|
* Returns `true` when the tab is visible, `false` when hidden. Useful for
|
||||||
|
* pausing work (polling, animations, expensive DOM effects) while the tab
|
||||||
|
* is backgrounded. SSR-safe: defaults to `true` when `document` is undefined.
|
||||||
|
*/
|
||||||
|
export function usePageVisible(): boolean {
|
||||||
|
const [visible, setVisible] = useState(() =>
|
||||||
|
typeof document === 'undefined' ? true : document.visibilityState === 'visible',
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setVisible(document.visibilityState === 'visible');
|
||||||
|
document.addEventListener('visibilitychange', onChange);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user