Модуль 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, але закласти різницю варто вже зараз.