ui5

Cómo filtrar agregaciones en UI5

5 diciembre 2022 · 9 minutos de lectura

Antes de empezar, un apunte para los que no conozcan UI5: aggregation binding o list binding es el nombre que se le da a bindar (vincular, unir) un Array a una agregación de UI5. Hay controles UI5 que pueden generar n controles internos. Un ejemplo sería un listado: crea una serie de ítems que son los que componen la lista. Simplificando: es una forma de recorrer en bucle un Array y crear tantos controles como elementos tenga ese array.

Disclaimer: los servicios deberían tener implementados los métodos para poder filtrar directamente en backend. Hay veces que no es el caso. Me he encontrado casos en el que el servicio devuelve un listado grande de datos que se vuelcan a una agregación de algún control UI5. Ya no solo en sitios tan obvios como una sap.m.ListBase o sus derivados, sap.ui.table.Table, etc. A veces, tenemos una ayuda de búsqueda en un sap.m.Input, sap.m.SearchField,… que tiene un modelo de datos por detrás excesivamente grande.

Los filtros, siempre que sea posible, deben estar implementados en el backend

En este artículo vamos a hacer un repaso de formas de filtrar en UI5 cuando, por el motivo que sea, el servicio no está filtrando los datos. Vamos a empezar desde el ejemplo más sencillo hasta alguno más complejo. Partiremos de un modelo JSONModel que contendrá un Array de objetos con datos de jugadores de la NBA:

import Controller from 'sap/ui/core/mvc/Controller';
import JSONModel from 'sap/ui/model/json/JSONModel';

interface Jugador {
  nombre: string;
  altura: number;
  posicion: 'base' | 'escolta' | 'alero' | 'pivot';
}

/** @namespace ui5.filtros.controller */
export default class main extends Controller {
  oNBAModel = new JSONModel({
    posicion: '',
    altura: 0,
    jugadores: [
      { nombre: 'Stephen Curry', altura: 188, posicion: 'base' },
      { nombre: 'Michael Jordan', altura: 198, posicion: 'escolta' },
      { nombre: 'Magic Johnson', altura: 206, posicion: 'base' }
      // Aqui tenemos todos los jugadores que han jugado alguna vez en la NBA
    ],
    jugadoresBinding: []
  });

  onInit() {
    this.getView().setModel(this.oNBAModel, 'NBA');
  }
}

Y para estos ejemplos sencillos, los pintaremos en una sap.m.Table:

<mvc:View xmlns:mvc="sap.ui.core.mvc" xmlns="sap.m" controllerName="ui5.filtros.controller.main">
  <Table í="{NBA>/jugadores}">
    <columns>
      <Column><Text text="Nombre" /></Column>
      <Column><Text text="Altura" /></Column>
      <Column><Text text="Posición" /></Column>
    </columns>
    <items>
      <ColumnListItem>
        <cells>
          <Text text="{NBA>nombre}" />
          <Text text="{NBA>altura}" />
          <Text text="{NBA>posicion}" />
        </cells>
      </ColumnListItem>
    </items>
  </Table>
</mvc:View>

Esta tabla tiene un problema. Teniendo en cuenta que en cada temporada de la NBA participan unos 450 jugadores y que la NBA ya ha celebrado más de 70 campeonatos, podemos determinar que vamos a tener un problema de rendimiento al renderizarse. Para ello podemos activar el growing, así mejoraremos el renderizado inicial. De todas formas, este no es el objetivo del artículo. Nosotros lo que queremos es filtrar la tabla. Si sabemos que solo queremos pintar los jugadores que juegan en la posición de escolta, podemos cambiar el aggregation binding directamente en el xml:

<Table items="{path:'NBA>/jugadores', filters:{path:'posicion', operator:'EQ', value1:'escolta' }}">

En este caso no tiene mucho sentido el filtro. Lo más probable es que necesitemos filtrar de forma dinámica con algún selector de posición. Vamos a añadir un Select a la vista para poder filtrar por una posición concreta:

<Select change=".changeSelectPosicion">
  <core:Item key="" text="" />
  <core:Item key="base" text="Base" />
  <core:Item key="escolta" text="Escolta" />
  <core:Item key="alero" text="Alero" />
  <core:Item key="pivot" text="Pivot" />
</Select>
<Table id="table" items="{NBA>/jugadores}">
  <!-- ... -->
</Table>

Y en el controller añadiremos la función changeSelectPosicion que se encargará de recuperar el valor seleccionado y filtrar la tabla:

import Controller from 'sap/ui/core/mvc/Controller';
import Event from 'sap/ui/base/Event';
import Item from 'sap/ui/core/Item';
import Table from 'sap/m/Table';
import Filter from 'sap/ui/model/Filter';
import ListBinding from 'sap/ui/model/ListBinding';
import FilterOperator from 'sap/ui/model/FilterOperator';

