Filtrer une liste via un Pipe Angular

gfab

Fabien

Posted on February 5, 2022

Filtrer une liste via un Pipe Angular

👀 Démo sur Stackblitz

Après avoir vu comment filtrer une liste avec RXJS, j'ai pensé qu'il serait intéressant de voir comment on pourrait arriver au même résultat en étant un peu plus Angular Friendly.

Le Pipe Angular est parfait pour transformer une donnée depuis le template. Le principe est simple, on lui passe une valeur et ses arguments en entrée, sur laquelle on applique une transformation.

C'est exactement ce dont on a besoin !


Les arguments

import { Pipe, PipeTransform } from '@angular/core';

type TList = string | number | IListItem;

interface IListItem {
  [key: string]: TList;
}

@Pipe({
  name: 'filter',
})
export class FilterPipe implements PipeTransform {
  public transform(
    list: Array<TList>,
    text: string,
    filterOn?: string
  ): TList[] {}
}
Enter fullscreen mode Exit fullscreen mode

Un Pipe Angular implémente PipeTransform qui impose la méthode transform. Le premier argument (qui se situe sur la gauche du pipe) correspond à la valeur sur laquelle on applique la transformation. Puis suivent les arguments qui nous seront utiles pour notre filtrage.

Dans notre cas, nous nous attendons à recevoir une liste list, la recherche text que rentrera l'utilisateur et une clé filterOn sur laquelle filtrer, qui est optionnelle. Le tableau pourrait ne pas être un objet, mais une liste simple.

Nous connaissons plus ou moins la valeur de retour, c'est pour cela que j'ai défini une interface IListItem qui prend un type pour définir la valeur de chaque propriété TList, un type représentant soit un number, soit une string soit IListItem lui-même. Enfin notre valeur de retour qui sera du même type que TList.


🤜 TypeScript est un outil génial, il fait partie intégrante d'Angular et pour le meilleur. Un bon typage du code permet d'éviter beaucoup d'erreurs, permet de mieux comprendre les contextes de fonctionnalités, facilite sa maintenance et son évolution.


Le cas où le texte serait vide

public transform(
  list: Array<TList>,
  text: string,
  filterOn?: string
): TList[] {
  if (text === '') return list;
}
Enter fullscreen mode Exit fullscreen mode

Le premier point à prendre en compte, également le plus simple à gérer, est que faire quand le texte est vide ? Simplement retourner le tableau d'entrée. Chaque fois que text sera vide on affichera le tableau initial.

Quand les éléments de la liste ne sont pas des objets

public transform(
  list: Array<TList>,
  text: string,
  filterOn?: string
): TList[] {
  if (text === '') return list;

  return list.filter((item: TList) => {
    let valueToCheck: string = filterOn
      ? selectValue<TList>(item, filterOn)
      : `${item}`;

    if (valueToCheck) {
      valueToCheck = replaceDiacritics(valueToCheck)?.toLowerCase();
    }

    const formattedText: string = replaceDiacritics(text).toLowerCase();

    return valueToCheck?.includes(formattedText);
  });
}
Enter fullscreen mode Exit fullscreen mode

J'utilise l'opérateur filter il nous retournera uniquement les valeurs du tableau qui respectent la condition.

Premièrement on vérifie si la propriété filterOn est définie, dans le cas où le troisième argument de notre Pipe serait défini, on suppose que notre liste est une liste d'objets.

Une fois la valeur trouvée, on la transforme en minuscule, ainsi, peu importe la casse, l'entrée est retrouvable.

Pour filtrer notre liste j'utilise includes.

On notera également l'utilisation de toLowerCase() sur l'argument text afin de conserver une cohérence avec la valeur trouvée dans l'objet. Ainsi peu importe la casse on saura retrouver les occurrences.


🤜 J'utilise le point d'interrogation (?) pour prévenir des erreurs dans le cas où valueToCheck serait null ou undefined.


Les diacritiques

Notre liste est maintenant correctement filtrée… Oui… mais Thomas Sabre m'a fait remarqué que les caractères spéciaux ne sont pas pris en compte. Effectivement si notre valeur est "J'ai mangé" et que l'utilisateur entre "j'ai mange" notre pipe ne retournera aucun résultat.

Alors comment gérer le cas des diacritiques ?

A chaque caractère lui est assigné un code, par exemple A vaut U+0041 quand Z vaut U+005A. Les lettres sont différentes, les codes sont donc différents, facile et logique.

Bien… il en va de même pour les lettres accentuées. Quand pour l'humain il comprend que "j'ai mange" puisse faire référence à "j'ai mangé", nos machines, elles, nécessitent plus de précisions. En effet "e" et "é" sont différents. Tout comme "é" et "è" le sont aussi :

  • e = U+0065
  • é = U+00E9
  • è = U+00E8

