fix(ux): friendly error UI вместо raw AxiosError
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, Badge, LoadingBlock, Modal } from '@/ui'
|
||||
import { Badge, LoadingBlock, Modal, QueryErrorState } from '@/ui'
|
||||
import { useChangelogDiff } from '@/api/queries'
|
||||
import { kindBadgeVariant, kindLabel } from './kind-meta'
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ChangelogDiffModal({
|
||||
entryId: number | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading, error } = useChangelogDiff(
|
||||
const { data, isLoading, error, refetch } = useChangelogDiff(
|
||||
open ? dictionaryName : undefined,
|
||||
open ? entryId : undefined,
|
||||
)
|
||||
@@ -46,11 +46,7 @@ export function ChangelogDiffModal({
|
||||
}
|
||||
>
|
||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||
{error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
{/* Header strip — kind + author */}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from 'd3-force'
|
||||
import { useDictionaries, dictionaryDependentsQuery, recordsQuery } from '@/api/queries'
|
||||
import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
|
||||
import { GlobeLoader, LoadingBlock } from '@/ui'
|
||||
import { GlobeLoader, LoadingBlock, QueryErrorState } from '@/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не
|
||||
@@ -165,7 +165,7 @@ export function DictionaryGraph() {
|
||||
}
|
||||
|
||||
if (dictsQuery.error) {
|
||||
return <div className="text-body text-pink">Ошибка загрузки: {String(dictsQuery.error)}</div>
|
||||
return <QueryErrorState error={dictsQuery.error} onRetry={() => dictsQuery.refetch()} />
|
||||
}
|
||||
|
||||
if (dicts.length === 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Alert, Badge, Button, LoadingBlock } from '@/ui'
|
||||
import { Badge, Button, LoadingBlock, QueryErrorState } from '@/ui'
|
||||
import { CaretLeftIcon, CaretRightIcon, ArrowSquareOutIcon } from '@phosphor-icons/react'
|
||||
import { useRecordDependents } from '@/api/queries'
|
||||
import type { OnCloseAction } from '@/api/client'
|
||||
@@ -32,7 +32,7 @@ export const RecordDependentsPanel = ({ dictionaryName, businessKey }: Props) =>
|
||||
const { t } = useTranslation()
|
||||
const [page, setPage] = useState(0)
|
||||
const size = 20
|
||||
const { data, isLoading, error } = useRecordDependents(
|
||||
const { data, isLoading, error, refetch } = useRecordDependents(
|
||||
dictionaryName,
|
||||
businessKey,
|
||||
page,
|
||||
@@ -42,11 +42,7 @@ export const RecordDependentsPanel = ({ dictionaryName, businessKey }: Props) =>
|
||||
if (!businessKey) return null
|
||||
if (isLoading) return <LoadingBlock size="sm" label={t('loading')} />
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
)
|
||||
return <QueryErrorState error={error} onRetry={() => refetch()} />
|
||||
}
|
||||
if (!data || data.total === 0) return null
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, Badge, Button, Drawer, LoadingBlock } from '@/ui'
|
||||
import { Badge, Button, Drawer, LoadingBlock, QueryErrorState } from '@/ui'
|
||||
import { ArrowsLeftRightIcon, ArrowCounterClockwiseIcon, BracketsCurlyIcon, InfoIcon } from '@phosphor-icons/react'
|
||||
import { useRecordHistory } from '@/api/queries'
|
||||
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
|
||||
@@ -23,7 +23,7 @@ export const RecordHistoryDrawer = ({
|
||||
open, onClose, dictionaryName, businessKey, onCompare, onRevert,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading, error } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
|
||||
const { data, isLoading, error, refetch } = useRecordHistory(dictionaryName, open ? businessKey : undefined)
|
||||
// Per-row collapsible JSON viewer — выносим из <details> чтобы хранить
|
||||
// expanded state поверх renders.
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
@@ -48,11 +48,7 @@ export const RecordHistoryDrawer = ({
|
||||
)}
|
||||
|
||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||
{error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-body text-mute">{t('history.empty')}</p>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import axios from 'axios'
|
||||
import { Alert, Badge, Button, Drawer, LoadingBlock, TextArea, toast } from '@/ui'
|
||||
import { Badge, Button, Drawer, LoadingBlock, QueryErrorState, TextArea, toast } from '@/ui'
|
||||
import {
|
||||
CheckIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
@@ -69,7 +69,7 @@ export function SchemaDraftDrawer({
|
||||
const { t } = useTranslation()
|
||||
const [comment, setComment] = useState('')
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const { data: draft, isLoading, error } = useDictActiveSchemaDraft(
|
||||
const { data: draft, isLoading, error, refetch } = useDictActiveSchemaDraft(
|
||||
open ? dictionaryName : undefined,
|
||||
)
|
||||
|
||||
@@ -204,11 +204,7 @@ export function SchemaDraftDrawer({
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||
{error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
|
||||
{!isLoading && !draft && (
|
||||
<p className="text-body text-mute">
|
||||
{t('workflow.schemaDraft.empty', {
|
||||
|
||||
@@ -183,6 +183,24 @@ i18n
|
||||
'webhooks.stats.successRateOf_one': 'из {{count}} завершённой',
|
||||
'webhooks.stats.successRateOf_few': 'из {{count}} завершённых',
|
||||
'webhooks.stats.successRateOf_many': 'из {{count}} завершённых',
|
||||
// === Friendly query error states ===
|
||||
'queryError.retry': 'Повторить',
|
||||
'queryError.network.title': 'Нет соединения с сервером',
|
||||
'queryError.network.body': 'Проверьте сеть. Когда соединение восстановится — повторите.',
|
||||
'queryError.unavailable.title': 'Сервер временно недоступен',
|
||||
'queryError.unavailable.body': 'Возможно идёт обновление или плановое обслуживание. Подождите несколько секунд и повторите.',
|
||||
'queryError.server.title': 'Внутренняя ошибка сервера',
|
||||
'queryError.server.body': 'Что-то пошло не так на стороне сервера. Если ошибка повторяется — сообщите администратору.',
|
||||
'queryError.notFound.title': 'Не найдено',
|
||||
'queryError.notFound.body': 'Запрашиваемые данные не существуют или были удалены.',
|
||||
'queryError.forbidden.title': 'Нет доступа',
|
||||
'queryError.forbidden.body': 'У вас нет прав на просмотр этих данных.',
|
||||
'queryError.unauthorized.title': 'Требуется вход',
|
||||
'queryError.unauthorized.body': 'Сессия истекла. Войдите снова чтобы продолжить.',
|
||||
'queryError.client.title': 'Запрос некорректен',
|
||||
'queryError.client.body': 'Сервер отклонил запрос.',
|
||||
'queryError.generic.title': 'Не удалось загрузить данные',
|
||||
'queryError.generic.body': 'Что-то пошло не так. Попробуйте ещё раз.',
|
||||
'webhooks.deliveries.col.eventId': 'Event ID',
|
||||
'webhooks.deliveries.col.attempts': 'Попыток',
|
||||
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
|
||||
@@ -830,6 +848,24 @@ i18n
|
||||
'webhooks.stats.successRate': 'Success rate',
|
||||
'webhooks.stats.successRateOf_one': 'of {{count}} terminal',
|
||||
'webhooks.stats.successRateOf_other': 'of {{count}} terminal',
|
||||
// === Friendly query error states ===
|
||||
'queryError.retry': 'Retry',
|
||||
'queryError.network.title': 'No connection to server',
|
||||
'queryError.network.body': 'Check your network. Once connected, retry.',
|
||||
'queryError.unavailable.title': 'Server temporarily unavailable',
|
||||
'queryError.unavailable.body': 'Possibly an update or scheduled maintenance. Wait a few seconds and retry.',
|
||||
'queryError.server.title': 'Internal server error',
|
||||
'queryError.server.body': 'Something went wrong on the server side. If it keeps happening, please contact the admin.',
|
||||
'queryError.notFound.title': 'Not found',
|
||||
'queryError.notFound.body': 'The requested data does not exist or has been removed.',
|
||||
'queryError.forbidden.title': 'Access denied',
|
||||
'queryError.forbidden.body': 'You do not have permission to view this data.',
|
||||
'queryError.unauthorized.title': 'Login required',
|
||||
'queryError.unauthorized.body': 'Session expired. Sign in again to continue.',
|
||||
'queryError.client.title': 'Request rejected',
|
||||
'queryError.client.body': 'The server rejected the request.',
|
||||
'queryError.generic.title': 'Failed to load data',
|
||||
'queryError.generic.body': 'Something went wrong. Please try again.',
|
||||
'webhooks.deliveries.col.eventId': 'Event ID',
|
||||
'webhooks.deliveries.col.attempts': 'Attempts',
|
||||
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
DatePicker,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
IconButton,
|
||||
LoadingBlock,
|
||||
PageHeader,
|
||||
QueryErrorState,
|
||||
SingleSelect,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -266,9 +266,7 @@ function AuditPage() {
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
<QueryErrorState error={error} onRetry={() => refetch()} />
|
||||
) : isLoading ? (
|
||||
<LoadingBlock size="md" label={t('loading')} />
|
||||
) : !data || data.content.length === 0 ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
IconButton,
|
||||
LoadingBlock,
|
||||
Modal,
|
||||
QueryErrorState,
|
||||
SearchInput,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -629,11 +630,7 @@ function DictionaryDetail() {
|
||||
)
|
||||
}
|
||||
if (detailQuery.error) {
|
||||
return (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(detailQuery.error)}
|
||||
</Alert>
|
||||
)
|
||||
return <QueryErrorState error={detailQuery.error} onRetry={() => detailQuery.refetch()} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { useDeferredValue, useMemo, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@/ui'
|
||||
import { Badge, Button, EmptyState, LoadingBlock, PageHeader, QueryErrorState } from '@/ui'
|
||||
import { PlusIcon } from '@phosphor-icons/react'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents, useRecords } from '@/api/queries'
|
||||
@@ -94,7 +94,7 @@ function DictionariesPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate({ from: '/dictionaries/' })
|
||||
const search = Route.useSearch()
|
||||
const { data, isLoading, error } = useDictionaries()
|
||||
const { data, isLoading, error, refetch } = useDictionaries()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const canMutate = useCanMutate()
|
||||
|
||||
@@ -193,11 +193,7 @@ function DictionariesPage() {
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(error)}
|
||||
</Alert>
|
||||
)
|
||||
return <QueryErrorState error={error} onRetry={() => refetch()} />
|
||||
}
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
EmptyState,
|
||||
LoadingBlock,
|
||||
PageHeader,
|
||||
QueryErrorState,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -64,9 +64,7 @@ function OutboxPage() {
|
||||
</div>
|
||||
|
||||
{dlq.error ? (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(dlq.error)}
|
||||
</Alert>
|
||||
<QueryErrorState error={dlq.error} onRetry={() => dlq.refetch()} />
|
||||
) : dlq.isLoading ? (
|
||||
<LoadingBlock size="md" label={t('loading')} />
|
||||
) : !dlq.data || dlq.data.content.length === 0 ? (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Tabs,
|
||||
TextArea,
|
||||
TextInput,
|
||||
QueryErrorState,
|
||||
} from '@/ui'
|
||||
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
|
||||
@@ -204,9 +205,7 @@ function ReviewsPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'records' && queue.error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(queue.error)}
|
||||
</Alert>
|
||||
<QueryErrorState error={queue.error} onRetry={() => queue.refetch()} />
|
||||
)}
|
||||
|
||||
{activeTab === 'records' && bulkResult && (
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useState } from 'react'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
EmptyState,
|
||||
LoadingBlock,
|
||||
PageHeader,
|
||||
Panel,
|
||||
QueryErrorState,
|
||||
SearchInput,
|
||||
} from '@/ui'
|
||||
import { useSearch } from '@/api/queries'
|
||||
@@ -78,9 +78,7 @@ function SearchPage() {
|
||||
)}
|
||||
|
||||
{hasQuery && result.error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{String(result.error)}
|
||||
</Alert>
|
||||
<QueryErrorState error={result.error} onRetry={() => result.refetch()} />
|
||||
)}
|
||||
|
||||
{hasQuery && result.data && result.data.total === 0 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,10 @@
|
||||
export { Button, buttonVariants, type ButtonProps } from './components/button'
|
||||
export { Badge, type BadgeProps } from './components/badge'
|
||||
export { Alert, type AlertProps } from './components/alert'
|
||||
export {
|
||||
QueryErrorState,
|
||||
type QueryErrorStateProps,
|
||||
} from './components/query-error'
|
||||
export { IconButton, type IconButtonProps } from './components/icon-button'
|
||||
export { Input, type InputProps } from './components/input'
|
||||
export { Label } from './components/label'
|
||||
|
||||
Reference in New Issue
Block a user