GrResponseErrorBanner
A universal generic banner for server or network response errors: it takes a raw error/response, runs it through a chain of parsers (HTTP statuses, Laravel/JSON:API/RFC 7807 validation, file/network/abort) and renders a title, main message, an optional list of per-field details and retry/dismiss actions — without knowing anything about a specific feature (file upload, forms, transactions).
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the request failed — the network, a 500, a 422 with field errors: the type of the error is determined by itself and changes the heading;
- there are several errors by field — they are printed as a list with labels instead of one general phrase;
- the request can be retried —
canRetrygives a button next to the message; - the HTTP status is needed — a badge with the code helps support without getting in the way of an ordinary user.
When to take something else
| Need | Take |
|---|---|
| The message is not about a server answer | GrAlert |
| A short notification about a result | GrToaster |
| The error of a single form field | GrFormField |
| An error inside a dialog | GrConfirmDialog / GrPromptDialog |
| There is no data, but that is not an error | GrEmptyState |
Three layers
normalizeErrorbrings axios / afetch Response/ anXMLHttpRequest/ a bareError/ a string to a common shape: the status, the parsed body, the headers, the signs of a cancellation and of a network error. The body of aResponseis read from a clone, so the consumer can read it themselves;- the chain of parsers — from the normalised context to a
ResponseErrorInfo(kind,message,details,fieldErrors). The order matters, and a parser may stop the chain (stop); - the banner — the display: the tone by
kind, the texts from the locale, the deduplication of the details.
The chain of parsers
The default is the universal core (coreResponseErrorParsers): abort, network, HTTP status, plain
message. It works at the transport level and makes no assumptions about the format of the body, so
it does not fire falsely on “almost Laravel” answers.
Server-specific parsers are connected deliberately:
const { setRaw } = useResponseError({
parsers: () => extendDefaultParsers([myParser]),
})
// or through the builder in the options of the dialogs and of the banner
errorParsers: presets => [...presets.core, presets.laravel]
The ready presets: laravel, problemDetails (RFC 7807), jsonApi, fileValidation (the
client-side checking of files from GrFileUpload).
The texts, i18n and the fallback flag
The texts come from the locale (gr.responseError.*), and the texts prop goes on top. The label
of the status badge uses the same mechanism: statusLabel with a {status} insertion.
When none of the parsers produced a message, it is substituted by the classifier and marked
isFallbackMessage: true. The banner replaces only such a message with a translation.
Recognising a fallback by comparing strings, which used to be done here, threw away the server’s
answer if it happened to match the default word for word (and a server may well return
"Network error.").
Hence the contract of a parser: message is filled only with what was found in the answer. A
general text by kind is the job of the classifier, and a parser does not have to substitute it;
otherwise the message will arrive without the flag and it will no longer be possible to translate
it. Parsers that know only the type of the error (httpStatus, abort, network) do not fill the
message at all.
The message of a transport error (axios’s Request failed with status 500) does not count as a
message from the server: it is taken only if there was no answer at all. Otherwise the user would
see a technical phrase in English instead of a translated text.
The tone and accessibility
kind → the default tone: validation/client is warning, network/server/unknown is
danger, aborted is info. It is overridden pointwise (toneByKind) or as a whole (tone).
The role is inherited from GrAlert: warning and danger are announced as role="alert" (they
interrupt the speech of a screen reader), the rest as role="status" (they wait for a pause). The
banner deliberately has no setting of its own for that — GrAlert has one.
autoHideKinds hides the banner entirely for the listed kinds: the typical case is swallowing
aborted quietly.
The presets
GrFormErrorBanner and GrUploadErrorBanner are thin wrappers with preconfigurations: forms need
no retry and do need field labels, while an upload needs a retry and the context of the files (its
preset gives that into retry as { error, files } — the base banner gives away the error
itself).
They add no markup of their own and live in the folder of the base component deliberately: a separate unit of granularity would give nothing — neither CSS of its own nor a safelist of its own — and would cost two extra entries in the build. They are imported from the root of the package.
Playground 8
Loading…
<GrResponseErrorBanner />Install
npm i @feugene/granularityImport
import { GrResponseErrorBanner } from '@feugene/granularity/components/GrResponseErrorBanner'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | A rigid override of the tone. It beats `toneByKind`. |
texts | Partial<ResponseErrorTexts> | undefined | {} | A partial override of the texts. It is merged with `DEFAULT_RESPONSE_ERROR_TEXTS`. |
toneByKind | Partial<Record<ResponseErrorKind, "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure">> | undefined | {} | The mapping of `kind` → `tone`. It is merged with `DEFAULT_TONE_BY_KIND`. |
showDetails | boolean | undefined | true | Whether to show the `details`/`fieldErrors` block under the main message. |
showFieldLabels | boolean | undefined | true | Whether to prefix `field:` before the per-field messages. |
fieldLabels | Record<string, string> | undefined | {} | Human-readable labels of the fields (`{ file: 'File' }`). |
dedupeDetails | boolean | undefined | true | Remove duplicate messages between `message` and `details`. |
canRetry | boolean | undefined | false | Whether to show the "Retry" button. |
canDismiss | boolean | undefined | true | Whether to show the "Dismiss" button. |
autoHideKinds | ResponseErrorKind[] | undefined | DEFAULT_AUTO_HIDE_KINDS | Which `kind`s not to render at all (the banner hides). |
showStatus | boolean | undefined | true | Whether to show the HTTP status badge. |
testIdPrefix | string | undefined | "response-error" | The `data-testid` prefix. |
errorrequired | ResponseErrorInfo | null | — | A ready structure of the error. If `null`, the banner is not rendered. |
Events
| Event | Type | Description |
|---|---|---|
retry | [error: ResponseErrorInfo] | — |
dismiss | [] | — |
Examples 5
Minimal
<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>Presets
Current ResponseErrorInfo (JSON)
—
—
Kind Filter
—
Gr Upload Error Banner
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>Server message vs. classifier fallback
<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>