Angular: Создаем multiple checkbox компонент, который подойдет для повторного использования

Давайте создадим Angular компонент для создания множества checkbox из определенной логической группы. Компонент будет написан с идей повторного использования. Что это значит? Приведем пример ниже:

Представьте что перед вами задача сделать редактирование пользователей. При редактвровании обычно открывается форма со всеми полями. Пользователь может иметь одну или множество ролей из списка «Adimin», «Director», «Professor», «Student».

Для реализации множественного выбора ролей было решено нарисовать на форме по одному checkbox для каждой роли. Ставя галочки или снимая роли пользователя будут меняться.

Для начало давайте создадим родительский компонент (то есть страница для формы) в котором уже будет содержаться наш checkbox-group компонент.

export class AppComponent implements OnInit {
 private userRoles = [
  { id: 1, name: ‘Admin’ },
  { id: 2, name: ‘Director’ },
  { id: 3, name: ‘Professor’ },
  { id: 4, name: ‘Student’ }
 ];

 public userModel = {
   id: 0,
   name: "",
   roles: []
 };

 constructor() { }
 ngOnInit(): void { 
 }
}

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

//app.component.ts
userRolesOptions = new Array<any>();

ngOnInit(): void {
 setTimeout(() => {
   this.userRolesOptions = this.userRoles;
 }, 
 1000);  //здесь мы добавляем задержку в 1 секунду
}

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

Для начала нам понадобиться такой класс CheckboxItem.ts

export class CheckboxItem {
 value: string;
 label: string;
 checked: boolean;

 constructor(value: any, label: any, checked?: boolean) {
  this.value = value;
  this.label = label;
  this.checked = checked ? checked : false;
 }
}

Он будет использоваться CheckboxComponent для рендера всех возможных вариантов выбора (в нашем случае это роли) и сохранения из состояния (выбран вариант или нет). Заметьте что свойство «checked» это необязательный параметр в конструкторе и по умолчанию будет иметь значение false, то есть все значения сперва будут не выбраны. Это подходит когда мы создаем нового пользователя без единой роли.

Далее изменим немного нашу функую симулрующее запрос на сервер, так чтобы сделать мапинг между JSON ответом и Array
userRolesOptions = new Array<CheckboxItem>();

ngOnInit(): void {
 setTimeout(() => {
   this.userRolesOptions = this.userRoles.map(x => new CheckboxItem(x.id, x.name));
 }, 1000);
}

Не имеет значения какую JSON структуру вернет нам сервер. Каждый HTML checkbox всегда имеет значение (value) и описание (label). В нашем случае мы делает маппинг «id» с «value» и «name» с «label». Value будет использоваться как ключ или id для опции, а label просто строка с описанием которую читает пользователь.

Следующий шаг это создание CheckboxGroupComponent. Выгледит он вот так:

@Component({
 selector: 'checkbox-group',
 templateUrl: './checkbox-group.component.html',
 styleUrls: ['./checkbox-group.component.css']
})

export class CheckboxGroupComponent implements OnInit {
 @Input() options = Array<CheckboxItem>();

 constructor() { }

 ngOnInit() {}

 }

Это не туториал Angular так что я не буду объяснять специфику фреймворка. Кому нужно может прочесть в официальной документации.

Свойство @Input с названием options будет содержать список всех возможных значений, которые по умолчанию не выбраны. Наш HTML шаблон компонента будет рендерить столько checkbox сколько содержится в этом списке.

Так выглядит html для CheckboxGroupComponent:

<div *ngFor=”let item of options”>
  <input type=”checkbox” [(ngModel)]=”item.checked”>{{item.label}}
</div>

Заметьте что я использовал ngModel для связывания (binding) каждого «checked» свойства item из списка options.

Последний шаг это добавить наш новый созданный компонент в шаблон родительского AppComponent.

// somewhere in AppComponent html template

<checkbox-group [options]=”userRolesOptions”></checkbox-group>

Результат должен быть такой:

image

Чтобы получить все текущие выбранные опции мы создадим Output event который при любом клике на один из checkboxes вернет наш список выбранных. Примерно так: [1,2,4]

В CheckboxGroupComponent шаблоне свзяжем change event с новой функцией.

<div *ngFor=”let item of options”>
  <input type=”checkbox” [(ngModel)]=”item.checked” (change)=”onToggle()”>{{item.label}}
</div>

Настало время реализовать эту самую функцию:

