GrResponseErrorBanner

Пакет: @feugene/granularityядроГруппа: Обратная связь

Берут, когда запрос упал.

Когда брать

  • запрос упал — сеть, 500, 422 с ошибками полей: тип ошибки определяется сам и меняет заголовок;
  • ошибок по полям несколько — они печатаются списком с подписями вместо одной общей фразы;
  • запрос можно повторитьcanRetry даёт кнопку рядом с сообщением;
  • нужен статус HTTP — бейдж кода помогает поддержке, не мешая обычному пользователю.

Когда взять другое

НужноБерите
Сообщение не про ответ сервераGrAlert
Короткое уведомление о результатеGrToaster
Ошибка одного поля формыGrFormField
Ошибка внутри диалогаGrConfirmDialog / GrPromptDialog
Данных нет, но это не ошибкаGrEmptyState

Три слоя

  1. normalizeError — приводит axios / fetch Response / XMLHttpRequest / голый Error / строку к общему виду: статус, разобранное тело, заголовки, признаки отмены и сетевой ошибки. Тело Response читается клоном, поэтому потребитель может прочитать его сам;
  2. цепочка парсеров — от нормализованного контекста к ResponseErrorInfo (kind, message, details, fieldErrors). Порядок значим, парсер может остановить цепочку (stop);
  3. баннер — показ: тон по 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/clientwarning, network/ server/unknowndanger, abortedinfo. Перебивается точечно (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

PropTypeпо умолчаниюОписание
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefinedЖёсткий оверрайд тона. Бьёт `toneByKind`.
textsPartial<ResponseErrorTexts> | undefined{}Частичный override текстов. Мерджится с `DEFAULT_RESPONSE_ERROR_TEXTS`.
toneByKindPartial<Record<ResponseErrorKind, "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure">> | undefined{}Маппинг `kind` → `tone`. Мерджится с `DEFAULT_TONE_BY_KIND`.
showDetailsboolean | undefinedtrueПоказывать ли блок `details`/`fieldErrors` под основным сообщением.
showFieldLabelsboolean | undefinedtrueПрефиксить ли `field:` перед сообщениями по полям.
fieldLabelsRecord<string, string> | undefined{}Человекочитаемые подписи полей (`{ file: 'Файл' }`).
dedupeDetailsboolean | undefinedtrueУдалять дубликаты сообщений между `message` и `details`.
canRetryboolean | undefinedfalseПоказывать ли кнопку «Повторить».
canDismissboolean | undefinedtrueПоказывать ли кнопку «Скрыть».
autoHideKindsResponseErrorKind[] | undefinedDEFAULT_AUTO_HIDE_KINDSКакие `kind` не рендерить вообще (баннер скрывается).
showStatusboolean | undefinedtrueПоказывать ли HTTP-status бейдж.
testIdPrefixstring | undefined"response-error"Префикс `data-testid`.
errorобязательныйResponseErrorInfo | nullГотовая структура ошибки. Если `null` — баннер не рендерится.

Events

EventTypeОписание
retry[error: ResponseErrorInfo]
dismiss[]

Примеры 5

Минимум обвязки

Поймали ошибку запроса — отдали её баннеру. Разбирать ответ, выбирать тон и писать текст не нужно: HTTP 409 сам станет предупреждением с сообщением сервера и кнопкой повтора того же тона.

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>

Универсальный баннер — пресеты ошибок

Классификация и отображение разных типов ошибок (network, abort, Laravel/JSON:API validation, RFC 7807, client/server, file validation, plain string) через useResponseError(). Фейковые классы ошибок в демо — стенд, а не часть обвязки: сниппет скрыт, минимальный вариант выше.

Current ResponseErrorInfo (JSON)
Event log

Фильтрация по `kind` — баннер реагирует только на нужные ошибки

Whitelist через autoHideKinds: разрешаем network и validation (включая Laravel 422 с errors). Остальные ошибки (client, server, aborted) тихо проглатываются — setRaw() возвращает null и баннер не рендерится. Чекбоксы в демо позволяют менять whitelist на лету.

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

GrUploadErrorBanner — пресет для загрузки файлов

Тонкая обёртка над GrResponseErrorBanner с текстами под «загрузка», canRetry=true и опциональным prop files, попадающим в payload события retry.

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>

Сообщение сервера против запасного текста

Сообщение подменяется переводом только тогда, когда его подставил сам классификатор (isFallbackMessage). Ответ сервера остаётся на экране, даже если его текст дословно совпал с дефолтным — прежнее опознание фолбэка сравнением строк выбрасывало такой ответ молча.

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

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>

Документация компонентаВсе компоненты