Кастомный Angular multiselect контрол с поиском на сервере

Denis Khrunov
5 min readJan 10, 2021

--

Пример использования

Полный код тут🔥!

Статья на английском языке (Article in English)

1. Предисловие

Всем привет 👋, это мой первый пост во всем интернете, и сегодня я хотел бы поделиться с вами решением проблемы, с которой я недавно столкнулся на работе. Нужно было реализовать компонент множественного выбора, который предлагал элементы по части слова, выполняя поиск на сервере. А проблема в том, что из предлагаемых мне библиотек и UI китов я не нашел ни одного годного решения и мне пришлось писать свою реализацию. Ну что же начнем!

Мой GitHub профиль

2. Необходимые навыки и знания

  • Angular 2+.
  • Средний уровень знаний (уровня middle и выше) Angular 2+.
  • Создание custom controls в Angular.
  • UI библиотека — Angular Material, так как наш multiselect с поиском будет основан на некоторых компонентах Angular Material.

3. Как создать custom controls в Angular?

Если с данной темой вы еще не знакомы, то рекомендую к прочтению этот раздел, а если уже знакомы с данным функционалом Angular, то смело пропускайте и переходите к следующему разделу .

Angular предлагает нам простой, но мощный инструмент для создание кастомных контролов, если вдруг по какой-то причине нам не понравились, либо не хватает нативных (input, checkbox, select и т.д.).

Еще важное замечание кастомные контролы можно использовать и с реактивными формами (reactive form), и с шаблонными формами (template-driven form). Для примера будем использовать компонент счетчик.

Сначала напишем следующий код:

Обычный компонент-счетчик

Для того чтобы интегрировать кастомный контрол в Angular forms, нужно чтобы этот контрол реализовывал интерфейс — ControlValueAccessor, это яркий пример полиморфизма.

Angular и сам использует под капотом ControlValueAccessor, для приведения к единому виду поведение нативных контролов. Но что Angular делает, используя ControlValueAccessor? Все просто, записывает значение из модели в DOM (view), а также поднимает событие изменения контрола до FormGroup и других директив.

Официальная дока по ControlValueAccessor здесь — https://angular.io/api/forms/ControlValueAccessor.

Или вариант с не официальной документацией, на мой взгляд она понятнее— https://tyapk.ru/blog/post/angular-custom-form-field-control.

Как видно из официальной документации интерфейс обязывает нас реализовать три обязательных writeValue, registerOnChange, registerOnTouched и один необязательный метод setDisabledState.

writeValue(value: any) — записывает новое значение в контрол. Вызывается при задании контрола new FormControl('Default value') или при установки нового значения, например, через control.setValue('New value').

registerOnChange(fn: any)— регистрирует callback функцию, которая вызывается при изменении значения контрола в интерфейсе, для метода (change) в представлении.

registerOnTouched(fn: any)— · определяет callback, который вызывается на событие снятия фокуса с контрола (on blur).

setDisabledState(isDisabled: boolean) — опциональная функция, которая вызываться при изменении значения [disabled]="true" на контроле.

