martes, 16 de junio de 2015

SlickGrid. Proveedor de datos (I) - Creando un proveedor de datos

Artículos anteriores:
El SlickGrid es muy flexible a la hora de consumir datos. Los datos se pasan al grid a través del constructor y éstos se pueden proporcionar en un array, como hemos visto hasta ahora, o en un objeto proveedor de datos que nos permita tener un control total sobre la forma en la que SlickGrid muestra la información.

El paquete de descarga de SlickGrid contiene una implementación de un proveedor de datos en el archivo slick.dataview.js. Este proveedor de datos, al que se puede acceder a través de SlickGrid.Data.DataView, implementa un importante número de funcionalidades y puede ajustarse a las necesidades de un gran número de escenarios que nos podamos encontrar en el desarrollo de nuestras aplicaciones. Además resulta un código de ejemplo muy valioso para los casos en que necesitamos definir nuestro propio proveedor.

Dado que el objetivo de este ejemplo es comprender cómo funcionan los proveedores de datos del SlickGrid no voy a utilizar esta implementación, si no que me crearé un proveedor de datos simple al que iré añadiendo funcionalidad para mostrar la potencia que nos puede dar este elemento.


Interfaz de un proveedor de datos


El SlickGrid se comunica con el proveedor de datos a través de tres métodos que éste debe implementar:

  • getLength(): devuelve el número de elementos del conjunto de datos
  • getItem(index): devuelve el elemento para un índice determinado
  • getItemMetadata(index): devuelve información adicional sobre el elemento con el índice indicado. La implementación de este método es opcional.

Más adelante veremos la información que podemos especificar a través del método getItemMetadata.

Creando un proveedor de datos básico

Voy a crear un proveedor de datos para los datos de productos que he estado utilizando a lo largo de los diferentes ejemplos.
El proveedor utilizará como origen de datos el array products que teníamos ya creado e implementará los dos métodos obligatorios que debe tener un proveedor de datos de SlickGrid: getLength y getItem.
Para ello me he creado un nuevo archivo productsdataprovider.js en la carpeta Data del proyecto.

function ProductsDataProvider() {
    var _data = products;

    this.getLength = function () {
        return _data.length;
    };

    this.getItem = function (index) {
        return _data[index];
    }
}

Como podemos ver la implementación es muy simple. getLength devuelve el número de elementos del conjunto de datos y getItem devuelve el elemento de datos del índice especificado. El conjunto de datos que utiliza es el array products definido en el archivo productdatasource.js.
Voy a añadir una nueva página DataProvider.html al sitio web partiendo de la que utilizamos en el anterior ejemplo de carga asíncrona de datos.
En la página añadiré una referencia al nuevo archivo productsdataprovider.js y pasaré al constructor del SlickGrid una instancia del objeto ProductsDataProvider en lugar de pasarle directamente el array de datos:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <title>Proveedor de datos simple</title>
    <link href="https://code.jquery.com/ui/1.11.4/themes/blitzer/jquery-ui.css" rel="stylesheet" />
    <link href="css/slick.grid.css" rel="stylesheet" />
    <link href="css/theme/slick-default-theme.css" rel="stylesheet" />
    <link href="css/pildorasgrid.css" rel="stylesheet" />
    <script src="http://code.jquery.com/jquery-1.11.3.min.js"></script>
    <script src="http://code.jquery.com/ui/1.11.4/jquery-ui.min.js"></script>
    <script src="Scripts/jquery.event.drag-2.2.js"></script>
    <script src="Scripts/slick.core.js"></script>
    <script src="Scripts/slick.grid.js"></script>
    <script src="Scripts/gridformatters.js"></script>
    <script src="data/productdatasource.js"></script>
    <script src="data/productsdataprovider.js"></script>
    <script src="Scripts/grideditors.js"></script>
    <script type="text/javascript">
        $(function () {
            var columns = [
                { name: "ID", field: "ProductID", id: "ProductID", width: 60, resizable: false
                    , headerCssClass: "prKeyHeadColumn", cssClass: "numericCell", editor: Slick.Editors.Text },
                { name: "Nº Producto", field: "ProductNumber", id: "ProductNumber", width: 120, resizable: false
                    , headerCssClass: "headColumn", editor: Slick.Editors.Text },
                { name: "Denominación", field: "Name", id: "Name", width: 250, minWidth: 150, maxWidth: 400
                    , headerCssClass: "headColumn", editor: Slick.Editors.Text },
                { name: "Color", field: "Color", id: "Color", width: 80, minWidth: 60, maxWidth: 120
                    , headerCssClass: "headColumn", formatter: Slick.Formatters.ColorFormatter
                    , editor: Slick.Editors.Color },
                { name: "Precio", field: "StandardCost", id: "StandardCost", width: 110, minWidth: 80, maxWidth: 170
                    , headerCssClass: "headColumn", cssClass: "numericCell", formatter: Slick.Formatters.CurrencyFormatter
                    , editor: Slick.Editors.Text },
                { name: "Sub", field: "ProductSubcategoryID", id: "ProductSubcategoryID", width: 60, resizable: false
                    , headerCssClass: "headColumn", cssClass: "numericCell", editor: subcategoryEditor },
                { name: "Subcategoría", field: "ProductSubcategoryID", id: "SubcategoryName"
     , width:200, minWidth: 150, maxWidth: 400, headerCssClass: "headColumn"
     , formatter: asyncSubcategoryNameFormatter, asyncPostRender: getSubcategoryName, cache: {}
                }
            ];

            var options = {
                editable: true,
                enableAsyncPostRender: true,
                asyncPostRenderDelay: 10
            };

            var grid = new Slick.Grid("#DataProviderGrid", new ProductsDataProvider(), columns, options);
        });
    </script>
