feat: add notification store and NotificationStack component
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user