Add notification centre and realtime toasts
This commit is contained in:
1 parent
b400042244
commit
a85b99ceb6
5 files changed
+515
-12
No files matched your search
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { act, cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DashboardEvent } from '../../packages/api-contracts/src/index.js';
|
||||
import {
|
||||
NotificationBell,
|
||||
NotificationProvider,
|
||||
NotificationViewport,
|
||||
notificationForEvent,
|
||||
} from '../../apps/dashboard/src/app/notifications.js';
|
||||
|
||||
class FakeRealtime {
|
||||
private listener?: (event: DashboardEvent) => void;
|
||||
|
||||
subscribe(listener: (event: DashboardEvent) => void): () => void {
|
||||
this.listener = listener;
|
||||
return () => { this.listener = undefined; };
|
||||
}
|
||||
|
||||
emit(event: DashboardEvent): void {
|
||||
this.listener?.(event);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('dashboard notifications', () => {
|
||||
it('turns actionable realtime events into useful notification copy', () => {
|
||||
expect(notificationForEvent({
|
||||
id: 'reply-1',
|
||||
occurredAt: '2026-08-09T09:30:00.000Z',
|
||||
type: 'reply.received',
|
||||
payload: { conversationId: 'room/one', replyId: 'message-1', sender: 'alice', preview: 'Sounds good!' },
|
||||
})).toMatchObject({
|
||||
title: 'New reply from u/alice',
|
||||
message: 'Sounds good!',
|
||||
tone: 'success',
|
||||
href: '/conversations/room%2Fone',
|
||||
});
|
||||
|
||||
expect(notificationForEvent({
|
||||
id: 'log-1',
|
||||
occurredAt: '2026-08-09T09:30:00.000Z',
|
||||
type: 'log.created',
|
||||
payload: { level: 'info', message: 'Routine detail' },
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('shows new events as toasts and keeps them in a readable notification centre', async () => {
|
||||
const realtime = new FakeRealtime();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<NotificationProvider realtime={realtime}>
|
||||
<NotificationBell />
|
||||
<NotificationViewport />
|
||||
</NotificationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
||||
act(() => realtime.emit({
|
||||
id: 'reply-1',
|
||||
occurredAt: '2026-08-09T09:30:00.000Z',
|
||||
type: 'reply.received',
|
||||
payload: { conversationId: 'conversation-1', replyId: 'message-1', sender: 'alice', preview: 'Sounds good!' },
|
||||
}));
|
||||
|
||||
const toast = screen.getByRole('status');
|
||||
expect(toast).toHaveTextContent('New reply from u/alice');
|
||||
expect(screen.getByRole('button', { name: 'Notifications, 1 unread' })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Notifications, 1 unread' }));
|
||||
const centre = screen.getByLabelText('Notifications', { selector: 'aside' });
|
||||
expect(centre).toHaveClass('is-open');
|
||||
expect(within(centre).getByText('Sounds good!')).toBeInTheDocument();
|
||||
expect(within(centre).getByRole('link', { name: /New reply from u\/alice/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/conversations/conversation-1',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
||||
|
||||
await user.click(within(centre).getByRole('button', { name: 'Clear all' }));
|
||||
expect(within(centre).getByText('All quiet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user