</head>
<body>
    <div id="DataProviderGrid" style="width:1024px; height:500px;"></div>
</body>
</html>

Si cargamos la nueva página en el navegador veremos que el comportamiento del grid no ha variado en absoluto.

Grid con datos

Puede parecer que no hemos hecho nada con este cambio, excepto complicar nuestro código, sin embargo ahora tenemos una gran ventaja: disponemos de un objeto intermedio entre los datos y el SlickGrid el cual nos permite un control total sobre los datos que recibe el grid.

Añadiendo una línea de totales

Para comprobar el tipo de control que nos permite tener el proveedor de datos, voy a añadir una línea de totales en las que mostraré el número total de productos (en la columna ProductID) y el precio medio de los productos (en la columna StandardCost).
La línea de totales se mostrará como primera línea del grid.

Para añadir la línea de totales simplemente necesitaremos modificar el proveedor de datos. El método getLength, que devuelve el número de elementos a mostar en el grid, devolverá el número de productos más uno (la línea de totales). El método getItem deberá devolver la línea de totales cuando el índice recibido sea igual a cero (primera línea del grid), para ello he creado una función getTotalRows que crea el elemento de datos para la línea de totales a partir de los datos del array productos. Para el resto de índices deberá realizar una traducción entre los índices de las líneas del grid y los del array products restando uno al índice de línea (la línea 1 se corresponderá con el elemento 0 del array, la 2 con el elemento 1, etc.)

function ProductsDataProvider() {
    var _data = products;

    this.getLength = function () {
        return _data.length + 1;
    };

    this.getItem = function (index) {
        if (index == 0)
            return getTotalsRow();
        else
            return _data[index - 1];
    }

    function getTotalsRow() {
        var sumOfPrice = 0;
        for (var item in _data) {
            sumOfPrice += _data[item].StandardCost;
        }
        
        return { ProductID: _data.length, StandardCost: (sumOfPrice / _data.length) };
    }
}

Si volvemos a cargar la página veremos que debajo de la cabecera del grid aparece en primer lugar nuestra línea de totales y a continuación las correspondientes a los productos.

Grid con totales

Sin embargo podremos comprobar que el resultado es bastante pobre: la línea de totales no debería comportarse ni mostrarse igual que el resto de líneas (ahora mismo incluso nos permite editar las celdas, aunque si modificamos un valor éste no persiste) y, si actualizamos los datos de los productos, podremos comprobar que la línea de totales no se recalcula automáticamente. De hecho, si editamos un precio y forzamos que se vuelva a visualizar la línea de totales (haciendo scroll para dejarla fuera de la ventana de visualización y volviendo a mostrarla) veremos que en el cálculo de precios aparece un error NaN.

