Aprende a construir un tablero Kanban con Ext JS

El poder de Sencha Ext JS está en su versatilidad y componentes. Aunque cuenta con una gran cantidad de componentes preexistentes como lo son los Grids y gráficas, en ocasiones, como desarrollador, te será requerido crear tus propios componentes. Al estar basado en programación orientada a objetos, Ext JS te permitirá heredar a partir de componentes existentes para extender su funcionalidad, o utilizar overrides para sobreescribir el funcionamiento de un componente existente.

En esta ocasión estaremos construyendo un tablero de Kanban reutilizable. Es importante mencionar que utilizaremos una conexión a un backend existente; Sin embargo, es posible utilizar otro backend, o un simple archivo de JSON para este ejemplo. También es importante mencionar que utilizaremos el Classic Toolkit para este ejemplo.


Creación de componentes propios.

Ext JS nos permite crear y guardar nuestros componentes en cualquier lugar desde nuestra estructura de archivos; Sin embargo, es recomendable hacer uso del estándar, y guardar nuestros componentes bajo la carpeta ux en la raíz de nuestro proyecto.



Así que crearemos una carpeta llamada ux y dentro de ella una carpeta llamada Kanban. Es aquí donde guardaremos nuestra definición y archivos del tablero de Kanban.

De manera general, consideremos que tendremos 3 archivos para nuestro tablero:
  • KanbanBoard.js
    Será la clase Tablero que contendrá la estructura completa del tablero.
  • KanbanColumn.js
    La clase columna que contendrá la estructura de cada columna del tablero. Es aquí donde nos conectaremos al back end, y obtendremos cada uno de los registros.
  • KanbanBoard.scss
    Es un archivo de estilos, que nos permitirá configurar el diseño de nuestro tablero.

Creación del Proyecto

Existen muchas maneras de crear un proyecto en ExtJS, en esta ocasión, utilizaremos Sencha Command. Descárgalo del Sitio Web de Sencha, o desde el panel de soporte.

sencha -sdk [ubicación del SDK] generate app [toolkit] [Nombre de la App] [Ubicación de la nueva App] 

La instrucción anterior deberá ser ejecutada desde línea de comando. Ejemplo:

sencha -sdk ..\ext-7.7.0\ generate app classic MyApp .\
 

Utilizando nuestra clase

Antes de iniciar, y para tener una mejor idea de lo que estaremos creando. Al finalizar, y suponiendo que el tablero será hijo de otra clase, podremos crear un tablero de kanban haciendolo de la siguiente manera:

    items: [{
        xtype: 'kanban-board',
        store: { type: 'kanbanstore' },
        columns: [{
            title: 'Identificado',
            columnId: 1,
            color: 'gray'
        }, {
            title: 'Entrevista',
            columnId: 2,
            color: 'yellow'
        }, {
            title: 'Aceptado',
            columnId: 3,
            color: 'blue'
        }, {
            title: 'Contratado',
            columnId: 4,
            color: 'green'
        }]
    }]

Solo será necesario indicar el archivo de almacen de datos de Ext JS (store), las columnas del tablero, y un identificador único por columna para vincular con el backend.

El Tablero Kanban (Parte 1)

Si aun no lo hemos hecho, creemos nuestro archivo principal. En este ejemplo, lo crearemos en app/us/Kanban/KanbanBoard.js. Para el nombre de la clase utilizaremos la estructura de carpetas, quedando: MyApp.ux.kanban.KanbanBoard, la primera parte corresponde al nombre de mi App al crearla. 

Esta clase heredará de un panel de ExtJS, y para hacer referencia a la clase, su nuevo xtype será kanban-board

Ext.define('MyApp.ux.kanban.KanbanBoard', {
    extend: 'Ext.panel.Panel',
    xtype: 'kanban-board',

    // Utilizaremos un layout de tipo hbox para ubicar los "hijos" del panel
    // como columnas, stretch se asegurará de que su altura cubra el 100% del
    // componente
    layout: {
        type: 'hbox',
        align: 'stretch'
    },

    cls: 'board-container',
});

