El objetivo
En este ejercicio vamos a desarrollar una aplicación para gestionar varias listas de tareas.
La funcionalidad a implementar será la siguiente:
- Gestionar dos listas de tareas independientes.
- Añadir tareas indicando la fecha, la descripción de la tarea, y un icono identificativo de la prioridad.
- Borrar de golpe todas las tareas de una lista.
- Borrar una sola tarea de una lista determinada mediante un botón oculto que se visualizará al deslizar hacia la derecha la tarea a borrar.
- Pedir confirmación antes de borrar cualquier tarea.
- Visualizar y editar los detalles de cada tarea haciendo clic sobre el elemento correspondiente de la lista.
- Permitir ordenar las tareas arrastrando cualquier elemento a una nueva posición.
Elementos HTML de IONIC 4
El encabezado principal de la aplicación
Utilizaremos tres botones de tipo icono para acceder a las funciones básicas desde la pantalla principal:
<ion-icon name="trash"></ion-icon>
: Para borrar todas las tareas de la lista seleccionada.<ion-icon name="reorder"></ion-icon>
: Para activar los botones que nos permitirán reordenar las tareas arrastrando y soltando.<ion-icon name="add"></ion-icon>
: Para crear una nueva tarea.
Los agruparemos junto con el título de la aplicación en el encabezado del fichero index.html de la siguiente forma:
<ion-header> <ion-toolbar color="primary"> <ion-title>ToDo!</ion-title> <ion-buttons slot="primary"> <ion-button onclick="deleteItem()" color="danger"> <ion-icon slot="icon-only" name="trash"></ion-icon> </ion-button> <ion-button onclick="toggleReorder()"> <ion-icon slot="icon-only" name="reorder"></ion-icon> </ion-button> <ion-button onclick="addEditItem()"> <ion-icon slot="icon-only" name="add"></ion-icon> </ion-button> </ion-buttons> </ion-toolbar> </ion-header>
Más adelante veremos el código JavaScript necesario para llevar a cabo las funciones correspondientes:
deleteItem()
toggleReorder()
addEditItem()
Las listas de tareas
Para mantener dos listas separadas, desde el archivo index.html utilizaremos el elemento <ion-tabs></ion-tabs>
que personalizaremos escogiendo un icono y un texto descriptivo. Por ejemplo, si quisiéramos utilizar una lista de tareas para el instituto y otra para casa podríamos utilizar los iconos school
y home
respectivamente:
<ion-tabs color="primary"> <ion-tab tab="school"> <ion-list lines="full"> <ion-reorder-group> ... </ion-reorder-group> </ion-list> </ion-tab> <ion-tab tab="home"> <ion-list lines="full"> <ion-reorder-group> ... </ion-reorder-group> </ion-list> </ion-tab> <ion-tab-bar slot="bottom" color="primary"> <ion-tab-button tab="home"><ion-icon name="home"></ion-icon></ion-tab-button> <ion-tab-button tab="school"><ion-icon name="school"></ion-icon></ion-tab-button> </ion-tab-bar> </ion-tabs>
Además utilizaremos el nuevo elemento <ion-reorder-group></ion-reorder-group>
que nos permitirá reordenar las tareas que coloquemos dentro. Bastará con pulsar encima de un botón habilitado a tal efecto, y sin soltar arrastrar la tarea en cuestión a la nueva posición. En la documentación de IONIC podemos observar cómo esta simple etiqueta gestiona todo el proceso de arrastrar y soltar.
Las tareas dentro de las listas
Utilizaremos elementos <ion-item-sliding></ion-item-sliding>
para cada tarea dentro de las listas. De esta forma podremos tener oculto un botón rojo con una papelera, que aparecerá al deslizar la tarea hacia la derecha, y nos permitirá borrar de la lista sólo ese elemento:
<ion-item-sliding> ... <ion-item-option color="danger" onclick="..."> <ion-icon slot="icon-only" name="trash"></ion-icon> </ion-item-option> </ion-item-sliding>
Utilizaremos también un elemento <ion-item></ion-item>
para agrupar todos los datos de la tarea. De esta forma, bastará con hacer clic sobre una determinada tarea para acceder a los detalles de la misma: <ion-item onclick="..."></ion-item>
. Un poco más adelante detallaremos la funcionalidad JavaScript necesaria para crear y mostrar un formulario que nos permita visualizar y editar la tarea seleccionada.
Resaltaremos la descripción de la tarea con una etiqueta <h2></h2>
, y a continuación mostremos la fecha de la misma dentro de un párrafo (etiqueta <p></p>
). Encerraremos ambos elementos con una etiqueta <ion-label text-wrap></ion-label>
que permitirá que el tamaño de cada elemento de la lista se ajuste para contener todo el texto descriptivo de la tarea.
Resumiendo, cada elemento de la lista de tareas vendrá especificado por el siguiente código HTML:
<ion-item-sliding> <ion-item onclick="..."> <ion-label text-wrap> <h2>...</h2> <p>...</p> </ion-label> <ion-icon slot="end" name="..."></ion-icon> <ion-reorder slot="end"></ion-reorder> </ion-item> <ion-item-options side="start"> <ion-item-option color="danger" onclick="..."> <ion-icon slot="icon-only" name="trash"></ion-icon> </ion-item-option> </ion-item-options> </ion-item-sliding>
El formulario con los detalles de cada tarea
Para visualizar y editar los detalles de cada tarea (al crear una nueva, o al hacer clic sobre una ya existente) utilizaremos el siguiente código HTML:
<ion-header> <ion-toolbar> <ion-title>ToDo - Task details</ion-title> <ion-buttons slot="primary"> <ion-button color="danger"><ion-icon slot="icon-only" name="close"></ion-icon></ion-button> <ion-button color="primary"><ion-icon slot="icon-only" name="checkmark"></ion-icon></ion-button> </ion-buttons> </ion-toolbar> </ion-header> <ion-content> <ion-list> <ion-item> <ion-label position="floating">Select date</ion-label> <ion-datetime display-format="D MMM YYYY" max="2050-12-31" value="..."></ion-datetime> </ion-item> <ion-item> <ion-label position="floating">Enter task</ion-label> <ion-input value="..."></ion-input> </ion-item> </ion-list> <ion-segment value="..."> <ion-segment-button value="radio-button-off"> <ion-icon name="radio-button-off"></ion-icon> </ion-segment-button> <ion-segment-button value="radio-button-on"> <ion-icon name="radio-button-on"></ion-icon> </ion-segment-button> <ion-segment-button value="snow"> <ion-icon name="snow"></ion-icon> </ion-segment-button> <ion-segment-button value="flame"> <ion-icon name="flame"></ion-icon> </ion-segment-button> </ion-segment> </ion-content>
En el encabezado (<ion-header></ion-header>
) simplemente tenemos dos botones para confirmar o cancelar la edición o creación de la tarea.
En el contenido principal del formulario (<ion-content></ion-content>
) tendremos tres campos:
<ion-datetime><ion-datetime>
: Para indicar la fecha de la tarea. Si estamos creando una nueva tarea, en dicho valor introduciremos la fecha actual utilizando código JavaScript.<ion-input></ion-input>
: Para introducir o modificar la descripción de la tarea. Estará en blanco si se trata de una tarea nueva.<ion-segment></ion-segment>
: Para seleccionar la prioridad de la tarea. En nuestro ejemplo proponemos 4 opciones (radio-button-off
para tareas pendientes,radio-button-on
para tarea finalizada,snow
para una prioridad baja, yflame
para una prioridad alta), aunque los iconos utilizados son una simple sugerencia, y se pueden cambiar fácilmente según el gusto de cada uno.
El código JavaScript
Accediendo a cada una de las listas
Por simplificar un poco el código, utilizaremos tres funciones para acceder y actualizar los dos elementos principales (tabs y lists):
function getTab() { return(document.querySelector('ion-tab:not(.tab-hidden)')); } function getList(tab = getTab()) { let list = localStorage.getItem('todo-list-'+tab.tab); return list ? JSON.parse(list) : []; } function saveList(tab, list) { localStorage.setItem('todo-list-'+tab.tab, JSON.stringify(list)); printList(tab); }
La última función (saveList()
) nos permitirá guardar de manera persistente cada una de las listas de tareas, utilizando localStorage
, de forma que al actualizar el contenido del navegador (o cerrar la app y volverla a abrir), podamos volver a cargar la lista de tareas.
Construyendo cada elemento de la lista de tareas
Mediante la siguiente función obtendremos todo el código necesario para cada tarea, variando simplemente el texto descriptivo de la misma, la fecha, y el icono indicativo de la prioridad:
function printList(tab) { tab.querySelector('ion-reorder-group').innerHTML = ""; getList(tab).forEach((item, index) => { tab.querySelector('ion-reorder-group').innerHTML += `<ion-item-sliding> <ion-item onClick="addEditItem(`+index+`)"> <ion-label text-wrap> <h2>`+item.text+`</h2> <p>`+item.date.slice(0,10)+`</p> </ion-label> <ion-icon slot="end" name="`+item.icon+`"></ion-icon> <ion-reorder slot="end"></ion-reorder> </ion-item> <ion-item-options side="start"> <ion-item-option color="danger" onClick="deleteItem(`+index+`)"> <ion-icon slot="icon-only" name="trash"></ion-icon> </ion-item-option> </ion-item-options> </ion-item-sliding>`; }); }
Para añadir una nueva tarea o editar los detalles de una tarea existente
Utilizaremos la misma función (addEditItem(index)
) para añadir una nueva tarea o modificar una existente. En ella, siguiendo las instrucciones indicadas en la documentación de IONIC, crearemos un cuadro de diálogo modal que mostrará el formulario para introducir o actualizar los detalles de la tarea:
function addEditItem(index = false) { closeItems(); let list = getList(); let item = null; if (index !== false) item = list[index]; else item = { text:"", date:new Date().toISOString(), icon:"radio-button-off" }; const modal = document.createElement('ion-modal'); modal.component = document.createElement('div'); modal.component.innerHTML = ` <ion-header> ... </ion-header> <ion-content> ... </ion-content>`; modal.component.querySelector('[color="danger"]').addEventListener('click', () => { modal.dismiss(); }); modal.component.querySelector('[color="primary"]').addEventListener('click', () => { let newDate = modal.component.querySelector('ion-datetime').value; let newText = modal.component.querySelector('ion-input').value; let newIcon = modal.component.querySelector('ion-segment').value; if (!newText.length) { error('The task cannot be empty'); } else { let newItem = { text:newText, date:newDate, icon:newIcon }; if (index !== false) list[index] = newItem; else list.unshift(newItem); saveList(getTab(), list); modal.dismiss(); } }); document.querySelector('ion-app').appendChild(modal); modal.present(); }
En la primera parte de la función, si recibimos como parámetro la tarea a modificar, recuperaremos los detalles de la misma (fecha, texto descriptivo e icono con la prioridad):
if (index !== false) item = list[index]; else item = { text:"", date:new Date().toISOString(), icon:"radio-button-off" };
Como se puede observar en la fecha, si en vez de modificar una tarea existente, estamos creando una nueva (parámetro de la función con valor false
), entonces obtendremos la fecha actual.
A continuación, crearemos el formulario de edición o creación de tareas, con un encabezado con los botones correspondientes para cancelar y confirmar, y también con el contenido principal, utilizando el código HTML indicado previamente:
const modal = document.createElement('ion-modal'); modal.component = document.createElement('div'); modal.component.innerHTML = ` <ion-header> ... </ion-header> <ion-content> ... </ion-content>`;
Finalmente, nos queda capturar los eventos de clic en el botón de cancelar (color="danger"
) o confirmar (color="primary"
). En el primer caso simplemente cerraremos el cuadro de diálogo sin hacer nada más:
modal.component.querySelector('[color="danger"]').addEventListener('click', () => { modal.dismiss(); });
En caso de confirmar la edición o creación de una nueva tarea, primero cogeremos los valores introducidos en el formulario:
modal.component.querySelector('[color="primary"]').addEventListener('click', () => { let newDate = modal.component.querySelector('ion-datetime').value; let newText = modal.component.querySelector('ion-input').value; let newIcon = modal.component.querySelector('ion-segment').value; ... });
En segundo lugar comprobaremos la longitud de la descripción de la tarea, para asegurarnos que no está vacía, en cuyo caso mostraríamos un error:
function error(message) { const alert = document.createElement('ion-alert'); alert.message = message; alert.buttons = ['OK']; document.querySelector('ion-app').appendChild(alert); alert.present(); } function addEditItem(index = false) { ... if (!newText.length) { error('The task cannot be empty'); } ... }});
Si estamos modificando una tarea existente, crearemos la nueva tarea con los nuevos datos del formulario, la reemplazaremos por la existente, guardaremos la lista, y cerraremos el cuadro de diálogo.
Si estamos añadiendo una nueva tarea, crearemos un nuevo elemento <ion-item-sliding></ion-item-sliding>
que contendrá la fecha, descripción e icono introducidos en el formulario. A continuación añadiremos dicha tarea a la lista que estemos modificando, la guardaremos, y cerraremos el cuadro de diálogo:
let newItem = { text:newText, date:newDate, icon:newIcon }; if (index !== false) { list[index] = newItem; } else { list.unshift(newItem); } saveList(getTab(), list); modal.dismiss();
Reordenando las tareas
La totalidad de la funcionalidad necesaria para reordenar las tareas de una lista ya nos la proporciona IONIC mediante el elemento <ion-reorder></ion-reorder>
. Lo único que debemos hacer por nuestra parte, es cambiar el valor del atributo disabled
del elemento <ion-reorder-group></ion-reorder-group>
de la lista seleccionada:
function toggleReorder() { closeItems(); let reorder = getTab().querySelector('ion-reorder-group'); reorder.disabled = !reorder.disabled; }
En caso de que se esté mostrando algún botón de borrado de una tarea, se ocultará para habilitar correctamente la funcionalidad de reordenar elementos.
Cuando la opción de reordenar se encuentra activa, se mostrará un nuevo icono en todas las tareas. Si lo mantenemos pulsado, nos permitirá arrastrar la tarea correspondiente para soltarla en una nueva posición.
Al volver a pulsar el botón de reordenar ubicado en el encabezado de la aplicación, la opción de reordenar se desactivará, evitando que cambiemos accidentalmente el orden de las tareas.
Borrando tareas
Para borrar todas las tareas de la lista seleccionada, pulsaremos el botón que hemos puesto en el encabezado principal de la aplicación. Y para borrar una tarea específica, pulsaremos el botón que aparece al deslizar la tarea hacia la derecha. En ambos casos, utilizaremos la misma función JavaScript (deleteItem(index)
), pasando como parámetro el elemento HTML que contiene la tarea a borrar, o el valor false para borrar todas las tareas de la lista seleccionada.
Para evitar borrados accidentales, pediremos confirmación al usuario utilizando un cuadro de diálogo de tipo Alert que ya nos proporciona IONIC:
function deleteItem(index = false) { const alert = document.createElement('ion-alert'); alert.header = index !== false ? 'Delete item' : 'Delete all', alert.message = 'Are you sure?', alert.buttons = [{ text: 'YES', handler: () => { let list = getList(); if (index !== false) { list.splice(index, 1); } else { list.length = 0; } saveList(getTab(), list); } }, { text: 'NO', role: 'cancel' }] document.querySelector('ion-app').appendChild(alert); alert.present(); }
Como se puede observar, al confirmar el borrado, si se recibe un elemento por parámetro, sólo se borra dicho elemento. En caso contrario, se borrará toda la lista, utilizando una cadena vacía para eliminar todo el código HTML que contenía. Finalmente, guardaremos la lista correspondiente:
... let list = getList(); if (index !== false) { list.splice(index, 1); } else { list.length = 0; } saveList(getTab(), list); ...
Eventos a tener en cuenta
Cada vez que reordenemos las tareas de alguna lista, deberemos volverla a guardar para que aparezcan en el orden correcto al volver a abrir la aplicación. Para ello capturaremos el evento ionItemReorder
, tal como se indica en la documentación de IONIC, apartado de JavaScript:
document.addEventListener('ionItemReorder', (event) => { moveItem(event.detail); }); ... function moveItem(indexes) { let tab = getTab(); let list = getList(tab); let item = list[indexes.from]; list.splice(indexes.from, 1); list.splice(indexes.to, 0, item); indexes.complete(); saveList(tab, list); }
Cada vez que cerremos un cuadro de diálogo (eventos ionModalDidDismiss
y ionAlertDidDismiss
) cerraremos los elementos de la lista que pudieran estar deslizados hacia la derecha.
document.addEventListener("ionModalDidDismiss", closeItems); document.addEventListener("ionAlertDidDismiss", closeItems); ... function closeItems() { getTab().querySelector('ion-list').closeSlidingItems(); }
Y por último, cada vez que iniciemos la aplicación (evento onload
), leeremos las listas que se encuentran guardadas en el navegador:
function onLoad() { ... document.querySelectorAll('ion-tab').forEach(function(t) { printList(t); }); }
En resumen…
El fichero index.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Reorder</title> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"> <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.esm.js"></script> <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.js"></script> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/css/ionic.bundle.css"/> <script src="cordova.js"></script> <script src="todo.js"></script> </head> <body onload="onLoad()"> <ion-app> <ion-menu content-id="content"> <ion-header> <ion-toolbar mode="ios" color="danger"> <ion-buttons slot="start"> <ion-menu-button></ion-menu-button> </ion-buttons> <ion-title>Menu</ion-title> </ion-toolbar> </ion-header> <ion-content> <ion-menu-toggle> <ion-list lines="full"> <ion-item-divider> <ion-label>Reorder and Share</ion-label> </ion-item-divider> <ion-item onclick="toggleReorder()"> <ion-label>Enable/Disable reorder</ion-label> <ion-icon slot="end" name="reorder"></ion-icon> </ion-item> <ion-item onclick="share()"> <ion-label>Share</ion-label> <ion-icon slot="end" name="share"></ion-icon> </ion-item> <ion-item-divider> <ion-label>Delete</ion-label> </ion-item-divider> <ion-item onclick="deleteItem()"> <ion-label>Delete all</ion-label> <ion-icon slot="end" name="trash" color="danger"></ion-icon> </ion-item> </ion-list> </ion-menu-toggle> </ion-content> </ion-menu> <div class="ion-page" id="content"> <ion-header> <ion-toolbar mode="ios" color="primary"> <ion-buttons slot="start"> <ion-button> <ion-menu-button></ion-menu-button> </ion-button> </ion-buttons> <ion-title>ToDo!</ion-title> <ion-buttons slot="end"> <ion-button onclick="addEditItem()"> <ion-icon name="add" slot="icon-only"></ion-icon> </ion-button> </ion-buttons> </ion-toolbar> </ion-header> <ion-content> <ion-tabs> <ion-tab tab="school"> <ion-list lines="full"> <ion-reorder-group></ion-reorder-group> </ion-list> </ion-tab> <ion-tab tab="home"> <ion-list lines="full"> <ion-reorder-group></ion-reorder-group> </ion-list> </ion-tab> <ion-tab-bar slot="bottom" color="primary"> <ion-tab-button tab="school"> <ion-icon name="school"></ion-icon> </ion-tab-button> <ion-tab-button tab="home"> <ion-icon name="home"></ion-icon> </ion-tab-button> </ion-tab-bar> </ion-tabs> </ion-content> </div> </ion-app> </body> </html>
El fichero todo.js
function onLoad() { document.addEventListener("ionModalDidDismiss", closeItems); document.addEventListener("ionAlertDidDismiss", closeItems); document.addEventListener("ionDidOpen", closeItems); document.addEventListener('ionItemReorder', (event) => { moveItem(event.detail); }); document.querySelectorAll('ion-tab').forEach(function(t) { printList(t); }); } function getTab() { return(document.querySelector('ion-tab:not(.tab-hidden)')); } function getList(tab = getTab()) { let list = localStorage.getItem('todo-list-'+tab.tab); return list ? JSON.parse(list) : []; } function saveList(tab, list) { localStorage.setItem('todo-list-'+tab.tab, JSON.stringify(list)); printList(tab); } function printList(tab) { tab.querySelector('ion-reorder-group').innerHTML = ""; getList(tab).forEach((item, index) => { tab.querySelector('ion-reorder-group').innerHTML += `<ion-item-sliding> <ion-item onClick="addEditItem(`+index+`)"> <ion-label text-wrap> <h2>`+item.text+`</h2> <p>`+item.date.slice(0,10)+`</p> </ion-label> <ion-icon slot="end" name="`+item.icon+`"></ion-icon> <ion-reorder slot="end"></ion-reorder> </ion-item> <ion-item-options side="start"> <ion-item-option color="danger" onClick="deleteItem(`+index+`)"> <ion-icon slot="icon-only" name="trash"></ion-icon> </ion-item-option> </ion-item-options> </ion-item-sliding>`; }); } function error(message) { const alert = document.createElement('ion-alert'); alert.message = message; alert.buttons = ['OK']; document.querySelector('ion-app').appendChild(alert); alert.present(); } function toggleReorder() { closeItems(); let reorder = getTab().querySelector('ion-reorder-group'); reorder.disabled = !reorder.disabled; } function closeItems() { getTab().querySelector('ion-list').closeSlidingItems(); } function addEditItem(index = false) { closeItems(); let list = getList(); let item = null; if (index !== false) item = list[index]; else item = { text:"", date:new Date().toISOString(), icon:"radio-button-off" }; const modal = document.createElement('ion-modal'); modal.component = document.createElement('div'); modal.component.innerHTML = ` <ion-header> <ion-toolbar> <ion-title>ToDo - `+(index !== false ? 'Edit task' : 'New task')+`</ion-title> <ion-buttons slot="primary"> <ion-button color="danger"><ion-icon slot="icon-only" name="close"></ion-icon></ion-button> <ion-button color="primary"><ion-icon slot="icon-only" name="checkmark"></ion-icon></ion-button> </ion-buttons> </ion-toolbar> </ion-header> <ion-content> <ion-list> <ion-item> <ion-label position="floating">Select date</ion-label> <ion-datetime display-format="D MMM YYYY" max="2050-12-31" value="`+item.date+`"></ion-datetime> </ion-item> <ion-item> <ion-label position="floating">Enter task</ion-label> <ion-input value="`+item.text+`"></ion-input> </ion-item> </ion-list> <ion-segment value="`+item.icon+`"> <ion-segment-button value="radio-button-off"> <ion-icon name="radio-button-off"></ion-icon> </ion-segment-button> <ion-segment-button value="radio-button-on"> <ion-icon name="radio-button-on"></ion-icon> </ion-segment-button> <ion-segment-button value="snow"> <ion-icon name="snow"></ion-icon> </ion-segment-button> <ion-segment-button value="flame"> <ion-icon name="flame"></ion-icon> </ion-segment-button> </ion-segment> </ion-content>`; modal.component.querySelector('[color="danger"]').addEventListener('click', () => { modal.dismiss(); }); modal.component.querySelector('[color="primary"]').addEventListener('click', () => { let newDate = modal.component.querySelector('ion-datetime').value; let newText = modal.component.querySelector('ion-input').value; let newIcon = modal.component.querySelector('ion-segment').value; if (!newText.length) { error('The task cannot be empty'); } else { let newItem = { text:newText, date:newDate, icon:newIcon }; if (index !== false) list[index] = newItem; else list.unshift(newItem); saveList(getTab(), list); modal.dismiss(); } }); document.querySelector('ion-app').appendChild(modal); modal.present(); } function moveItem(indexes) { let tab = getTab(); let list = getList(tab); let item = list[indexes.from]; list.splice(indexes.from, 1); list.splice(indexes.to, 0, item); indexes.complete(); saveList(tab, list); } function deleteItem(index = false) { const alert = document.createElement('ion-alert'); alert.header = index !== false ? 'Delete item' : 'Delete all', alert.message = 'Are you sure?', alert.buttons = [{ text: 'YES', handler: () => { let list = getList(); if (index !== false) { list.splice(index, 1); } else { list.length = 0; } saveList(getTab(), list); } }, { text: 'NO', role: 'cancel' }] document.querySelector('ion-app').appendChild(alert); alert.present(); }
El resultado
Puedes hacer clic aquí para observar el aspecto que tiene la aplicación de lista de tareas y probar la funcionalidad resultante utilizando el código especificado.