miércoles, 30 de septiembre de 2009

Flex/AIR: filtros de datos

Es muy habitual tener una colección de datos en nuestras interfaces de usuario. Cuando ésta es muy numerosa, es conveniente facilitar al usuario de medios para poder acceder más rápida y directa a una información concreta, y para ello se aplican filtros.

En el ejemplo que voy a exponer utilizaré un DataGrid, que es uno de los elementos de interfaz más utilizados para presentar colecciones de datos. Lo mismo puede aplicarse a otros elementos de interfaz, como las listas o AdvancedDataGrids.

Antes de iniciar el cómo vamos realizar un planteamiento. La mayor parte de las veces, los datos provendrán de fuentes externas o remotas, como un fichero, un HTTPService o un WebService. La invocación a un servicio de datos remoto, normalmente se obtiene en formato e4x, para poder trabajar cómodamente en XML. Pero este sistema carece de la cualidad de filtrar o realizar búsquedas concretas. Se puede realizar filtros manualmente e invocar a los servicios de datos con los parámetros del filtro, pero esto implicaría desperdiciar muchos recursos, ya que cada filtrado es una invocación remota (una llamada a un servicio remoto), con el tiempo que esto conlleva, ejecutar el código de invocación, carga y visualización de datos.

Así pues, nos queda otra opción: obtener la información y almacenarlo en un ArrayCollection, que posee métodos para filtrado y que puede ser enlazado a elementos de interfaz de usuario. En este caso, los datos son los mismos que en la primera carga, e internamente, en memoria, se realiza el filtrado, por lo que es mucho más óptimo en recursos (tiempo, ejecuciones, memoria, etc.)

Para ello, se define un ArrayCollection en la región dedicado al ActionScript (<mx:Script>...</mx:Script>):

[Bindable]
private var miAC:ArrayCollection;


En este post se pondrá un ejemplo con un HTTPService, que es el método más habitual de obtener la información. Recordemos que HTTPService invoca a una URL donde se retornará una fichero XML formado con un nodo padre y con varios hijos en forma de lista (cada uno como un registro o fila del DataGrid). Esta invocación puede realizarse a una página ASP, JSP, PHP, Servlet, etc, que en lugar de retornar una página web retorne un XML. También puede ser una URL con un fichero XML.

<mx:HTTPService
  result="handleResultMetodo(event);"
  fault="handleFaultMetodo(event);"
  id="nombreServicio" resultFormat="object"
  url="URLHTTPService"
  useProxy="false">
  <mx:request xmlns="">
    <nombreParametro>valorParametro</nombreParametro>
    ...
  </mx:request>
</mx:HTTPService>

Los parámetros a utilizar son:
- result -> Nombre del método que gestionará el resultado de la invocación
- fault -> Nombre del método que se ejecutará en caso de haber un error en la invocación
- id -> Nombre que se da al servicio
- url -> URL donde se encuentra el XML (ASP, JSP, PHP, Servlet, fichero XML...)
- resultFormat -> Formato del resultado. En este caso se ha usado un tipo "object" para que sea recogido por el ArrayCollection. En casos normales se utiliza el formato "e4x" que permite gestionar cómodamente cualquier XML.

El bloque mx:request es opcional, y se utiliza en el caso de que el recurso XML requiera de parámetros de entrada para formar el XML resultante.

En algún momento determinado del ciclo de vida de la aplicación, se invocará al HTTPService para recoger el XML con los datos. Habitualmente se realiza en el método creationComplete de la aplicación, o del componente Flex. En algunas ocasiones se realiza en respuesta a algún evento (como un clic sobre un botón). El código que realiza la invocación sería el siguiente:

nombreServicio.send();

Una vez invocado el servicio HTTPService generará un resultado (en caso de ir bien) o bien un error (en caso de ir mal). En ambos casos se invocará a los métodos respectivos declarados:

private function handleResultMetodo(event:ResultEvent):void
{
  miAC = event.result.elementoPadre.elementoHijo;
}

