diff --git a/client/src/shared/model/__tests__/notification.test.ts b/client/src/shared/model/__tests__/notification.test.ts new file mode 100644 index 0000000..953ecf0 --- /dev/null +++ b/client/src/shared/model/__tests__/notification.test.ts @@ -0,0 +1,97 @@ +import { allSettled, fork } from 'effector' +import { describe, it, expect } from 'vitest' +import { $notifications, addNotification, dismissNotification, dismissAll } from '../notification' + +describe('notification store', () => { + it('starts empty', () => { + const scope = fork() + expect(scope.getState($notifications)).toEqual([]) + }) + + it('adds a notification with generated id', async () => { + const scope = fork() + await allSettled(addNotification, { + scope, + params: { type: 'info', message: 'test' }, + }) + const list = scope.getState($notifications) + expect(list).toHaveLength(1) + expect(list[0].id).toEqual(expect.any(String)) + expect(list[0].message).toBe('test') + expect(list[0].type).toBe('info') + }) + + it('caps at 3 visible notifications', async () => { + const scope = fork() + for (let i = 0; i < 5; i++) { + await allSettled(addNotification, { + scope, + params: { type: 'info', message: `msg-${i}` }, + }) + } + const list = scope.getState($notifications) + expect(list).toHaveLength(3) + expect(list[0].message).toBe('msg-2') + expect(list[2].message).toBe('msg-4') + }) + + it('dismisses notification by id', async () => { + const scope = fork() + await allSettled(addNotification, { + scope, + params: { type: 'info', message: 'test' }, + }) + const [{ id }] = scope.getState($notifications) + await allSettled(dismissNotification, { + scope, + params: id, + }) + expect(scope.getState($notifications)).toEqual([]) + }) + + it('clears all on dismissAll', async () => { + const scope = fork() + await allSettled(addNotification, { + scope, + params: { type: 'info', message: 'test' }, + }) + await allSettled(dismissAll, { scope }) + expect(scope.getState($notifications)).toEqual([]) + }) + + it('defaults autoHideDuration to 4000 for info', async () => { + const scope = fork() + await allSettled(addNotification, { + scope, + params: { type: 'info', message: 'test' }, + }) + expect(scope.getState($notifications)[0].autoHideDuration).toBe(4000) + }) + + it('defaults autoHideDuration to 4000 for success', async () => { + const scope = fork() + await allSettled(addNotification, { + scope, + params: { type: 'success', message: 'test' }, + }) + expect(scope.getState($notifications)[0].autoHideDuration).toBe(4000) + }) + + it('defaults autoHideDuration to 4000 for warning', async () => { + const scope = fork() + await allSettled(addNotification, { + scope, + params: { type: 'warning', message: 'test' }, + }) + expect(scope.getState($notifications)[0].autoHideDuration).toBe(4000) + }) + + it('defaults autoHideDuration to 6000 for error', async () => { + const scope = fork() + await allSettled(addNotification, { + scope, + params: { type: 'error', message: 'test' }, + }) + expect(scope.getState($notifications)[0].autoHideDuration).toBe(6000) + }) +}) diff --git a/client/src/shared/model/notification.ts b/client/src/shared/model/notification.ts new file mode 100644 index 0000000..15e08cf --- /dev/null +++ b/client/src/shared/model/notification.ts @@ -0,0 +1,35 @@ +import { createEvent, createStore } from 'effector' + +type NotificationType = 'success' | 'error' | 'info' | 'warning' + +export interface Notification { + id: string + type: NotificationType + message: string + autoHideDuration?: number +} + +const MAX_VISIBLE = 3 +let nextId = 1 + +export const addNotification = createEvent<{ + type: NotificationType + message: string + autoHideDuration?: number +}>() + +export const dismissNotification = createEvent() +export const dismissAll = createEvent() + +export const $notifications = createStore([]) + .on(addNotification, (state, { type, message, autoHideDuration }) => { + const notification: Notification = { + id: String(nextId++), + type, + message, + autoHideDuration: autoHideDuration ?? (type === 'error' ? 6000 : 4000), + } + return [...state, notification].slice(-MAX_VISIBLE) + }) + .on(dismissNotification, (state, id) => state.filter((n) => n.id !== id)) + .reset(dismissAll) diff --git a/client/src/shared/ui/NotificationStack/NotificationStack.test.tsx b/client/src/shared/ui/NotificationStack/NotificationStack.test.tsx new file mode 100644 index 0000000..4dd63eb --- /dev/null +++ b/client/src/shared/ui/NotificationStack/NotificationStack.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeEach } from 'vitest' +import { addNotification, dismissAll } from '../../model/notification' +import { NotificationStack } from './NotificationStack' + +beforeEach(() => { + dismissAll() +}) + +describe('NotificationStack', () => { + it('renders nothing when empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders notification when added', async () => { + render() + addNotification({ type: 'info', message: 'Hello' }) + await waitFor(() => { + expect(screen.getByText('Hello')).toBeInTheDocument() + }) + }) + + it('dismiss button works', async () => { + render() + addNotification({ type: 'info', message: 'Dismiss me' }) + await waitFor(() => { + expect(screen.getByText('Dismiss me')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('CloseIcon')) + await waitFor(() => { + expect(screen.queryByText('Dismiss me')).not.toBeInTheDocument() + }) + }) +}) diff --git a/client/src/shared/ui/NotificationStack/NotificationStack.tsx b/client/src/shared/ui/NotificationStack/NotificationStack.tsx new file mode 100644 index 0000000..d7b8e4a --- /dev/null +++ b/client/src/shared/ui/NotificationStack/NotificationStack.tsx @@ -0,0 +1,47 @@ +import { useUnit } from 'effector-react' +import { Snackbar, Alert, Stack, IconButton } from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import { $notifications, dismissNotification } from '../../model/notification' + +export function NotificationStack() { + const notifications = useUnit($notifications) + + if (notifications.length === 0) return null + + return ( + + {notifications.map((n) => ( + dismissNotification(n.id)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + dismissNotification(n.id)}> + + + } + > + {n.message} + + + ))} + + ) +} diff --git a/client/src/shared/ui/NotificationStack/index.ts b/client/src/shared/ui/NotificationStack/index.ts new file mode 100644 index 0000000..88522fb --- /dev/null +++ b/client/src/shared/ui/NotificationStack/index.ts @@ -0,0 +1 @@ +export { NotificationStack } from './NotificationStack'