Retomamos esta serie sobre el fascinante mundo de Firebase. Si acabas de aterrizar aquí, puedes ver la parte #1 antes. ?
Cómo guardar documents en nuestra database
El paso lógico después de aprender a obtener datos de nuestra database, es aprender a añadir y guardar nuevos datos desde nuestro código. Así que vamos a aprender a guardar datos enviándolos a través de nuestro <form>. ?
1. Obtenemos una referencia de dicho formulario.
const form = document.querySelector('form');
2. Le añadimos un event listener vinculado a un submit event. A la callback function del submit le pasamos el event object como parámetro.
Evitamos que la página se recargue al enviar el formulario.
3. Creamos un nuevo Date object y lo guardamos en una variable llamada now, que utilizaremos para mostrar la fecha en la que el usuario guarda una nueva receta y la envía a la database.
4. Construimos un objeto y lo guardamos en una variable llamada recipe. Recordemos que un document tiene aspecto de JS object, porque sus datos se almacenar en key-value pairs. Así que cuando guardemos un dato a través de nuestro código, ese dato debe tener forma de JS object.
Dentro del objeto escribimos todas las propiedades que queremos que tenga:
a) Un title, que lo sacamos de lo que el usuario escriba. Podemos acceder a esa información a través del id del form.
b) Una fecha de creación (created_at), que será igual a la variable now. ? Pero ojo, porque lo que queremos guardar en Firestore no es exactamente una fecha, sino un timestamp (marca de tiempo), que es un objeto especial.
Para crear un timestamp object, accedemos al firebase object, luego al firestore object, luego al Timestamp object y finalmente al método fromDate(), al cual le pasamos la fecha que queremos convertir en un timestamp. ?
De momento no vamos a crear ningún author, y no pasa nada, porque Firebase dejará ese campo vacío y punto. ?
// add documents form.addEventListener('submit', e => { e.preventDefault(); let now = new Date(); const recipe = { title: form.recipe.value, created_at: firebase.firestore.Timestamp.fromDate(now) }; });
5. Para añadir nuestro recipe object a Firestore, tomamos la referencia db y la vinculamos al método collection(), al que le pasamos el nombre de nuestra collection (recipes). A eso le vinculamos un método llamado add(), que espera un JS object como el que acabamos de crear.
El resultado es una promesa, porque añadir datos a nuestra database requiere algo de tiempo. Por tanto, debemos vincularle un método then() y un método catch(). Si la promesa se resuelve, simplemente imprimimos que la receta ha sido añadida. Al método catch() le pasamos cualquier error que se pueda producir si la promesa es rechazada.
db.collection('recipes').add(recipe) .then(() => console.log('recipe added!')) .catch(err => console.log(err))
Si ahora vas al campo del formulario y escribes cualquier palabra, verás que se añade a tu database como el título de una nueva receta, más la fecha en la que has enviado el formulario. ¡Genial! ?
Añade todas las recetas que quieras. Yo voy a añadir una más de momento.
El único problemilla que habrás notado es que, aunque se añade a la base de datos, no pasa lo mismo con nuestra UI. Para ver la receta nueva tendríamos que recargar la página. Esto ocurre porque cuando obtenemos los documents desde Firestore lo hacemos al principio de todo, en el momento en el que se carga la página. Y además, lo hacemos una sola vez.
Así que cuando añadimos un document nuevo, JS no sabe que tiene que obtenerlo y mostrarlo también. Vamos a encargarnos de esto más adelante, cuando aprendamos sobre actualizaciones en tiempo real. Pero antes, veamos cómo borrar información de nuestra database.
Cómo borrar documents
1. En la HTML template del sandbox.js añadimos un pequeño botón a cada receta, que posteriormente se encargará de eliminarla cuando hagamos click en él.
Y con esto ya deberías ver un botón rojo debajo de cada receta.
Para conseguir nuestro objetivo, debemos añadir un event listener a los botones. Pero no se lo añadiremos a cada botón individualmente, porque eso afectaría al rendimiento del código, entre otros problemas. Lo que haremos será usar event delegation. Lo cual significa que añadiremos únicamente un event listener a un solo elemento (a la lista <ul>).
Dentro del <ul>, comprobaremos si el usuario ha hecho click en el botón y de ser así, disparemos nuestro evento, eliminando así la receta. ?
2. Adjuntamos un event listener a la list y lo vinculamos a un click event. Al parámetro de la callback function le pasamos el event object, porque necesitaremos usar la propiedad target para comprobar dónde ha hecho click el usuario.
Hacemos un console.log del event object para tener claro dónde estamos haciendo click.
// delete documents list.addEventListener('click', e => { console.log(e); });
Si ahora vamos al navegador y hacemos click en cualquier lugar dentro de la zona que ocupa cualquier <li>, por ejemplo, al lado de nuestro botón delete, verás que se dispara un MouseEvent.
> MouseEvent {isTrusted: true, screenX: 521, screenY: 360, clientX: 521, clientY: 257, …}
En mi caso he tenido que hacer un poco de puntería hasta dar con el <li>, porque a veces me saltaba el <div> dentro del <li>. ?
Verás que la propiedad target nos indica que efectivamente hemos hecho click en el <li>. Si hacemos click en el botón, podremos ver que el target ahora es button.
3. Hacemos una comprobación para ver si el usuario ha hecho click sobre el botón. Si así es, borramos la receta a la que esté vinculado el botón. Pero JS necesita saber exactamente a qué receta nos estamos refiriendo, y no podemos hacer eso pasándole simplemente las propiedades title o created_at, porque éstas no son únicas.
list.addEventListener('click', e => { // console.log(e); if(e.target.tagName === 'BUTTON') { // Delete recipe } });
En lugar de eso, vamos a usar los IDs únicos que Firebase ha asignado a cada document (a cada receta, vaya). Para eso, primero debemos guardar cada ID en su <li> correspondiente.
Vamos a obtener ese ID en el mismo momento en el que pedimos y obtenemos los datos de la database. Puedes comprobar que tenemos acceso a ese ID haciendo un console.log del mismo.
// get documents db.collection('recipes').get() .then(snapshot => { // console.log(snapshot.docs[0].data()); snapshot.forEach(doc => { console.log(doc.id); addRecipe(doc.data()); }); }) .catch(err => console.log(err));
Ahora los IDs de las cuatro recetas ya deberían aparecerte en tu consola.
Lo que haremos será pasar ese ID como segundo argumento de la addRecipe function, y por tanto, establecerlo como segundo parámetro de la función. Con eso hecho, podemos mostrarlo dentro de la HTML template del <li>.
El lugar donde colocaremos el ID dentro del <li> template es precisamente dentro de la etiqueta <li>, como un atributo de HTML. Pero no un atributo cualquiera, sino un custom data attribute. Estos atributos empiezan siempre por data- y después del guión podemos añadir el nombre que queramos. Así que podemos personalizarlos a nuestro gusto. En este caso lo vamos a llamar data-id.
Si ahora guardamos e inspeccionamos cualquier <li> en el navegador (Elements tab), verás que ya tiene asignado su ID único. ? ¡Fantástico! ✌
5. Una vez comprobado que el usuario ha hecho click en un botón, obtendremos su parent element, que siempre será un <li>, y, como ahora cada <li> tiene un ID asociado, le daremos ese ID a la database para que lo elimine.
Para obtener el ID usamos el método getAttribute sobre el <li>. Si hacemos un console.log, verás que en la consola aparece un ID diferente cada vez que pulsamos un botón distinto.
// delete documents list.addEventListener('click', e => { // console.log(e); if(e.target.tagName === 'BUTTON') { // Delete recipe const id = e.target.parentElement.getAttribute('data-id'); console.log(id); } });
6. Para eliminar el document de la database, usamos la variable db para acceder a la recipes collection. Para acceder a un solo document, usamos el método doc(), al que le pasamos el id del document que queremos eliminar. Para eliminarlo por fin, le aplicamos el método delete().
⌚ Esto resulta en un código asíncrono, porque se tarda cierto tiempo en localizar un document en la database y eliminarlo. Por tanto, tenemos de nuevo una promesa, a la que vinculamos un método then() y un método catch().
En método then() simplemente hacemos un console.log si la receta ha sido eliminada, y al catch(), le pasamos el error en caso de que la promesa sea rechazada. Voy a hacer click en mi primera receta, "pollo al curry".
list.addEventListener('click', e => { // console.log(e); if(e.target.tagName === 'BUTTON') { const id = e.target.parentElement.getAttribute('data-id'); // console.log(id); db.collection('recipes').doc(id).delete() .then(() => console.log('recipe deleted!')) .catch((err) => console.log(err)); } });
Si todo ha salido bien, en tu consola verás el mensaje "recipe deleted!" Y lo más importante, si ahora vas a tu panel de control de Firebase, verás que ese receta ya no existe. ¡Chachi! ? Lo único es que tenemos el mismo problema de antes, porque el cambio no se ve en el navegador de manera automática, sino que tenemos que recarga la página para verlo.
Cómo hacer cambios en la UI en tiempo real
Llega el momento de hacer que nuestros cambios en la database se muestren en nuestra web a tiempo real, sincronizados. Afortunadamente, Firestore nos permite hacer esto por medio de su tecnología llamada real time listeners.
Esta característica nos permite crear un código JS que compruebe los cambios en nuestra database en tiempo real y los aplique a nuestra UI.
?️♀️ Es como tener un espía que está constantemente observando si ha habido algún movimiento en la database, y de ser así, nos da el parte. Con esa información que nos pasa, nosotros podemos actualizar la UI para mostrar los cambios. Veámoslo en acción.
1. Nos deshacemos del código donde obtenemos los documents, porque está escrito de una manera que solo obtiene los datos una única vez (cuando la página se carga), y no continuamente cuando ocurre cualquier cambio, como queremos.
//db.collection('recipes').get() // .then(snapshot => { // // console.log(snapshot.docs[0].data()); // snapshot.forEach(doc => { // // console.log(doc.id); // addRecipe(doc.data(), doc.id); // }); // }) // .catch(err => console.log(err));
Con ese cambio, ahora ya no podemos ver ninguna receta en la UI.
Creamos un real time listener. Para eso:
2. Obtenemos una referencia a nuestra database usando la variable db y la recipes collection, y le aplicamos un método llamado onSnapshot(). Este método dispara una callback function cuyo parámetro es un snapshot.
Lo que ocurre detrás del mecanismo de Firestore es que cada vez que la collection cambia, Firestore toma un snapshot (como una foto) del estado de la collection en ese preciso momento. Ese snapshot nos proporciona información sobre el estado de la collection en cada momento, es decir, que podemos ver los cambios en los datos, entre otras cosas.
3. Hacemos un console.log del snapshot para ver el resultado en la consola.
db.collection('recipes').onSnapshot(snapshot => { console.log(snapshot); });
Si expandimos el snapshot en la consola, verás que tenemos acceso a docs, donde vemos nuestros tres documents. Si seguimos bajando, verás que tenemos acceso a su prototype, donde está un método llamado docChanges(). Este método nos informa de todos los cambios sucedidos en nuestra database. ?✈️
Usemos ese método para ver qué pasa en la consola.
db.collection('recipes').onSnapshot(snapshot => { console.log(snapshot.docChanges()); });
Y ahora podemos ver un array con tres objetos en la consola.
| Elements Console Sources Performance Network ...
Este sería el snapshot inicial con el que empieza nuestra web cuando se carga por primera vez. Esos "cambios" se consideran como valores añadidos, por eso tienen el type "added". Si en lugar de tres documents tuviésemos cuatro registrados en nuestra database, la consola nos devolvería cuatro objetos, porque consideraría que han habido cuatro cambios (cuatro documents añadidos).
A partir de aquí, cada vez que los datos cambien, se creará un nuevo snapshot y podremos ver dichos cambios ?. Prueba a introducir algo en el formulario, verás que la consola te devuelve un nuevo objeto que recoge el cambio, que es de tipo added.
También podemos probar a borrar un document desde Firebase, y ahora tendremos otro cambio registrado en la consola, esta vez de tipo removed. Así que ya tenemos a nuestro espía configurado ?. Lo que podemos hacer con eso es usar la función addRecipe e invocarla cuando añadamos algo a la database. Lo mismo para cuando borremos algo, pero para esto tenemos que crear todavía una función.
El primer paso para conseguir esto es comprobar qué tipo de cambio se ha producido. Ya hemos visto que nada más cargar la web, todos los cambios se consideran de tipo added, pero después pueden ser de tipo added o de tipo removed.
4. Utilizamos el docChanges() sobre el parámetro snapshot. Como eso nos da un array con los cambios en los documents, iteramos sobre él con un loop. Si hacemos un console.log de la variable local del loop, veremos que eso nos devuelve cada cambio (en forma de objeto).
db.collection('recipes').onSnapshot(snapshot => { // console.log(snapshot.docChanges()); snapshot.docChanges().forEach(change => { console.log(change); }); });
Pero nosotros lo que queremos es la referencia del document al que pertenece cada cambio, y a eso tenemos acceso a través de la propiedad doc (expande alguno de los objetos para verla).
db.collection('recipes').onSnapshot(snapshot => { // console.log(snapshot.docChanges()); snapshot.docChanges().forEach(change => { // console.log(change); const doc = change.doc; console.log(doc); }); });
5. Comprobamos qué tipo de cambio es (added o removed). Si es added, le pasamos la función addRecipe. Como argumentos le pasamos las propiedades de la receta (doc.data()) y su ID (doc.id).
db.collection('recipes').onSnapshot(snapshot => { // console.log(snapshot.docChanges()); snapshot.docChanges().forEach(change => { // console.log(change); const doc = change.doc; // console.log(doc); if(change.type === 'added') { addRecipe(doc.data(), doc.id); } }); });
Con estos cambios ya deberíamos volver a ver nuestras recetas en la UI.
Si el tipo es removed, debemos invocar a una función distinta que se encargue de borrar elementos de la UI, así que vamos a crearla. ? Recuerda que no estamos eliminado recetas de la database, porque llegados a este punto, eso ya ha sucedido en el bloque:
// delete documents list.addEventListener('click', e => { // console.log(e); if(e.target.tagName === 'BUTTON') { const id = e.target.parentElement.getAttribute('data-id'); // console.log(id); db.collection('recipes').doc(id).delete() .then(() => console.log('recipe deleted!')) .catch((err) => console.log(err)); } });
6. A la función nueva la llamamos deleteRecipe y le pasamos un id como parámetro.
7. Obtenemos una referencia de todos los <li> e iteramos sobre ellos. Dentro del loop comprobamos si un <li> tiene un atributo data-id que coincide con el id del parámetro de la función. Si ese es el caso, eliminamos esa receta del DOM.
const deleteRecipe = id => { const recipes = document.querySelectorAll('li'); recipes.forEach(recipe => { if(recipe.getAttribute('data-id') === id) { recipe.remove(); } }); };
8. Invocamos a la función deleteRecipe desde nuestro real time listener. A la función le pasamos como argumento el doc.id.
// real time listener db.collection('recipes').onSnapshot(snapshot => { // console.log(snapshot.docChanges()); snapshot.docChanges().forEach(change => { // console.log(change); const doc = change.doc; // console.log(doc); if (change.type === 'added') { addRecipe(doc.data(), doc.id); } else if (change.type === 'removed') { deleteRecipe(doc.id); } }); });
Y ahora desde tu web ya puedes añadir y eliminar recetas, con una UI que se actualiza en tiempo real, mostrándote cada cambio al instante sin necesidad de recargar la página. ? ?
Cómo cancelar los cambios en tiempo real
También es posible cancelar nuestro real time listener para que deje de escuchar los cambios que suceden en nuestra database. Sería como volver al estado anterior a la configuración de nuestro real time listener. Es lo que en inglés se llama unsubscribe. En español quedaría un poco raro hablar de "suscribirnos a los cambios" o "desuscribirnos de esos cambios", así que me referiré a esto como "activar" o "desactivar" la sincronización en tiempo real. O simplemente usaré los términos en inglés. ?
Vamos a ver cómo funciona en la práctica. Para eso, vamos a crear un botón que al hacer click nos permita desactivar la sincronización. Debemos tener en cuenta que Firestore trae por defecto una cualidad que nos permite hacer unsubscribe de una manera muy simple, aunque desconcertante a primera vista, al menos para mí. ?
Lo que ocurre es que esta línea de código:
firestore.collection().onSnapshot()
devuelve una función que Firestore categoriza directamente como de tipo unsubscribe. Esto significa que la dota de la característica para desactivar la sincronización en tiempo real con nuestra database. Por lo tanto, para hacer unsubscribe no tendríamos más que guardar esa función en una variable y llamarla posteriormente. Para más info, este post y este otro de stackOverflow resolvieron mis dudas.
1. Creamos el botón debajo del formulario, en el archivo index.html.
2. Guardamos el real time listener en una variable, a la que podemos llamar unsubscribe. Así, activamos la funcionalidad de Firestore por la cual el real time listener se mantendrá activo hasta que decidamos cancelar la sincronización invocando a la función unsubscribe.
// real time listener const unsubscribe = db.collection('recipes').onSnapshot(snapshot => { // console.log(snapshot.docChanges()); snapshot.docChanges().forEach(change => { // console.log(change); const doc = change.doc; // console.log(doc); if (change.type === 'added') { addRecipe(doc.data(), doc.id); } else if (change.type === 'removed') { deleteRecipe(doc.id); } }); });
De hecho, para que quede claro, ve a tu web e intenta añadir y borrar una receta. Verás que la sincronización en tiempo real con nuestra database sigue activa, por extraño que parezca a priori. ?
3. Obtenemos una referencia del botón de "unsubscribe" que hemos creado.
const unsubscribeBtn = document.querySelector('.unsubscribe');
4. Le añadimos un event listener vinculado a un click event. Dentro de la callback function del event listener lo único que hacemos es invocar a la función unsubscribe. Hacemos también un console.log para asegurarnos de que todo ha salido bien.
// unsubscribe from database changes unsubscribeBtn.addEventListener('click', () => { unsubscribe(); console.log('you unsubscribed from all changes'); });
¡Y voilà! Ahora puedes jugar con activar o desactivar la sincronización en tiempo real pulsando un botón.
? ? ?
THE END!
¡Y con esto terminamos nuestro guía práctica sobre Firebase! Espero que hayas aprendido algo nuevo ?. Si te queda alguna duda, ¡nos vemos en los comentarios!
Y si crees que este post puede serle útil a alguien, ¡compártelo!
Otros artículos que pueden interesarte
Hola!
Muchas gracias por aportar tus conocimientos, yo tambien soy abogado y me dedico a la programación, soy amante empedernido de las Tic´s, nadie entendería nuestra pasión.
Saludos cordiales desde México
Emilio Ordoñez