private function handleFaultMetodo(event:FaultEvent):void
{
  Alert.show(event.fault.faultString, "ERROR");
}

Cuando la invocación ha sido exitosa se ejecutará el primer método, pasando en el parámetro "event" el resultado del XML. Para agregar este XML en el ArrayCollection, hay que acceder a la propiedad "result" del mismo, e indicar cuál es el elemento padre y cuál el elemento hijo (registro o fila). Por ejemplo, un XML con esta estructura:

<?xml version="1.0" encoding="ISO-8859-1" ?>

<producto>
  <item>
    <codigo>1</codigo>
    <nombre>Boligrafo negro</nombre>
    <precio>1.25</nombre>
  </item>
  <item>
    <codigo>2</codigo>
    <nombre>Boligrafo azul</nombre>
    <precio>1.50</nombre>
  </item>
  <item>
    <codigo>3</codigo>
    <nombre>Boligrafo rojo</nombre>
    <precio>1.75</nombre>
  </item>
<producto/>

El elemento padre sería "producto", y el elemento hijo sería "item". El ArrayCollection contendría 3 registros (uno por cada elemento hijo), y cada uno de estos registros sería una fila en el DataGrid.

El ArrayCollection está cargado en este momento con la información. El ArrayCollection definido es enlazable (tiene la propiedad Bindable), lo que permite o un control visual, como un DataGrid o un List, enlazarse a los datos de este ArrayCollection, y cada cambio en áquel actualiza automáticamente los datos del control.

El DataGrid se define así:

<mx:DataGrid
  id="miDataGrid">
  DataProvider="{miAC}">
    <mx:DataGridColumn dataField="codigo" headerText="CODIGO" />
    <mx:DataGridColumn dataField="nombre" headerText="NOMBRE" />
    <mx:DataGridColumn dataField="precio" headerText="PRECIO" />
</mx:DataGrid>

El control DataGrid es un control de datos en forma de tabla. El atributo "id" define el nombre para el DataGrid. El elemento "DataProvider" indica al DataGrid de dónde debe obtener los datos. En este caso, se indica entre llaves el nombre del ArrayCollection para que se lleve a cabo el enlace automático.

El DataGrid tiene tres columnas, que se definen con "DataGridColumn". El atributo "dataField" especifica el campo de donde va a obtener el valor ("codigo", "nombre" o "precio", definidos en el XML). El atributo "headerText" define el título que aparecerá en la cabecera superior de cada columna.

Ahora viene la parte interesante, y es el filtro. Para ello, se definen controles de usuario, como una caja de texto, una lista desplegable o un calendario para obtener una fecha. En cualquiera de los casos utilizados, se ha de invocar a una método cuando el valor de estos controles cambia (el que aplique según el caso). En este caso, vamos a poner que haya sido un campo de texto y un botón de anulación:

<mx:TextInput id="txtBuscar" change="buscarMetodo()" />
<mx:Button label="Reiniciar" click="reiniciarMetodo()" />

En el caso de que se introduzca un carácter en la caja de texto se producirá un cambio, que recogerá el evento "change", y aquí le indicamos que ejecute el método
buscarMetodo(), el cual contiene el siguiente código:

private function buscarMetodo():void {
  miAC.filterFunction = filtroMetodo;
  miAC.refresh();
}

El ArrayCollection tiene una propiedad que se encarga de la gestión de filtrado de los datos que contiene. Para ello, delega en un método que especifica cómo llevar a cabo este filtrado, y el cual, su nombre se especifica a esta propiedad, en este caso, "filtroMetodo". Este método contendrá el siguiente código:

private function filtroMetodo(item:Object):Boolean{
  var encontrado:Boolean = false;
  if(item.nombre.toLowerCase().search(txtBuscar.text.toLowerCase()) != -1){
    encontrado = true;
  }
  return encontrado;
}