Demasiados problemas para cogerlos todos juntos. Así que vamos uno por uno:

- Podemos indicar al grid que el modo de visualización de la línea de totales es diferente al del resto de líneas a través del método getItemMetadata del proveedor de datos. La funcionalidad de este método y la información que podemos proveer al grid a través de él lo examinaremos en profundidad en el próximo artículo.
- El hecho de que aparezca un error al recalcular el precio medio no tiene nada que ver en realidad con el proveedor de datos. El problema se encuentra en el editor de la celda. Utilizamos el mismo editor de textos para las celdas con valores de cadena y las de valores numéricos, por lo que cuando realizamos una modificación el valor que guardamos en el elemento de datos es el texto introducido. Esto provoca que se genere un error al tratar este valor como numérico. Para solucionarlo crearé un editor específico para las celdas con valores numéricos.
- La línea de totales no se actualiza al editar porque no le hemos indicado al grid que deba refrescar el contenido de esta línea cuando se edita otra. Para solucionarlo voy a crear en el proveedor de datos un método onCellChange que deberá ejecutarse cada vez que se modifique una celda del grid. El SlickGrid no va a ejecutar este método de forma automática (sería algo para valorar en futuras versiones) por lo que deberemos suscribir manualmente el método al evento onCellChange del grid.

El código del editor es muy similar al de texto. Las modificaciones que tiene son:

  • El elemento input que genera es de tipo numeric
  • El método serializeValue realiza la traducción del texto introducido a un valor numérico a través de la función parseFloat
  • He añadido una regla de validación para evitar que el usuario introduzca valores no numéricos

No me voy a extender en la explicación del editor. Si se desea una explicación detallada sobre cómo definir un editor de celda se puede encontrar en el ejemplo SlickGrid. Habilitando la edición.