On comprend alors pourquoi notre pipe ne retrouve aucune valeur correspondante à "J'ai mange".

é et è sont basés sur e, grâce à cette base commune nous sommes capable de trouver une compatibilité entre ces caractères. JavaScript nous offre la possibilité de normaliser facilement notre texte et de remplacer les occurences : 

return value.normalize("NFD").replace(/\p{Diacritic}/gu, "")

NFD (Normalization Form Canonical Decomposition) permet de décomposer les caractères, exemple : é = e + ◌̀

Le replace recherche, quant à lui, toutes les occurrences diacritiques. Le flag u permet de supporter les caractères Unicode et le g les recherches dans toute la chaîne de caractères.

function replaceDiacritics(value: string): string {
  return value.normalize('NFD').replace(/\p{Diacritic}/gu, '');
}
Enter fullscreen mode Exit fullscreen mode

Les extras

Filtrer dans un objet à plusieurs niveaux

Ok, c'est bien, mais dans un projet réel, parfois, souvent, la propriété sur laquelle on veut filtrer ne se trouve pas à la racine de l'objet. Alors comment faire, comment filtrer sur ces propriétés ?

<book-item *ngFor="let book of books | filter:author:'address.city'; trackBy: trackBySku" [book]="book"></book-item>
Enter fullscreen mode Exit fullscreen mode

J'utilise un point pour indiquer que l'on souhaite accéder à une propriété, plus bas dans l'arborescence de l'objet. Chaque point serait un nœud.

function selectValue<TItem>(item: TItem, selector: string): string {
  if (!item) return;

  let value = null;

  if (selector.includes('.')) {
    value = selector
      .split('.')
      .reduce((previous: string, current: string) => previous[current], item);
  }

  return value ?? item[selector];
}
Enter fullscreen mode Exit fullscreen mode

Dans un premier temps, je vérifie si item existe, s'il n'existe pas je ne vais pas plus loin dans la fonction. S'il existe, je vérifie si le sélecteur passé en paramètre a un point. Si c'est le cas, je split le sélecteur on aura ['address', 'city'], sur lesquels on bouclera.

Grâce à .reduce on va pouvoir descendre jusqu'à la propriété demandée et retourner sa valeur.

Dans le cas où le sélecteur ne comporte pas de point (.) Cela signifie que la valeur se trouve à la racine de l'item de la liste passée en paramètre.

Utiliser le pipe dans une classe

Je suis un grand adepte de TypeScript, un code bien décrit est un atout considérable, lors de la phase de développement et de debug.

public transform(
 list: Array<TList>,
 text: string,
 filterOn?: string
): TList[] {
   ...
}
Enter fullscreen mode Exit fullscreen mode

Si je veux utiliser mon pipe dans un fichier .ts, je vais être confronté à des erreurs de typage, qu'on pourrait régler en mettant des any partout (non, ne faites pas ça 😢). Plus sainement, en une ligne on peut régler le problème tout en gardant une description propre de notre code :

public transform<T>(list: Array<T>, text: string, filterOn: string): Array<T>;
Enter fullscreen mode Exit fullscreen mode

Et voilà, c'est propre, simple et on garde notre typage. Lorsqu'on utilisera notre pipe on sera en mesure de garder un typage fort et de travailler tout en profitant des avantages de TypeScript.


🤜 Typescript offre la possibilité de typer de manière dynamique en utilisant des alias. L'alias va créer un nouveau nom qui fait référence au type qui lui est passé.


Filtrer depuis plusieurs champs

<search-input (onSearch)="searchTerm = $event" placeholder="Title"></search-input>
<search-input (onSearch)="addressTerm = $event" placeholder="Address"></search-input>
<search-input (onSearch)="descriptionTerm = $event" placeholder="Sypnosis"></search-input>

<book-item *ngFor="let book of books
  | filter:searchTerm:'title'
  | filter:addressTerm:'address.city'
  | filter:descriptionTerm:'sypnosis'; trackBy: trackBySku"
  [book]="book"></book-item>
Enter fullscreen mode Exit fullscreen mode

Filtrer une même liste suivant plusieurs critères (via plusieurs champs) peut être fait facilement. Il nous suffit de chaîner les pipes sur notre liste. Dans la limite du raisonnable, si vous avez une liste filtrable sur beaucoup de conditions, peut-être serait-il préférable de revoir le pipe.


👀 Démo sur Stackblitz

🤞 En lien
Filtrer une liste avec RXJS et Angular

❤ Merci à Godson Yebadokpo pour la relecture.
❤ Merci à Thomas Sabre pour son commentaire sur les dialectiques.

📸 Photo by Joshua Rodriguez on Unsplash

Sur ce, bon dev ;-)

💖 💪 🙅 🚩
gfab
Fabien

Posted on February 5, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related