El ArrayCollection ejecutará este método por cada fila que contiene, enviando en el objeto "item" la fila que se está procesando. Aquí se compara la fila ("item") y el campo a comparar (en este caso "nombre"), anmbos igualados en minúsculas. En caso de que case esta comparación se retorna un valor true (se muestra) o en caso contrario retorna un valor false (no se muestra).

Otra forma de comparación sería así:

private function filtroMetodo(item:Object):Boolean{
  var encontrado:Boolean = true;
  var filter:String = item["nombre"]; // valor del campo "nombre"
  if(filter.toLowerCase().indexOf(txtBuscar.text.toLowerCase())<0){
    encontrado = false;
  }
  return encontrado;
}

Una vez se ha procesado todas las filas e identificadas cuáles cumplen los requisitos del filtro, la siguiente sentencia:

  miAC.refresh();

Para anular los filtros y que el ArrayCollection (y por ende, el DataGrid) contenga toda la información, hay que anular la propiedad "filterFunction" y refrescar el Arraycollection:

private function reiniciarMetodo():void {
  miAC.filterFunction = null;
  miAC.refresh();
}


Refresca o actualiza el contenido del ArrayCollection, propagándose automáticamente hacia el DataGrid que actualizará la información a mostrar.

Un ejemplo muy sencillo es el siguiente:

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="vertical" creationComplete="initData()">
<mx:Script>
<![CDATA[
import mx.collections.ArrayCollection;
[Bindable]
private var dataList:ArrayCollection ;

private function initData():void{
dataList= new ArrayCollection([
{name:"Atlantic City Medical Center-City Division", city:"Atlantic City"},
{name:"Atlantic City Medical Center-Mainland Division", city:"Pomona "},
{name:"Barnert Hospital", city:"Paterson"},
{name:"Bayonne Medical Center", city:"Bayonne"},
{name:"Bayshore Community Hospital", city:"Holmdel"},
{name:"Bergen Regional Medical Center. L.P.", city:"Paramus"},
{name:"Burdette Tomlin Memorial Hospital", city:"Cape May"},
{name:"Capital Health System - Fuld Campus", city:"Trenton"},
{name:"Capital Health System - Mercer Campus", city:"Trenton"},
{name:"CentraState Healthcare System", city:"Freehold"},
{name:"Chilton Memorial Hospital", city:"Pompton Plains"},
{name:"Christ Hospital", city:"Jersey City"},
{name:"Clara Maass Medical Center", city:"Belleville"},
{name:"Columbus Hospital", city:"Newark"},
{name:"Community Medical Center", city:"Toms River"},
{name:"East Orange General Hospital", city:"East Orange"},
{name:"Englewood Hospital and Medical Center", city:"Englewood"},
{name:"Hackensack University Medical Center", city:"Hackensack"},
{name:"Hackettstown Community Hospital", city:"Hackettstown"},
{name:"Holy Name Hospital", city:"Teaneck"},
{name:"Hospital Center at Orange", city:"Orange"},
{name:"Hunterdon Medical Center", city:"Flemington"},
{name:"Irvington General Hospital", city:"Irvington"},
{name:"Jersey Shore University Medical Center", city:"Neptune"},
{name:"JFK Medical Center", city:"Edison"},
{name:"Kennedy Memorial Hospitals/UMC Cherry Hill", city:"Cherry Hill"},
{name:"Kennedy Memorial Hospitals/UMC Stratford", city:"Stratford"},
{name:"Kennedy Memorial Hospitals/UMC Washington Twp", city:"Turnersville"},
{name:"Kessler Memorial Hospital", city:"Hammonton"},
{name:"Kimball Medical Center", city:"Lakewood"},
{name:"LibertyHealth-Greenville Hospital Campus", city:"Jersey City"},
{name:"LibertyHealth-Jersey City Medical Center Campus", city:"Jersey City"},
{name:"LibertyHealth-Meadowlands Hospital Campus", city:"Secaucus"},
{name:"Lourdes Medical Center of Burlington County", city:"Willingboro"},
{name:"Monmouth Medical Center", city:"Long Branch"},
{name:"MONOC", city:"Eatontown"},
{name:"Morristown Memorial Hospital", city:"Morristown"},
{name:"Muhlenberg Regional Medical Center", city:"Plainfield"},
{name:"Newark Beth Israel Medical Center", city:"Newark"},
{name:"Newton Memorial Hospital", city:"Newton"},
{name:"Ocean Medical Center", city:"Brick"},
{name:"Our Lady of Lourdes Medical Center", city:"Camden"},
{name:"Overlook Hospital", city:"Summit"},
{name:"Palisades Medical Center-New York Presbyterian ", city:"North Bergen"},
{name:"Pascack Valley Hospital ", city:"Westwood"},
{name:"PBI Regional Medical Center", city:"Passaic"},
{name:"Raritan Bay Medical Center - Old Bridge", city:"Old Bridge"},
{name:"Raritan Bay Medical Center - Perth Amboy", city:"Perth Amboy"},
{name:"Riverview Medical Center", city:"Red Bank"},
{name:"Robert Wood Johnson Univ Hospital", city:"New Brunswick"},
{name:"Robert Wood Johnson Univ Hospital at Hamilton", city:"Hamilton"},
{name:"Robert Wood Johnson Univ Hospital at Rahway", city:"Rahway"},
{name:"Saint Barnabas Medical Center", city:"Livingston"},
{name:"Saint Clares Hospital/Boonton Township", city:"Boonton"},
{name:"Saint Clares Hospital/Denville", city:"Denville"},
{name:"Saint Clares Hospital/Sussex", city:"Sussex"},
{name:"Saint James Hospital", city:"Newark"},
{name:"Saint Michaels Medical Center", city:"Newark"},
{name:"Saint Peters University Hospital", city:"New Brunswick"},
{name:"Shore Memorial Hospital", city:"Somers Point"},
{name:"Somerset Medical Center", city:"Somerville"},
{name:"South Jersey Healthcare-Bridgeton Hospital", city:"Bridgeton"},
{name:"South Jersey Healthcare-Elmer Hospital", city:"Elmer"},
{name:"South Jersey Healthcare-Newcomb Hospital", city:"Vineland"},
{name:"Southern Ocean County Hospital", city:"Manahawkin"},
{name:"St. Francis Medical Center", city:"Trenton"},
{name:"St. Josephs Regional Medical Center", city:"Paterson"},
{name:"St. Josephs Wayne Hospital", city:"Wayne"},
{name:"St. Mary Hospital", city:"Hoboken"},
{name:"St. Marys Hospital Passaic", city:"Passaic"},
{name:"Summit Hospital", city:"Summit"},
{name:"The Cooper Health System", city:"Camden"},
{name:"The Memorial Hospital of Salem County", city:"Salem"},
{name:"The Mountainside Hospital", city:"Montclair"},
{name:"The Valley Hospital", city:"Ridgewood"},
{name:"Trinitas Hospital", city:"Elizabeth"},
{name:"Underwood-Memorial Hospital", city:"Woodbury "},
{name:"Union Hospital", city:"Union"},
{name:"University Medical Center at Princeton", city:"Princeton"},
{name:"University of Medicine &amp; Dentistry of NJ-Univ Hosp", city:"Newark"},
{name:"Virtua Memorial Hospital Burlington County", city:"Mount Holly"},
{name:"Virtua West Jersey Hospital-Berlin", city:"Berlin"},
{name:"Virtua West Jersey Hospital-Marlton", city:"Marlton"},
{name:"Virtua West Jersey Hospital-Voorhees", city:"Voorhees"},
{name:"Warren Hospital", city:"Phillipsburg"}
])
}

private function filterDemo():void{
dataList.filterFunction = searchDemo;
dataList.refresh();
}

private function searchDemo(item:Object):Boolean{
var isMatch:Boolean = false
if(item.name.toLowerCase().search(search.text.toLowerCase()) != -1){
isMatch = true
}
return isMatch;
}

private function clearSearch():void{
dataList.filterFunction = null;
dataList.refresh();
search.text = '';
}
]]>
</mx:Script>
<mx:Form>
<mx:FormItem label="Search" direction="horizontal">
<mx:TextInput id="search" change="filterDemo()" />
<mx:Button label="Clear Search" click="clearSearch()" />
</mx:FormItem>
</mx:Form>
<mx:DataGrid dataProvider="{dataList}" width="400" height="400">
<mx:columns>
<mx:DataGridColumn headerText="Name" dataField="name" />
<mx:DataGridColumn headerText="City" dataField="city" />
</mx:columns>
</mx:DataGrid>