$(function () {
    $.extend(true, window, {
        "Slick": {
            "Editors": {
                "Text": TextEditor,
                "Color": ColorEditor,
                "Numeric": NumericEditor
            }
        }
    });


 .....
 
 
function NumericEditor(args) {
 var input;
 var oldValue;

 var init = function () {
  var inputElement = document.createElement("input");
  inputElement.setAttribute("type", "numeric");
  inputElement.className = "gridTextEditor";
  args.container.appendChild(inputElement);
  input = $(inputElement);
  input.bind("keydown", function (e) {
   if (e.keyCode === $.ui.keyCode.LEFT || e.keyCode === $.ui.keyCode.RIGHT) {
    e.stopImmediatePropagation();
   }
  })
   .focus()
   .select();
 }();

 this.destroy = function () { input.remove(); }

 this.focus = function () { input.focus(); }

 this.getValue = function () { return input.val(); }

 this.setValue = function (value) { input.val(value); }

 this.loadValue = function (item) {
  oldValue = item[args.column.field] || "";
  input.val(oldValue).attr("defaultValue", oldValue);
  input.select();
 }

 this.serializeValue = function () {
  return parseFloat(input.val()) || 0;
 }

 this.applyValue = function (item, state) { item[args.column.field] = state; }

 this.isValueChanged = function () { return (input.val() != oldValue); }

 this.validate = function () {
  if (isNaN(input.val())) {
   return {
    valid: false,
    msg: "Introduzca un valor numérico válido"
   };
  }

  return {
   valid: true,
   msg: null
  };
 }
}
var columns = [
{ name: "ID", field: "ProductID", id: "ProductID", width: 60, resizable: false
 , headerCssClass: "prKeyHeadColumn", cssClass: "numericCell", editor: Slick.Editors.Numeric },
{ name: "Nº Producto", field: "ProductNumber", id: "ProductNumber", width: 120, resizable: false
 , headerCssClass: "headColumn", editor: Slick.Editors.Text },
{ name: "Denominación", field: "Name", id: "Name", width: 250, minWidth: 150, maxWidth: 400
 , headerCssClass: "headColumn", editor: Slick.Editors.Text },
{ name: "Color", field: "Color", id: "Color", width: 80, minWidth: 60, maxWidth: 120
 , headerCssClass: "headColumn", formatter: Slick.Formatters.ColorFormatter
 , editor: Slick.Editors.Color },
{ name: "Precio", field: "StandardCost", id: "StandardCost", width: 110, minWidth: 80, maxWidth: 170
 , headerCssClass: "headColumn", cssClass: "numericCell", formatter: Slick.Formatters.CurrencyFormatter
 , editor: Slick.Editors.Numeric },
{ name: "Sub", field: "ProductSubcategoryID", id: "ProductSubcategoryID", width: 60, resizable: false
 , headerCssClass: "headColumn", cssClass: "numericCell", editor: subcategoryEditor },
{ name: "Subcategoría", field: "ProductSubcategoryID", id: "SubcategoryName"
 , width: 200, minWidth: 150, maxWidth: 400, headerCssClass: "headColumn"
 , formatter: asyncSubcategoryNameFormatter, asyncPostRender: getSubcategoryName, cache: {} }
];

Si volvemos a cargar la página comprobaremos que hemos solucionado el problema del error en el cálculo. Si editamos uno o varios precios y sacamos la fila de totales de la ventana de visualización, al volver a mostrarla comprobaremos que el precio medio se ha actualizado correctamente.

Sin embargo el precio medio debería actualizarse aunque la línea de totales se encuentre visible. Para ello voy a crear el comentado método onCellChange en el proveedor de datos que se encargue de comunicar al grid que debe refrescar la línea de totales. Este método comprueba si la celda modificada se corresponde con el campo "StandardCost" y, si es así, llama al método invalidateRow para la línea de totales y a continuación llama al método render del grid para que redibuje la línea.

Por último únicamente tendremos que suscribir este método al evento onCellChange del grid en la página DataProvider.html.

this.onCellChange = function (e, args) {
 var grid = args.grid;
 if (grid.getColumns()[args.cell].field == "StandardCost") {
  grid.invalidateRow(0);
  grid.render();
 }
}
var dataProvider = new ProductsDataProvider();
var grid = new Slick.Grid("#DataProviderGrid", dataProvider, columns, options);
grid.onCellChange.subscribe(dataProvider.onCellChange);

Si recargamos la página comprobaremos que, ahora sí, al editar el precio de un producto se actualiza el precio medio calculado en la línea de totales.

Podríamos realizar un último cambio para optimizar nuestro código. Ahora mismo la línea de totales se recalcula cada vez que el grid visualiza la fila. Podríamos almacenar la información de la línea en una variable de forma que únicamente llamemos a la función que recalcula los valores al crear el proveedor de datos (con los datos iniciales) y cuando cambie algún precio (desde el método onCellChange).

function ProductsDataProvider() {
    var _data = products;
    var _totals = getTotalsRow();

    this.getLength = function () {
        return _data.length + 1;
    };

    this.getItem = function (index) {
        if (index == 0)
            return _totals;
        else
            return _data[index - 1];
    }

    this.onCellChange = function (e, args) {
        var grid = args.grid;
        if (grid.getColumns()[args.cell].field == "StandardCost") {
            _totals = getTotalsRow();
            grid.invalidateRow(0);
            grid.render();
        }
    }

    function getTotalsRow() {
        var sumOfPrice = 0;
        for (var item in _data) {
            sumOfPrice += _data[item].StandardCost;
        }
        
        return { ProductID: _data.length, StandardCost: (sumOfPrice / _data.length) };
    }
}

Únicamente nos quedaría pendiente el problema del estilo de visualización de la línea de totales. Este problema lo solucionaremos en el próximo artículo en el que veremos cómo pasar información adicional de visualización de los datos a través del método getItemMetadata.

El código


Puedes descargar el código de todos los ejemplos de SlickGrid de:



Artículo siguiente:
SlickGrid. Proveedor de datos (y II) - El método getItemMetadata

No hay comentarios:

Publicar un comentario