/** @namespace ui5.filtros.controller */
export default class main extends Controller {
  // ...
  changeSelectPosicion(oEvent: Event) {
    const oItem = oEvent.getParameter('selectedItem') as Item;
    const sPosicion = oItem.getKey();

    const oTable = this.getView().byId('table') as Table;
    const oBinding = oTable.getBinding('items') as ListBinding;
    const aFilter = [new Filter('posicion', FilterOperator.EQ, sPosicion)];
    oBinding.filter(aFilter);
  }
}

Esto mismo lo podemos hacer con una función test y teniendo la posición a filtrar guardada en el modelo. El ejemplo siguiente lo he hecho sin especificar un path al filtro, con lo que podríamos implementar diferentes filtros utilizando el resto de propiedades:

<Input value="{path:'NBA>/altura',type:'sap.ui.model.type.Integer'}" liveChange=".refreshTable" valueLiveUpdate="true" />
<Select change=".refreshTable" selectedKey="{NBA>/posicion}">
  <!-- ... -->
</Select>
<Table id="table" items="{path:'NBA>/jugadores',filters:{path:'', test:'.testNBAPosicion'}}">
  <!-- ... -->
</Table>

Añadimos los métodos testNBAPosiciony refreshTable:

import Controller from 'sap/ui/core/mvc/Controller';
import Table from 'sap/m/Table';
import ListBinding from 'sap/ui/model/ListBinding';

/** @namespace ui5.filtros.controller */
export default class main extends Controller {
  // ...
  testNBAPosicion(oJugador: Jugador) {
    const sPosicion = this.oNBAModel.getProperty('/posicion');
    const iAltura = this.oNBAModel.getProperty('/altura');
    return sPosicion === oJugador.posicion && iAltura <= oJugador.altura;
  }

  refreshTable() {
    // Podemos refrescar todo el modelo
    this.oNBAModel.updateBindings(true);

    // o directamente refrescar el binding de la tabla
    const oTable = this.getView().byId('table') as Table;
    const oBinding = oTable.getBinding('items') as ListBinding;
    oBinding.refresh(true);
  }
}

Si el modelo es sencillo como el del ejemplo actual, podemos utilizar el updateBindings(true) sin problema. Por otro lado, si hay más listados en el modelo, es mejor atacar directamente a la agregación concreta. Así nos evitamos rerenderizar el resto de listados.

Ahora bien, también tenemos la opción de hacer el filtro a mano y modificar la propiedad del modelo con los datos ya filtrados, algo así:

<Input value="{path:'NBA>/altura',type:'sap.ui.model.type.Integer'}" liveChange=".refreshTable" valueLiveUpdate="true" />
<Select change=".refreshTable" selectedKey="{NBA>/posicion}">
  <!-- ... -->
</Select>
<Table items="{NBA>/jugadoresBinding}">
  <!-- ... -->
</Table>
import Controller from 'sap/ui/core/mvc/Controller';

/** @namespace ui5.filtros.controller */
export default class main extends Controller {
  // ...
  refreshTable() {
    const { posicion, altura, jugadores } = this.oNBAModel.getData();
    const aJugadores = jugadores.filter(
      (oJugador: Jugador) => oJugador.posicion === posicion && oJugador.altura >= altura
    );
    this.oNBAModel.setProperty('/jugadoresBinding', aJugadores);
  }
}

Como antes, en este ejemplo no tiene mucho sentido hacerlo así. En cambio, sí que puede ser interesante para otros casos más complejos, filtros que actúan en diferentes agregaciones, etc. (un campo del cual dependen dos o más ayudas de búsqueda)

Añadir que este caso también lo podemos hacer filtrando directamente el binding:

<Input value="{path:'NBA>/altura',type:'sap.ui.model.type.Integer'}" liveChange=".refreshTable" valueLiveUpdate="true" />
<Select change=".refreshTable" selectedKey="{NBA>/posicion}">
  <!-- ... -->
</Select>
<Table id="table" items="{NBA>/jugadores}">
  <!-- ... -->
</Table>
import Controller from 'sap/ui/core/mvc/Controller';
import Table from 'sap/m/Table';
import ListBinding from 'sap/ui/model/ListBinding';
import FilterOperator from 'sap/ui/model/FilterOperator';

/** @namespace ui5.filtros.controller */
export default class main extends Controller {
  // ...
  refreshTable() {
    const { posicion, altura } = this.oNBAModel.getData();

    const oTable = this.getView().byId('table') as Table;
    const oBinding = oTable.getBinding('items') as ListBinding;
    const aFilter = [
      new Filter('posicion', FilterOperator.EQ, posicion),
      new Filter('altura', FilterOperator.GE, altura)
    ];
    oBinding.filter(aFilter);
  }
}

