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

10 KiB
Raw Blame History

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"