</mx:Application>


En este caso, la información está en el propio código (por eso es tan extenso), y se carga directamente en el ArrayCollection sin usar un HTTPService. Pero el objetivo principal era demostrar el uso de los filtros.

Este ejemplo (el último) está en el siguiente enlace: http://www.boyzoid.com/blog/index.cfm/2006/10/19/Filtering-Data-in-Flex

Para terminar, indicar que los dos ejemplos expuestos anteriormente se han basado en un único campo, pero lo más normal es que un filtro pueda tener varios campos y se combinen entre sí.

A continuación voy a poner un ejemplo (no completo, si no únicamente en lo que concierne a este punto), con dos campos: uno en una combo y otro en un calendario.

El primero filtra un tipo de lotes. Sus valores pueden ser: * (todos), "I" (entrada o true) y "O" (salida o false).

Los dos campos de filtro, invocan a sus respectivos métodos cuando cambian su valor:

// User click on combo type of batch
private function changeType():void {
  if (cboTipoLote.value!="*" || datLote.text!=null)
    this.batchesList.filterFunction=filterBatches;
  else
    this.batchesList.filterFunction=null;

  this.batchesList.refresh();
}

// User change date on date field
private function changeDate():void {
  if (cboTipoLote.value!="*" || datLote.text!=null)
    this.batchesList.filterFunction=filterBatches;
  else
    this.batchesList.filterFunction=null;

  this.batchesList.refresh();
}

