This commit is contained in:
Kirill
2026-05-28 22:33:36 +05:00
parent 5b9c2f4c46
commit ef3cc25dd4
11 changed files with 297 additions and 30 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ export function MainLayout({ children }: PropsWithChildren) {
const year = new Date().getFullYear()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', minWidth: '500px' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', minWidth: 0, overflowX: 'hidden' }}>
<ScrollOnNavigate />
<ScrollToTop />
<AppHeader />
@@ -0,0 +1,36 @@
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { MainLayout } from '../MainLayout'
vi.mock('@/app/layout/AppHeader', () => ({
AppHeader: () => <header>Шапка</header>,
}))
vi.mock('@/shared/ui/CookieConsentBanner', () => ({
CookieConsentBanner: () => null,
}))
vi.mock('@/shared/ui/DemoBanner', () => ({
DemoBanner: () => null,
}))
vi.mock('@/shared/ui/ScrollOnNavigate', () => ({
ScrollOnNavigate: () => null,
}))
vi.mock('@/shared/ui/ScrollToTop', () => ({
ScrollToTop: () => null,
}))
describe('MainLayout', () => {
it('не задает фиксированную минимальную ширину, которая ломает мобильный экран', () => {
const { container } = render(
<MemoryRouter>
<MainLayout>Контент</MainLayout>
</MemoryRouter>,
)
expect(container.firstElementChild).not.toHaveStyle({ minWidth: '500px' })
})
})
+1 -1
View File
@@ -1,2 +1,2 @@
export type { FormState } from './model/types'
export { emptyForm } from './lib/use-product-form-helpers'
export { emptyForm, isValidProductPriceRub, isValidProductQuantity } from './lib/use-product-form-helpers'
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isValidProductPriceRub, isValidProductQuantity } from '../use-product-form-helpers'
describe('product form helpers', () => {
it('принимает корректную цену в рублях', () => {
expect(isValidProductPriceRub('1200')).toBe(true)
expect(isValidProductPriceRub('1200,50')).toBe(true)
})
it('отклоняет пустую или некорректную цену', () => {
expect(isValidProductPriceRub('')).toBe(false)
expect(isValidProductPriceRub('0')).toBe(false)
expect(isValidProductPriceRub('1200,555')).toBe(false)
expect(isValidProductPriceRub('1,2,3')).toBe(false)
})
it('принимает только целое количество от 0 до 10', () => {
expect(isValidProductQuantity('0')).toBe(true)
expect(isValidProductQuantity('10')).toBe(true)
expect(isValidProductQuantity('')).toBe(false)
expect(isValidProductQuantity('11')).toBe(false)
expect(isValidProductQuantity('1.5')).toBe(false)
})
})
@@ -12,3 +12,19 @@ export const emptyForm = (): FormState => ({
published: true,
categoryId: '',
})
export function isValidProductPriceRub(value: string): boolean {
const trimmed = value.trim()
if (!/^\d+([,.]\d{1,2})?$/.test(trimmed)) return false
const priceRub = Number(trimmed.replace(',', '.'))
return Number.isFinite(priceRub) && priceRub > 0 && priceRub <= 10_000
}
export function isValidProductQuantity(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
const quantity = Number(trimmed)
return Number.isInteger(quantity) && quantity >= 0 && quantity <= 10
}
@@ -13,6 +13,7 @@ import Typography from '@mui/material/Typography'
import { Controller, type UseFormReturn } from 'react-hook-form'
import type { Category } from '@/entities/product/model/types'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { isValidProductPriceRub, isValidProductQuantity } from '../lib/use-product-form-helpers'
import type { FormState } from '../model/types'
export function ProductFormFields({
@@ -31,7 +32,17 @@ export function ProductFormFields({
<Controller
control={form.control}
name="title"
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
rules={{ required: 'Укажите название' }}
render={({ field, fieldState }) => (
<TextField
label="Название"
fullWidth
required
{...field}
helperText={fieldState.error?.message}
error={!!fieldState.error}
/>
)}
/>
<Controller
control={form.control}
@@ -73,11 +84,8 @@ export function ProductFormFields({
control={form.control}
name="quantity"
rules={{
validate: (v) => {
const n = Number(v)
if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10'
return true
},
required: 'Укажите количество',
validate: (v) => isValidProductQuantity(v) || 'Целое число от 0 до 10',
}}
render={({ field, fieldState }) => (
<TextField
@@ -99,13 +107,7 @@ export function ProductFormFields({
name="priceRub"
rules={{
required: 'Укажите цену',
validate: (v) => {
const n = Number(v.replace(',', '.'))
if (!Number.isFinite(n) || n <= 0) return 'Цена должна быть больше 0'
if (n > 10_000) return 'Цена не может превышать 10 000 ₽'
if (!Number.isInteger(Math.round(n * 100))) return 'Не более 2 знаков после запятой'
return true
},
validate: (v) => isValidProductPriceRub(v) || 'Цена должна быть от 0,01 до 10 000 ₽, максимум 2 знака',
}}
render={({ field, fieldState }) => (
<TextField
@@ -194,6 +196,7 @@ export function ProductFormFields({
<Controller
control={form.control}
name="categoryId"
rules={{ required: 'Выберите категорию' }}
render={({ field }) => (
<FormControl fullWidth error={!field.value}>
<InputLabel id="cat-label">Категория</InputLabel>
@@ -14,7 +14,7 @@ import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { useForm, useWatch } from 'react-hook-form'
import {
createProduct,
deleteProduct,
@@ -23,7 +23,7 @@ import {
} from '@/entities/product/api/admin-product-api'
import { fetchCategories } from '@/entities/product/api/product-api'
import type { Product } from '@/entities/product/model/types'
import { emptyForm, type FormState } from '@/features/product-form'
import { emptyForm, isValidProductPriceRub, isValidProductQuantity, type FormState } from '@/features/product-form'
import { GalleryImagePicker } from '@/features/product-form/ui/GalleryImagePicker'
import { ProductFormFields } from '@/features/product-form/ui/ProductFormFields'
import { formatPriceRub } from '@/shared/lib/format-price'
@@ -42,7 +42,15 @@ export function AdminProductsPage() {
mode: 'onChange',
})
const titleValue = productForm.watch('title')
const [titleValue, categoryIdValue, quantityValue, priceRubValue, imageUrlsValue] = useWatch({
control: productForm.control,
name: ['title', 'categoryId', 'quantity', 'priceRub', 'imageUrls'],
})
const canSubmit =
titleValue.trim().length > 0 &&
categoryIdValue.trim().length > 0 &&
isValidProductQuantity(quantityValue) &&
isValidProductPriceRub(priceRubValue)
const categoriesQuery = useQuery({
queryKey: ['categories'],
@@ -246,15 +254,7 @@ export function AdminProductsPage() {
<Button
variant="contained"
onClick={handleSubmit}
disabled={
!titleValue.trim() ||
!productForm.watch('categoryId') ||
!productForm.watch('quantity').trim() ||
!productForm.watch('priceRub').trim() ||
!productForm.formState.isValid ||
createMut.isPending ||
updateMut.isPending
}
disabled={!canSubmit || createMut.isPending || updateMut.isPending}
>
{editing ? 'Сохранить' : 'Создать'}
</Button>
@@ -265,7 +265,7 @@ export function AdminProductsPage() {
open={galleryPickOpen}
onClose={() => setGalleryPickOpen(false)}
onSelect={handleGallerySelect}
currentUrls={productForm.watch('imageUrls')}
currentUrls={imageUrlsValue}
/>
</Box>
)
@@ -0,0 +1,77 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createProduct,
deleteProduct,
fetchAdminProducts,
updateProduct,
} from '@/entities/product/api/admin-product-api'
import { fetchCategories } from '@/entities/product/api/product-api'
import type { Category } from '@/entities/product/model/types'
import { AdminProductsPage } from '../AdminProductsPage'
vi.mock('@/entities/product/api/admin-product-api', () => ({
createProduct: vi.fn(),
deleteProduct: vi.fn(),
fetchAdminProducts: vi.fn(),
updateProduct: vi.fn(),
}))
vi.mock('@/entities/product/api/product-api', () => ({
fetchCategories: vi.fn(),
}))
const category: Category = {
id: 'cat-1',
name: 'Игрушки',
slug: 'igrushki',
sort: 0,
}
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<AdminProductsPage />
</QueryClientProvider>,
)
}
describe('AdminProductsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fetchCategories).mockResolvedValue([category])
vi.mocked(fetchAdminProducts).mockResolvedValue([])
vi.mocked(createProduct).mockResolvedValue({} as never)
vi.mocked(updateProduct).mockResolvedValue({} as never)
vi.mocked(deleteProduct).mockResolvedValue(undefined)
})
it('разрешает создать товар после ввода цены и не сбрасывает фокус поля цены', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: 'Новый товар' }))
await user.type(screen.getByLabelText(/Название/), 'Мишка ручной работы')
await user.click(screen.getByLabelText(/Категория/))
await user.click(await screen.findByRole('option', { name: 'Игрушки' }))
const priceInput = screen.getByLabelText(/Цена, ₽/)
await user.click(priceInput)
await user.type(priceInput, '1200')
expect(priceInput).toHaveFocus()
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Создать' })).not.toBeDisabled()
})
})
})