fix(ux): friendly error UI вместо raw AxiosError
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
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 { useChangelogDiff } from '@/api/queries'
|
||||||
import { kindBadgeVariant, kindLabel } from './kind-meta'
|
import { kindBadgeVariant, kindLabel } from './kind-meta'
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export function ChangelogDiffModal({
|
|||||||
entryId: number | undefined
|
entryId: number | undefined
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data, isLoading, error } = useChangelogDiff(
|
const { data, isLoading, error, refetch } = useChangelogDiff(
|
||||||
open ? dictionaryName : undefined,
|
open ? dictionaryName : undefined,
|
||||||
open ? entryId : undefined,
|
open ? entryId : undefined,
|
||||||
)
|
)
|
||||||
@@ -46,11 +46,7 @@ export function ChangelogDiffModal({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
{error && (
|
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
|
||||||
{String(error)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{data && (
|
{data && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header strip — kind + author */}
|
{/* Header strip — kind + author */}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from 'd3-force'
|
} from 'd3-force'
|
||||||
import { useDictionaries, dictionaryDependentsQuery, recordsQuery } from '@/api/queries'
|
import { useDictionaries, dictionaryDependentsQuery, recordsQuery } from '@/api/queries'
|
||||||
import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
|
import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
|
||||||
import { GlobeLoader, LoadingBlock } from '@/ui'
|
import { GlobeLoader, LoadingBlock, QueryErrorState } from '@/ui'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
// 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не
|
// 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не
|
||||||
@@ -165,7 +165,7 @@ export function DictionaryGraph() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dictsQuery.error) {
|
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) {
|
if (dicts.length === 0) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link } from '@tanstack/react-router'
|
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 { CaretLeftIcon, CaretRightIcon, ArrowSquareOutIcon } from '@phosphor-icons/react'
|
||||||
import { useRecordDependents } from '@/api/queries'
|
import { useRecordDependents } from '@/api/queries'
|
||||||
import type { OnCloseAction } from '@/api/client'
|
import type { OnCloseAction } from '@/api/client'
|
||||||
@@ -32,7 +32,7 @@ export const RecordDependentsPanel = ({ dictionaryName, businessKey }: Props) =>
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const size = 20
|
const size = 20
|
||||||
const { data, isLoading, error } = useRecordDependents(
|
const { data, isLoading, error, refetch } = useRecordDependents(
|
||||||
dictionaryName,
|
dictionaryName,
|
||||||
businessKey,
|
businessKey,
|
||||||
page,
|
page,
|
||||||
@@ -42,11 +42,7 @@ export const RecordDependentsPanel = ({ dictionaryName, businessKey }: Props) =>
|
|||||||
if (!businessKey) return null
|
if (!businessKey) return null
|
||||||
if (isLoading) return <LoadingBlock size="sm" label={t('loading')} />
|
if (isLoading) return <LoadingBlock size="sm" label={t('loading')} />
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <QueryErrorState error={error} onRetry={() => refetch()} />
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
|
||||||
{String(error)}
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (!data || data.total === 0) return null
|
if (!data || data.total === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { ArrowsLeftRightIcon, ArrowCounterClockwiseIcon, BracketsCurlyIcon, InfoIcon } from '@phosphor-icons/react'
|
||||||
import { useRecordHistory } from '@/api/queries'
|
import { useRecordHistory } from '@/api/queries'
|
||||||
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
|
import { RecordDependentsPanel } from '@/components/lineage/RecordDependentsPanel'
|
||||||
@@ -23,7 +23,7 @@ export const RecordHistoryDrawer = ({
|
|||||||
open, onClose, dictionaryName, businessKey, onCompare, onRevert,
|
open, onClose, dictionaryName, businessKey, onCompare, onRevert,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation()
|
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> чтобы хранить
|
// Per-row collapsible JSON viewer — выносим из <details> чтобы хранить
|
||||||
// expanded state поверх renders.
|
// expanded state поверх renders.
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||||
@@ -48,11 +48,7 @@ export const RecordHistoryDrawer = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
{error && (
|
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
|
||||||
{String(error)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{data && data.length === 0 && (
|
{data && data.length === 0 && (
|
||||||
<p className="text-body text-mute">{t('history.empty')}</p>
|
<p className="text-body text-mute">{t('history.empty')}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import axios from 'axios'
|
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 {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
PaperPlaneTiltIcon,
|
PaperPlaneTiltIcon,
|
||||||
@@ -69,7 +69,7 @@ export function SchemaDraftDrawer({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const { data: draft, isLoading, error } = useDictActiveSchemaDraft(
|
const { data: draft, isLoading, error, refetch } = useDictActiveSchemaDraft(
|
||||||
open ? dictionaryName : undefined,
|
open ? dictionaryName : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,11 +204,7 @@ export function SchemaDraftDrawer({
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
{isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
{error && (
|
{error && <QueryErrorState error={error} onRetry={() => refetch()} />}
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
|
||||||
{String(error)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{!isLoading && !draft && (
|
{!isLoading && !draft && (
|
||||||
<p className="text-body text-mute">
|
<p className="text-body text-mute">
|
||||||
{t('workflow.schemaDraft.empty', {
|
{t('workflow.schemaDraft.empty', {
|
||||||
|
|||||||
@@ -183,6 +183,24 @@ i18n
|
|||||||
'webhooks.stats.successRateOf_one': 'из {{count}} завершённой',
|
'webhooks.stats.successRateOf_one': 'из {{count}} завершённой',
|
||||||
'webhooks.stats.successRateOf_few': 'из {{count}} завершённых',
|
'webhooks.stats.successRateOf_few': 'из {{count}} завершённых',
|
||||||
'webhooks.stats.successRateOf_many': 'из {{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.eventId': 'Event ID',
|
||||||
'webhooks.deliveries.col.attempts': 'Попыток',
|
'webhooks.deliveries.col.attempts': 'Попыток',
|
||||||
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
|
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
|
||||||
@@ -830,6 +848,24 @@ i18n
|
|||||||
'webhooks.stats.successRate': 'Success rate',
|
'webhooks.stats.successRate': 'Success rate',
|
||||||
'webhooks.stats.successRateOf_one': 'of {{count}} terminal',
|
'webhooks.stats.successRateOf_one': 'of {{count}} terminal',
|
||||||
'webhooks.stats.successRateOf_other': '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.eventId': 'Event ID',
|
||||||
'webhooks.deliveries.col.attempts': 'Attempts',
|
'webhooks.deliveries.col.attempts': 'Attempts',
|
||||||
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
|
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
QueryErrorState,
|
||||||
SingleSelect,
|
SingleSelect,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -266,9 +266,7 @@ function AuditPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
<QueryErrorState error={error} onRetry={() => refetch()} />
|
||||||
{String(error)}
|
|
||||||
</Alert>
|
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<LoadingBlock size="md" label={t('loading')} />
|
<LoadingBlock size="md" label={t('loading')} />
|
||||||
) : !data || data.content.length === 0 ? (
|
) : !data || data.content.length === 0 ? (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
Modal,
|
Modal,
|
||||||
|
QueryErrorState,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -629,11 +630,7 @@ function DictionaryDetail() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (detailQuery.error) {
|
if (detailQuery.error) {
|
||||||
return (
|
return <QueryErrorState error={detailQuery.error} onRetry={() => detailQuery.refetch()} />
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
|
||||||
{String(detailQuery.error)}
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { useDeferredValue, useMemo, useState } from 'react'
|
import { useDeferredValue, useMemo, useState } from 'react'
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { PlusIcon } from '@phosphor-icons/react'
|
||||||
import { useQueries } from '@tanstack/react-query'
|
import { useQueries } from '@tanstack/react-query'
|
||||||
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents, useRecords } from '@/api/queries'
|
import { dictionaryDependentsQuery, useDictionaries, useDictionaryDependents, useRecords } from '@/api/queries'
|
||||||
@@ -94,7 +94,7 @@ function DictionariesPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate({ from: '/dictionaries/' })
|
const navigate = useNavigate({ from: '/dictionaries/' })
|
||||||
const search = Route.useSearch()
|
const search = Route.useSearch()
|
||||||
const { data, isLoading, error } = useDictionaries()
|
const { data, isLoading, error, refetch } = useDictionaries()
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const canMutate = useCanMutate()
|
const canMutate = useCanMutate()
|
||||||
|
|
||||||
@@ -193,11 +193,7 @@ function DictionariesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <QueryErrorState error={error} onRetry={() => refetch()} />
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
|
||||||
{String(error)}
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { useState } from 'react'
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
QueryErrorState,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -64,9 +64,7 @@ function OutboxPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dlq.error ? (
|
{dlq.error ? (
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
<QueryErrorState error={dlq.error} onRetry={() => dlq.refetch()} />
|
||||||
{String(dlq.error)}
|
|
||||||
</Alert>
|
|
||||||
) : dlq.isLoading ? (
|
) : dlq.isLoading ? (
|
||||||
<LoadingBlock size="md" label={t('loading')} />
|
<LoadingBlock size="md" label={t('loading')} />
|
||||||
) : !dlq.data || dlq.data.content.length === 0 ? (
|
) : !dlq.data || dlq.data.content.length === 0 ? (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
QueryErrorState,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||||
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
|
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
|
||||||
@@ -204,9 +205,7 @@ function ReviewsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'records' && queue.error && (
|
{activeTab === 'records' && queue.error && (
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
<QueryErrorState error={queue.error} onRetry={() => queue.refetch()} />
|
||||||
{String(queue.error)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'records' && bulkResult && (
|
{activeTab === 'records' && bulkResult && (
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { useState } from 'react'
|
|||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Badge,
|
Badge,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Panel,
|
Panel,
|
||||||
|
QueryErrorState,
|
||||||
SearchInput,
|
SearchInput,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { useSearch } from '@/api/queries'
|
import { useSearch } from '@/api/queries'
|
||||||
@@ -78,9 +78,7 @@ function SearchPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasQuery && result.error && (
|
{hasQuery && result.error && (
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
<QueryErrorState error={result.error} onRetry={() => result.refetch()} />
|
||||||
{String(result.error)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasQuery && result.data && result.data.total === 0 && (
|
{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 { Button, buttonVariants, type ButtonProps } from './components/button'
|
||||||
export { Badge, type BadgeProps } from './components/badge'
|
export { Badge, type BadgeProps } from './components/badge'
|
||||||
export { Alert, type AlertProps } from './components/alert'
|
export { Alert, type AlertProps } from './components/alert'
|
||||||
|
export {
|
||||||
|
QueryErrorState,
|
||||||
|
type QueryErrorStateProps,
|
||||||
|
} from './components/query-error'
|
||||||
export { IconButton, type IconButtonProps } from './components/icon-button'
|
export { IconButton, type IconButtonProps } from './components/icon-button'
|
||||||
export { Input, type InputProps } from './components/input'
|
export { Input, type InputProps } from './components/input'
|
||||||
export { Label } from './components/label'
|
export { Label } from './components/label'
|
||||||
|
|||||||
Reference in New Issue
Block a user