Estaremos creando cada columna de manera dinámica, pero podriamos hacerlo directo en el arreglo de items. Es por ello que utilizaremos initComponent para generar cada columna.

Debido a que estaremos recibiendo dos parametros al crear una instancia de nuestro componente, ingresaremos los valores por default en el parámetro config.


    config: {
        store: null,
        columns: []
    },


Aquí indicaremos un store nulo, y un arreglo de columnas vacío. Antes de continuar con el tablero Kaban, continuemos con la definición de la clase columna.

Columna Kanban (Parte 1)

Comencemos definiendo la clase con las opciones minimas necesarias. Extenderemos de un Panel, y en su interior incluiremos un dataview para crear cada uno de los elementos del tablero. 

Ext.define('MyApp.ux.kanban.KanbanColumn', {
    extend: 'Ext.panel.Panel',
    xtype: 'kanban-column',

    layout: 'fit',
    cls: 'board-column-container',

    flex: 1,

    config: {
        store: null,
        columnId: 0
    },

    items: [{
        xtype: 'dataview',
        itemSelector: 'div.task-selector',
        tpl: [
            '<tpl for=".">',
                '<div class="task-selector">',
                    '{title}',
                '</div>',
            '</tpl>'
        ],
    }],
});

De igual manera, incluiremos el parámetro config, para inicializar el store, y el Id de Columna. Como podemos notar, el dataview recibe los siguientes parametros:

  • itemSelector para la clase de css que funcionará como contenedor
  • tpl como template/plantilla. El cual se ciclará para crear cada uno de los elementos

Estamos por terminar la parte básica del board, nos falta crear el store de datos. 

El Tablero Kanban (Parte 2)

Ya que tenemos la clase columna definida, agreguemos el ciclo dentro del KanbanBoard para crear las columnas.

    initComponent: function () {
        let me=this;
        this.callParent();

        if (me.store!==null && me.columns!==null && me.columns.length > 0) {
            for (let i=0; i<me.columns.length; i++) {
                let columnData = me.columns[i];
                let column = Ext.create('MyApp.ux.kanban.KanbanColumn', {
                    title: columnData.title || 'Columna '+i,
                    store: me.store,
                    columnId: columnData.columnId,
                });
                me.add(column);
            }
        }
    }

Agreguemos initComponent dentro de KabanBoard, este método se manda llamar al inicializar el componente, es decir, antes de leer su configuración y crear el componente de ExtJS. Si utilizaramos el toolkit moderno, en su lugar utilizariamos initialize.

El método initComponent siempre debe llamar this.callParent(), el cual generará el componente. Dependiendo de lo que estemos haciendo, nuestro código puede ir antes o después de esta llamada.

En este ejemplo, después de revisar que el store y columnas no estén vacías, se procederá a realizar un ciclo para crear cada columna. Cada columna, tendrá su título, store, e id de columna.

Almacen de Datos (Store)

En esta ocasión, no explicaremos el store a fondo; Sin embargo, por referencia, este será su contenido:

Ext.define('MyApp.store.KanbanStore', {
    extend: 'Ext.data.Store',
    alias: 'store.kanbanstore',

    autoLoad: true,
    remoteSort: true,
    remoteFilter: true,

    columnId: 0,

    listeners: {
        beforeload: function (store) {
            // Agrega el Column Id para realizar la consulta de las tareas de
            // la columna actual
            store.getProxy().setExtraParams({columnId: this.columnId});
        },
    },

    proxy: {
        type: 'rest',
        // End point que regresará los elementos de la columna
        url: '/backend/handlers/kanban',
        reader: {
            type: 'json',
            rootProperty: 'data',
            idProperty: 'id',
            totalProperty: 'total'
        }
    },
});

Lo importante a considerar del store, es que recibirá los datos con el siguiente formato como respuesta del backend:

