GrResponseErrorBanner

Package: @feugene/granularitycoreGroup: feedback

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 retriedcanRetry gives 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

NeedTake
The message is not about a server answerGrAlert
A short notification about a resultGrToaster
The error of a single form fieldGrFormField
An error inside a dialogGrConfirmDialog / GrPromptDialog
There is no data, but that is not an errorGrEmptyState

Three layers

  1. normalizeError brings axios / a fetch Response / an XMLHttpRequest / a bare Error / 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 a Response is read from a clone, so the consumer can read it themselves;
  2. 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);
  3. 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…

Code
<GrResponseErrorBanner />

Install

npm i @feugene/granularity

Import

import { GrResponseErrorBanner } from '@feugene/granularity/components/GrResponseErrorBanner'

API

Props

PropTypedefaultDescription
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefinedA rigid override of the tone. It beats `toneByKind`.
textsPartial<ResponseErrorTexts> | undefined{}A partial override of the texts. It is merged with `DEFAULT_RESPONSE_ERROR_TEXTS`.
toneByKindPartial<Record<ResponseErrorKind, "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure">> | undefined{}The mapping of `kind` → `tone`. It is merged with `DEFAULT_TONE_BY_KIND`.
showDetailsboolean | undefinedtrueWhether to show the `details`/`fieldErrors` block under the main message.
showFieldLabelsboolean | undefinedtrueWhether to prefix `field:` before the per-field messages.
fieldLabelsRecord<string, string> | undefined{}Human-readable labels of the fields (`{ file: 'File' }`).
dedupeDetailsboolean | undefinedtrueRemove duplicate messages between `message` and `details`.
canRetryboolean | undefinedfalseWhether to show the "Retry" button.
canDismissboolean | undefinedtrueWhether to show the "Dismiss" button.
autoHideKindsResponseErrorKind[] | undefinedDEFAULT_AUTO_HIDE_KINDSWhich `kind`s not to render at all (the banner hides).
showStatusboolean | undefinedtrueWhether to show the HTTP status badge.
testIdPrefixstring | undefined"response-error"The `data-testid` prefix.
errorrequiredResponseErrorInfo | nullA ready structure of the error. If `null`, the banner is not rendered.

Events

EventTypeDescription
retry[error: ResponseErrorInfo]
dismiss[]

Examples 5

Minimal

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)
Event log

Kind Filter

Allowed kinds (whitelist for autoHideKinds)
All kinds that are not checked are silently swallowed: setRaw() returns null and the banner does not render.
Event log

Gr Upload Error Banner

Thin wrapper: text preset for "upload", canRetry=true, optional `files` prop in the retry payload.

Event log

Gr Upload Error Banner
<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

Источник текста:

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>

Component documentationAll components