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:
parent
1418c79b04
commit
cee92d86ce
|
|
@ -96,7 +96,7 @@ api.interceptors.response.use(
|
|||
// если страница не отображала mutation.error явно.
|
||||
const status = error.response?.status
|
||||
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)
|
||||
},
|
||||
|
|
@ -115,8 +115,18 @@ function errorTitle(status: number): string | undefined {
|
|||
/** Извлекает человеко-читаемый текст из ответа API. Бэкенд использует
|
||||
* ProblemDetails (RFC 7807) с `title`/`detail`/`errors`; иногда отдаёт
|
||||
* простой `message`/`error`/`error_description`. Падать в крайнем случае
|
||||
* — на статус-текст («Internal Server Error»). */
|
||||
function humanizeError(err: AxiosError): string {
|
||||
* — на статус-текст («Internal Server Error»). Экспортируется, чтобы
|
||||
* страницы с 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
|
||||
if (data && typeof data === 'object') {
|
||||
// ASP.NET validation errors: { errors: { Field: ['msg', ...] } }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
|
|||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||
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 { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
||||
import {
|
||||
|
|
@ -177,10 +177,7 @@ export function ProductEditPage() {
|
|||
qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })
|
||||
navigate(created ? `/catalog/products/${created.id}` : '/catalog/products')
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
|
||||
setError(msg)
|
||||
},
|
||||
onError: (e: Error) => setError(humanizeError(e)),
|
||||
meta: { successMessage: 'Сохранено' },
|
||||
})
|
||||
|
||||
|
|
@ -213,7 +210,9 @@ export function ProductEditPage() {
|
|||
.filter((pt) => pt.isRequired)
|
||||
.filter((pt) => {
|
||||
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
|
||||
|
|
@ -447,6 +446,7 @@ export function ProductEditPage() {
|
|||
<Field key={pt.id} label={`${pt.name}${required ? ' *' : ''}`}>
|
||||
<MoneyInput
|
||||
value={row?.amount ?? null}
|
||||
disabled={!currencies.data}
|
||||
onChange={(n) => {
|
||||
if (n == null) {
|
||||
setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) })
|
||||
|
|
@ -455,7 +455,12 @@ export function ProductEditPage() {
|
|||
if (idx >= 0) {
|
||||
updatePrice(idx, { amount: n })
|
||||
} 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 }] })
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
Loading…
Reference in a new issue