208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
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>
|
||
)
|
||
}
|