{
    "success": true,
    "data": [
        {
            "column_id": 2,
            "title": "Juan",
            "id": 3,
            "icon": "user-secret",
            "content": "No disponible"
        },
        {
            "column_id": 2,
            "title": "Felipe",
            "id": 5,
            "icon": "users",
            "content": "Buscar CV"
        }
    ],
    "count": 2
}

El archivo de estilos

En este punto ya se creará el tablero; Sin embargo, no contará con diseño. Por ello, crearemos un archivo de SCSS con los estilos correspondientes.


Este tutorial no entrará en detalle en CSS, pero por ahora, podemos considerar que las siguientes clases serán utilizadas para dar estilo y diseño al tablero y sus registros. Anteriormente utilizamos propiedades css en cada uno de los componentes. Los nombres que seleccionamos, deben coincidir con las clases del siguiente css.

.board-container {
    background-color: #eee;
}

.board-column-container {
    margin: 16px;
    border: 1px solid #ccc;
    background-color: #fff;
    border-radius: 8px;
    box-shadow: 0px 0px 8px rgba(0,0,0,0.2);
}

/* task-selector debe coincidir con el nombre del itemSelector del dataview */
.board-column-container .task-selector {
    position: relative;
    border: 1px solid #ccc;
    margin: 8px;
    border-radius: 8px;
    user-select: none;
    cursor:grab !important;
}

.board-column-container .task-selector:hover  {
    background-color: #eee;
}

.board-column-container .task-selector .title {
    background-color: #ccc;
    font-weight: bold;;
    padding: 8px 16px;
    border-radius: 8px 8px 0px 0px;
}
.board-column-container .task-selector .content {
    position: relative;
}

.board-column-container .task-selector .content .text {
    display: flex;
    position: relative;
    padding: 8px 8px 8px 48px;
    min-height: 54px;
    align-items: center;
}

.board-column-container .task-selector .content .icon {
    position: absolute;
    top:50%;
    transform: translate(0%,-50%);
    left:8px;
    width:32px;
    height: 32px;
    line-height: 32px;
    vertical-align: middle;
    text-align: center;
    background-color: #666;
    color: #fff;
    border-radius: 50%;
    font-size: 16px;
}

Para que nuestro archivo SCSS se intengre a nuestro proyecto, debemos realizar las siguientes dos acciones:
  • Asegurarnos que el nombre del archivo coincida con el de nuestra clase de KanbanBoard. Es decir, el archivo deberá llamarse KanbanBoard.scss y deberá estar ubicado en la misma carpeta.
  • Reconstruir la aplicación
    • Si es la primera vez que se utilizará el archivo, ejecutar las dos siguientes instrucciones:
      • sencha app build development
        Para construir la app en modo desarrollo
      • sencha app refresh
        Para leer el archivo app.json y generar el proyecto
    • Si ya se habia integrado el archivo anteriormente, ejecutar cualquiera de las dos siguientes opciones:
      • sencha app build development
        Para cargar los cambios del archivo al proyecto
      • sencha app watch
        O en su lugar ejecutar app watch para que Sencha escuche los cambios al proyecto, y actualice todo lo necesario de manera automática.



Columna Kanban (Parte 2)

Ahora regresemos al dataview de la columna, y demos mas detalle a cada entrada. Agregaremos el siguiente HTML para mejorar el diseño de los elementos del tablero.

    xtype: 'dataview',
    itemSelector: 'div.task-selector',
    tpl: [
      '<tpl for=".">',
           '<div class="task-selector">',
                '<div class="title">{title}</div>',
                '<div class="content">',
                   '<div class="icon"><span class="fa fa-{icon}"></span></div>',
                   '<div class="text">{content}</div>',
                '</div>',
           '</div>',
       '</tpl>'
    ],

Los cuales ahora se deben ver de la siguiente manera:


Tenemos la parte visual del Kanban lista, pero nos falta dar la funcionalidad de arrastre. Para ello, continuemos con las clases de DragZone y DropZone.

DragZone

Utilizaremos esta clase para iniciar el arrastre. Aunque podriamos iniciarlo en varios lugares de nuestro código, pero según la documentación, la mejor ubicación es el listener render de la columna.

