Filtros (Pipes)

<< Señales (Signals) Anidar componentes >>

Por lo general, se utilizan tuberías (pipes) junto a la interpolación y data-binding para transformar los datos antes de mostrarlos. Angular proporciona algunas pipes integradas para operaciones con cadenas, arrays, fechas, JSON, números, etc...

En el ejemplo de productos vamos a utilizar algunas de ellas ( uppercase → string a mayúsculas, currency → número a moneda, date → fecha formateada). Debes importar las clases de los filtros que vayas a usar (UpperCasePipe, CurrencyPipe y DatePipe) en el componente en cuestión.

<tr>
  <td>
    @if (showImage()) {
    <img [src]="product.imageUrl" [title]="product.description | uppercase"  alt="" />
    }
  </td>
  <td>{{ product.description }}</td>
  <td>{{ product.price | currency }}</td>
  <td>{{ product.available | date }}</td>
</tr>

Los filtros también pueden recibir parámetros (opciones), que se les pueden pasar concatenados (en orden) usando dos puntos ':'. Por ejemplo, vamos a decirle al filtro de moneda que nuestro precio está en Euros (EUR) y debe mostrar el símbolo de la moneda (€) → 'símbolo' (más información). También vamos a formatear la fecha en un formato dd/mm/yyyy (más información).

<tr>
  <td>
    @if (showImage()) {
    <img [src]="product.imageUrl" [title]="product.description | uppercase"  alt="" />
    }
  </td>
  <td>{{ product.description }}</td>
  <td>{{ product.price | currency:'EUR':'symbol' }}</td>
  <td>{{ product.available | date:'dd/MM/yyyy' }}</td>
</tr>

Custom pipes

Además de los pipes definidos en Angular, podemos crear otros. En realidad es un método que recibe un dato de entrada y una serie de parámetros. Lo que esta función devuelve es lo que al final se sistituye en la plantilla. Podemos encadenar varios pipes seguidos. La salida de la función a la izquierda sería la entrada de la siguiente.

Ejemplo: Filtrando productos

Para el ejemplo, vamos a crear un campo de texto para que se filtre el array de productos en base a que la descripción contenga el texto escrito. El valor se almacenará en una señal para así poder reaccionar ante cambios de valor si así lo quisiéramos (la directiva [(ngModel)] soporta valores tipo signal):

<div class="card">
  <div class="card-header bg-primary text-white">{{ title }}</div>
  <div class="card-block">
    <div class="row">
      <label class="col-form-label col-sm-2 text-sm-end" for="filterDesc">
        Filter:
      </label>
      <div class="col-sm-5">
        <input type="text" [(ngModel)]="search" class="form-control" id="filterDesc" placeholder="Filter..." />
      </div>
      <div class="col-form-label col-sm-5">
        Filtered by: {{ search() }}
      </div>
    </div>
    <!-- Tabla con los productos -->
  </div>
</div>

Ahora vamos a crear el pipe que filtrará el array de productos:

ng g pipe pipes/product-filter

Esta es la clase que acabamos de crear:

@Pipe({
  name: 'productFilter',
  standalone: true
})
export class ProductFilterPipe implements PipeTransform {
  transform(value: unknown, ...args: unknown[]): unknown {
    return null;
  }
}

La clase utiliza el decorador @Pipe para indicar a Angular el tipo de clase que es. La propiedad name indica el nombre del pipe que tendremos que poner en la plantilla del componente donde lo vayamos a usar.

La clase implementa la interfaz PipeTransform, que nos obliga a su vez a implementar el método transform. El primer parámetro de este método será el dato que tenemos que transformar (lo que hay a la izquierda del caracter '|'), mientras que el resto será los parámetros de entrada del pipe.

En la implementación del método devolveremos un nuevo array con los productos filtrados, y si la cadena de búsqueda no está presente, o está vacía, devolvemos el array original.

export class ProductFilterPipe implements PipeTransform {
  transform(products: Product[], search?: string): Product[] {
    const searchLower = search?.toLocaleLowerCase();
    return searchLower
      ? products.filter((prod) =>
          prod.description.toLocaleLowerCase().includes(searchLower)
        )
      : products;
  }
}

Para probarlo, iremos al componente products-page, importamos el pipe que acabamos de crear, y lo usamos en la plantilla pasándole por parámetro el valor de search.

<!-- No olvides que search es de tipo signal y debes utilizar () para devolver su valor -->
@for (product of products | productFilter:search(); track product.id) {
  <!-- Filtered products -->
}

Con esto debería funcionar.

Detección de cambios

Por defecto, los pipes que creamos en Angular se establecen como 'puros'. Esto implica que para que vuelva a ejecutarse y se actualice la información, debe de cambiar la referencia interna de alguno de los parámetros que se reciben.

Por ejemplo, si se recibe un array u objeto, no se detectarán los cambios internos (añadir o borrar un elemento del array o cambiar el valor de una propiedad del objeto). Para que angular vuelva a ejecutar el pipe, debemos generar un nuevo array u objeto (una copia con las modificaciones pertinentes).

Esto lo hace así por cuestiones de optimizar rendimiento. Es decir, es mucho menos costoso comprobar que la referencia (número) del objeto o colección que se recibe ha cambiado que estar comprobando si se ha cambiado el valor de una propiedad interna o se ha añadido, sustituido o borrado algún elemento de un array (implicar ir recorriéndolo periódicamente).

