Files
shop-server/docs/superpowers/plans/2026-05-27-toast-notifications.md
T
2026-05-27 20:56:08 +05:00

379 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```