fix(ux): friendly error UI вместо raw AxiosError
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import axios from 'axios'
|
||||
import { Alert } from './alert'
|
||||
import { Button } from './button'
|
||||
import { ArrowClockwiseIcon } from '@phosphor-icons/react'
|
||||
|
||||
/**
|
||||
* Friendly error renderer для TanStack Query errors. Замена паттерна
|
||||
* {@code <Alert variant="error">{String(error)}</Alert>} который раздавал
|
||||
* raw {@code "AxiosError: Request failed with status code 503"} в UI.
|
||||
*
|
||||
* <p>Маппинг status → human message:
|
||||
* <ul>
|
||||
* <li>503/502/504 — сервер временно недоступен (rolling restart, GW timeout)</li>
|
||||
* <li>500 — внутренняя ошибка сервера</li>
|
||||
* <li>404 — не найдено</li>
|
||||
* <li>403 — нет доступа</li>
|
||||
* <li>401 — handled global'ным interceptor'ом, но fallback'нем</li>
|
||||
* <li>network err — нет соединения</li>
|
||||
* <li>default — generic failure с raw message только в debug режиме</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Backend code/message парсятся если возвращены: вместо `AxiosError: ...`
|
||||
* показывается {@code body.message} (что backend сам положил). Это работает
|
||||
* только для structured 4xx responses; 5xx обычно plain html error page.
|
||||
*
|
||||
* <p>Retry кнопка — opt-in через {@code onRetry} prop. Caller передаёт
|
||||
* {@code () => query.refetch()}.
|
||||
*/
|
||||
|
||||
export type QueryErrorStateProps = {
|
||||
/** TanStack Query error (или любая Error/unknown) */
|
||||
error: unknown
|
||||
/** Optional retry callback — обычно `() => query.refetch()`. */
|
||||
onRetry?: () => void
|
||||
/** Optional title override (по умолчанию по status code'у). */
|
||||
title?: React.ReactNode
|
||||
/** className для Alert wrapper (e.g. mt-4). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
type ErrorClassification = {
|
||||
titleKey: string
|
||||
titleFallback: string
|
||||
bodyKey: string
|
||||
bodyFallback: string
|
||||
/** Можно retry'нуть. False для permanent errors (404/403). */
|
||||
retryable: boolean
|
||||
/** Backend-provided code (если был structured response). */
|
||||
backendCode?: string
|
||||
/** Backend-provided human message (если был). */
|
||||
backendMessage?: string
|
||||
}
|
||||
|
||||
function classify(error: unknown): ErrorClassification {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
const body = error.response?.data as
|
||||
| { code?: string; message?: string; error?: string }
|
||||
| undefined
|
||||
const backendCode = body?.code
|
||||
const backendMessage = body?.message ?? body?.error
|
||||
|
||||
// Network error (no response, e.g. DNS, offline, CORS preflight failed).
|
||||
if (!error.response) {
|
||||
return {
|
||||
titleKey: 'queryError.network.title',
|
||||
titleFallback: 'Нет соединения с сервером',
|
||||
bodyKey: 'queryError.network.body',
|
||||
bodyFallback: 'Проверьте сеть. Когда соединение восстановится — повторите.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 503 || status === 502 || status === 504) {
|
||||
return {
|
||||
titleKey: 'queryError.unavailable.title',
|
||||
titleFallback: 'Сервер временно недоступен',
|
||||
bodyKey: 'queryError.unavailable.body',
|
||||
bodyFallback:
|
||||
'Возможно идёт обновление или плановое обслуживание. Подождите несколько секунд и повторите.',
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
if (status === 500) {
|
||||
return {
|
||||
titleKey: 'queryError.server.title',
|
||||
titleFallback: 'Внутренняя ошибка сервера',
|
||||
bodyKey: 'queryError.server.body',
|
||||
bodyFallback:
|
||||
'Что-то пошло не так на стороне сервера. Если ошибка повторяется — сообщите администратору.',
|
||||
retryable: true,
|
||||
backendCode,
|
||||
backendMessage,
|
||||
}
|
||||
}
|
||||
if (status === 404) {
|
||||
return {
|
||||
titleKey: 'queryError.notFound.title',
|
||||
titleFallback: 'Не найдено',
|
||||
bodyKey: 'queryError.notFound.body',
|
||||
bodyFallback: 'Запрашиваемые данные не существуют или были удалены.',
|
||||
retryable: false,
|
||||
backendCode,
|
||||
backendMessage,
|
||||
}
|
||||
}
|
||||
if (status === 403) {
|
||||
return {
|
||||
titleKey: 'queryError.forbidden.title',
|
||||
titleFallback: 'Нет доступа',
|
||||
bodyKey: 'queryError.forbidden.body',
|
||||
bodyFallback: 'У вас нет прав на просмотр этих данных.',
|
||||
retryable: false,
|
||||
backendCode,
|
||||
backendMessage,
|
||||
}
|
||||
}
|
||||
if (status === 401) {
|
||||
return {
|
||||
titleKey: 'queryError.unauthorized.title',
|
||||
titleFallback: 'Требуется вход',
|
||||
bodyKey: 'queryError.unauthorized.body',
|
||||
bodyFallback: 'Сессия истекла. Войдите снова чтобы продолжить.',
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return {
|
||||
titleKey: 'queryError.client.title',
|
||||
titleFallback: 'Запрос некорректен',
|
||||
bodyKey: 'queryError.client.body',
|
||||
bodyFallback: backendMessage ?? 'Сервер отклонил запрос.',
|
||||
retryable: false,
|
||||
backendCode,
|
||||
backendMessage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-axios error: Error instance or unknown
|
||||
const msg =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: 'unknown error'
|
||||
return {
|
||||
titleKey: 'queryError.generic.title',
|
||||
titleFallback: 'Не удалось загрузить данные',
|
||||
bodyKey: 'queryError.generic.body',
|
||||
bodyFallback: msg,
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function QueryErrorState({
|
||||
error,
|
||||
onRetry,
|
||||
title,
|
||||
className,
|
||||
}: QueryErrorStateProps) {
|
||||
const { t } = useTranslation()
|
||||
const c = classify(error)
|
||||
|
||||
// Backend message приоритетней generic — если backend сказал что-то
|
||||
// понятное в body.message, покажем это. Иначе fallback.
|
||||
const body = c.backendMessage
|
||||
? c.backendMessage
|
||||
: t(c.bodyKey, { defaultValue: c.bodyFallback })
|
||||
// Alert.title типизирован как HTML title (string) из-за HTMLAttributes
|
||||
// intersection с custom React.ReactNode. Stringify чтобы TS не ругался —
|
||||
// в practice title всегда plain string label.
|
||||
const titleStr =
|
||||
typeof title === 'string'
|
||||
? title
|
||||
: String(title ?? t(c.titleKey, { defaultValue: c.titleFallback }))
|
||||
|
||||
return (
|
||||
<Alert
|
||||
variant="error"
|
||||
title={titleStr}
|
||||
className={className}
|
||||
action={
|
||||
c.retryable && onRetry ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
leftIcon={<ArrowClockwiseIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('queryError.retry', { defaultValue: 'Повторить' })}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="text-body">{body}</div>
|
||||
{c.backendCode && (
|
||||
<div className="text-cap text-mute font-mono uppercase tracking-wider">
|
||||
code: {c.backendCode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user