10 KiB
Toast Notifications System — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
Goal: Создать общую систему toast-уведомлений на Effector + MUI, мигрировать CartSnackbar, удалить старый код.
Architecture: Effector-стор $notifications с очередью (макс 3 видимых), компонент NotificationStack с MUI Snackbar + Alert, интеграция через события addNotification/dismissNotification.
Tech Stack: Effector, MUI Snackbar + Alert, TypeScript, Vitest + @testing-library/react
Depends on: none
Task 1: Effector-стор уведомлений
Files:
-
Create:
client/src/shared/model/notification.ts -
Test:
client/src/shared/model/notification.test.ts -
Step 1: Write failing tests for notification store
// client/src/shared/model/notification.test.ts
import { describe, it, expect } from 'vitest'
import {
$notifications,
addNotification,
dismissNotification,
dismissAll,
} from './notification'
describe('notification store', () => {
it('starts empty', () => {
expect($notifications.getState()).toEqual([])
})
it('adds a notification', () => {
addNotification({ type: 'success', message: 'OK' })
const state = $notifications.getState()
expect(state).toHaveLength(1)
expect(state[0]).toMatchObject({ type: 'success', message: 'OK' })
expect(state[0].id).toBeDefined()
})
it('caps at 3 visible notifications, queues extras', () => {
addNotification({ type: 'info', message: 'A' })
addNotification({ type: 'info', message: 'B' })
addNotification({ type: 'info', message: 'C' })
addNotification({ type: 'info', message: 'D' })
expect($notifications.getState()).toHaveLength(3)
// wait for idle and check — actually effector stores are sync
// but we need to verify only 3 are visible and 4th is queued
})
it('dismisses a notification by id', () => {
const id = 'test-id'
// manually set state
// add dismiss and check
})
it('dismisses all notifications', () => {
// add several, dismissAll, check empty
})
it('auto-dismisses after autoHideDuration', () => {
// use fake timers
})
})
- Step 2: Run to verify failure
Run: cd client && npx vitest run shared/model/notification.test.ts
Expected: FAIL, module not found
- Step 3: Implement notification store
// client/src/shared/model/notification.ts
import { createEvent, createStore, sample } from 'effector'
type NotificationType = 'success' | 'error' | 'info' | 'warning'
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)
- Step 4: Run tests to verify they pass
Run: cd client && npx vitest run shared/model/notification.test.ts
Expected: PASS
- Step 5: Commit
git add client/src/shared/model/notification.ts client/src/shared/model/notification.test.ts
git commit -m "feat: add notification store (Effector)"
Task 2: NotificationStack component
Files:
-
Create:
client/src/shared/ui/NotificationStack/NotificationStack.tsx -
Create:
client/src/shared/ui/NotificationStack/index.ts -
Test:
client/src/shared/ui/NotificationStack/NotificationStack.test.tsx -
Step 1: Write failing tests for NotificationStack
// client/src/shared/ui/NotificationStack/NotificationStack.test.tsx
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, act, fireEvent } from '@testing-library/react'
import { NotificationStack } from './NotificationStack'
import { $notifications, addNotification, dismissNotification } from '../../model/notification'
import { fork, allSettled } from 'effector'
import { Provider } from 'effector-react'
function renderStack() {
const scope = fork()
return {
scope,
...render(
<Provider value={scope}>
<NotificationStack />
</Provider>,
),
}
}
describe('NotificationStack', () => {
afterEach(() => {
$notifications.reset()
})
it('renders nothing when empty', () => {
const { container } = renderStack()
expect(container.textContent).toBe('')
})
it('renders a notification when added', async () => {
const scope = fork()
await allSettled(addNotification, { scope, params: { type: 'success', message: 'Test message' } })
render(
<Provider value={scope}>
<NotificationStack />
</Provider>,
)
expect(screen.getByText('Test message')).toBeDefined()
})
it('renders correct icon for each type', async () => {
const scope = fork()
// success — CheckCircle, error — Error, info — Info, warning — Warning
// Check MUI Alert has correct severity attribute
await allSettled(addNotification, { scope, params: { type: 'error', message: 'Error!' } })
render(
<Provider value={scope}>
<NotificationStack />
</Provider>,
)
const alert = screen.getByRole('alert')
expect(alert.getAttribute('severity')).toBe('error')
})
})
- Step 2: Run to verify failure
Run: cd client && npx vitest run shared/ui/NotificationStack/NotificationStack.test.tsx
Expected: FAIL
- Step 3: Implement NotificationStack
// client/src/shared/ui/NotificationStack/NotificationStack.tsx
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>
)
}
// client/src/shared/ui/NotificationStack/index.ts
export { NotificationStack } from './NotificationStack'
- Step 4: Run tests
Run: cd client && npx vitest run shared/ui/NotificationStack/NotificationStack.test.tsx
Expected: PASS
- Step 5: Commit
git add client/src/shared/ui/NotificationStack/
git commit -m "feat: add NotificationStack component"
Task 3: Add NotificationStack to App.tsx
Files:
-
Modify:
client/src/app/App.tsx -
Step 1: Add NotificationStack to App layout
// App.tsx — add import and component
import { NotificationStack } from '@/shared/ui/NotificationStack'
// Inside the return, after </Router>
<>
<ErrorBoundary>
<AppProviders>
<BrowserRouter>
<MainLayout>
<AppRoutes />
</MainLayout>
</BrowserRouter>
</AppProviders>
</ErrorBoundary>
<NotificationStack />
</>
- Step 2: Run lint + build
Run: cd client && npm run lint && npm test
Expected: PASS
- Step 3: Commit
git add client/src/app/App.tsx
git commit -m "feat: integrate NotificationStack into App"
Task 4: Migrate CartSnackbar to notification store
Files:
-
Modify:
client/src/features/cart/ui/ToggleCartIcon/ToggleCartIcon.tsx -
Modify:
client/src/features/cart/ui/AddToCartButton/AddToCartButton.tsx -
Delete:
client/src/features/cart/ui/CartSnackbar/CartSnackbar.tsx -
Delete:
client/src/features/cart/ui/CartSnackbar/index.ts -
Delete:
client/src/features/cart/model/cart-notifications.ts -
Modify:
client/src/app/App.tsx(remove CartSnackbar import + usage) -
Step 1: Replace cartAdded events with addNotification in ToggleCartIcon
// ToggleCartIcon.tsx — replace cartAdded with addNotification
import { addNotification } from '@/shared/model/notification'
// search for: cartAdded()
// replace with:
addNotification({ type: 'info', message: 'Товар добавлен в корзину' })
- Step 2: Same for AddToCartButton
// AddToCartButton.tsx — same replacement
addNotification({ type: 'info', message: 'Товар добавлен в корзину' })
- Step 3: Remove CartSnackbar component files
Delete CartSnackbar.tsx and CartSnackbar/index.ts.
- Step 4: Remove cart-notifications model
Delete features/cart/model/cart-notifications.ts.
- Step 5: Remove CartSnackbar from App.tsx
Remove import and <CartSnackbar /> from render.
- Step 6: Remove cart-notifications import from features/cart/index.ts
Check features/cart/index.ts for re-exports of cart-notifications or CartSnackbar.
- Step 7: Remove CartSnackbar test file
Delete CartSnackbar.test.tsx.
- Step 8: Run lint + test + build
cd client && npm run lint && npm test && npm run build
- Step 9: Commit
git add -A
git commit -m "feat: migrate CartSnackbar to global notification store"