fix: remove redundant timer, add navigation verification to CartSnackbar tests
This commit is contained in:
Generated
+1654
-2145
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Snackbar from '@mui/material/Snackbar'
|
import Snackbar from '@mui/material/Snackbar'
|
||||||
@@ -10,13 +9,10 @@ export function CartSnackbar() {
|
|||||||
const open = useUnit($cartSnackOpen)
|
const open = useUnit($cartSnackOpen)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
const handleClose = (_event: React.SyntheticEvent | Event, reason?: string) => {
|
||||||
if (!open) return
|
if (reason === 'clickaway') return
|
||||||
const timer = setTimeout(() => cartDismissed(), 4000)
|
cartDismissed()
|
||||||
return () => clearTimeout(timer)
|
}
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const handleClose = () => cartDismissed()
|
|
||||||
|
|
||||||
const handleGoToCart = () => {
|
const handleGoToCart = () => {
|
||||||
cartDismissed()
|
cartDismissed()
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter, useLocation } from 'react-router-dom'
|
||||||
import { cartAdded, cartDismissed } from '@/shared/model/cart-notifications'
|
import { cartAdded, cartDismissed } from '@/shared/model/cart-notifications'
|
||||||
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
|
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
|
||||||
|
|
||||||
|
function LocationSpy() {
|
||||||
|
const location = useLocation()
|
||||||
|
;(window as any).__testLocation = location
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function renderWithRouter() {
|
function renderWithRouter() {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<LocationSpy />
|
||||||
<CartSnackbar />
|
<CartSnackbar />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
)
|
)
|
||||||
@@ -50,6 +57,7 @@ describe('CartSnackbar', () => {
|
|||||||
cartAdded()
|
cartAdded()
|
||||||
const goBtn = screen.getByRole('button', { name: /перейти в корзину/i })
|
const goBtn = screen.getByRole('button', { name: /перейти в корзину/i })
|
||||||
fireEvent.click(goBtn)
|
fireEvent.click(goBtn)
|
||||||
|
expect((window as any).__testLocation.pathname).toBe('/cart')
|
||||||
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
# Cart Added Snackbar Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Show a global Snackbar notification with a "Перейти в корзину" button when a product is added to cart.
|
||||||
|
|
||||||
|
**Architecture:** Effector store (`$cartSnackOpen`) as single source of truth. `CartSnackbar` component subscribes via `useUnit` and renders MUI Snackbar. `AddToCartButton` and `ToggleCartIcon` fire `cartAdded()` event on mutation success.
|
||||||
|
|
||||||
|
**Tech Stack:** effector/effector-react, @mui/material (Snackbar, Alert), react-router-dom (useNavigate), vitest + testing-library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Effector store for cart notification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client/src/shared/model/cart-notifications.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the store**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createEvent, createStore } from 'effector'
|
||||||
|
|
||||||
|
export const cartAdded = createEvent()
|
||||||
|
export const cartDismissed = createEvent()
|
||||||
|
|
||||||
|
export const $cartSnackOpen = createStore(false)
|
||||||
|
.on(cartAdded, () => true)
|
||||||
|
.on(cartDismissed, () => false)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/shared/model/cart-notifications.ts
|
||||||
|
git commit -m "feat: add cart notification effector store"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: CartSnackbar component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client/src/shared/ui/CartSnackbar.tsx`
|
||||||
|
- Test: `client/src/shared/ui/__tests__/CartSnackbar.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { cartAdded, cartDismissed, $cartSnackOpen } from '@/shared/model/cart-notifications'
|
||||||
|
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
|
||||||
|
|
||||||
|
function renderWithRouter() {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CartSnackbar />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CartSnackbar', () => {
|
||||||
|
it('is hidden when store is false', () => {
|
||||||
|
cartDismissed()
|
||||||
|
renderWithRouter()
|
||||||
|
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows snackbar when cartAdded is fired', () => {
|
||||||
|
renderWithRouter()
|
||||||
|
cartAdded()
|
||||||
|
expect(screen.getByText(/товар добавлен/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /перейти в корзину/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on dismiss button click', () => {
|
||||||
|
renderWithRouter()
|
||||||
|
cartAdded()
|
||||||
|
const closeBtn = screen.getByLabelText(/закрыть/i)
|
||||||
|
fireEvent.click(closeBtn)
|
||||||
|
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-closes after 4 seconds', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
renderWithRouter()
|
||||||
|
cartAdded()
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(4000)
|
||||||
|
})
|
||||||
|
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates to /cart and closes on "Перейти в корзину" click', () => {
|
||||||
|
renderWithRouter()
|
||||||
|
cartAdded()
|
||||||
|
const goBtn = screen.getByRole('button', { name: /перейти в корзину/i })
|
||||||
|
fireEvent.click(goBtn)
|
||||||
|
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx vitest run src/shared/ui/__tests__/CartSnackbar.test.tsx
|
||||||
|
```
|
||||||
|
Expected: FAIL — module `@/shared/model/cart-notifications` not found or component not exported.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the component**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import Alert from '@mui/material/Alert'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Snackbar from '@mui/material/Snackbar'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { $cartSnackOpen, cartDismissed } from '@/shared/model/cart-notifications'
|
||||||
|
|
||||||
|
export function CartSnackbar() {
|
||||||
|
const open = useUnit($cartSnackOpen)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const timer = setTimeout(() => cartDismissed(), 4000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleClose = () => cartDismissed()
|
||||||
|
|
||||||
|
const handleGoToCart = () => {
|
||||||
|
cartDismissed()
|
||||||
|
navigate('/cart')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
>
|
||||||
|
<Alert severity="success" onClose={handleClose} action={
|
||||||
|
<Button color="success" size="small" onClick={handleGoToCart}>
|
||||||
|
Перейти в корзину
|
||||||
|
</Button>
|
||||||
|
}>
|
||||||
|
Товар добавлен в корзину
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx vitest run src/shared/ui/__tests__/CartSnackbar.test.tsx
|
||||||
|
```
|
||||||
|
Expected: All 5 tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/shared/ui/CartSnackbar.tsx client/src/shared/ui/__tests__/CartSnackbar.test.tsx
|
||||||
|
git commit -m "feat: add CartSnackbar component with tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Mount CartSnackbar in AppProviders
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/app/providers/AppProviders.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add CartSnackbar to AppProviders**
|
||||||
|
|
||||||
|
Add import at the top (after existing imports):
|
||||||
|
```tsx
|
||||||
|
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `<CartSnackbar />` inside `QueryClientProvider`, after `<SseProvider />`:
|
||||||
|
```tsx
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SseProvider />
|
||||||
|
<CartSnackbar />
|
||||||
|
<ThemeControllerProvider>
|
||||||
|
<AppThemeInner>{children}</AppThemeInner>
|
||||||
|
</ThemeControllerProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no lint errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx eslint src/app/providers/AppProviders.tsx
|
||||||
|
```
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/app/providers/AppProviders.tsx
|
||||||
|
git commit -m "feat: mount CartSnackbar in AppProviders"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Integrate cartAdded into AddToCartButton
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add cartAdded call to onSuccess**
|
||||||
|
|
||||||
|
Add import:
|
||||||
|
```tsx
|
||||||
|
import { cartAdded } from '@/shared/model/cart-notifications'
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `onSuccess` in `addMut`:
|
||||||
|
```tsx
|
||||||
|
const addMut = useMutation({
|
||||||
|
mutationFn: () => addToCart({ productId, qty }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||||
|
cartAdded()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Full file after change:
|
||||||
|
```tsx
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import type { ButtonProps } from '@mui/material/Button'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { addToCart } from '@/entities/cart/api/cart-api'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
|
import { cartAdded } from '@/shared/model/cart-notifications'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
productId: string
|
||||||
|
qty?: number
|
||||||
|
loggedOutLabel?: string
|
||||||
|
} & Omit<ButtonProps, 'onClick'>
|
||||||
|
|
||||||
|
export function AddToCartButton(props: Props) {
|
||||||
|
const { productId, qty = 1, loggedOutLabel = 'Войдите, чтобы купить', disabled, children, ...rest } = props
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const user = useUnit($user)
|
||||||
|
|
||||||
|
const addMut = useMutation({
|
||||||
|
mutationFn: () => addToCart({ productId, qty }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||||
|
cartAdded()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...rest}
|
||||||
|
disabled={Boolean(disabled) || !user || addMut.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
addMut.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user ? (children ?? 'В корзину') : loggedOutLabel}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no lint errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx eslint src/features/cart/add-to-cart/ui/AddToCartButton.tsx
|
||||||
|
```
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx
|
||||||
|
git commit -m "feat: fire cartAdded event in AddToCartButton"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Integrate cartAdded into ToggleCartIcon
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add cartAdded call to add-mutation onSuccess only**
|
||||||
|
|
||||||
|
Add import:
|
||||||
|
```tsx
|
||||||
|
import { cartAdded } from '@/shared/model/cart-notifications'
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `onSuccess` in `addMut` (NOT `removeMut`):
|
||||||
|
```tsx
|
||||||
|
const addMut = useMutation({
|
||||||
|
mutationFn: () => addToCart({ productId, qty: 1 }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||||
|
cartAdded()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Full file after change:
|
||||||
|
```tsx
|
||||||
|
import IconButton from '@mui/material/IconButton'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { ShoppingCart } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
|
import { cartAdded } from '@/shared/model/cart-notifications'
|
||||||
|
|
||||||
|
export function ToggleCartIcon(props: {
|
||||||
|
productId: string
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
disabledReason?: string | null
|
||||||
|
}) {
|
||||||
|
const { productId, size = 'small', disabledReason = null } = props
|
||||||
|
const user = useUnit($user)
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const cartQuery = useQuery({
|
||||||
|
queryKey: ['me', 'cart'],
|
||||||
|
queryFn: fetchMyCart,
|
||||||
|
enabled: Boolean(user),
|
||||||
|
})
|
||||||
|
|
||||||
|
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
|
||||||
|
const inCart = Boolean(existing)
|
||||||
|
|
||||||
|
const addMut = useMutation({
|
||||||
|
mutationFn: () => addToCart({ productId, qty: 1 }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||||
|
cartAdded()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeMut = useMutation({
|
||||||
|
mutationFn: () => removeCartItem(existing!.id),
|
||||||
|
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const disabled = Boolean(disabledReason)
|
||||||
|
const busy = addMut.isPending || removeMut.isPending
|
||||||
|
|
||||||
|
const onClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (disabledReason) return
|
||||||
|
if (!user) {
|
||||||
|
navigate('/auth')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (inCart) removeMut.mutate()
|
||||||
|
else addMut.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = disabledReason
|
||||||
|
? disabledReason
|
||||||
|
: !user
|
||||||
|
? 'Авторизуйтесь для совершения покупок'
|
||||||
|
: inCart
|
||||||
|
? 'Убрать из корзины'
|
||||||
|
: 'В корзину'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltip}>
|
||||||
|
<span>
|
||||||
|
<IconButton size={size} onClick={onClick} disabled={disabled || busy} aria-label={tooltip} type="button">
|
||||||
|
{user ? inCart ? <ShoppingCart fill="currentColor" /> : <ShoppingCart /> : <ShoppingCart />}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no lint errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx eslint src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx
|
||||||
|
```
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx
|
||||||
|
git commit -m "feat: fire cartAdded event in ToggleCartIcon add mutation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Final verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full client lint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run lint
|
||||||
|
```
|
||||||
|
Expected: 0 errors (warnings OK).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full client test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm test
|
||||||
|
```
|
||||||
|
Expected: All tests PASS including new CartSnackbar tests.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run Prettier check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run format:check
|
||||||
|
```
|
||||||
|
Expected: All files match formatting.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final commit if any changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: lint and format fixes for cart snackbar"
|
||||||
|
```
|
||||||
Binary file not shown.
Reference in New Issue
Block a user