GrResponseErrorBanner
Берут, когда запрос упал.
Когда брать
- запрос упал — сеть, 500, 422 с ошибками полей: тип ошибки определяется сам и меняет заголовок;
- ошибок по полям несколько — они печатаются списком с подписями вместо одной общей фразы;
- запрос можно повторить —
canRetryдаёт кнопку рядом с сообщением; - нужен статус HTTP — бейдж кода помогает поддержке, не мешая обычному пользователю.
Когда взять другое
| Нужно | Берите |
|---|---|
| Сообщение не про ответ сервера | GrAlert |
| Короткое уведомление о результате | GrToaster |
| Ошибка одного поля формы | GrFormField |
| Ошибка внутри диалога | GrConfirmDialog / GrPromptDialog |
| Данных нет, но это не ошибка | GrEmptyState |
Три слоя
normalizeError— приводит axios /fetch Response/XMLHttpRequest/ голыйError/ строку к общему виду: статус, разобранное тело, заголовки, признаки отмены и сетевой ошибки. ТелоResponseчитается клоном, поэтому потребитель может прочитать его сам;- цепочка парсеров — от нормализованного контекста к
ResponseErrorInfo(kind,message,details,fieldErrors). Порядок значим, парсер может остановить цепочку (stop); - баннер — показ: тон по
kind, тексты из локали, дедупликация деталей.
Цепочка парсеров
По умолчанию — универсальное ядро (coreResponseErrorParsers): abort, сеть,
HTTP-статус, plain-сообщение. Оно работает на транспортном уровне и не делает
допущений о формате тела, поэтому не срабатывает ложно на «почти-Laravel»
ответах.
Серверо-специфичные парсеры подключаются осознанно:
const { setRaw } = useResponseError({
parsers: () => extendDefaultParsers([myParser]),
})
// или через builder в опциях диалогов и баннера
errorParsers: presets => [...presets.core, presets.laravel]
Готовые пресеты: laravel, problemDetails (RFC 7807), jsonApi,
fileValidation (клиентская проверка файлов из GrFileUpload).
Тексты, i18n и флаг фолбэка
Тексты берутся из локали (gr.responseError.*), поверх — проп texts.
Подпись бейджа статуса — тот же механизм: statusLabel со вставкой
{status}.
Когда ни один парсер не дал сообщения, его подставляет классификатор и
помечает isFallbackMessage: true. Баннер заменяет переводом только такое
сообщение. Опознание фолбэка сравнением строк, которое здесь было раньше,
выбрасывало ответ сервера, если тот дословно совпал с дефолтом (а
"Network error." сервер вернуть вполне может).
Отсюда контракт парсера: message заполняется только тем, что нашлось в
ответе. Общий текст по kind — работа классификатора, и парсеру не надо его
подставлять; иначе сообщение приедет без флага, и перевести его будет уже
нельзя. Парсеры, знающие только тип ошибки (httpStatus, abort, network),
сообщение не заполняют вовсе.
Сообщение транспортной ошибки (Request failed with status 500 у axios)
сообщением сервера не считается: оно берётся, только если ответа не было
вовсе. Иначе пользователь видел бы техническую фразу на английском вместо
переведённого текста.
Тон и доступность
kind → тон по умолчанию: validation/client — warning, network/
server/unknown — danger, aborted — info. Перебивается точечно
(toneByKind) или целиком (tone).
Роль наследуется от GrAlert: warning и danger объявляются как
role="alert" (перебивают речь диктора), остальные — role="status" (ждут
паузы). Отдельной настройки у баннера нет намеренно — она есть у GrAlert.
autoHideKinds прячет баннер целиком для перечисленных kind: типовой случай
— тихо проглатывать aborted.
Пресеты
GrFormErrorBanner и GrUploadErrorBanner — тонкие обёртки с преднастройками:
формам не нужен повтор и нужны подписи полей, загрузке нужен повтор и контекст
файлов (его пресет отдаёт в retry как { error, files } — базовый баннер
отдаёт саму ошибку).
Своей разметки они не добавляют и живут в папке базового компонента осознанно: отдельная единица гранулярности не даст ничего — ни своего CSS, ни своего safelist, — а стоить будет двух лишних entry в сборке. Импортируются из корня пакета.
Playground 8
Загружается…
<GrResponseErrorBanner />Установка
npm i @feugene/granularityИмпорт
import { GrResponseErrorBanner } from '@feugene/granularity/components/GrResponseErrorBanner'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | Жёсткий оверрайд тона. Бьёт `toneByKind`. |
texts | Partial<ResponseErrorTexts> | undefined | {} | Частичный override текстов. Мерджится с `DEFAULT_RESPONSE_ERROR_TEXTS`. |
toneByKind | Partial<Record<ResponseErrorKind, "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure">> | undefined | {} | Маппинг `kind` → `tone`. Мерджится с `DEFAULT_TONE_BY_KIND`. |
showDetails | boolean | undefined | true | Показывать ли блок `details`/`fieldErrors` под основным сообщением. |
showFieldLabels | boolean | undefined | true | Префиксить ли `field:` перед сообщениями по полям. |
fieldLabels | Record<string, string> | undefined | {} | Человекочитаемые подписи полей (`{ file: 'Файл' }`). |
dedupeDetails | boolean | undefined | true | Удалять дубликаты сообщений между `message` и `details`. |
canRetry | boolean | undefined | false | Показывать ли кнопку «Повторить». |
canDismiss | boolean | undefined | true | Показывать ли кнопку «Скрыть». |
autoHideKinds | ResponseErrorKind[] | undefined | DEFAULT_AUTO_HIDE_KINDS | Какие `kind` не рендерить вообще (баннер скрывается). |
showStatus | boolean | undefined | true | Показывать ли HTTP-status бейдж. |
testIdPrefix | string | undefined | "response-error" | Префикс `data-testid`. |
errorобязательный | ResponseErrorInfo | null | — | Готовая структура ошибки. Если `null` — баннер не рендерится. |
Events
| Event | Type | Описание |
|---|---|---|
retry | [error: ResponseErrorInfo] | — |
dismiss | [] | — |
Примеры 5
Минимум обвязки
Поймали ошибку запроса — отдали её баннеру. Разбирать ответ, выбирать тон и писать текст не нужно: HTTP 409 сам станет предупреждением с сообщением сервера и кнопкой повтора того же тона.
<script setup lang="ts">
import { GrButton, GrResponseErrorBanner, useResponseError } from '@feugene/granularity'
const { currentError, setRaw, dismiss } = useResponseError()
async function loadReport() {
try {
// На месте этой строки был бы `await fetch('/api/reports/42')`; ответ собран
// здесь, чтобы демо работало на витрине без бэкенда.
const response = new Response('{"message":"Отчёт ещё не готов: расчёт закончится через 2 минуты"}', {
status: 409,
headers: { 'content-type': 'application/json' },
})
if (!response.ok)
throw response
}
catch (error) {
await setRaw(error)
}
}
</script>
<template>
<div class="grid gap-3">
<GrButton size="sm" class="justify-self-start" @click="loadReport">
Загрузить отчёт
</GrButton>
<GrResponseErrorBanner
:error="currentError"
can-retry
@retry="loadReport"
@dismiss="dismiss"
/>
</div>
</template>Универсальный баннер — пресеты ошибок
Классификация и отображение разных типов ошибок (network, abort, Laravel/JSON:API validation, RFC 7807, client/server, file validation, plain string) через useResponseError(). Фейковые классы ошибок в демо — стенд, а не часть обвязки: сниппет скрыт, минимальный вариант выше.
Current ResponseErrorInfo (JSON)
—
—
Фильтрация по `kind` — баннер реагирует только на нужные ошибки
Whitelist через autoHideKinds: разрешаем network и validation (включая Laravel 422 с errors). Остальные ошибки (client, server, aborted) тихо проглатываются — setRaw() возвращает null и баннер не рендерится. Чекбоксы в демо позволяют менять whitelist на лету.
—
GrUploadErrorBanner — пресет для загрузки файлов
Тонкая обёртка над GrResponseErrorBanner с текстами под «загрузка», canRetry=true и опциональным prop files, попадающим в payload события retry.
Thin wrapper: text preset for "upload", canRetry=true, optional `files` prop in the retry payload.
—
<script setup lang="ts">
import { shallowRef } from 'vue'
import {
GrButton,
GrCard,
GrUploadErrorBanner,
type ResponseErrorInfo,
useResponseError,
} from '@feugene/granularity'
class FakeHttpError extends Error {
isAxiosError = true
response: { status: number, data: unknown, headers?: Record<string, string> }
constructor(status: number, data: unknown, headers?: Record<string, string>) {
super(`Request failed with status ${status}`)
this.name = 'AxiosError'
this.response = { status, data, headers }
}
}
const uploadClassifier = useResponseError({ texts: () => ({ retryLabel: 'Upload again' }) })
const fakeUploadError = shallowRef<ResponseErrorInfo | null>(null)
const events = shallowRef<string[]>([])
const uploadFiles = typeof File !== 'undefined' ? [new File([], 'photo.heic')] : []
function log(msg: string) {
events.value = [`[${new Date().toLocaleTimeString()}] ${msg}`, ...events.value].slice(0, 8)
}
async function triggerUploadDemo() {
const info = await uploadClassifier.classify(
new FakeHttpError(413, {
message: 'File is too large',
errors: { file: ['Maximum 5 MB'] },
}),
)
fakeUploadError.value = info
log(`upload-wrapper classify -> kind=${info.kind}`)
}
</script>
<template>
<GrCard class="grid gap-3 p-4">
<p class="text-[12px] text-[var(--gr-muted-fg)]">
Thin wrapper: text preset for "upload", canRetry=true, optional `files` prop in the retry payload.
</p>
<div class="flex flex-wrap gap-2">
<GrButton size="sm" @click="triggerUploadDemo">
Simulate 413 upload error
</GrButton>
<GrButton size="sm" variant="outline" @click="fakeUploadError = null">
Hide
</GrButton>
</div>
<GrUploadErrorBanner
:error="fakeUploadError"
:files="uploadFiles"
@retry="({ files }) => log(`upload-retry payload files=${files.length}`)"
@dismiss="fakeUploadError = null"
/>
<div class="grid gap-1">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Event log
</div>
<pre class="max-h-[120px] overflow-auto rounded bg-[var(--gr-muted)] p-3 text-[12px]">{{ events.join('\n') || '—' }}</pre>
</div>
</GrCard>
</template>Сообщение сервера против запасного текста
Сообщение подменяется переводом только тогда, когда его подставил сам классификатор (isFallbackMessage). Ответ сервера остаётся на экране, даже если его текст дословно совпал с дефолтным — прежнее опознание фолбэка сравнением строк выбрасывало такой ответ молча.
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import {
GrButton,
GrResponseErrorBanner,
type ResponseErrorInfo,
useResponseError,
} from '@feugene/granularity'
const { currentError, setRaw, dismiss } = useResponseError()
const source = ref('—')
// Русские тексты вместо английских дефолтов: на них и видно, что подменяется,
// а что нет.
const texts = {
networkMessage: 'Нет связи с сервером — проверьте интернет.',
serverMessage: 'Сервер не справился, попробуйте ещё раз.',
}
class FakeHttpError extends Error {
isAxiosError = true
response: { status: number, data: unknown }
constructor(status: number, data: unknown) {
super(`Request failed with status ${status}`)
this.name = 'AxiosError'
this.response = { status, data }
}
}
async function showServerMessage() {
source.value = 'Сообщение сервера'
// Сервер вернул текст, дословно совпадающий с английским дефолтом пакета.
await setRaw(new FakeHttpError(500, { message: 'A server error occurred. Please try again.' }))
}
async function showFallback() {
source.value = 'Фолбэк классификатора'
// Тела нет — сообщение подставит классификатор и пометит флагом.
await setRaw(new FakeHttpError(500, null))
}
const lastInfo = shallowRef<ResponseErrorInfo | null>(null)
function onRetry(info: ResponseErrorInfo) {
lastInfo.value = info
}
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap gap-3">
<GrButton variant="outline" @click="showServerMessage">
Ответ с сообщением
</GrButton>
<GrButton variant="outline" @click="showFallback">
Ответ без сообщения
</GrButton>
<GrButton variant="ghost" @click="dismiss">
Скрыть
</GrButton>
</div>
<div class="text-xs text-[var(--gr-muted-fg)]">
Источник текста: <span class="font-medium text-[var(--gr-fg)]">{{ source }}</span>
<template v-if="currentError">
· isFallbackMessage: {{ String(currentError.isFallbackMessage) }}
</template>
</div>
<GrResponseErrorBanner
:error="currentError"
:texts="texts"
can-retry
@retry="onRetry"
@dismiss="dismiss"
/>
<div v-if="lastInfo" class="text-xs text-[var(--gr-muted-fg)]">
Повтор запрошен для kind={{ lastInfo.kind }}
</div>
</div>
</template>