fix(catalog): ProductEditPage — race на currencies.data + читаемая ошибка

Найдено через UI-deep тестирование (Playwright):

Баг 1: race-condition. Если юзер быстро кликает Сохранить до того как
прогрузился справочник currencies, цена-MoneyInput добавляет строку с
currencyId='' (фолбэк `?? ''`). Сервер возвращает 400 с криптичным
JSON-validation: "$.prices[0].currencyId" не парсится.

Фикс: MoneyInput для цен disabled пока !currencies.data; вместо фолбэка
'' возвращаемся из onChange (no-op). canSave дополнительно проверяет
row.currencyId — двойная страховка.

Баг 2: при ошибке сохранения page показывал "Request failed with status
code 400" — generic axios message. Toast при этом показывал
человеко-читаемый текст через humanizeError (api interceptor).

Фикс: exporting humanizeError из @/lib/api, ProductEditPage onError
использует тот же helper. Теперь form-level error == toast-сообщение.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 12:27:07 +05:00
parent 1418c79b04
commit cee92d86ce
2 changed files with 25 additions and 10 deletions

View file

@ -96,7 +96,7 @@ api.interceptors.response.use(
// если страница не отображала mutation.error явно. // если страница не отображала mutation.error явно.
const status = error.response?.status const status = error.response?.status
if (status && status >= 400 && status !== 401 && !original.__silent) { if (status && status >= 400 && status !== 401 && !original.__silent) {
toast.error(humanizeError(error), { title: errorTitle(status) }) toast.error(humanizeAxios(error), { title: errorTitle(status) })
} }
return Promise.reject(error) return Promise.reject(error)
}, },
@ -115,8 +115,18 @@ function errorTitle(status: number): string | undefined {
/** Извлекает человеко-читаемый текст из ответа API. Бэкенд использует /** Извлекает человеко-читаемый текст из ответа API. Бэкенд использует
* ProblemDetails (RFC 7807) с `title`/`detail`/`errors`; иногда отдаёт * ProblemDetails (RFC 7807) с `title`/`detail`/`errors`; иногда отдаёт
* простой `message`/`error`/`error_description`. Падать в крайнем случае * простой `message`/`error`/`error_description`. Падать в крайнем случае
* на статус-текст («Internal Server Error»). */ * на статус-текст («Internal Server Error»). Экспортируется, чтобы
function humanizeError(err: AxiosError): string { * страницы с form-level error display'ем (ProductEditPage и др.) могли
* показывать ту же подсказку, что и toast. */
export function humanizeError(err: AxiosError | Error): string {
if (!('isAxiosError' in err) && !((err as AxiosError).response)) {
// обычный Error (не axios) — возвращаем message
return err.message
}
return humanizeAxios(err as AxiosError)
}
function humanizeAxios(err: AxiosError): string {
const data = err.response?.data as Record<string, unknown> | undefined const data = err.response?.data as Record<string, unknown> | undefined
if (data && typeof data === 'object') { if (data && typeof data === 'object') {
// ASP.NET validation errors: { errors: { Field: ['msg', ...] } } // ASP.NET validation errors: { errors: { Field: ['msg', ...] } }

View file

@ -2,7 +2,7 @@ import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom' import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react' import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
import { api } from '@/lib/api' import { api, humanizeError } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field' import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import { import {
@ -177,10 +177,7 @@ export function ProductEditPage() {
qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }) qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })
navigate(created ? `/catalog/products/${created.id}` : '/catalog/products') navigate(created ? `/catalog/products/${created.id}` : '/catalog/products')
}, },
onError: (e: Error) => { onError: (e: Error) => setError(humanizeError(e)),
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg)
},
meta: { successMessage: 'Сохранено' }, meta: { successMessage: 'Сохранено' },
}) })
@ -213,7 +210,9 @@ export function ProductEditPage() {
.filter((pt) => pt.isRequired) .filter((pt) => pt.isRequired)
.filter((pt) => { .filter((pt) => {
const row = form.prices.find((p) => p.priceTypeId === pt.id) const row = form.prices.find((p) => p.priceTypeId === pt.id)
return !row || row.amount <= 0 // !row.currencyId — гонка currencies.data: если попало пустое значение,
// canSave должен остаться disabled пока currencies не подгрузятся.
return !row || row.amount <= 0 || !row.currencyId
}) })
const canSave = form.name.trim().length > 0 const canSave = form.name.trim().length > 0
@ -447,6 +446,7 @@ export function ProductEditPage() {
<Field key={pt.id} label={`${pt.name}${required ? ' *' : ''}`}> <Field key={pt.id} label={`${pt.name}${required ? ' *' : ''}`}>
<MoneyInput <MoneyInput
value={row?.amount ?? null} value={row?.amount ?? null}
disabled={!currencies.data}
onChange={(n) => { onChange={(n) => {
if (n == null) { if (n == null) {
setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) }) setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) })
@ -455,7 +455,12 @@ export function ProductEditPage() {
if (idx >= 0) { if (idx >= 0) {
updatePrice(idx, { amount: n }) updatePrice(idx, { amount: n })
} else { } else {
const cur = currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? '' // Race-guard: пока currencies.data не загрузились — не
// создаём строку с пустым currencyId (сервер вернёт
// невнятный JSON-validation 400). Поле disabled до
// загрузки, эта ветка — двойная страховка.
const cur = currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id
if (!cur) return
setForm({ ...form, prices: [...form.prices, { priceTypeId: pt.id, amount: n, currencyId: cur }] }) setForm({ ...form, prices: [...form.prices, { priceTypeId: pt.id, amount: n, currencyId: cur }] })
} }
}} }}