Cuenta con varios métodos y propiedades que podríamos configurar; en este ejemplo, trabajaremos solo con dos de ellos:
  • getDragData(event)
    Inicia el arrastre. Es necesario sobreescribirla para el correcto funcionamiento
  • getRepairXY()
    Llamada al soltar sobre una coordenada no válida, recupera las coordenadas originales, para regresar el objeto a su ubicación original

listeners: {
    render: function (me) {
    // Inicio del Arrastre
        me.dragZone = new Ext.dd.DragZone(me.getEl(), {
            getDragData: function (e) {
                let elFuente = e.getTarget(me.itemSelector, 10);
                if (elFuente) {
                    let duplicado = elFuente.cloneNode(true);
                    duplicado.id = Ext.id();
                    return {
                        ddel: duplicado,
                        sourceEl: elFuente,
                        sourceZone: me,
                        sourceStore: me.store,
                        repairXY: Ext.fly(elFuente).getXY(),
                        draggedRecord: me.getRecord(elFuente)
                    }
                }
            },

            getRepairXY: function () {
                return this.dragData.repairXY;
            }
        });
    }
}

  • getDragData
let elFuente = e.getTarget(me.itemSelector, 10);

Obtendrá el objeto a arrastrar y lo guardará en la variable elemento fuente: elFuente. Si no lo pudo obtener, el método terminará, de lo contario realizará un duplicado y le asignará un nuevo Id:

let duplicado = elFuente.cloneNode(true);
duplicado.id = Ext.id();

 Finalmente regresará una estructura con los siguientes datos:

    • ddel
      El objeto nuevo que acabamos de duplicar
    • sourceEl
      El elemento original
    • sourceZone
      La columna de la cual estamos obteniendo el elemento
    • sourceStore
      El store del dataview de la columna original
    • repairXY
      Las coordenadas del objeto antes de ser arrastrado
    • draggedRecord 
      El registro del elemento original
    • getRepairXY
      utilizará las coordenadas iniciales (fuente) para regresar el objeto en caso de que sea soltado en un lugar inválido.

DropZone

Define areas donde un objeto puede ser soltado. Tiene una gran cantidad de métodos que pueden ser utilizados con la clase. En este ejemplo solo estaremos utilizando dos de ellos:

  • notifyOver
    Notificará si el area donde se va a soltar el elemento es válida o no. En este ejemplo, estamos indicando que solo las columnas diferentes a la inicial son válidas.
    El método regresa dropAllowed o dropNotAllowed dependiendo el estatus. Por ello creamos un método de ayuda llamado allowDrop.
  • onContainerDrop
    Este método será llamado al soltar el elemento, y validará si es posible o no soltar el elemento.

me.dropZone = new Ext.dd.DropZone(me.getEl(), {
    allowDrop: function(allowed) {
        return allowed ?
            Ext.dd.DropZone.prototype.dropAllowed :
            Ext.dd.DropZone.prototype.dropNotAllowed;
    },

    notifyOver: function (source) {
        return this.allowDrop(source !== me.dragZone );
    },

    onContainerDrop: function (source) {
        if (source == me.dragZone) {
            return false;
        }

        let rec = source.dragData.draggedRecord;
        source.dragData.sourceStore.remove(rec);
        me.getStore().add(rec);
    }
});

  • onContainerDrop
    Primero valida si source es me.dragZone, en caso de serlo, regresará falso no permitiendo soltarlo.
if (source == me.dragZone) {
    return false;
}
 
Segundo, retirará el elemento de la columna original, y lo insertará en el store del nuevo.
 
let rec = source.dragData.draggedRecord;
source.dragData.sourceStore.remove(rec);
me.getStore().add(rec);

Guardar Cambios

Dentro del mismo método de onContainerDrop, podemos guardar el registro enviándolo al backend, el funcionamiento de esta sección dependerá de la configuración del endpoint utilizado. El siguiente bloque de código se muestra de manera informativa, y se deberá agregar justo después del código anterior.