export class CheckboxGroupComponent implements OnInit {
 @Input() options = Array<CheckboxItem>();
 @Output() toggle = new EventEmitter<any[]>();

 constructor() {}

 ngOnInit(){}

 onToggle() {
  const checkedOptions = this.options.filter(x => x.checked);
  this.selectedValues = checkedOptions.map(x => x.value);
  this.toggle.emit(checkedOptions.map(x => x.value));
 }
}

Подпишемся на это событие (Output свойство с названием toggle) в шаблоне AppComponent.

<checkbox-group [options]=”userRolesOptions” (toggle)=”onRolesChange($event)”></checkbox-group>

И присвоем возвращаемый результат (выбранные роли) в userModel.

export class CheckboxGroupComponent implements OnInit {
 //..остальной код

onRolesChange(value) {
  this.userModel.roles = value;
  console.log('Model role:' , this.userModel.roles);
 }
}

Теперь на каждом клике на checkbox вы увидите в консле список выбранных ролей. Точнее их id. Например если я выбрал роль Admin и Professor, я получу “Model roles: (2) [1, 3]”.

Компонент почти завершен и готов для повторного использования. Последнее что осталось это сделать поддержку инициализации группы checkbox. Это понадобиться в случае когда мы будем делать редактирование пользователя. Перед этим нам понадобиться сделать запрос на сервер чтобы получить текущий список ролей пользователя и инициализировать CheckboxGroupComponent.

У нас есть два способа сделать это. Первый это использовать конструктор класса CheckboxItem и использовать опциональный параметр «checked». В том месте где мы делали маппинг.

//AppComponent.ts

setTimeout(() => {
  this.userRolesOptions = this.userRoles.map(x => new CheckboxItem(x.id, x.name, true)); 
}, 1000); 
// в таком случае все роли будут сразу же выбраны


Второй способ это добавить еще один список selectedValues для инициализации нашего компонента.

<checkbox-group [options]=”userRolesOptions” [selectedValues]=”userModel.roles” (toggle)=”onRolesChange($event)”></checkbox-group>

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

//AppComponent.ts

public userModel = {
 id: 1,
 name: ‘Vlad’,
 roles: [1,2,3]
};

constructor() { }  

//rest of the code

В CheckboxGroupComponent мы инициализируем все свойства «checked» каждого checkbox в значение true, если id роли содержится в списке selectedValues .

//CheckboxGroupComponent.ts

ngOnInit() {
 this.selectedValues.forEach(value => {
  const element = this.options.find(x => x.value === value);
  if (element) {
    element.checked = true;
  }
 });

}

В итоге должны получить следующий результат:

image
Тут я использовал стили от Angular Material

При запуске будет задержка в одну секунду перед тем как Angular нарисует все checkboxes на странице. Это имитирует время затраченное на загрузку ролей с базы данных.

Важно отметить что вы же можете получить все выбранные роли с помощью подписки на событие (toggle) или просто использовать свойство «checked» в каждом объекте item из списка userRolesOptions , который находиться в родительском компоненте. Это происходит потому что ссылка на список передается через @Input (binding) и все изменения внутри объекта будут синхронизированы.

const checkedOptions = this.userRolesOptions.filter(x => x.checked);

Такой компонент можно стилизовать как угодно и использовать для любой задачи где нужен multi-select.

Спасибо за то, что прочитали эту статью! Надеюсь вам понравилось делать компонент Angular для с идей повторного использования.

