Angular IDPStrong Junior
Модулі / Грид / Фільтри й пошук
🔥 0

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

Фільтри й пошук

«Dynamic search filter» у таблиці ліда виглядає як одне поле вводу. Насправді за ним стоять чотири задачі: не смикати систему на кожну літеру, шукати так, як очікує людина, поєднувати кілька умов і не забути скинути сторінку.

Потік пошуку

readonly query = new FormControl('', { nonNullable: true });

constructor() {
  this.query.valueChanges.pipe(
    startWith(''),
    map(v => v.trim()),
    debounceTime(300),
    distinctUntilChanged(),
    takeUntilDestroyed(),
  ).subscribe(q => this.store.setQuery(q));
}

Це той самий канонічний ланцюжок із модуля 4. Тут він не робить запиту — фільтрація клієнтська, — але debounceTime усе одно потрібен: без нього кожна літера перераховує 800 рядків і перемальовує таблицю.

Нормалізація: шукати як людина

function normalize(value: string): string {
  return value
    .toLowerCase()
    .trim()
    .replace(/\s+/g, ' ')          // подвійні пробіли всередині
    .replace(/['ʼ`]/g, "'");        // різні апострофи в українській — типова біда
}

function matches(task: Task, q: string): boolean {
  if (!q) return true;
  const haystack = normalize(`${task.code} ${task.title} ${task.assigneeName ?? ''}`);
  return q.split(' ').every(word => haystack.includes(word));   // усі слова, у будь-якому порядку
}
Три деталі, які роблять пошук «розумним»

Пошук по кількох полях одразу. Людина вводить код або шматок назви — і не має думати, у яку колонку це вписати.

Усі слова в будь-якому порядку. Запит «kyiv анімація» має знайти «Анімація — KYIV-104». Просте includes(q) цього не вміє.

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

Кілька фільтрів разом

export interface Filters {
  query: string;
  status: TaskStatus | 'all';
  assigneeId: string | 'all';
  overdueOnly: boolean;
}
function applyFilters(rows: readonly Task[], f: Filters): readonly Task[] {
  const q = normalize(f.query);
  const now = Date.now();

  return rows.filter(t =>
    (f.status === 'all' || t.status === f.status) &&
    (f.assigneeId === 'all' || t.assigneeId === f.assigneeId) &&
    (!f.overdueOnly || new Date(t.dueDate).getTime() < now) &&
    matches(t, q)
  );
}

Один прохід замість чотирьох filter підряд — на 800 рядках різниці не відчути, але звичка корисна. Головне тут інше: усі умови зібрані в одному місці, і кожна читається як речення.

Скидання сторінки

setFilters(patch: Partial<Filters>): void {
  this.state.next({
    ...this.state.value,
    filters: { ...this.state.value.filters, ...patch },
    page: 1,                        // ← найважливіший рядок цього уроку
  });
}

Один метод на всі фільтри — і скидання сторінки написане один раз. Якщо ж робити setStatus, setQuery, setAssignee окремо, рано чи пізно в одному з них забудеш page: 1.

Фільтри в адресі

// стан фільтрів у query-параметрах: посилання можна переслати колезі
this.router.navigate([], {
  relativeTo: this.route,
  queryParams: { status: f.status, q: f.query || null },
  queryParamsHandling: 'merge',
  replaceUrl: true,               // не засмічувати історію браузера
});

replaceUrl: true тут принципово: інакше кожна натиснута літера в пошуку створить запис в історії, і кнопка «Назад» перетвориться на знущання.

Порожній стан: два різні

@if (rows().length === 0) {
  @if (hasActiveFilters()) {
    <app-empty
      title="Нічого не знайдено"
      hint="Спробуйте змінити фільтри"
      action="Скинути фільтри"
      (act)="store.resetFilters()" />
  } @else {
    <app-empty
      title="Задач ще немає"
      hint="Створіть першу — це займе хвилину"
      action="Створити задачу"
      (act)="create.emit()" />
  }
}

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