let params = {
    id: rec.get('id'),
    columnId: me.up('kanban-column').columnId
};

Ext.Ajax.request({
    url: '/backend/handlers/kanban/',
    method: 'POST',
    jsonData: params,
    timeout: 60000,
    headers: {
        'Content-Type': 'application/json'
    },
    success: function (response) {
        // Alta
        Ext.toast('Guardado con éxito');
    },
    failure: function (response) {
        // Baja
        Ext.toast('No fue posible guardar el cambio:<br><b>'+rec.get('title')+'</b>');
    }
});

Hemos terminado la parte principal del ejemplo. Para dar un poco mas de diseño, agregaremos la opción de cambiar el color de las columnas.

Color de Columnas

Para cambiar el color de las columnas, estaremos creando una serie de nuevas clases dentro del archivo css. Los nombres de estas son arbitrarios, y como desarrollador, puedes crear tus propias clases. En este ejemplo se crearon: red, yellow, blye, green, cyan, pink, orange, purple, white, gray, black, y dark gray.

.board-column-container .x-panel-header-default .x-title-text {
    color: #333;
}

.board-column-container.board-column-title-red .x-panel-header-default {
    background-color: #c33;
}

.board-column-container.board-column-title-yellow .x-panel-header-default {
    background-color: rgb(253, 253, 151);
}

.board-column-container.board-column-title-blue .x-panel-header-default {
    background-color: rgb(138, 138, 236);
}

.board-column-container.board-column-title-green .x-panel-header-default {
    background-color: rgb(170, 238, 170);
}

.board-column-container.board-column-title-cyan .x-panel-header-default {
    background-color: rgb(179, 224, 224);
}

.board-column-container.board-column-title-pink .x-panel-header-default {
    background-color: rgb(204, 159, 204);
}

.board-column-container.board-column-title-orange .x-panel-header-default {
    background-color: rgb(224, 184, 119);
}

.board-column-container.board-column-title-purple .x-panel-header-default {
    background-color: rgb(181, 145, 199);
}

.board-column-container.board-column-title-white .x-panel-header-default {
    background-color: #fff;
}

.board-column-container.board-column-title-gray .x-panel-header-default {
    background-color: #ccc;
}

.board-column-container.board-column-title-black .x-panel-header-default {
    background-color: #000;
    color: #fff;
}

.board-column-container.board-column-title-dark-gray .x-panel-header-default {
    background-color: #333;
    color: #fff;
}


Una vez creados los estilos, recuerda ejecutar sencha app watch o sencha app build development. Ahora en el archivo KanbanBoard, agregaremos una línea:

let column = Ext.create('MyApp.ux.kanban.KanbanColumn', {
    title: columnData.title || 'Columna '+i,
    store: me.store,
    columnId: columnData.columnId,
    userCls: 'board-column-title-'+(columnData.color || 'black')
});

Esta línea llamará la clase CSS que corresponda al color obtenido del backend. En caso de que no se encuentre definido, asignará el color negro.

Resultado Final



Aunque aun quedan algunas tareas por realizar, como la posibilidad de editar los elementos, o crear nuevos, la parte del drag-and-drop del tablero de Kamban y su funcionalidad ha quedado finalizada.

Resumen

Generamos un tablero de Kanban utilizando componentes existentes de Ext JS. Tomamos la funcionalidad de un panel simple, una vista de datos (dataview), y utilizamos las clases DropZone y DragZone para habilitar el arrastre.

Para su funcionamiento, el backend debió contar con dos endpoints:
  • Método Get con el id de columna para recuperar los elementos por columna,
  • Método Post con el id de columna e id del elemento para guardar la nueva posición del elemento.

Autor

Andrés Villalba
Sales Engineer Sencha

Vínculos de Interés

Comentarios

Entradas populares de este blog

Aprenda las diferencias: texto enriquecido vs. Texto sin formato

Construye tu aplicación en minutos con Sencha Ext JS y RAD Server