P.S.: Если статья будет пользоваться популярностью, я опубликую вторую небольшую часть, где этот же пример написан с использованием Angular Reactive Forms.
Поделиться публикацией
Комментарии 23
    0
    А также легко и быстро можно сделать дерево из ролей?
      0
      А в чем заключается смысл дерева ролей? Сделать дерево из чекбосков несложно.
        0
        Думаю смысл в RBAC, который в большинстве случаев иерархии использует.
      +4
      Автор, Вы, конечно, простите, но Вы действительно считаете, что люди которые используют angular в своей работе не смогут написать компонент в 3 строчки, чтоб чекбоксы выбрать? Компонент, кстати, на переиспользование тянет в рамках проекта, в котором он был написан. Ещё и дефолтная change detection strategy даст просадку. Вы же и так знаете в какой момент меняется состояние, почему бы не использовать OnPush и самому не говорить ChangeDetector”у когда произошли изменения, которые нужно проверить?
        0

        Мне кажется, проблемы заметны уже в этом месте:


        export class CheckboxItem {
         value: string;
         label: string;
         checked: boolean;
        
         constructor(value: any, label: any, checked?: boolean) {
          this.value = value;
          this.label = label;
          this.checked = checked ? checked : false;
         }
        }

        Не думаю, что автор статьи уже добрался до стратегий детектора, просто решил поделиться своим небольшим опытом (правда, я не понимаю зачем).

          +1

          Я автора никак обидеть не хочу, просто статья тянет на "пишем гостевую книгу на PHP", которых на хабре было (и, наверное, еще будет вагон и маленькая тележка).

            0
            С вами согласен, но надеюсь в будущем будут менее тривиальные статьи)
              0

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

            +2
            Конечно, там полно проблем, помимо описанных. Начиная с того, что автору неплохо было бы для этих целей использовать новую возможность CLI — ng g library. Так же, неплохо было бы, изучить стайлгайд из официальной документации и начать использовать ng lint по своему коду перед публикацией статей и комитов в репозиторий. Ну и с rxjs ознакомиться, само собой.
            А сейчас статья выглядит как «эй, я неделю как использую ангуляр, смотри чо могу». Не в обиду автору, но ему самому неплохо было бы ещё статей почитать
              0
              Это моя первая статья на хабре, хотелось дебютировать. Спасибо за комментарии, этого я и ждал. Есть тот же самый пример написанный с Reactive Forms, там есть и subscribe() из rxjs. А вот ng lint еще не пробовал, только ts lint.
                –1

                ng lint немножко поможет Вам соблюдать styleguide.


                Про rxjs я писал не из-за subscribe и прочего, а просто непривычно видеть здесь имитацию задержки от сервера через setTimeout, если в боевых условиях там все равно будет Observable для асинхронности. В таком случае уместнее было бы использовать of(value) и через pipe добавить delayTime оператор.

              0
              Здесь конечно сразу checked = false должно быть. За это извиняюсь. За все комментарии спасибо, мне нравится конструктивная критика.
                0

                Можно сократить до такого:


                export class CheckboxItem {
                  constructor(
                    public value: string,
                    public label: string,
                    public checked?: boolean
                  ) {}
                }
                  0

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

                    0

                    Это же просто класс для данных, а не сервис, который обычно пишут как


                    interface {
                     value: string;
                     label:string;
                     checked?: booolean;
                    }

                    та что инжектор ругаться точно не будет

                0

                А какие вы видите проблемы в этом месте?

                0
                На счет переиспользования совсем не соглашусь. Назовите причины?
                  0

                  Как уже сказал комментатор выше, при использовании стратегии Default, детектор будет реагировать на срабатывание детекторов соседних по стеку компонентов. Использование OnPush позволит вам запускать детектор вашего компонента только тогда, когда изменятся входящие данные.

                    0

                    А Вы заверните в npm пакет, добавьте в другой проект и попробуйте собрать его с aot :)

                  0
                  1. Если инпуты не являются константами или обсерваблами, то факт их изменения должен быть обработан либо в onChanges, либо в сеттерах @Input() переменных. В данном конкретном случае при изменении selectedValues после стартовой инициализации (например, если состояние, которое кладется в чекбоксы, тоже подгружается) все отвалится.
                  2. можно написать ”onToggle(item)” и не страдать херней, пытаясь найти нужный элемент фильтром
                  3. вообще писать в onToggle() и т.п. обработчиках событий, код завязанный на факте апдейта биндинга — плохая идея, т.к. в данном случае мы имеем несколько независимых обработчиков, логика работы которых зависит от порядка их вызова (который может поменяться в следующей версии, например).
                  4. следует осторожнее менять из события внутреннего компонента стейт внешнег окомпонента, который потом прокидывается обратно внутрь. В данном случае, если поправить п.1, то это приведет к ошибке expression changed after checked.
                  5. есть интерфейс ControlValueAccessor и соответствующий провайдер, которые, будучи реализованы компонентом, делают этот компонент полноценным formControl с поддержкой биндингом ngModel либо реактивных форм
                    0
                    Спасибо!
                      0
                      Как раз ожидал здесь увидеть использование ngModel с двусторонним байндингом. В таком виде компонент так себе подходит для повторного использования, будет много дублирующего кода методов 'onChange'
                        0
                        Будем редактировать в скором времени и исправлять ситуацию)

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

                    Самое читаемое