Ловля и отображение ошибок пользовательского интерфейса с помощью тостовых сообщений в Angular

В предыдущей статье: Ловля и обработка ошибок в Angular, мы обрабатывали ошибки, поступающие от Http-ответов и операторов RxJS, отбрасывая их обратно к потребителю, чтобы каждый потребитель обрабатывал их по-своему. Сегодня мы создадим компонент для сообщений Toast, который потенциально может быть использован для обработки ошибок.

Окончательный проект можно найти на StackBlitz. Найдите в components/list.partial.ts пример использования catchError, нажмите на «transactions» в навигации, чтобы увидеть его в работе. Также смотрите components/form.partial.ts для другого примера.

Какой тост!

Давайте начнем с простого. Действительно просто: добавим компонент в корень app.component, и будем управлять несколькими вещами в нем.

@Component({
    selector: 'gr-toast',
    template: `
      <div class="toast">
        <div class="text">text here</div>
        <button>Dismiss</button>
      </div>
    `,
    changeDetection: ChangeDetectionStrategy.OnPush,
    styleUrls: ['./toast.css'],
})
export class ToastPartialComponent  {
    constructor() {

    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Это будет добавлено в body вручную:

<!--  in app.component.html -->
<gr-toast></gr-toast>

<!--  and in app.module, add a declaration, this will end in Angular 14, hopefully -->
Ввести полноэкранный режим Выйти из полноэкранного режима

Давайте придадим ему минимальный стиль, чтобы с ним можно было работать:

/* basic css for the toast */
.toast {
  border-radius: 5px;
  max-width: 80vw;
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: space-between;
  background-color: #263238;
  color: #fff;
  position: fixed;
  bottom: 10px;
  left: 10px;
  font-size: 90%;
  z-index: 5100;
}
.text {
  padding: 20px;
  flex-basis: 100%;
  margin-right: 10px;
}

button {
  padding: 20px;
  cursor: pointer;
  font-weight: bold;
  color: inherit;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В левом нижнем углу это выглядит так.

Для того чтобы получить доступ к видимости тоста в любом месте, его нужно обработать с помощью службы, предусмотренной в root. Сервис в своей простейшей форме имеет внутренний субъект, представленный в виде наблюдаемой, который меняет свое содержимое с «null» на «something».

Позже мы можем превратить его в сервис состояния, который мы создали в RxJS State Management.

Сервис выглядит следующим образом:

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

// the simple model will become bigger as wel move on
export interface IToast {
    text?: string;
}

@Injectable({ providedIn: 'root' })
export class Toast {
  // internal subject to control the state
  private toast: BehaviorSubject<IToast | null> = new BehaviorSubject(null);
  toast$: Observable<IToast | null> = this.toast.asObservable();

 // show, simply updates the state to something
  Show(text: string) {
    this.toast.next({ text: text });
  }
  // hide, simple updates the state to null
  Hide() {
    this.toast.next(null);
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем в шаблоне тоста мы наблюдаем за состоянием тоста:

@Component({
  selector: 'gr-toast',
  // in template watch the toast observable for null values to hide all
  template: `
    <ng-container *ngIf="toastState.toast$ | async as toast">
      <div class="toast">
        <div class="text">{{ toast.text }} </div>
        <!-- on click, hide the toast -->
        <button (click)="toastState.Hide()">Dismiss</span>
      </div>
    </ng-container>
    `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['./toast.css'],
})
export class ToastPartialComponent {
  // inject the state
  constructor(public toastState: Toast) {}
}
Войти в полноэкранный режим Выход из полноэкранного режима

Показывать и скрывать в любом компоненте очень просто:

this.toast.Show('hello world');

Выглядит ли это слишком просто? Так и есть. Специально.

Итак, вернемся к нашей пойманной и не обработанной ошибке RxJS. Конечный результат выглядел следующим образом:

// in a component that uses the custom operator to unify the error model:
getProjects() {
  this.projects$ = this.projectService.GetProjects().pipe(
    catchError(error => {
      // here we use our toast, we pass the code only
      this.toast.Show(error.code);

      // then continue, nullifying
      return of(null);
    })
  )
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь мы знаем, что первым аргументом должен быть код, который переводится как сообщение.

Текстовые ресурсы

Код, который переводит сообщение? Это похоже на ресурсы. Как я уже говорил в SEO-сервисе, я избегаю пакета i18n и создаю свой собственный файл ресурсов. Мы будем строить на основе этого, но с небольшим изменением. Поскольку коды возвращаются с сервера, мы хотим иметь две вещи:

  • общий текст «неизвестно» для кодов, которые не могут быть найдены.
  • Сообщение об отступлении в случае, если мы хотим, чтобы отступление было специфичным для некоторых случаев.
// under root/locale/resources.ts, lets add a few codes
export const keys = {
  // an unknown key to fall back to
  Unknown:
    'Oops! We could not perform the required action for some reason. We are looking into it right now.',
  // an empty one just in case
  NoRes: '', // if resource is not found
  // some generic keys of our choice
  Required: 'Required',
  Error: 'An error occurred',
  DONE: 'Done',
  // some specific ones
  UNAUTHORIZED: 'Login or register first.',
  INVALID_VALUE: 'Value entered is not within the range allowed',

  // mapping from server or API
  PROJECT_ADD_FAILED: 'Server did not like this project',
};
Вход в полноэкранный режим Выход из полноэкранного режима

Метод Show написан с учетом этого:

// Toast show method takes code, and fallback
Show(code: string, fallback?: string) {
  // get message from code, keys is found in locale/resources.ts
  let message = keys[code];
  // if it does not exist, fall back message
  if (!message) {
    // if fallback is not provided, return unknown
    message = fallback || keys.Unknown;
  }
  this.toast.next({ text: message });
}
Вход в полноэкранный режим Выход из полноэкранного режима

Эта схема, когда сначала находится ключ, а затем происходит откат, используется везде, где используются ресурсы. Она отделяет коды от ресурсов, делая более распространенным наличие «неизвестных». Но, поскольку она отделяет их, она более щадящая во время разработки, когда мы действительно не знаем, какие коды ожидать от сервера. Итак, на свой страх и риск (это мой личный выбор) сделайте следующее:

Класс ресурсов

Давайте поместим этот паттерн в собственный класс:

import { keys } from '../../locale/resources';

// a simple class that translates resources into actual messages
export class Res {
  public static Get(key: string, fallback?: string): string {
    // get message from key
    if (keys[key]) {
      return keys[key];
    }
    // if not found, fallback, if not provided return NoRes
    return fallback || keys.NoRes;
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы можем использовать его где угодно, например, так

Res.Get('Invalid_Email');

Или если код более непредсказуем.

Res.Get(serverCode, 'Use this message instead');

А поскольку второй аргумент также является текстом, мы можем выбрать существующий ресурс для его замены

Res.Get(serverCode, keys.Unknown);

И это то, что мы будем использовать для тоста, в дополнение к раскрытию обратного хода. Вернемся к нашему методу Show, это должен быть самый гибкий и универсальный способ определения сообщения.

Show(code: string, fallback?: string) {
  // use code, then use fallback, then use keys.Unknown
  const message = Res.Get(code, fallback || keys.Unknown);
  this.toast.next({ text: message });
}
Вход в полноэкранный режим Выход из полноэкранного режима

Особенности обработки ошибок

Пока что тост — это общий инструмент, который мы можем использовать непосредственно для отображения ошибок в компонентах:

create(project: Partial<IProject>) {
  // we can catch errors in "error" body or as an operator to RxJS pipe
  this.projectService.CreateProject(project).subscribe({
    next: (data) => {
      console.log(data?.id);
    },
    error: (error: IUiError) => {
      // this needs a bit more information, specifically style
      // also error may not have 'code'
      this.toast.Show(error.code);
    }
  });
}

// in a simpler non-subscribing observable
getProjects() {
  this.projects$ = this.projectService.GetProjects().pipe(
    catchError((error:IUiError) => {
      // same as above
      this.toastShow(error.code);
      // then continue, nullifying, remember here to account for "null" values
      // in the component consuming this observable
      return of(null);
    })
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Это станет шаблоном, поэтому давайте уменьшим размер и уберем его из компонента:

catchError(e=>this.toast.HandleUiError(e[, fallBack]))

Также нам нужно позаботиться о последних воротах обработки ошибок, что если ошибка не является UiError? Что если это ошибка JavaScript? В службе состояния тостов мы добавляем новый метод HandleUiError, а также финальный повторный бросок:

export class Toast {
  // ...

  // show code then return null
  HandleUiError(error: IUiError, fallback?: string): Observable<any> {
    // if error.code exists it is our error
    if (error.code) {
      this.Show(error.code, fallback);
      return of(null);
    } else {
      // else, throw it back to Angular Error Service, this is a JS error
      return throwError(() => error);
    }
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Все ли ошибки одинаковы?

По мере продвижения вперед и создания стилей, можно подумать, что все тосты, исходящие из утверждений catchError, являются красными ошибками. Но все ли они таковы? С точки зрения пользовательского интерфейса, определенно нет. Рассмотрим следующие примеры использования

Добавление участника по электронной почте

Функция, позволяющая администраторам добавлять новых пользователей по их электронной почте, хороший API должен иметь (по крайней мере) два пункта:

  • Создать нового пользователя: POST users/ с немного большим, чем просто email.
  • Добавить пользователя: POST members/ с ID пользователя, или email, который может быть предварен «найти пользователя по email».

Как разработчики мы бы начали с добавления пользователя по электронной почте. Если email не существует, то будет возвращена ошибка 404. Пользовательский интерфейс должен быть построен только на этом, позволяя администратору продолжать заполнять другие поля. Сообщение, если таковое имеется, в данном случае; это не ошибка, а информация.Сообщение, если таковое имеется, в данном случае; это не ошибка, а информация.

Пользователь вышел по таймеру

Сервер может вернуть ошибку 401 или 403, которая не является терминальной. Сообщение в этом случае будет не ошибкой, а информационным, с кнопкой «повторный вход» или, в более тяжелом случае, перенаправлением.

Давайте сначала добавим стиль и посмотрим, как мы можем адаптировать.

Стилизация

Будут повторяющиеся шаблоны, чтобы показать красную рамку, желтую рамку, зеленую рамку и так далее.

this.toast.ShowError

this.toast.ShowWarning

this.toast.ShowSuccess

this.toast.Show (default)

Мы добавим эти повторяющиеся функции в службу состояния. Поскольку у нас есть дополнительные опции, текст отката будет объединен в текстовую опцию.

// adapt the toast state service to have options as a second argument
// fallback message is now part of options
  Show(code: string, options?: IToast): void {
    // get message from code
    const message = Res.Get(code, options?.text || keys.Unknown);
    // pass options
    this.toast.next({...options, text: message})
  }

// shortcuts for specific styles, replace fallback with options
  ShowError(code: string, options?: IToast) {
    this.Show(code, { extracss: 'error', ...options });
  }
  ShowSuccess(code: string, options?: IToast) {
    this.Show(code, { extracss: 'success', ...options });
  }
  ShowWarning(code: string, options?: IToast) {
    this.Show(code, { extracss: 'warning', ...options });
  }

  // replace fallback here as well
  HandleUiError(error: IUiError, options?: IToast): Observable<any> {
    // if error.code exists it is our error
    if (error.code) {
      this.Show(error.code, options);
      return of(null);
    } else {
      // else, throw it back to Angular Error Service, this is a JS error
      return throwError(() => error);
    }
  }

  // using it is now like this
  // this.toast.Show('SomeCode', {text: 'fallback message'});
Вход в полноэкранный режим Выход из полноэкранного режима

В нашем css-файле

/* add to toast.css */
.toast.warning {
  background-color: var(--yellow);
  color: #263238;
}
.toast.error {
  background-color: var(--red);
}
.toast.success {
  background-color: var(--green);
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В нашем шаблоне компонента тоста

<div class="toast {{toast.extracss}}">

Но мы хотим, чтобы «toast» был адаптирован, и мы также хотим, чтобы он был установлен по умолчанию. Для этого нам нужна переменная опций по умолчанию в службе состояния

// in toast state service
 private defaultOptions: IToast = {
    css: 'toast',
    extracss: '',
    text: '',
  };

  Show(code: string, options?: IToast) {
    // extend default options
    const _options: IToast = { ...this.defaultOptions, ...options };

    const message = Res.Get(code, options?.text || keys.Unknown);
    this.toast.next({ ..._options, text: message });
  }
Войти в полноэкранный режим Выйти из полноэкранного режима

В нашем шаблоне компонента toast

<div class="{{ toast.css }} {{toast.extracss}}">

Со всеми этими гаджетами создание очень специфического и динамичного предупреждающего сообщения выглядит следующим образом

// extreme case of a warning when an upload file is too large
const size = Config.Upload.MaximumSize;
this.toast.ShowWarning(
  // empty code to fallback
  '',
  // fallback to a dynamically created message
  { text: Res.Get('FILE_LARGE').replace('$0', size)}
);

// where FILE_LARGE is:
// FILE_LARGE: 'The size of the file is larger than the specified limit ($0 KB)'
Войти в полноэкранный режим Выйти из полноэкранного режима

Ошибки состояния Http

Вернемся к нашему компоненту-потребителю, где происходит отлов ошибок. В примере функции «get project» мы имеем следующий возможный результат:

getProjects() {
  this.projects$ = this.projectService.GetProjects().pipe(
    catchError(error => {
     // what is the error? is it 404? or 401?
     if (error.status === 400){
      this.toast.ShowError(error.code);
     }
     if (error.status === 404) {
       // ignore code from server
       this.toast.ShowWarning('PROJECT_NOT_FOUND');
     }
     if ([401, 403].includes(error.status)){
       // ignore codes and always show unauthorized
       // and in the future also pass a button to login
       this.toast.Show('UNAUTHORIZED', {button: 'TODO'} );
       // or simply log out
       this.authService.logout();
     }
     // and other error statuses...
     // then continue, nullifying
     return of(null);
    })
  )
}
Вход в полноэкранный режим Выход из полноэкранного режима

Примечание: если мы поместим компонент тостов в корневое приложение, перенаправление на login не приведет к его исчезновению, а именно этого мы и хотим. Если вы перенаправитесь на маршрут, где нет компонента тостов, нет смысла показывать его, потому что он будет удален.

Это один из способов решения проблемы. Однако это шаблон, мы должны перенести его в наш государственный сервис. А пока мы должны создать интерфейс кнопок.

// rewriting HandleUiError
 HandleUiError(error: IUiError, options?: IToast): Observable<any> {
    if (error.code) {
      // do a switch case for specific errors
      switch (error.status) {
        case 500:
          // terrible error, code always unknown
          this.ShowError('Unknown', options);
          break;
        case 400:
          // server error
          this.ShowError(error.code, options);
          break;
        case 401:
        case 403:
          // auth error, just show a unified message, need to add options for button
          this.Show('UNAUTHORIZED', options);
          break;
        case 404:
          // thing does not exist, better let each component decide
          this.ShowWarning(error.code, options);
          break;
        default:
          // other errors
          this.ShowError(error.code, options);
      }
      return of(null);
    } else {
      return throwError(() => error);
    }
  }
Вход в полноэкранный режим Выход из полноэкранного режима

Потребление этого, очень гибкое, вот конечный результат, если я хочу быть конкретным в отношении ошибки 404:

getProjects() {
  this.projects$ = this.projectService.GetProjects().pipe(
    catchError((error) => {
      // you could override the extracss, or fallback text
      // this.toast.HandleUiError(error, { extracss: 'warning' })
      // but if 404, i want a different code
      if (error.status === 404) {
        error.code = 'PROJECT_NOT_FOUND';
      }
      return this.toast.HandleUiError(error);
    });
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь мы можем делать любые специфические комбинации, вот наш предыдущий сценарий добавления участника, пользователь 404 не является ошибкой:

// example of handling 404 differently
assignMember() {
  this.user$ = this.userService.GetUser('email@something.com').pipe(
    catchError((error) => {
      if (error.status !== 404) {
        return this.toast.HandleUiError(error);
      }
      // a 404 means new user needs to be created
      // may be a toast of that? optional
      this.toast.Show('ADDING_NEW_USER');
      // return new user object and continue
      return of({email: 'email@something.com'});
    });
}
Войти в полноэкранный режим Выход из полноэкранного режима

Вы можете быть более конкретными в зависимости от потребностей проекта, над которым вы работаете. Поэтому следите за повторяющимися шаблонами и заботьтесь о них.

Кнопки действий

И последнее, но не менее важное: нам нужно раскрыть кнопки, чтобы можно было использовать другие кнопки, кроме «уволить». Сначала добавим кнопки в шаблон и передадим событие click:

<!-- in toast template -->
 <div class="{{toast.css}} {{toast.extracss}}">
  <div class="text">{{ toast.text }} </div>
  <div class="buttons" *ngIf="toast.buttons.length">
    <!-- TODO: add buttons collection to model, and click handler prop -->
      <button *ngFor="let button of toast.buttons"
       [class]="button.css"
       (click)="button.click($event)"
      >{{button.text}}</button>
  </div>
</div>
Войти в полноэкранный режим Выйти из полноэкранного режима

В модели тостов мы добавим коллекцию кнопок, каждая кнопка — это элемент, имеющий как минимум текст и css, а также метод нажатия:

export interface IToast {
  text?: string;
  css?: string; // basic css, defaults to toast
  extracss?: string; // extra styling
  buttons?: IToastButton[]; // action buttons
}

export interface IToastButton {
  text: string;
  css?: string;
  // and a click handler
  click?: (event: MouseEvent) => void;
}
Войти в полноэкранный режим Выход из полноэкранного режима

В нашем потребительском компоненте, например, в сценарии Login to continue, добавим кнопку для входа в систему в тосте.

// inside a catchError operator
 return this.toast.HandleUiError(error, {
  buttons: [
    {
      text: 'Login', // better use resources keys
      click: (event) => {
        // route to login then close toast
        this.router.navigateByUrl('/login');
        this.toast.Hide();
      }
    }
  ],
});
Вход в полноэкранный режим Выход из полноэкранного режима

Мы также можем создать кнопку увольнения по умолчанию. Самый безопасный способ опционально добавить ее, это раскрыть кнопку увольнения и обращаться с ней как с любой другой кнопкой. В службе состояния тоста:

// public dismiss button
dismissButton = {
  css: 'btn-close',
  text: keys.DISMISS,
  click: (event: MouseEvent) => {
    this.Hide();
  },
};

// added to default options
private defaultOptions: IToast = {
  css: 'toast',
  extracss: '',
  text: '',
  // add dismiss by default
  buttons: [this.dismissButton]
};
Войти в полноэкранный режим Выход из полноэкранного режима

Вернемся к нашему потребляющему компоненту, где мы хотим добавить две кнопки:

// inside a catchError operator
return this.toast.HandleUiError(error, {
  buttons: [
    {
      text: 'Login',
      click: (event) => {
        // route to login then close toast
        this.router.navigateByUrl('/login');
        this.toast.Hide();
      }
    },
    // add dismiss as well
    this.toast.dismissButton
  ]
});
Войти в полноэкранный режим Выход из полноэкранного режима

Небольшое дополнение к CSS для новых кнопок:

/*allow multiple buttons to appear on one line*/
.buttons {
  display: flex;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Автоматическое скрытие

Тосты должны автоматически скрываться через некоторое время, но это подождет до следующей недели. Спасибо, что дочитали до конца, и дайте мне знать, если вы заметили паука в углу.

Окончательный проект можно найти на StackBlitz.

Оцените статью
Procodings.ru
Добавить комментарий