379 lines
10 KiB
Markdown
379 lines
10 KiB
Markdown
# 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**
|
||
|
||
```ts
|
||
// 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**
|
||
|
||
```ts
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
// 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**
|
||
|
||
```tsx
|
||
// 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>
|
||
)
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
// 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
// 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**
|
||
|
||
```tsx
|
||
// 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**
|
||
|
||
```bash
|
||
cd client && npm run lint && npm test && npm run build
|
||
```
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: migrate CartSnackbar to global notification store"
|
||
```
|