feat: migrate CartSnackbar to global notification store
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { AppProviders } from '@/app/providers/AppProviders'
|
import { AppProviders } from '@/app/providers/AppProviders'
|
||||||
import { AppRoutes } from '@/app/routes'
|
import { AppRoutes } from '@/app/routes'
|
||||||
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
|
|
||||||
import { NotificationStack } from '@/shared/ui/NotificationStack'
|
import { NotificationStack } from '@/shared/ui/NotificationStack'
|
||||||
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
||||||
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
||||||
@@ -13,7 +12,6 @@ export function App() {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<CartSnackbar />
|
|
||||||
<NotificationStack />
|
<NotificationStack />
|
||||||
<NoiseOverlay />
|
<NoiseOverlay />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { addToCart } from '@/entities/cart/api/cart-api'
|
import { addToCart } from '@/entities/cart/api/cart-api'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { cartAdded } from '@/shared/model/cart-notifications'
|
import { addNotification } from '@/shared/model/notification'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
productId: string
|
productId: string
|
||||||
@@ -21,7 +21,7 @@ export function AddToCartButton(props: Props) {
|
|||||||
mutationFn: () => addToCart({ productId, qty }),
|
mutationFn: () => addToCart({ productId, qty }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||||
cartAdded()
|
addNotification({ type: 'info', message: 'Товар добавлен в корзину' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import * as notifications from '@/shared/model/cart-notifications'
|
import * as notifications from '@/shared/model/notification'
|
||||||
import { AddToCartButton } from '../AddToCartButton'
|
import { AddToCartButton } from '../AddToCartButton'
|
||||||
|
|
||||||
vi.mock('@/entities/cart/api/cart-api', () => ({
|
vi.mock('@/entities/cart/api/cart-api', () => ({
|
||||||
@@ -21,8 +21,8 @@ describe('AddToCartButton', () => {
|
|||||||
qc.clear()
|
qc.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls cartAdded after successful add', async () => {
|
it('calls addNotification after successful add', async () => {
|
||||||
const spy = vi.spyOn(notifications, 'cartAdded')
|
const spy = vi.spyOn(notifications, 'addNotification')
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<AddToCartButton productId="test-product" />
|
<AddToCartButton productId="test-product" />
|
||||||
@@ -32,7 +32,7 @@ describe('AddToCartButton', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(spy).toHaveBeenCalled()
|
expect(spy).toHaveBeenCalledWith({ type: 'info', message: 'Товар добавлен в корзину' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ShoppingCart } from 'lucide-react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
|
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { cartAdded } from '@/shared/model/cart-notifications'
|
import { addNotification } from '@/shared/model/notification'
|
||||||
|
|
||||||
export function ToggleCartIcon(props: {
|
export function ToggleCartIcon(props: {
|
||||||
productId: string
|
productId: string
|
||||||
@@ -31,7 +31,7 @@ export function ToggleCartIcon(props: {
|
|||||||
mutationFn: () => addToCart({ productId, qty: 1 }),
|
mutationFn: () => addToCart({ productId, qty: 1 }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||||
cartAdded()
|
addNotification({ type: 'info', message: 'Товар добавлен в корзину' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import * as api from '@/entities/cart/api/cart-api'
|
import * as api from '@/entities/cart/api/cart-api'
|
||||||
import * as notifications from '@/shared/model/cart-notifications'
|
import * as notifications from '@/shared/model/notification'
|
||||||
import { ToggleCartIcon } from '../ToggleCartIcon'
|
import { ToggleCartIcon } from '../ToggleCartIcon'
|
||||||
|
|
||||||
vi.mock('@/entities/cart/api/cart-api', () => ({
|
vi.mock('@/entities/cart/api/cart-api', () => ({
|
||||||
@@ -25,8 +25,8 @@ describe('ToggleCartIcon', () => {
|
|||||||
qc.clear()
|
qc.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls cartAdded after successful add', async () => {
|
it('calls addNotification after successful add', async () => {
|
||||||
const spy = vi.spyOn(notifications, 'cartAdded')
|
const spy = vi.spyOn(notifications, 'addNotification')
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -38,15 +38,15 @@ describe('ToggleCartIcon', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(spy).toHaveBeenCalled()
|
expect(spy).toHaveBeenCalledWith({ type: 'info', message: 'Товар добавлен в корзину' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not call cartAdded on remove', async () => {
|
it('does not call addNotification on remove', async () => {
|
||||||
vi.mocked(api.fetchMyCart).mockResolvedValueOnce({
|
vi.mocked(api.fetchMyCart).mockResolvedValueOnce({
|
||||||
items: [{ id: 'cart-1', qty: 1, product: { id: 'test-product' } as never }],
|
items: [{ id: 'cart-1', qty: 1, product: { id: 'test-product' } as never }],
|
||||||
})
|
})
|
||||||
const spy = vi.spyOn(notifications, 'cartAdded')
|
const spy = vi.spyOn(notifications, 'addNotification')
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { allSettled, fork } from 'effector'
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { $cartSnackOpen, cartAdded, cartDismissed } from '../cart-notifications'
|
|
||||||
|
|
||||||
describe('cart-notifications store', () => {
|
|
||||||
it('opens on cartAdded', async () => {
|
|
||||||
const scope = fork()
|
|
||||||
await allSettled(cartAdded, { scope })
|
|
||||||
expect(scope.getState($cartSnackOpen)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('closes on cartDismissed', async () => {
|
|
||||||
const scope = fork()
|
|
||||||
await allSettled(cartAdded, { scope })
|
|
||||||
await allSettled(cartDismissed, { scope })
|
|
||||||
expect(scope.getState($cartSnackOpen)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('starts closed by default', () => {
|
|
||||||
const scope = fork()
|
|
||||||
expect(scope.getState($cartSnackOpen)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { createEvent, createStore } from 'effector'
|
|
||||||
|
|
||||||
export const cartAdded = createEvent()
|
|
||||||
export const cartDismissed = createEvent()
|
|
||||||
|
|
||||||
export const $cartSnackOpen = createStore(false)
|
|
||||||
.on(cartAdded, () => true)
|
|
||||||
.on(cartDismissed, () => false)
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import Alert from '@mui/material/Alert'
|
|
||||||
import Button from '@mui/material/Button'
|
|
||||||
import Snackbar from '@mui/material/Snackbar'
|
|
||||||
import { useUnit } from 'effector-react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { $cartSnackOpen, cartDismissed } from '@/shared/model/cart-notifications'
|
|
||||||
|
|
||||||
export function CartSnackbar() {
|
|
||||||
const open = useUnit($cartSnackOpen)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const handleClose = (_event: React.SyntheticEvent | Event, reason?: string) => {
|
|
||||||
if (reason === 'clickaway') return
|
|
||||||
cartDismissed()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGoToCart = () => {
|
|
||||||
cartDismissed()
|
|
||||||
navigate('/cart')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Snackbar
|
|
||||||
open={open}
|
|
||||||
onClose={handleClose}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
autoHideDuration={4000}
|
|
||||||
sx={{
|
|
||||||
'& .MuiSnackbarContent-root': {
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'rgba(0,0,0,0.06)',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Alert
|
|
||||||
severity="success"
|
|
||||||
variant="standard"
|
|
||||||
onClose={handleClose}
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: 'none',
|
|
||||||
p: 1.5,
|
|
||||||
alignItems: 'center',
|
|
||||||
'& .MuiAlert-icon': {
|
|
||||||
padding: 0,
|
|
||||||
mr: 1.5,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
'& .MuiAlert-message': {
|
|
||||||
padding: 0,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
'& .MuiAlert-action': {
|
|
||||||
padding: 0,
|
|
||||||
mr: 0,
|
|
||||||
ml: 1,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
onClick={handleGoToCart}
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.5,
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'success.main',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'action.hover',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Перейти в корзину
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Товар добавлен в корзину
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
||||||
import { cartAdded, cartDismissed } from '@/shared/model/cart-notifications'
|
|
||||||
import { CartSnackbar } from '@/shared/ui/CartSnackbar'
|
|
||||||
|
|
||||||
const navigateMock = vi.fn()
|
|
||||||
|
|
||||||
vi.mock('react-router-dom', async (importOriginal) => {
|
|
||||||
const mod = await importOriginal<typeof import('react-router-dom')>()
|
|
||||||
return { ...mod, useNavigate: () => navigateMock }
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
navigateMock.mockClear()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
function renderWithRouter() {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/']}>
|
|
||||||
<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()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('navigates to /cart and closes on "Перейти в корзину" click', () => {
|
|
||||||
renderWithRouter()
|
|
||||||
cartAdded()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /перейти в корзину/i }))
|
|
||||||
expect(navigateMock).toHaveBeenCalledWith('/cart')
|
|
||||||
expect(screen.queryByText(/товар добавлен/i)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user