Angular: Застосування кількох фільтрів одночасно до масиву об’єктів за допомогою Pipe
Фільтрація та сортування є базовою вимогою для будь-якого веб-додатку. Як фронтенд-розробник, знання фільтрації за одним або кількома параметрами та сортування за одним чи кількома параметрами є обов'язковим.
Нижче наведена історія, яка демонструє багаторазове рішення для сортування за одним чи кількома параметрами.
[
Angular: Різні сценарії сортування — багаторазове сортування за одним і кількома параметрами
Я активно використовував сортування у своїх робочих проектах. Найчастіше це було сортування чисел/рядків/дат.
У цій історії ми зосередимося на частині фільтрації. Нижче показано короткий демо-пример нашого додатку. Ми фільтруємо список з 200 ToDo елементів за 10 користувачами на основі userId користувача та/або title ToDo елемента. Для фільтрації даних за кількома фільтрами ми будемо використовувати пайп.
При фільтрації даних на основі кількох умов ми будемо перевіряти наступні пункти всередині пайпа:
Чи не змінились жодні значення фільтрів? Якщо так, повертаємо масив об'єктів без змін до компонента.
Чи змінилось хоча б одне значення фільтра? Якщо так, то шукаємо об'єкти в масиві, що відповідають зміненому фільтру, враховуючи, що інші фільтри залишаються незмінними.
I. Шаблон AppComponent: Шаблон нижче — це точно те, що ви бачите в демо. Є
для відображення списку з 200 ToDo елементів, а також є ToDoFilterComponent з селектором: ***, який відображає два фільтри вгорі таблиці. Давайте розглянемо ще кілька спостережень: =>todos$* — це обсервабель, який випромінює масив з 200 ToDo елементів. Ми підписалися на цей обсервабель, використовуючи пайп async, ітераційно перебрали його і відобразили кожен ToDo елемент. =>Розглядаючи кожен ToDo елемент, він моделює наступний інтерфейс. Ми будемо фільтрувати ці 200 ToDo елементів на основі полів userId та/або title. export interface ToDo { userId: number; id: number; title: string; completed: boolean; } =>Наступне питання — як будемо фільтрувати? Пайпи! Як видно з нижченаведеного коду, ми використали пайп з іменем DataFilterPipe з селектором: dataFilter. Ми передаємо два параметри до цього пайпа: масив з 200 ToDo елементів та деталі фільтрів, які потрібно застосувати до цих елементів. Деталі фільтра зберігаються в властивості filterCategories. Незабаром побачимо, як ми їх визначили і як вони використовуються всередині пайпа. =>Зрештою, переходимо до ToDoFilterComponent з селектором: . До цього компонента ми передаємо деталі фільтру, що зберігаються у властивості filterCategories. Коли будь-яке значення фільтра змінюється, спрацьовує подія filterSelection з ToDoFilterComponent. Коли подія спрацьовує, викликається метод filterSelection() в AppComponent. II. Клас AppComponent =>Всередині хука життєвого циклу ngOnInit ми отримуємо 200 ToDo елементів з ToDoService. =>Властивість filterCategories — це масив об'єктів. Кожен об'єкт визначає фільтр і як його потрібно налаштувати.
Ми визначили 2 фільтри з етикетками: фільтр “userId” і фільтр “title”, щоб фільтрувати ToDo елементи за полем “userId” та полем “title” відповідно.
label: Визначає назву фільтра. Це має бути ім’я поля ToDo елемента, значення якого фільтр має використовувати для порівняння.
type: Визначає тип фільтра. Це може бути string/number/boolean/null. Залежно від цього поля, ми визначили HTML елемент, який буде використовуватися як фільтр у ToDoFilterComponent. Наприклад, для типу “string” ми використали , а для типу “number” — . Якщо потрібно додати тип “boolean”, ми могли б додати радіокнопки або чекбокси для фільтра.
value: Визначає значення фільтра.
defaultValue: Визначає значення фільтра за замовчуванням. Спочатку воно рівне значенню поля value, коли додаток завантажується.
exactMatch: Визначає, чи має значення поля ToDo елемента точно/частково співпадати з полем value фільтра.
=> Метод filterSelection() нижче викликається, коли подія filterSelection спрацьовує з ToDoFilterComponent. Подія спрацьовує, коли змінюється значення будь-якого з фільтрів. У методі ми просто оновлюємо властивість filterCategories у AppComponent з останніми оновленнями, отриманими від ToDoFilterComponent.
Це дуже простий компонент. Як ми вже знаємо, він приймає filterCategories як вхідні дані від AppComponent, і щоразу, коли змінюється якийсь фільтр, ми передаємо оновлені значення filterCategories назад до AppComponent через @Output filterSelection.
Кожного разу, коли значення будь-якого фільтра змінюється, викликається метод filterChanged(), де ми ініціюємо подію filterSelection, передаючи оновлені filterCategories як аргумент назад до AppComponent.
IV. DataFilterPipe
Пайп отримує масив з 200 ToDo елементів та filterCategories. Я б не сказав, що це багаторазовий пайп, тому що хоча логіка і є багаторазовою, ми жорстко прив'язали інтерфейси до конкретних ToDo елементів. Наприкінці цієї історії я продемонструю можливий спосіб зробити пайпи багаторазовими.
=> Метод transform() приймає масив 200 ToDo елементів та фільтри як аргументи.
transform(todos: ToDo[], categories: FilterCategoryModel[]): ToDo[]
{
//логіка тут
}
=> Починаємо з наступного фрагмента коду:
const allDefaults = categories.every(
(category) => category.value === category.defaultValue
);
//якщо жоден з фільтрів не було змінено, повертаємо вхідні дані без змін
if (allDefaults) {
return todos;
}
//відфільтрований список повертається тільки якщо 1 чи більше фільтрів змінились і є дані, що відповідають фільтрам
return filteredList.length ? filteredList : [];
Розглянемо першу умову: якщо значення всіх фільтрів залишаються незмінними, або іншими словами, якщо поле value фільтра досі співпадає з полем defaultValue цього фільтра.
У цьому сценарії ми повертаємо 200 ToDo елементів без змін.
const allDefaults = categories.every(
(category) => category.value === category.defaultValue
);
//якщо жоден з фільтрів не було змінено, повертаємо вхідні дані без змін
if (allDefaults) {
return todos;
}
Нижче наведено 2 варіанти використання цієї умови:
Коли додаток завантажує 200 ToDo елементів при старті, поле value всіх фільтрів буде таким самим, як поле defaultValue. Тому ви побачите всі 200 ToDo елементів у таблиці.
Також, коли ви змінюєте поле value всіх фільтрів назад на значення defaultValue, ви знову побачите всі 200 ToDo елементів у таблиці.
Наступна умова стосується ситуації, коли змінюється значення хоча б одного фільтра. Щоб обробити цю умову, ми використовуємо ще одну змінну: filteredList.
let filteredList: ToDo[] = [];
Якщо filteredList не порожній, ми можемо бути впевнені, що значення хоча б одного фільтра змінилося і більше не збігається з полем defaultValue, а також є хоча б один запис у таблиці, що відповідає новому значенню поля value фільтра.
В такому випадку, метод transform() повертає filteredList замість оригінальних 200 ToDo елементів назад до AppComponent для відображення в таблиці.
//відфільтрований список повертається тільки якщо 1 чи більше фільтрів змінились і є дані, що відповідають фільтрам
return filteredList.length ? filteredList : [];
Якщо filteredList порожній, або значення поля фільтра не змінилося, або жоден із записів таблиці не відповідає новому значенню поля value фільтра. У цьому випадку, як видно вище, метод transform() повертає порожній масив назад до AppComponent.
=> Перейдемо до наступного фрагмента коду, де ми перевіряємо, як ми оновлюємо значення filteredList.
let conditions: boolean[] = [];
filteredList = todos.reduce((acc: ToDo[], curr: any) => {
conditions = categories.reduce(
(filteracc: boolean[], category: FilterCategoryModel) => {
const categoryLabel: string = category.label;
if (category.value !== category.defaultValue) {
//перевіряємо, чи змінився фільтр
if (category.exactMatch) {
//якщо фільтр змінився, перевіряємо на точне співпадіння
filteracc.push(curr[categoryLabel] === category.value);
} else {
//не точне співпадіння
filteracc.push(curr[categoryLabel].includes(category.value));
}
} else {
//фільтр не змінено
filteracc.push(true);
}
return filteracc;
},[]);
if (conditions.every((condition) => condition)) {
//якщо рядок таблиці відповідає всім фільтрам, додаємо його до відфільтрованого списку
acc.push(curr);
}
return acc;
}, []);
Ця логіка є дуже загальною і може бути застосована до будь-якої групи фільтрів. Розглянемо її детальніше, щоб краще зрозуміти.
Масив filteredList присвоюється результату роботи reduce(), застосованого до масиву з 200 ToDo елементів, що зберігаються в todos. Головне завдання цього reduce() — перевірити кожен ToDo елемент, який зберігається в параметрі curr, на відповідність значенню поля value кожного фільтра. Остаточне значення акумулятора: параметр acc буде містити список ToDo елементів, що відповідають всім фільтрам.
filteredList = todos.reduce((acc: ToDo[], curr: any) => {
//логіка зовнішнього reduce тут
return acc;
},[])
Всередині цього reduce() є ще один внутрішній reduce(), який застосовується до фільтрів, отриманих як другий вхідний параметр для пайпа.
conditions = categories.reduce((filteracc: boolean[], category: FilterCategoryModel) => {
// логіка внутрішнього reduce тут
return filteracc;
},[])
Результат цього внутрішнього reduce() зберігається в акумуляторі: filteracc і присвоюється змінній conditions.
Змінна conditions є масивом типу boolean.
let conditions: boolean[] = [];
Мета цього внутрішнього reduce() — пройти по кожному фільтру та порівняти його з ToDo елементом з зовнішнього reduce(). Значення масиву conditions міститиме масив булевих значень. Якщо всі значення true, можна сказати, що ToDo елемент відповідає всім категоріям фільтрів.
Таким чином, на основі значення цього масиву conditions, ми визначимо, чи повинен ToDo елемент бути включений до filteredList, чи ні.
Логіка всередині цього внутрішнього reduce() є основною логікою.
Ми зберігаємо поле label категорії фільтра в константі categoryLabel.
const categoryLabel: string = category.label;
Далі ми перевіряємо, чи змінилося поле value категорії фільтра з початкового значення defaultValue. Якщо не змінилося, ми додаємо true до акумулятора: filteracc внутрішнього reduce().
if (category.value !== category.defaultValue) {
//логіка тут
}
else{
//фільтр не змінено
filteracc.push(true);
}
Якщо значення змінилося, ми перевіряємо, чи є поле exactMatch категорії фільтра істинним чи хибним. Залежно від цього, ми виконуємо точне або часткове порівняння між полем value категорії та значенням відповідного поля ToDo елемента. Тобто ми порівнюємо значення фільтра “userId” з полем “userId” ToDo елемента та значення фільтра “title” з полем “title” ToDo елемента.
Ми додаємо результат порівняння — true чи false — до акумулятора: filteracc.
Масив conditions тепер міститиме фінальне значення акумулятора: filteracc.
Якщо кожне значення масиву conditions є true, ми впевнені, що поточний ToDo елемент у зовнішньому reduce() відповідає всім категоріям фільтрів. Тому ми додаємо ToDo елемент, що зберігається в curr, до акумулятора: acc зовнішнього reduce().
if (conditions.every((condition) => condition)) {
//якщо рядок таблиці відповідає всім фільтрам, додаємо його до відфільтрованого списку
acc.push(curr);
}
Остаточне значення акумулятора: acc присвоюється змінній filteredList. Якщо змінна filteredList містить хоча б один ToDo елемент, метод transform() поверне його назад до шаблону AppComponent для відображення. Якщо змінна filteredList порожня, це означає, що жоден з ToDo елементів не відповідає всім категоріям фільтрів, і оригінальний нефільтрований масив з 200 ToDo елементів буде повернутий назад до компонента.
Pipe Reusability
Як я вже згадував раніше, Pipe можна зробити багаторазовим, визначивши логіку фільтрації в методі компонента і передавши цей метод як аргумент у Pipe. Метод буде викликаний всередині transform() Pipe.
Нижче наведено приклад такого багаторазового Pipe, який отримує оригінальний масив даних і функцію, яку потрібно виконати.
Ми викликали функцію, передавши масив даних як аргумент.
Давайте подивимось, як ми використовуємо цей pipe в TemplateAppComponent.
Ми замінили наступний рядок коду
НА
dataFilterReusable — це селектор pipe, який ми використовуємо. Ми передали метод filterToDos() як аргумент у pipe. Цей метод буде викликаний всередині transform() pipe.
Ми визначили метод filterToDos() в класі AppComponent таким чином. Зміст цього методу повністю збігається з transform() в DataFilterPipe. Різниця в тому, що filterToDos() приймає тільки 1 аргумент.