Se puede cambiar en cualquier momento este comportamiento con la propiedad pure: false en el decorador. Sin embargo, no está aconsejado ya que perderemos rendimiento al aumentar el costo de las comprobaciones que debe hacer Angular para detectar cambios internos.

@Pipe({
  name: 'productFilter',
  standalone: true,
  pure: false // Mejor no lo hagas
})
export class ProductFilterPipe implements PipeTransform {
  // ...
}

Gestionando cambios en los parámetros

Para trabajar con pipes debemos aplicar los principios de inmutabilidad de los datos (aunque sean mutables). Es decir, tratar a los datos de entrada como si no pudieran ser modificados internamente (el pipe no haría caso a esos cambios), y en cada modificación generar una nueva referencia.

Prueba a filtrar los productos, por ejemplo, con el texto "ssd". Mientras filtras, inserta un nuevo producto que contenga ese texto en la descripción. Verás que no aparece hasta que no modificas la cadena de búsqueda. Al no detectar un nuevo array, el pipe no se ejecuta de nuevo y no se actualiza la lista.

Ya de paso que vamos a tratar al array de productos como una colección inmutable, lo vamos a meter dentro de una señal, ya que estas siguen los mismos principios de inmutabilidad (solo detectan cambios con nuevas referencias). Proximamente utilizaremos esta señal para filtrar los productos de una manera alternativa.

Ahora, para insertar un nuevo producto en el array, generaremos un nuevo array con el mismo contenido (operador spread ...) y añadiremos el producto en la última posición:

 @for (product of products() | productFilter:search(); track product.id) {
    <!-- No olvides que products ahora es de tipo signal. Utiliza () para acceder al valor -->
}

Vamos a añadir también un botón de borrar producto y vamos a gestionarlo de la misma manera. En este caso utilizando el método filter, que devuelve siempre un nuevo array.

<thead>
  <tr>
    <!-- Añade un nuevo th vacío al encabezado-->
    <th><!--Para el botón de borrar --></th>
  </tr>
</thead>
<tbody>
  @for (product of products() | productFilter:search(); track product.id) {
    <tr>
      <!-- Resto de campos -->
      <td><button (click)="deleteProduct(product)" class="btn btn-danger btn-sm fw-bold">X</button></td>
    </tr>
  }
</tbody>

Custom Pipe →¿Computed Signal? 

En algunos casos puede convenir utilizar una señal computada (computed) en lugar de crear un pipe. En el caso de filtrar el array global de productos (es un único valor), es más sencillo hacerlo con una señal computada que crear un pipe para ello. Si quisieramos hacer un filtro reutilizable en varios componentes, o filtrar de forma individual elementos dentro de una colección u objeto, en ese caso sí convendría hacerlo con pipes.

@if (filteredProducts().length) {
    <!-- ... -->
        @for (product of filteredProducts(); track product.id) {
          <!-- ... -->
        }
    <!-- ... -->
} @else {
  <h3 class="p-4">No hay productos disponibles</h3>
}

Al haber metido el array de productos en una señal, estamos creando una dependencia en la función computed, tanto de este array, como de la cadena de búsqueda (products y search). Cualquier cambio en ambos valores implica que filteredProducts sería recalculado.

Podríamos optimizar más la función comprobando si la cadena de búsqueda está vacía para no filtrar en ese caso. 

export class ProductsPageComponent implements OnInit {
  //...
  filteredProducts = computed(() => {
    return this.search()
      ? this.products().filter((p) =>
          p.description.toLowerCase().includes(this.search().toLowerCase())
        )
      : this.products();
  });
  //...
}

Ya podríamos prescindir totalmente del pipe creado antes, y borrarlo si así lo deseamos. Hemos visto un caso concreto donde trabajar con señales simplifica el código, pero eso no significa que un pipe no sea mejor alternativa en otras situaciones (filtrar datos que no sean de tipo signal por ejemplo).

Ejemplo: Formateando un precio con la API Intl

A la hora de utilizar CurrencyPipe integrado en Angular (y otros como DatePipe), la configuración de idioma no funcionará si no tenemos la dependencia de @angular/localize instalada y además compilamos la aplicación con el soporte de idioma requerido. Esto implica que no están utilizando la API de Internacionalización de JavaScript por debajo.

 Vamos a crear un filtro para formatear un número como un precio en cualquier idioma y región utilizando la API de Internacionalización de JavaScript.

ng g pipe pipes/intl-currency

Además del número que representa el precio, el filtro recibirá 2 parámetros más:

  • El código de la moneda: EUR, USD, JPY, etc.
  • El idioma y región para el formato: es-ES, en-US, etc. Según la cultura tanto el número (decimales, miles) como el símbolo de la moneda (delante, detrás, etc.) se representan de diferentes maneras.

Este sería el código para implementar el filtro que acabamos de crear:

@Pipe({
  name: 'intlCurrency',
  standalone: true
})
export class IntlCurrencyPipe implements PipeTransform {
  transform(price: number, currency: string, lang: string): string {
    return new Intl.NumberFormat(lang, { style:"currency" ,currency}).format(price);
  }
}

Se lo vamos a aplicar a nuestro ejemplo de productos. Para ello lo importamos en el componente ProductItem y lo utilizamos en su plantilla para formatear el precio de cada producto en euros y español de España, por ejemplo.

<div class="col my-auto">
  {{ product().price | intlCurrency:"EUR":"es-ES" }}
</div>

<< Señales (Signals) Anidar componentes >>