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 явно.
|
// если страница не отображала 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', ...] } }
|
||||||
|
|
|
||||||
|
|
@ -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 }] })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue