From cee92d86ce5aa1a791f922ecafb2c0c5cb553afd Mon Sep 17 00:00:00 2001 From: nns Date: Sat, 30 May 2026 12:27:07 +0500 Subject: [PATCH] =?UTF-8?q?fix(catalog):=20ProductEditPage=20=E2=80=94=20r?= =?UTF-8?q?ace=20=D0=BD=D0=B0=20currencies.data=20+=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D0=BC=D0=B0=D1=8F=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено через 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 --- src/food-market.web/src/lib/api.ts | 16 +++++++++++++--- .../src/pages/ProductEditPage.tsx | 19 ++++++++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/food-market.web/src/lib/api.ts b/src/food-market.web/src/lib/api.ts index f18a8f8..79b3ada 100644 --- a/src/food-market.web/src/lib/api.ts +++ b/src/food-market.web/src/lib/api.ts @@ -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 | undefined if (data && typeof data === 'object') { // ASP.NET validation errors: { errors: { Field: ['msg', ...] } } diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index f3b5d2c..5c85891 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -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() { { 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 }] }) } }}