El ArrayCollection está definido en "batchesList".

El método delegado para el filtrado es el siguiente:

// Filter of batches
private function filterBatches(item:Object):Boolean
{
  var match:Boolean = true;
  var sDate:String="";
  var sType:String="";

  if (cboTipoLote.value=="I")
    sType="true";
  else if (cboTipoLote.value=="O")
    sType="false";
  else
    sType="";

  if (datLote.text !="") {
    sDate =""+datLote.selectedDate.fullYear +"-";
    var vValue:String=""+(datLote.selectedDate.month+1);
    if (vValue.length==1)
      vValue = "0" + vValue;

      sDate+= vValue + "-";

      vValue = ""+(datLote.selectedDate.date);
    if (vValue.length==1)
      vValue = "0" + vValue;

      sDate += vValue;
    }
    else
      sDate = "";

    var filter1:String = item["input_batch"];
    var filter2:String = item["date"];

    if (sDate!="" && sType=="") {
      if (!filter2 || filter2.indexOf(sDate)<0)
      match=false;
    }
    else if (sDate=="" && sType!="") {
      if (!filter1 ||
        filter1.toLowerCase().indexOf(sType.toLowerCase())<0)
        match=false;
    }
    else {
      if (!filter1 ||
        filter1.toLowerCase().indexOf(sType.toLowerCase())<0 ||
        !filter2 || filter2.indexOf(sDate)<0)
        match=false;
    }

  return match;
}

En este caso se contemplan 3 casos:
1) Se ha facilitado tipo pero no fecha
2) Se ha facilitado fecha pero no tipo
3) Se han facilitado tipo y fecha

Espero que estos ejemplos sean claros y útiles para desarrollar aplicaciones AIR y Flex más interactivas y optimizadas.