feat(web): notifications bell, unread badge and inbox panel in the header (#5342)
- The bell sits between search and the user menu. Its badge is parchment on obsidian, not the accent, which the house style keeps off general chrome. It counts up to 9, then shows 9+. - The panel lists the server-rendered title, body and relative time, newest first, with unread rows marked. Clicking a row marks it read and opens its link. "Mark all read" appears while anything is unread, and an empty inbox says "Nothing waiting for you." - createNotificationsQuery and createUnreadCountQuery poll every 60s while the tab is visible. The `notification.created` live event invalidates ['notifications'] so the badge and list refresh promptly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
|
||||
const invalidateMock = vi.fn();
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/notifications', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
badgeLabel: actual.badgeLabel,
|
||||
createUnreadCountQuery: vi.fn(),
|
||||
createNotificationsQuery: vi.fn(),
|
||||
markNotificationRead: vi.fn().mockResolvedValue(undefined),
|
||||
markAllNotificationsRead: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
badgeLabel,
|
||||
createNotificationsQuery,
|
||||
createUnreadCountQuery,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem
|
||||
} from '$lib/api/notifications';
|
||||
|
||||
function item(over: Partial<NotificationItem> = {}): NotificationItem {
|
||||
return {
|
||||
id: 'n1',
|
||||
kind: 'request_completed',
|
||||
title: 'Arrived: Boards of Canada – Geogaddi',
|
||||
body: 'Your request is in the library.',
|
||||
link: '/albums/al-1',
|
||||
created_at: new Date().toISOString(),
|
||||
read_at: null,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function setInbox(items: NotificationItem[], unread = items.filter((i) => !i.read_at).length) {
|
||||
(createUnreadCountQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: unread }));
|
||||
(createNotificationsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: { items, unread_count: unread } })
|
||||
);
|
||||
}
|
||||
|
||||
async function openPanel() {
|
||||
await fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('badgeLabel', () => {
|
||||
test('is empty at zero, the count to nine, then 9+', () => {
|
||||
expect(badgeLabel(0)).toBe('');
|
||||
expect(badgeLabel(1)).toBe('1');
|
||||
expect(badgeLabel(9)).toBe('9');
|
||||
expect(badgeLabel(10)).toBe('9+');
|
||||
expect(badgeLabel(250)).toBe('9+');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationBell', () => {
|
||||
test('shows the unread count on the badge and in the label', () => {
|
||||
setInbox([item(), item({ id: 'n2' }), item({ id: 'n3' })]);
|
||||
render(NotificationBell);
|
||||
expect(screen.getByTestId('notification-badge')).toHaveTextContent('3');
|
||||
expect(screen.getByRole('button', { name: 'Notifications, 3 unread' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('caps the badge at 9+', () => {
|
||||
setInbox([item()], 42);
|
||||
render(NotificationBell);
|
||||
expect(screen.getByTestId('notification-badge')).toHaveTextContent('9+');
|
||||
});
|
||||
|
||||
test('no badge when everything is read', () => {
|
||||
setInbox([item({ read_at: new Date().toISOString() })]);
|
||||
render(NotificationBell);
|
||||
expect(screen.queryByTestId('notification-badge')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('lists each kind with the server-rendered title and body, unread marked', async () => {
|
||||
setInbox([
|
||||
item({ id: 'a', kind: 'request_approved', title: 'Request approved', body: 'Geogaddi is on its way.' }),
|
||||
item({
|
||||
id: 'b',
|
||||
kind: 'tracks_missing',
|
||||
title: '4 tracks went missing',
|
||||
body: 'Their files are no longer where the library expects them.',
|
||||
link: '/admin/missing-files',
|
||||
read_at: new Date().toISOString()
|
||||
})
|
||||
]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
|
||||
const rows = screen.getAllByTestId('notification-item');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('Request approved');
|
||||
expect(rows[0]).toHaveTextContent('Geogaddi is on its way.');
|
||||
expect(rows[0]).toHaveAttribute('data-unread', 'true');
|
||||
expect(rows[1]).toHaveTextContent('4 tracks went missing');
|
||||
expect(rows[1]).not.toHaveAttribute('data-unread');
|
||||
});
|
||||
|
||||
test('clicking an unread item marks it read and opens its subject', async () => {
|
||||
setInbox([item({ id: 'n7', link: '/albums/al-7' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByTestId('notification-item'));
|
||||
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/albums/al-7'));
|
||||
expect(markNotificationRead).toHaveBeenCalledWith('n7');
|
||||
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
|
||||
expect(screen.queryByRole('dialog', { name: 'Notifications' })).toBeNull();
|
||||
});
|
||||
|
||||
test('clicking a read item only opens it', async () => {
|
||||
setInbox([item({ read_at: new Date().toISOString(), link: '/requests' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByTestId('notification-item'));
|
||||
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/requests'));
|
||||
expect(markNotificationRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mark all read clears everything and refreshes', async () => {
|
||||
setInbox([item(), item({ id: 'n2' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
|
||||
|
||||
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalled());
|
||||
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
|
||||
});
|
||||
|
||||
test('an empty inbox says so and offers no mark all', async () => {
|
||||
setInbox([]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
expect(screen.getByText('Nothing waiting for you.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Mark all read' })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user