Теперь, когда у нас есть базовые знания по ControlValueAccessor, применим его к нашему CounterComponent`у.

Пользовательский элемент управления счетчиком, готовый к использованию в Angular Forms

Для того чтобы форма знала об изменениях контрола, нам нужно вызывать метод onChange на каждое изменение значения value в интерфейсе. Чтобы не писать вызов onChange в каждом методе (up и down), в сеттере value мы вызываем метод onChange, который уведомит форму об обновлении значения в контроле.

Чтобы Angular понимал, что это кастомный компонент мы описали это в декораторе @Component():

providers: [{    provide: NG_VALUE_ACCESSOR,    useExisting: forwardRef(() => CounterControlComponent),    multi: true}]

🎯Теперь наш кастомный контрол готов к использованию в реактивной форме и в template-driven форме.

Вот и вся магия 🔮, теперь мы можем использовать наш кастомный контрол в связке c [(ngModel)] в template-driven форме:

<counter-control [(ngModel)]="controlValue"></counter-control>

И в связке с реактивной формой (reactive-driven):

<form [formGroup]="form"><counter-control formControlName="counter"></counter-control></form>

4. Создаем multiselect-search компонент

Для создания нашего поля мы будем использовать Angular Material Chips component и Autocomplete component.

Вот как будет выглядеть код нашего компонента:

Multiselect search component

Если вы уже работали с Angular Material, то тут для вас мало чего нового, но давайте разберемся более дельно, что происходит в коде выше.

Во-первых, мы связали два отдельных компонента (Chips и Autocomplete) в один, используя композицию компонентов. Это позволило нам, вписывать произвольные элементы в список Chips, а также выбирать из предложенных из компонента Autocomplete.

Теперь поговорим про каждый отдельно:

<mat-chip-list>

В нем есть три важных момента, это событие добавления chip в список, событие удаления и событие ввода значение в поле Input.

1️⃣ На событие добавления chip у нас есть метод — onAddItem(), который добавляет элемент в <mat-chip-list> если оно еще не было добавлено, избавляя нас от дублирования.

2️⃣ На событие удаления chip у нас есть метод — onRemoveItem(), который удалит элемент из списка выбранных элементов.

3️⃣ Так же метод emitSearchOnTyping() слушает событие ввода значения в поле Input, и emit`ет событие search, если пользователь остановился печатать на 300мс debounceTime(300) и введённое значение отличается от прошлого distinctUntilChanged().

<mat-autocomplete>

В этом компоненте есть два ключевых момента на которые следует обратить внимание:

1️⃣ Это обработка события выбора конкретного <mat-option> (optionSelected)=”onSelectOptionFromSearchResult($event)”. onSelectOptionFromSearchResult() метод почти схож с методом onAddItem() и выполняет ту же самую функцию — добавляет элемент в список выбранных.

2️⃣ В наш multiselect-search компонент мы передаем шаблон от рисовки option у <mat-autocomplete>:

multiselect-search.component.ts

@Input()
public optionTemplate: TemplateRef<any>;

multiselect-search.component.html

<mat-option
*ngFor="let searchedItem of searchResults
[value]="searchedItem.value"
>
<ng-container
[ngTemplateOutlet]="optionTemplate"
[ngTemplateOutletContext]="{ $implicit: searchedItem.source }"
></ng-container>
</mat-option>

✅ Вот в принципе и все!

5. Улучшаем UX нашего multiselect-search компонента

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

Добавьте <mat-option> для этих случаев использования:

Для индикатора загрузки:

<mat-option *ngIf="loading; else searchResultsOptions" disabled>
<div style="height: 50px;">
<nb-spinner message="Loading..." size="large"></nb-spinner>
</div>
</mat-option>

Для пустого результата поиска:

<mat-option *ngIf="!loading && searchResults.length === 0 && inputElement?.nativeElement.value.length !== 0" disabled>
<div class="user-message">
<app-user-message message="Nothing found"></app-user-message>
<p class="caption user-message__subtext">
Try to enter a different value for the search, or press Enter if you can confirm the current value.
</p>
</div>
</mat-option>

Для результатов поиска:

<ng-template #searchResultsOptions>
<mat-option
*ngFor="let searchedItem of searchResults"
[value]="searchedItem.value"
>
<ng-container
[ngTemplateOutlet]="optionTemplate"
[ngTemplateOutletContext]="{ $implicit: searchedItem.source }"
></ng-container>
</mat-option>
</ng-template>

Полный код MultiselectSearchComponent с индикатором загрузки и для пустого результата поиска:

Компонент MultiselectSearchComponent с загрузкой и с сообщением о пустом результате поиска

Теперь наш компонент может информировать пользователя, когда поиск на сервере ничего не выдал и когда идет выполнение запроса-поиска.

6. Пример использования multiselect-search компонента

Пример использования multiselect-search

Спасибо за чтение данной статьи, оставляйте свои комментарии и пожелания по улучшению. Если статья была вам полезна, поставьте clap 👏.

--

--

Denis Khrunov
Denis Khrunov

Written by Denis Khrunov

I’m a Front-end Typescript Developer from Russia. I am engaged in front-end development, the main stack of Angular 2+ and in my free time I keep learning.