Debes tener en cuenta que lo que estamos haciendo en todos los ejemplos es filtrar un modelo de datos sin modificarlo. Utilizando el mismo origen, solo cambiamos lo que se pinta por pantalla pero el modelo que lo alimenta siempre contendrá todos los datos. En el caso en el que volcamos los datos en la propiedad jugadoresBinding, nunca modificamos la propiedad que contiene todo el Array. Si lo hicieramos, perderíamos datos mientras estamos filtrando y podríamos quedarnos sin información para alimentar la aplicación.

El caso un poco mas complejo, sería una búsqueda en un MultiInput con ayudas. Tendremos un modelo de datos de 10000 entradas:

import Controller from 'sap/ui/core/mvc/Controller';

type Posiciones = 'base' | 'escolta' | 'alero' | 'pivot';
interface Jugador {
  id: number;
  nombre: string;
  altura: number;
  posicion: Posiciones;
}

const TOTAL_JUGADORES = 10000;
const POSICIONES: Posiciones[] = ['base', 'escolta', 'alero', 'pivot'];

const aJugadores: Jugador[] = [];
for (let i = 0; i < TOTAL_JUGADORES; i++) {
  const oJugador: Jugador = {
    id: i,
    nombre: `Jugador ${i}`,
    altura: Number(((Math.random() + 1.3) * 100).toFixed(0)),
    posicion: POSICIONES[i % 4]
  };
  aJugadores.push(oJugador);
}

/** @namespace ui5.filtros.controller */
export default class main extends Controller {
  oNBAModel = new JSONModel({
    jugadores: aJugadores,
    jugadoresSuggestion: []
  });

  onInit() {
    this.getView().setModel(this.oNBAModel, 'NBA');
  }
}

El primer problema viene en el límite de los modelos UI5. Por defecto, las agregaciones pintan 100 elementos. En la ayuda de búsqueda tenemos un modelo con 10000 ítems. El problema viene en que solo podremos trabajar con los 100 primeros elementos, el resto no los llegaremos a ver por pantalla. Podemos decirle al binding o al propio modelo que el límite es mayor, pero eso provocará muchos problemas de rendimiento, la pantalla perderá toda fluidez y será inusable (a menos que dispongas de un ordenador cuántico 👻). Comentar que ya hay controles pensados para manejar búsquedas de este tipo, pero no se comportan igual que un MultiInput.

Ahora me dirás que esto es un caso irreal… pues no tanto 😂. He trabajado en proyectos donde se tiene que filtrar el personal de la empresa. La empresa tiene unos 15000 empleados en la base de datos 🤪 y aparentemente no es trivial filtrar en el backend, con lo que devuelven todo el listado y que UI5 se las apañe. No discutiremos más sobre el porqué y buscaremos una solución que permita buscar tanto por nombre como por id.

La idea que voy a plantear es similar a la anterior, tendremos el origen de datos inmutable y lo iremos filtrando a mano en otra propiedad. Para no saturar el hilo de ejecución de javascript a cada pulsación de tecla, solo haremos el filtrado 250 milisegundos después de que el usuario haya escrito el último carácter. Simplemente haremos el filtro, rellenamos la propiedad y ya. La limitación de 100 elementos seguirá, pero esta vez pintará los 100 primeros elementos respecto a la lista ya filtrada.

<MultiInput
  showSuggestion="true"
  suggestionItems="{NBA>/jugadoresSuggestion}"
  suggest=".suggestJugador(${$parameters>suggestValue})"
  showValueHelp="false">
  <suggestionItems>
    <core:Item
      key="{NBA>id}"
      text="{NBA>nombre}" />
  </suggestionItems>
</MultiInput>
import Controller from 'sap/ui/core/mvc/Controller';
// ...

/** @namespace ui5.filtros.controller */
export default class main extends Controller {
  liveChangeTimeout = 0;
  //...
  suggestJugador(suggestValue: string) {
    clearTimeout(this.liveChangeTimeout);
    this.liveChangeTimeout = setTimeout(() => this.filterSuggestions(suggestValue), 250);
  }

  filterSuggestions(value: string) {
    const aJugadores = this.oNBAModel.getProperty('/jugadores');
    const aJugadoresFiltered = aJugadores.filter((oJugador: Jugador) => oJugador.nombre.includes(value));
    this.oNBAModel.setProperty('/jugadoresSuggestion', aJugadoresFiltered);
  }
}

Hecho esto, vemos que la búsqueda es totalmente usable y no bloquea la pantalla. No voy a entrar en como recoger los tokens y tenerlos bindados, ya que no es el asunto del artículo.

Dicho esto, espero te haya gustado y si puedes compartirlo te lo agradecería muchísimo.