feat: add notification store and NotificationStack component

This commit is contained in:
Kirill
2026-05-27 21:07:55 +05:00
parent f6414adf2f
commit e2d4423e2e
5 changed files with 216 additions and 0 deletions
@@ -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)
})
})
+35
View File
@@ -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<string>()
export const dismissAll = createEvent()
export const $notifications = createStore<Notification[]>([])
.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)
@@ -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(<NotificationStack />)
expect(container.firstChild).toBeNull()
})
it('renders notification when added', async () => {
render(<NotificationStack />)
addNotification({ type: 'info', message: 'Hello' })
await waitFor(() => {
expect(screen.getByText('Hello')).toBeInTheDocument()
})
})
it('dismiss button works', async () => {
render(<NotificationStack />)
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()
})
})
})
@@ -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 (
<Stack
spacing={1}
sx={{
position: 'fixed',
bottom: 80,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 2000,
width: 'auto',
maxWidth: 400,
}}
>
{notifications.map((n) => (
<Snackbar
key={n.id}
open
autoHideDuration={n.autoHideDuration}
onClose={() => dismissNotification(n.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={n.type}
variant="filled"
action={
<IconButton size="small" color="inherit" onClick={() => dismissNotification(n.id)}>
<CloseIcon fontSize="small" />
</IconButton>
}
>
{n.message}
</Alert>
</Snackbar>
))}
</Stack>
)
}
@@ -0,0 +1 @@
export { NotificationStack } from './NotificationStack'