En la Primera parte exploro explicoque quiero hacer, y las herramientas que utilize, en especial para la implementación de el api RESTful, ahora quedaba la desición sobre que data grid en javascript implementar.
El requerimiento era que soporta Bootstrap, por lo tanto solo encontré dos opciones viables:
BBGrid y FuelUX
BBGrid esta altamenta relacionado con un objeto o collección hecho con backbone.js, hacerlo funcionar es sumamente sencillo:
var MyGrid = new bbGrid.View({ container: $('#bbGrid-container'), collection: MyCollection, colModel: [{ title: 'ID', name: 'id', sorttype: 'number' }, { title: 'Full Name', name: 'name' }, { title: 'Company', name: 'company' }, { title: 'Email', name: 'email' } ] });
El contenedor debe de ser un div, la colleción es una hecha con backbone y el modelo.
PERO, y es aquí lo que no me explico de las librerias, y tambien por lo que las odio. Las colecciones de backbone esperan que en un solo query, se haga el "fetch" de toooda la colección, no hay "lazy load", lo cual, como desarrollador veterano, me queda con un ¿Oseq que Pedo?, de entraqda la colección que quiero paginar de entrada es de más de 6mil "items", y desafortunadamente bbGrid espera lo mismo, que le pases toda la colección para que ella haga el filtrado y paginado "client side", así que.. desafortunadamente tuve que desechar bbGrid. Fue geníal en su momento como con "solo el modelo" y la colección me creaba el grid, pero... Toda la colección? Seguramente el tamaño de los datos en javascript lo deben de obtener con un vil <arreglo>.lenght, por lo tanto, eso me indicó que lo que necesitaba era:
Un datagrid el cual le pudiera pasar el número de datos por un lado y los datos, para despues poder ir haciendo yo el "lazy load" que se requiera.
Con eso en mente, seguí buscando y la otro que encontré fué FuelUX, una adición notese las negritas e itálicas, que extiende el bootstrap y ademas es fácilmente extendible, y el ejemplo de la página se ve... relativamente bonito:
Geographic Data Sample | |||
---|---|---|---|
Name | Country | Population | Type |
Mexico City | MX | 12294193 | capital of a political entity |
Manila | PH | 10444527 | capital of a political entity |
Dhaka | BD | 10356500 | capital of a political entity |
Seoul | KR | 10349312 | capital of a political entity |
Jakarta | ID | 8540121 | capital of a political entity |
Tokyo | JP | 8336599 | capital of a political entity |
Taipei | TW | 7871900 | capital of a political entity |
Beijing | CN | 7480601 | capital of a political entity |
Bogotá | CO | 7102602 | capital of a political entity |
Hong Kong | HK | 7012738 | capital of a political entity |
Pero el primer problema fué que, no esta tán encapsulado como el bbGrid, y hay que hacer mucho "markup" a mano, por que hay que admitirlo, es muy flexible y es, propiamente un plugin de jquery, y no requeria exactamente una colecion de backbone.js (cosa que ahorita que estoy escribiendo me da caé el 20), como la parte del markup te la dan, pues me fuí a ver la implementación del ejemplo:
Using datagrid
Call the datagrid via javascript:
- $('#MyGrid').datagrid({ dataSource: dataSource, stretchHeight: true })
Data Source
Provide a data source to the datagrid to supply column information and data. Your data source must support the following two methods.
Name | Parameters | Description |
---|---|---|
columns | (none) | Returns an array of objects containing column information |
data | options (object), callback(function) | The options parameter contains search, sortProperty, sortDirection, pageIndex, and pageSize. Retrieve data then callcallback with an object containing data, start, end, count, pages, and page. View the source of this page for an example static data source. |
Ohhhh, igual que BBgrid, asocio un id y llamo el plugin, con un parámetro importante, el dataSource, el cual yo creí de inicio que era una colección de backbone.js, pero nó, es un objeto que tiene dos métodos, columns y data. Columns es la descripcion del modelo y data, como la documentación lo dice es un método que recibe 2 parametros, options con las opciones y un callback, el callback debe de regresar data, start, end, count, pages y page, en otras palabras... la paginación es hecha a patín, sin embargo, tenía exactamente las características que buscaba. Así a a hacer el ejemplo.
FuelUX vs bbGrid
bbGrid, lo unico que necesitaba era un <div id="myGrid"></div> y se encargaba del render de todo el grid. FlexUX, no, hay que definir el markup y aprovechar la funcionalidad de Bootstrap para el render de los elementos de control, busquedas y filtros, el markup recomendado es:
- <table id="MyGrid" class="table table-bordered datagrid">
- <thead>
- <tr>
- <th>
- <span class="datagrid-header-title">Geographic Data Sample</span>
- <div class="datagrid-header-left">
- <div class="input-append search datagrid-search">
- <input type="text" class="input-medium" placeholder="Search">
- <button type="button" class="btn"><i class="icon-search"></i></button>
- </div>
- </div>
- <div class="datagrid-header-right">
- <div class="select filter" data-resize="auto">
- <button type="button" data-toggle="dropdown" class="btn dropdown-toggle">
- <span class="dropdown-label"></span>
- <span class="caret"></span>
- </button>
- <ul class="dropdown-menu">
- <li data-value="all" data-selected="true"><a href="#">All</a></li>
- <li data-value="lt5m"><a href="#">Population < 5M</a></li>
- <li data-value="gte5m"><a href="#">Population >= 5M</a></li>
- </ul>
- </div>
- </div>
- </th>
- </tr>
- </thead>
- <tfoot>
- <tr>
- <th>
- <div class="datagrid-footer-left" style="display:none;">
- <div class="grid-controls">
- <span>
- <span class="grid-start"></span> -
- <span class="grid-end"></span> of
- <span class="grid-count"></span>
- </span>
- <div class="select grid-pagesize" data-resize="auto">
- <button type="button" data-toggle="dropdown" class="btn dropdown-toggle">
- <span class="dropdown-label"></span>
- <span class="caret"></span>
- </button>
- <ul class="dropdown-menu">
- <li data-value="5" data-selected="true"><a href="#">5</a></li>
- <li data-value="10"><a href="#">10</a></li>
- <li data-value="20"><a href="#">20</a></li>
- <li data-value="50"><a href="#">50</a></li>
- <li data-value="100"><a href="#">100</a></li>
- </ul>
- </div>
- <span>Per Page</span>
- </div>
- </div>
- <div class="datagrid-footer-right" style="display:none;">
- <div class="grid-pager">
- <button type="button" class="btn grid-prevpage"><i class="icon-chevron-left"></i></button>
- <span>Page</span>
- <div class="input-append dropdown combobox">
- <input class="span1" type="text">
- <button type="button" class="btn" data-toggle="dropdown"><i class="caret"></i></button>
- <ul class="dropdown-menu"></ul>
- </div>
- <span>of <span class="grid-pages"></span></span>
- <button type="button" class="btn grid-nextpage"><i class="icon-chevron-right"></i></button>
- </div>
- </div>
- </th>
- </tr>
- </tfoot>
- </table>
Ahora si se fijan, el markup, lo que esta entre el table hader y el table flooter no esta encapsulado en ningun span, entoces pues si, el bootstrap se vuelve loco y renderea como puede, muy probalmente acomodando los elementos en forma vertical, para hacerlos horizontal, a los class le agregaba un span6 o span4 para utilizar las funcionalies del grid x 12 de bootstrap.
Ahora, por alguna extraña razón, cuando incluia el loader.js para FuelUX, la parte del menú que es para filtrar con filtros estáticos (All, > 5m, <5m) dejaba de funcionar, despues de varios días, el primer problema que tuve fué que estaba utilizando un Boostrap más nuevo que el FuelUX, y despues, en algun lado rastreando el problema resulta que bootstrap.js esta incluido en fuelux.js que carga el loader.js definido por el require.js! Por eso no me gustan las librerias que dependen de librerias que dependen de librerias. Total que para que funcionara adecuadamente, solo habia que quitar la carga de bootstrap.js y dejar solo la carga de loader.js de FuelUX y se renderea correctamente.
Ahora, el siguiente paso fué entender el "datasource", que su definicion es... algo más que horrible, por que usa una serie de anidaciones típicas de javascript y el require e implementa patrón de diseño :
Y... FUCK YOU! aparte, yo quería utilizar las propiedades "RESTful" de el backbone.js para el modelo y colección... así que... a darle hack and slash.(function (root, factory) {if (typeof define === 'function' && define.amd) {define(['underscore'], factory);} else {root.StaticDataSource = factory();}}(this, function () {var StaticDataSource = function (options) {this._formatter = options.formatter;this._columns = options.columns;this._delay = options.delay || 0;this._data = options.data;};StaticDataSource.prototype = {columns: function () {return this._columns;},data: function (options, callback) {var self = this;
Backbone.js
Ahora, queria utilizar la colección de backbone.js por que ofrecía la creación, edición y borrado (CRUD) en forma restful nativo. A mí en este momento, lo que me importa es el datagrid para mostrar datos paginados, así que debia de trabajar con mi "api" resftul, así que lo primero que definí fue mi ruta en epiphani, para saber, antes que nada, cuanto es el número máximo de "algo", por lo que defini:
- getRoute()->get('/sync/totales/(\w+)', array('sync','totalof'));
Esta definición la pense para que utilizara un sanitización de la entrada a una sola palabra, de esta forma no pueden mandarme ataque de sql, por que tiene que ser una sola palabra o caracteres, y pensando en términos de OWASP, donde aun si algo es erroneo hay que responder algo, la funcion totalof tiene una sencilla estructura switch/case que en el caso por default, es decir, en el caso no existente, me genra una respuesta válida de 0. Nada truena y no revelo nada de información adicional:
static public function totalof($item=null) { $response=array(); if(is_null($item)) { $response['total']=0; print json_encode($response); return; } $table=null; switch($item) { case "clientes": $table="tabletaEntry"; $field="tabletaEntryid"; break; } if(is_null($table)) { $response['total']=0; print json_encode($response); return; } global $db; $total=$db->getOne("select count(!) from !", array($field, $table)); if(DB::isError($total)) { DB::raiseError($total); return; } $response['total']=$total; print json_encode($response); return; }
Ahora, epiphany es la única libreria RESTful que he utilizado, pero tambien al ver la parte del backbone, una vez que defines tu coleción defines un url del cual hará el fetch de la información, y aquí es en donde comienza mi conflicto con reftul.
Pero antes que eso veamos la implementación de backbone
Y dale con el MVC, ya habíamos leido que backbone es para implementar un MVC del lado del cliente, en este caso, con javascript. Por lo tanto, la parte del C dicen que es vía restuful con el servidor entendiendo que la idea del restful es mandar las cosas en formato de un url. Así que, defini mi modelo y mi coleccion de modelos de la siguiente forma:
var User = Backbone.Model.extend({ idAttribute: "tabletaEntryid" }); var Users = Backbone.Collection.extend({ model:User, url:"/sync/usuarios", });Para el modelo, solo me interesa asociar el "id" del modelo a el id que manejo en la base de datos y por eso declaro:
- idAttribute: "tabletaEntryid"
Y para la colección de Users de user declaro que mi url "RESTful" es /sync/usuarios, pero de nuevo nos enfrentamos a que el "get" que hace backbone espera hacer un "fetch" de toda la coleción, sin embargo hay forma de parametrizar el url, mandado parámetros en formato JSON del estilo:
collection.fetch({ data: { page: 1} });
Y eso generaria un "get" a "/sync/usuarios con los parámetros ?page=1, pero sin embargo, lo que me imaginaba de RESTful sin leer la tesis de Roy Fielding yo me imaginaba que TODO debía de ser "url friendly" y ?llave=valor dista mucho de ser friendly, aparte el método de pasar variables vía get siempre lleva consigo el riesgo de la inyección de datos, entoces para que demonios, en términos de seguridad me sirve RESTful? aparte, yo había diseñado mi "api" "RESTful" con un formato /sync/usuarios/fromid/<num>/offset/<num> que se mapea perfectamente con una ruta:
- getRoute()->get('/sync/users/fromid/(\d+)/offset/(\d+)', array('sync', 'showUsers'));
Eso es mucho más seguro que el pasar datos vía get, es "linkeable" y es URL friendly. Pero, desafortunadamente, esto no era algo estaba diseñado en la colección, sin embargo, llega javascript con su orientación a aspectos a salvar el día, permitiendome definir:
var Users = Backbone.Collection.extend({ model:User, url:function (){ return "/sync/users/fromid/"+this.maximoId+"/offset/"+this.pageSize; }, pageIndex:0, pageSize:0, maximoId:0, });
Donde creo una función anónima sociada a url que al "accesarla" me regresa el url que yo deseo al "mapear" el pageSize de el datagrid FuelUX como una propiedad de la colección, el maximoID como el id maximo a partir del cual haré el fetch y se define url como una funcion que regresa el url en el formato que yo quiero. Generando un get tipo /sync/users/fromid/0/offset/2 muy fácil de probar y validar, para que me traiga el 2 elementos desde el id 0. Ven? ESO es un URL no /sync/users?fromid=0&offset=2.
Por lo tanto, la colecicón queda configurada para hacer "fetchs" parciales. Ahora, backbone tiene sus "monadas", como por ejemplo, no sobreescribe datos que ya has obtenido previamente y si el fetch obtiene datos "que no estaban en la colección", entoces los agrega a la colección, por lo tanto la colección de backbone me ofrece el escenario ideal para un pager "lazy load". Puedo obtener los datos en forma segmentada y ademas me funciona como cache.
Ahora, con esto en mente, hay que comenzar a integrar el "dataSource" para el datagrid de FuelUX, como vimos inicialmente su definición es:
- $('#MyGrid').datagrid({ dataSource: dataSource, stretchHeight: true })
Y lo que hize, fue simplificar el objeto del dataSource de un factory a un simple objeto, por que mi objetivo no es ahorita aprender require o patrones en javascript si no unir el datagrid de FuelUX con Backbone y mi api REST
var StaticDataSource = function (options, collection) { this._columns = options.columns; this._collectionName=options.collectionName; this._collection=collection; this.totales=0; }; StaticDataSource.prototype = { columns: function () { return this._columns; }, data: function (options, callback) { console.log(JSON.stringify(options)); var totales=this.totales; if(this.totales==0) { $.ajaxSetup( { "async": false } ); $.getJSON('/sync/totales/'+this._collectionName, function(data){ totales=data.total; }); } this.totales=totales; this._collection.pageIndex=options.pageIndex; this._collection.pageSize=options.pageSize; // console.log(this._collection.url()); // console.log("Totales:"+totales); // console.log("Coleccion iinicial"+this._collection.length); // console.log("pageIndex:"+options.pageIndex); // console.log("pageSize:"+options.pageSize); if(this._collection.length <= options.pageIndex * options.pageSize || (options.pageIndex==0 && this._collection.length < options.pageSize)) { if(this._collection.length <= (options.pageIndex + 1) * options.pageSize && this._collection.length > 0) { offset= Math.ceil((options.pageIndex + 1)* options.pageSize / this._collection.length); this._collection.pageSize=options.pageSize * offset; } this._collection.fetch({async:false, add: true, remove: false}); } var data=this._collection.toJSON(); // console.log("Coleccion final"+this._collection.length); var count = totales; var startIndex = options.pageIndex * options.pageSize; var endIndex = startIndex + options.pageSize; var end = (endIndex > count) ? count : endIndex; var pages = Math.ceil(count / options.pageSize); var page = options.pageIndex + 1; var start = startIndex + 1; data = data.slice(startIndex, endIndex); //formateador del dato _.each(data, function(cliente, idx) { if(!cliente.validado) data[idx]["validado"]="No"; else data[idx]["validado"]="Si"; } ); if(this._collection.length > 0 && data.length > 0) { // console.log("Collecion para cData:"+data.length); cData=data.length-1; // console.log("Ultimo:"+cData+"Id:"+ data[cData.toString()]["tabletaEntryid"]); this._collection.maximoId=data[cData.toString()]["tabletaEntryid"]; } callback({ data: data, start: start, end: end, count: this.totales, pages: pages, page: page }); } };
Mi dataSource recibe dos parámetros, mi modelo de columnas para poderselas entregar al grid cuando accesen el metodo columns, el nombre de la colección para pregutar por los totales y mi coleccion de backbone. La parte interesante del datasource es data: por que es lo que interactuará con la colección del backbone y el datagrid de FuelUX.
Ya con la experiencia, lo primero que definí que debía de saber era el número total de items de la colección, pero tampoco quería estar golpeando el server cada vez que fuera a buscar mas datos, por lo tanto creo un "cache" con this.totales en el objeto del datasource para poder controlar si tengo o no tengo la colección. Si el total es 0 simplemente hago un get a mi api RESTfun con la ruta /sync/totales/<collectionName> como antes lo habiamos definido.
Una vez que ya tengo el total, no vuelvo a preguntar por el número de total de la colección, se que esto representa un problema para colecciones que crecen o decrecen, pero siendo honestos, si la colección que se va a manejar es por el orden de los miles un +100 o -100 de fluctuación es irrelevante, sí te interesa obsesivamente saber el numero exacto de la colección, entoces usa otro método o quita el cache y listo.
Una vez que tengo el total de la colección me interesa hacer mi "fetch" pero solo de el número mínimo de datos. FuelUX llamara al datasource la función data con las "options" que pageSize y page, donde pageSize es el primero número que se declaró en el pulldown de tú layout de FuelUX, en mi caso 2, y el page es 0, con estos datos es con lo que voy a jugar al con el cache de mi colección del backbone por medio del siguiente código:
if(this._collection.length <= options.pageIndex * options.pageSize || (options.pageIndex==0 && this._collection.length < options.pageSize)) { if(this._collection.length <= (options.pageIndex + 1) * options.pageSize && this._collection.length > 0) { offset= Math.ceil((options.pageIndex + 1)* options.pageSize / this._collection.length); this._collection.pageSize=options.pageSize * offset; } this._collection.fetch({async:false, add: true, remove: false}); }
Vamos a disectar parte por parte la lógica de la llamada a el fetch de la colección, que a mí me parece la parte interesante.
Los criteririos para saber si tengo que ir por más datos de la colección son dos:
Si cualquiera de estas dos condiciones se da, entoces es necesario ir por más datos, ahora la pregunta es. ¿Cuantos datos?. Lo normal es "ir por los siguientes Id+Offset", pero ¿Qué tal si "offset" se queda corto es decir, si estoy en la página 0 mostrando 5 items, y saldo a la 5, debería de ir por 20 items (20 + 5 en cache = 25), por lo tanto si uso el "offset" normal de 5 me quedo corto por que ire por "los siguientes 5, y mi colección tendria 10 elementos y yo quiero mostrar del 20 al 25, entoces para compensar este offset, lo ajusto por medio del código:
- Comparar el tamaño de la colección vs el tamaño de página por el número de páginas. Si el tamaño de la colección es igual o menor que la página * items, entoces "me faltan" items y hay que ir por ellos.
- Si estamos en la página inicial (pageIndex==0) y la colección es menor a los items que hay que mostrar.
if(this._collection.length <= (options.pageIndex + 1) * options.pageSize && this._collection.length > 0) { offset= Math.ceil((options.pageIndex + 1)* options.pageSize / this._collection.length); this._collection.pageSize=options.pageSize * offset; }Donde tal cual hace esa verificación, si el offset set no me alcanza, pues lo redefino y me jalo la colección, claro que esta opción, hace que si tengo 1000 páginas y estoy en la 0 y el cliente se va a la última, pues hara un fetch inmenso, peo es un riesgo y quien más sufriría sería su ancho de banda y su máquina. Aparte para evitar eso, aun no hemos explorado el "search" y los "filters", que espero hacerlos en la siguiente entrega. Una vez que tengo mi colección, la transformo en JSON al asignarsela a data, corto lo que realmente necesito, parametrizo lo que tengo que regresar y luego llamo el callback con los datos formateados:
callback({ data: data, start: start, end: end, count: this.totales, pages: pages, page: page });Con eso, termino mi paginado con cache basado en backbone.js. Despues explicare el _each que es un formateador, pero eso será en la siguiente entrega.
No hay comentarios.:
Publicar un comentario