Angular IDPStrong Junior
Модулі / Грид / Вибір рядків
🔥 0

Модуль 5 · урок 7 з 10

Вибір рядків

«Row selection» — останній функціональний пункт цілі. Виглядає просто: чекбокс у рядку й чекбокс у шапці. Але саме тут ховається питання, на якому плутаються навіть досвідчені: що означає «вибрати всі», коли на екрані лише одна сторінка з сорока.

SelectionModel з CDK

import { SelectionModel } from '@angular/cdk/collections';

export class TaskTable {
  rows = input.required<readonly Task[]>();

  // (multiple, initiallySelected)
  protected readonly selection = new SelectionModel<string>(true, []);

  toggle(id: string): void {
    this.selection.toggle(id);
  }

  get selectedCount(): number {
    return this.selection.selected.length;
  }
}
Зберігай ідентифікатори, а не обʼєкти

Якщо в SelectionModel класти самі рядки, після перезавантаження даних вибір «злетить»: прийдуть нові обʼєкти з іншими посиланнями, і модель їх не впізнає. З id такої проблеми немає — і це рівно та сама історія про порівняння за посиланням із модуля 3.

Три стани чекбокса в шапці

protected allOnPageSelected(): boolean {
  const ids = this.rows().map(r => r.id);
  return ids.length > 0 && ids.every(id => this.selection.isSelected(id));
}

protected someOnPageSelected(): boolean {
  const ids = this.rows().map(r => r.id);
  return ids.some(id => this.selection.isSelected(id)) && !this.allOnPageSelected();
}

protected toggleAllOnPage(): void {
  const ids = this.rows().map(r => r.id);
  if (this.allOnPageSelected()) {
    this.selection.deselect(...ids);
  } else {
    this.selection.select(...ids);
  }
}
<mat-checkbox
  [checked]="allOnPageSelected()"
  [indeterminate]="someOnPageSelected()"
  (change)="toggleAllOnPage()" />

Проміжний стан indeterminate — та деталь, яку часто пропускають. Без нього чекбокс у шапці показує «нічого не вибрано», коли насправді вибрано половину.

Головне питання: «усі на сторінці» чи «усі взагалі»

☑ шапка — вибрати всі НА ЦІЙ СТОРІНЦІ ☑ рядок 1 ☑ рядок 2 ☑ … 25 рядків сторінки Вибрано 25. Вибрати всі 387 знайдених? Друга дія — окремою смугою, і лише після того, як користувач вибрав усю сторінку.
Чекбокс у шапці означає «сторінка». Вибір усього набору — окрема свідома дія, інакше користувач видалить те, чого не бачив.
@if (allOnPageSelected() && total() > rows().length) {
  <div class="bulk-hint">
    Вибрано {{ selection.selected.length }} на цій сторінці.
    <button (click)="selectAllFound()">Вибрати всі {{ total() }} знайдених</button>
  </div>
}
Чому це важливо

Уяви масову дію «видалити вибрані». Якщо чекбокс у шапці мовчки вибрав усі 387 рядків, а користувач бачив 25 — він видалить те, чого не бачив, і навіть не зрозуміє, як це сталось. Розділення на дві дії — не педантизм, а захист від дорогої помилки.

Вибір і зміна даних

// після оновлення списку прибрати з вибору те, чого вже немає
effect(() => {
  const present = new Set(this.rows().map(r => r.id));
  const stale = this.selection.selected.filter(id => !present.has(id));
  if (stale.length) this.selection.deselect(...stale);
});

Питання спірне, і відповідь залежить від продукту: інколи вибір мають пережити зміну фільтра (щоб можна було зібрати добірку з різних фільтрів), інколи — ні. Головне ухвалити рішення свідомо й описати його, а не лишити «як вийшло».

Масові дії

@if (selection.hasValue()) {
  <div class="bulk-bar">
    <span>Вибрано: {{ selection.selected.length }}</span>
    <button (click)="bulkStatus('done')">Позначити готовими</button>
    <button class="danger" (click)="bulkDelete()">Видалити</button>
    <button (click)="selection.clear()">Скасувати вибір</button>
  </div>
}
bulkDelete(): void {
  const ids = this.selection.selected;
  // concatMap, а не switchMap: жодне видалення не має скасуватись
  from(ids).pipe(
    concatMap(id => this.api.remove(id)),
    toArray(),
  ).subscribe(() => {
    this.selection.clear();
    this.store.reload();
  });
}

Тут спрацьовує правило з модуля 4: для записів concatMap або mergeMap, ніколи switchMap. Інакше з десяти видалень пройде одне.