1. Introducción
Última actualización: 11/09/2020
Qué compilarás
En este codelab, crearemos un tablero Kanban web con Angular y Firebase. Nuestra app final tendrá tres categorías de tareas: tareas pendientes, en curso y completadas. Con la función de arrastrar y soltar, podremos crear, borrar tareas y transferirlas de una categoría a otra.
Desarrollaremos la interfaz de usuario mediante Angular y usaremos Firestore como nuestro almacén persistente. Al final del codelab, implementaremos la app en Firebase Hosting con la CLI de Angular.
Qué aprenderás
- Cómo usar el material de Angular y el CDK
- Cómo agregar la integración de Firebase a tu app de Angular
- Cómo conservar tus datos persistentes en Firestore
- Cómo implementar tu app en Firebase Hosting con la CLI de Angular con un solo comando.
Requisitos
En este codelab, se supone que tienes una Cuenta de Google y conocimientos básicos de Angular y de la CLI de Angular.
¡Comencemos!
2. Crea un proyecto nuevo
En primer lugar, crearemos un nuevo lugar de trabajo de Angular:
ng new kanban-fire
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS
Este paso puede tardar unos minutos. La CLI de Angular crea la estructura del proyecto y, luego, instala todas las dependencias. Cuando se complete el proceso de instalación, ve al directorio kanban-fire
y, luego, inicia el servidor de desarrollo de la CLI de Angular:
ng serve
Abra http://localhost:4200 y debería ver un resultado similar al siguiente:
En tu editor, abre src/app/app.component.html
y borra todo su contenido. Cuando navegue de nuevo a http://localhost:4200, debería ver una página en blanco.
3. Cómo agregar Material y CDK
Angular incluye una implementación de componentes de la interfaz de usuario que cumplen con los requisitos de Material Design y forma parte del paquete @angular/material
. Una de las dependencias de @angular/material
es el kit de desarrollo de componentes o el CDK. El CDK proporciona primitivas, como utilidades de a11y, arrastrar y soltar, y superposiciones. Distribuyemos el CDK en el paquete @angular/cdk
.
Para agregar material a tu app, ejecuta lo siguiente:
ng add @angular/material
Este comando te pide que elijas un tema, si deseas usar los estilos de tipografía global de material y si deseas configurar las animaciones del navegador para Angular Material. Selecciona "Indigo/Pink" para obtener el mismo resultado que en este codelab y responde con "Sí" a las dos últimas preguntas.
El comando ng add
instala @angular/material
y sus dependencias, y, luego, importa el BrowserAnimationsModule
en AppModule
. En el siguiente paso, podemos comenzar a usar los componentes que ofrece este módulo.
Primero, agregaremos una barra de herramientas y un ícono a AppComponent
. Abre app.component.html
y agrega el siguiente lenguaje de marcado:
src/app/app.component.html
<mat-toolbar color="primary">
<mat-icon>local_fire_department</mat-icon>
<span>Kanban Fire</span>
</mat-toolbar>
Aquí, agregamos una barra de herramientas con el color principal de nuestro tema de material design y, dentro de ella, usamos el ícono local_fire_depeartment
junto a la etiqueta "Kanban Fire". Si observa su consola ahora, verá que Angular arroja algunos errores. Para solucionarlos, asegúrate de agregar las siguientes importaciones a AppModule
:
src/app/app.module.ts
...
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatToolbarModule,
MatIconModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Dado que usamos el ícono y la barra de herramientas de Angular, debes importar los módulos correspondientes en AppModule
.
En la pantalla, deberías ver lo siguiente:
Nada mal con solo 4 líneas de HTML y dos importaciones.
4. Visualización de tareas
Como siguiente paso, crearemos un componente que podemos usar para visualizar las tareas en el kanban.
Ve al directorio src/app
y ejecuta el siguiente comando de la CLI:
ng generate component task
Este comando genera el TaskComponent
y agrega su declaración a la AppModule
. Dentro del directorio task
, crea un archivo llamado task.ts
. Usaremos este archivo para definir la interfaz de las tareas en el kanban. Cada tarea tendrá una string de tipo id
, title
y description
opcional:
src/app/task/task.ts
export interface Task {
id?: string;
title: string;
description: string;
}
Actualicemos task.component.ts
. Queremos que TaskComponent
acepte como entrada un objeto de tipo Task
, y queremos que pueda emitir los resultados de edit
:
src/app/task/task.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from './task';
@Component({
selector: 'app-task',
templateUrl: './task.component.html',
styleUrls: ['./task.component.css']
})
export class TaskComponent {
@Input() task: Task | null = null;
@Output() edit = new EventEmitter<Task>();
}
Editar plantilla de TaskComponent
Abre task.component.html
y reemplaza su contenido con el siguiente código HTML:
src/app/task/task.component.html
<mat-card class="item" *ngIf="task" (dblclick)="edit.emit(task)">
<h2>{{ task.title }}</h2>
<p>
{{ task.description }}
</p>
</mat-card>
Ten en cuenta que estamos recibiendo errores en la consola:
'mat-card' is not a known element:
1. If 'mat-card' is an Angular component, then verify that it is part of this module.
2. If 'mat-card' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.ng
En la plantilla anterior, usamos el componente mat-card
de @angular/material
, pero no importamos su módulo correspondiente en la app. Para corregir el error anterior, debemos importar el MatCardModule
en AppModule
:
src/app/app.module.ts
...
import { MatCardModule } from '@angular/material/card';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatCardModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
A continuación, crearemos algunas tareas en el AppComponent
y las visualizaremos usando el TaskComponent
.
En AppComponent
, define un array llamado todo
y, dentro de él, agrega dos tareas:
src/app/app.component.ts
...
import { Task } from './task/task';
@Component(...)
export class AppComponent {
todo: Task[] = [
{
title: 'Buy milk',
description: 'Go to the store and buy milk'
},
{
title: 'Create a Kanban app',
description: 'Using Firebase and Angular create a Kanban app!'
}
];
}
Ahora, en la parte inferior de app.component.html
, agrega la siguiente directiva *ngFor
:
src/app/app.component.html
<app-task *ngFor="let task of todo" [task]="task"></app-task>
Cuando abras el navegador, deberías ver lo siguiente:
5. Cómo implementar la función de arrastrar y soltar para las tareas
Estamos listos para la parte divertida. En estos tres estados, podemos crear tres configuraciones básicas y, con el CDK de Angular, implementar una funcionalidad de arrastrar y soltar.
En app.component.html
, quita el componente app-task
por la directiva *ngFor
en la parte superior y reemplázalo por lo siguiente:
src/app/app.component.html
<div class="container-wrapper">
<div class="container">
<h2>Backlog</h2>
<mat-card
cdkDropList
id="todo"
#todoList="cdkDropList"
[cdkDropListData]="todo"
[cdkDropListConnectedTo]="[doneList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
<app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
<div class="container">
<h2>In progress</h2>
<mat-card
cdkDropList
id="inProgress"
#inProgressList="cdkDropList"
[cdkDropListData]="inProgress"
[cdkDropListConnectedTo]="[todoList, doneList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="inProgress.length === 0">Empty list</p>
<app-task (edit)="editTask('inProgress', $event)" *ngFor="let task of inProgress" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
<div class="container">
<h2>Done</h2>
<mat-card
cdkDropList
id="done"
#doneList="cdkDropList"
[cdkDropListData]="done"
[cdkDropListConnectedTo]="[todoList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="done.length === 0">Empty list</p>
<app-task (edit)="editTask('done', $event)" *ngFor="let task of done" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
</div>
Esto está pasando. Veamos las partes individuales de este fragmento paso a paso. Esta es la estructura de nivel superior de la plantilla:
src/app/app.component.html
...
<div class="container-wrapper">
<div class="container">
<h2>Backlog</h2>
...
</div>
<div class="container">
<h2>In progress</h2>
...
</div>
<div class="container">
<h2>Done</h2>
...
</div>
</div>
Aquí, creamos una div
que une los tres nadadores, con el nombre de clase container-wrapper
. Cada natación tiene un nombre de clasecontainer
y un título dentro de una etiqueta h2
.
Ahora, analicemos la estructura de la primera línea:
src/app/app.component.html
...
<div class="container">
<h2>Backlog</h2>
<mat-card
cdkDropList
id="todo"
#todoList="cdkDropList"
[cdkDropListData]="todo"
[cdkDropListConnectedTo]="[doneList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list"
>
<p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
<app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
...
Primero, definimos la barra como una mat-card
, que utiliza la directiva cdkDropList
. Usamos un mat-card
debido a los estilos que proporciona este componente. Luego, cdkDropList
nos permitirá descartar tareas dentro del elemento. También configuramos las siguientes dos entradas:
cdkDropListData
: Es la entrada de la lista desplegable que nos permite especificar el arreglo de datos.cdkDropListConnectedTo
: Hace referencia a los otroscdkDropList
a los que está conectado elcdkDropList
actual. Cuando configuramos esta entrada, especificamos en qué otras listas podemos colocar elementos.
Además, queremos controlar el evento de soltar con el resultado de cdkDropListDropped
. Una vez que cdkDropList
emita este resultado, invocaremos el método drop
declarado dentro de AppComponent
y pasarámos el evento actual como argumento.
Ten en cuenta que también especificamos un elemento id
para usar como identificador de este contenedor y un nombre class
a fin de poder aplicar estilo. Ahora, analicemos el contenido secundario de mat-card
. Los dos elementos que tenemos son los siguientes:
- Un párrafo que usamos para mostrar el texto "Lista vacía" cuando no hay elementos en la lista
todo
- El componente
app-task
. Ten en cuenta que aquí estamos controlando la salidaedit
que declaramos originalmente llamando al métodoeditTask
con el nombre de la lista y el objeto$event
. Esto nos ayudará a reemplazar la tarea editada de la lista correcta. A continuación, iteramos la listatodo
como lo hicimos antes y pasamos la entradatask
. Sin embargo, esta vez, también agregaremos la directivacdkDrag
. Permite arrastrar las tareas individuales.
Para que todo esto funcione, debemos actualizar app.module.ts
e incluir una importación en DragDropModule
:
src/app/app.module.ts
...
import { DragDropModule } from '@angular/cdk/drag-drop';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
DragDropModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
También debemos declarar los arreglos inProgress
y done
, junto con los métodos editTask
y drop
:
src/app/app.component.ts
...
import { CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';
@Component(...)
export class AppComponent {
todo: Task[] = [...];
inProgress: Task[] = [];
done: Task[] = [];
editTask(list: string, task: Task): void {}
drop(event: CdkDragDrop<Task[]|null>): void {
if (event.previousContainer === event.container) {
return;
}
if (!event.container.data || !event.previousContainer.data) {
return;
}
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
}
}
Ten en cuenta que, en el método drop
, primero verificamos que se encuentre en la misma lista de la que proviene la tarea. Si ese es el caso, regresamos de inmediato. De lo contrario, transferimos la tarea actual a la línea de destino.
El resultado debería ser este:
En este punto, ya deberías poder transferir elementos entre las dos listas.
6. Cómo crear tareas nuevas
Ahora, implementemos una funcionalidad para crear tareas nuevas. Para ello, actualice la plantilla de AppComponent
:
src/app/app.component.html
<mat-toolbar color="primary">
...
</mat-toolbar>
<div class="content-wrapper">
<button (click)="newTask()" mat-button>
<mat-icon>add</mat-icon> Add Task
</button>
<div class="container-wrapper">
<div class="container">
...
</div>
</div>
Creamos un elemento div
de nivel superior alrededor de container-wrapper
y agregamos un botón con el ícono de material add
junto a una etiqueta "Agregar tarea". Necesitamos el wrapper adicional para colocar el botón en la parte superior de la lista de billetes, que más adelante colocaremos uno al lado del otro usando el flexbox. Dado que este botón usa el componente de botón de material, debemos importar el módulo correspondiente en el AppModule
:
src/app/app.module.ts
...
import { MatButtonModule } from '@angular/material/button';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatButtonModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Ahora, implementemos la funcionalidad para agregar tareas en el AppComponent
. Usaremos un diálogo de material. En el cuadro de diálogo, tendremos un formulario con dos campos: título y descripción. Cuando el usuario haga clic en el botón"Agregar tarea", abriremos el diálogo y, cuando envíe el formulario, agregaremos la tarea recién creada a la lista todo
.
Veamos la implementación de alto nivel de esta funcionalidad en AppComponent
:
src/app/app.component.ts
...
import { MatDialog } from '@angular/material/dialog';
@Component(...)
export class AppComponent {
...
constructor(private dialog: MatDialog) {}
newTask(): void {
const dialogRef = this.dialog.open(TaskDialogComponent, {
width: '270px',
data: {
task: {},
},
});
dialogRef
.afterClosed()
.subscribe((result: TaskDialogResult|undefined) => {
if (!result) {
return;
}
this.todo.push(result.task);
});
}
}
Declaramos un constructor en el que inyectamos la clase MatDialog
. Dentro del newTask
, sucede lo siguiente:
- Abre un diálogo nuevo con el
TaskDialogComponent
que definiremos en unos instantes. - Especifica que queremos que el diálogo tenga un ancho de
270px.
- Pasar una tarea vacía al diálogo como datos. En
TaskDialogComponent
, podremos obtener una referencia a este objeto de datos. - Suscríbete al evento de cierre y agregamos la tarea del objeto
result
al arraytodo
.
Para asegurarte de que esto funcione, primero debes importar el MatDialogModule
en el AppModule
:
src/app/app.module.ts
...
import { MatDialogModule } from '@angular/material/dialog';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatDialogModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Ahora, creemos el TaskDialogComponent
. Navega al directorio src/app
y ejecuta lo siguiente:
ng generate component task-dialog
Para implementar su funcionalidad, primero abre: src/app/task-dialog/task-dialog.component.html
y reemplaza su contenido con lo siguiente:
src/app/task-dialog/task-dialog.component.html
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput cdkFocusInitial [(ngModel)]="data.task.title" />
</mat-form-field>
<mat-form-field>
<mat-label>Description</mat-label>
<textarea matInput [(ngModel)]="data.task.description"></textarea>
</mat-form-field>
<div mat-dialog-actions>
<button mat-button [mat-dialog-close]="{ task: data.task }">OK</button>
<button mat-button (click)="cancel()">Cancel</button>
</div>
En la plantilla anterior, creamos un formulario con dos campos para title
y description
. Usamos la directiva cdkFocusInput
para enfocar automáticamente la entrada title
cuando el usuario abre el diálogo.
Observa que, dentro de la plantilla, hacemos referencia a la propiedad data
del componente. Este será el mismo data
que pasamos al método open
de dialog
en AppComponent
. Para actualizar el título y la descripción de la tarea cuando el usuario cambia el contenido de los campos correspondientes, usamos la vinculación de datos bidireccional con ngModel
.
Cuando el usuario hace clic en el botón Aceptar, se muestra automáticamente el resultado { task: data.task }
, que es la tarea que mutamos con los campos del formulario de la plantilla anterior.
Ahora, implementemos el controlador del componente:
src/app/task-dialog/task-dialog.component.ts
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Task } from '../task/task';
@Component({
selector: 'app-task-dialog',
templateUrl: './task-dialog.component.html',
styleUrls: ['./task-dialog.component.css'],
})
export class TaskDialogComponent {
private backupTask: Partial<Task> = { ...this.data.task };
constructor(
public dialogRef: MatDialogRef<TaskDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: TaskDialogData
) {}
cancel(): void {
this.data.task.title = this.backupTask.title;
this.data.task.description = this.backupTask.description;
this.dialogRef.close(this.data);
}
}
En el TaskDialogComponent
, insertamos una referencia al cuadro de diálogo para poder cerrarla y, luego, insertamos el valor del proveedor asociado con el token de MAT_DIALOG_DATA
. Este es el objeto de datos que pasamos al método abierto en el AppComponent
anterior. También declaramos la propiedad privada backupTask
, que es una copia de la tarea que pasamos junto con el objeto de datos.
Cuando el usuario presiona el botón Cancelar, restablecemos las propiedades de this.data.task
posiblemente modificadas, cerramos el diálogo y pasamos this.data
como resultado.
Hay dos tipos a los que nos referimos, pero todavía no declaramos: TaskDialogData
y TaskDialogResult
. Dentro de src/app/task-dialog/task-dialog.component.ts
, agrega las siguientes declaraciones al final del archivo:
src/app/task-dialog/task-dialog.component.ts
...
export interface TaskDialogData {
task: Partial<Task>;
enableDelete: boolean;
}
export interface TaskDialogResult {
task: Task;
delete?: boolean;
}
Lo último que debemos hacer antes de tener lista la funcionalidad es importar algunos módulos en AppModule
.
src/app/app.module.ts
...
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatInputModule,
FormsModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Cuando hagas clic en el botón "Agregar tarea", deberías ver la siguiente interfaz de usuario:
7. Mejorar los estilos de la aplicación
Para que la aplicación sea más atractiva, mejoraremos su diseño modificando un poco sus estilos. Queremos posicionar las flotaciones una junto a la otra. También queremos realizar algunos ajustes menores en el botón "Agregar tarea" y la etiqueta de la lista vacía.
Abre src/app/app.component.css
y agrega los siguientes estilos en la parte inferior:
src/app/app.component.css
mat-toolbar {
margin-bottom: 20px;
}
mat-toolbar > span {
margin-left: 10px;
}
.content-wrapper {
max-width: 1400px;
margin: auto;
}
.container-wrapper {
display: flex;
justify-content: space-around;
}
.container {
width: 400px;
margin: 0 25px 25px 0;
}
.list {
border: solid 1px #ccc;
min-height: 60px;
border-radius: 4px;
}
app-new-task {
margin-bottom: 30px;
}
.empty-label {
font-size: 2em;
padding-top: 10px;
text-align: center;
opacity: 0.2;
}
En el fragmento anterior, ajustamos el diseño de la barra de herramientas y su etiqueta. También nos aseguramos de que el contenido esté alineado horizontalmente estableciendo su ancho en 1400px
y su margen en auto
. Luego, con flexbox, ponemos las cuerdas una al lado de la otra y, finalmente, realizamos algunos ajustes en la forma en que visualizamos las tareas y las listas vacías.
Cuando se vuelva a cargar la app, deberías ver la siguiente interfaz de usuario:
Si bien mejoramos significativamente los estilos de nuestra app, aún tenemos un problema molesto cuando cambiamos de tarea:
Cuando empezamos a arrastrar la tarea "Comprar leche", vemos dos tarjetas para la misma tarea: una que arrastramos y otra en la piscina. El CDK de Angular nos proporciona nombres de clases de CSS que podemos usar para solucionar este problema.
Agrega las siguientes anulaciones de estilo a la parte inferior de src/app/app.component.css
:
src/app/app.component.css
.cdk-drag-animating {
transition: transform 250ms;
}
.cdk-drag-placeholder {
opacity: 0;
}
Mientras arrastramos un elemento, el CDK de Angular arrastra y suelta el archivo, y lo inserta en la posición en la que colocaremos el original. Para asegurarnos de que este elemento no sea visible, configuramos la propiedad de opacidad en la clase cdk-drag-placeholder
, que el CDK agregará al marcador de posición.
Además, cuando descartamos un elemento, el CDK agrega la clase cdk-drag-animating
. Para mostrar una animación fluida en lugar de ajustar directamente el elemento, definimos una transición con una duración de 250ms
.
También queremos realizar algunos ajustes menores en los estilos de nuestras tareas. En task.component.css
, configuraremos la pantalla del elemento de host en block
y configurar algunos márgenes:
src/app/task/task.component.css
:host {
display: block;
}
.item {
margin-bottom: 10px;
cursor: pointer;
}
8. Edita y borra tareas existentes
Para editar y quitar tareas existentes, volveremos a usar la mayoría de las funciones que ya implementamos. Cuando el usuario hace doble clic en una tarea, abriremos la TaskDialogComponent
y propagaremos los dos campos en el formulario con las tareas title
y description
de la tarea.
Para el TaskDialogComponent
, también agregaremos un botón de eliminación. Cuando el usuario haga clic en él, pasará una instrucción de borrar, que terminará en AppComponent
.
El único cambio que debemos realizar en TaskDialogComponent
es en su plantilla:
src/app/task-dialog/task-dialog.component.html
<mat-form-field>
...
</mat-form-field>
<div mat-dialog-actions>
...
<button
*ngIf="data.enableDelete"
mat-fab
color="primary"
aria-label="Delete"
[mat-dialog-close]="{ task: data.task, delete: true }">
<mat-icon>delete</mat-icon>
</button>
</div>
Este botón muestra el ícono de borrar material. Cuando el usuario haga clic en él, cerraremos el diálogo y pasaremos el literal de objeto { task: data.task, delete: true }
. Además, ten en cuenta que hacemos que el botón sea circular mediante mat-fab
, configuramos su color para que sea el principal y lo mostraremos solo cuando los datos del diálogo tengan habilitada la eliminación.
El resto de la implementación de las funciones de edición y eliminación se encuentra en el AppComponent
. Reemplaza su método editTask
por lo siguiente:
src/app/app.component.ts
@Component({ ... })
export class AppComponent {
...
editTask(list: 'done' | 'todo' | 'inProgress', task: Task): void {
const dialogRef = this.dialog.open(TaskDialogComponent, {
width: '270px',
data: {
task,
enableDelete: true,
},
});
dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
if (!result) {
return;
}
const dataList = this[list];
const taskIndex = dataList.indexOf(task);
if (result.delete) {
dataList.splice(taskIndex, 1);
} else {
dataList[taskIndex] = task;
}
});
}
...
}
Veamos los argumentos del método editTask
:
- Una lista de tipo
'done' | 'todo' | 'inProgress',
, que es un tipo de unión literal de string con valores correspondientes a las propiedades asociadas con los flotadores individuales. - La tarea actual que queremos editar.
En el cuerpo del método, primero abrimos una instancia del TaskDialogComponent
. Como su data
, pasamos un literal de objeto, que especifica la tarea que queremos editar, y también habilita el botón de edición en el formulario estableciendo la propiedad enableDelete
en true
.
Cuando obtenemos el resultado del diálogo, manejamos dos situaciones:
- Cuando la marca
delete
se configura entrue
(es decir, cuando el usuario presiona el botón Borrar), quitamos la tarea de la lista correspondiente. - Como alternativa, solo reemplazamos la tarea en el índice dado por la tarea que obtuvimos del resultado del diálogo.
9. Cómo crear un proyecto nuevo de Firebase
Ahora, creemos un proyecto de Firebase nuevo.
- Ve a Firebase Console.
- Crea un proyecto nuevo con el nombre KanbanFire.
10. Agrega Firebase al proyecto
En esta sección, integraremos nuestro proyecto a Firebase. El equipo de Firebase ofrece el paquete @angular/fire
, que proporciona integración entre las dos tecnologías. Para agregar compatibilidad con Firebase a tu app, abre el directorio raíz de tu espacio de trabajo y ejecuta lo siguiente:
ng add @angular/fire
Este comando instala el paquete @angular/fire
y te hace algunas preguntas. En la terminal, deberías ver algo como lo siguiente:
Mientras tanto, se abrirá una ventana de navegador para que puedas realizar la autenticación con tu cuenta de Firebase. Por último, se te pedirá que elijas un proyecto de Firebase y crees algunos archivos en el disco.
A continuación, debemos crear una base de datos de Firestore. En “Cloud Firestore”, haz clic en Crear base de datos.
Luego, crea una base de datos en modo de prueba:
Por último, selecciona una región:
Lo único que falta ahora es agregar la configuración de Firebase a tu entorno. Puedes encontrar la configuración de tu proyecto en Firebase console.
- Haga clic en el ícono de ajustes junto a Descripción general del proyecto.
- Elige la configuración del proyecto.
En "Tus apps", selecciona una app web.
A continuación, registra tu aplicación y asegúrate de habilitar Firebase Hosting:
Después de hacer clic en "Registrar app", puedes copiar tu configuración en src/environments/environment.ts
:
Al final, tu archivo de configuración debería verse de la siguiente manera:
src/environments/environment.ts
export const environment = {
production: false,
firebase: {
apiKey: '<your-key>',
authDomain: '<your-project-authdomain>',
databaseURL: '<your-database-URL>',
projectId: '<your-project-id>',
storageBucket: '<your-storage-bucket>',
messagingSenderId: '<your-messaging-sender-id>'
}
};
11. Mueve los datos a Firestore
Ahora que configuramos el SDK de Firebase, vamos a usar @angular/fire
para mover nuestros datos a Firestore. Primero, vamos a importar los módulos que necesitamos en AppModule
:
src/app/app.module.ts
...
import { environment } from 'src/environments/environment';
import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';
@NgModule({
declarations: [AppComponent, TaskDialogComponent, TaskComponent],
imports: [
...
AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
Dado que usaremos Firestore, debemos inyectar AngularFirestore
en el constructor AppComponent
:
src/app/app.component.ts
...
import { AngularFirestore } from '@angular/fire/firestore';
@Component({...})
export class AppComponent {
...
constructor(private dialog: MatDialog, private store: AngularFirestore) {}
...
}
A continuación, actualizaremos la forma en la que inicializamos los arrays de la línea de natación:
src/app/app.component.ts
...
@Component({...})
export class AppComponent {
todo = this.store.collection('todo').valueChanges({ idField: 'id' }) as Observable<Task[]>;
inProgress = this.store.collection('inProgress').valueChanges({ idField: 'id' }) as Observable<Task[]>;
done = this.store.collection('done').valueChanges({ idField: 'id' }) as Observable<Task[]>;
...
}
Aquí, usamos AngularFirestore
para obtener el contenido de la colección directamente desde la base de datos. Observa que valueChanges
muestra un observable en lugar de un array y también que especificamos que el campo de ID para los documentos de esta colección debe llamarse id
para coincidir con el nombre que usamos en la interfaz Task
. El observable que muestra valueChanges
emite una colección de tareas cada vez que cambia.
Debido a que trabajamos con elementos observables en lugar de arreglos, debemos actualizar la forma en que agregamos, quitamos y editamos tareas, y la funcionalidad para mover tareas entre los valores de configuración. En lugar de mutar nuestros arreglos en la memoria, usaremos el SDK de Firebase para actualizar los datos de la base de datos.
En primer lugar, veamos cómo sería el reordenamiento. Reemplaza el método drop
en src/app/app.component.ts
con lo siguiente:
src/app/app.component.ts
drop(event: CdkDragDrop<Task[]>): void {
if (event.previousContainer === event.container) {
return;
}
const item = event.previousContainer.data[event.previousIndex];
this.store.firestore.runTransaction(() => {
const promise = Promise.all([
this.store.collection(event.previousContainer.id).doc(item.id).delete(),
this.store.collection(event.container.id).add(item),
]);
return promise;
});
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
}
En el fragmento anterior, se destaca el código nuevo. Para mover una tarea de la fila actual a la objetivo, quitaremos la tarea de la primera colección y la agregaremos a la segunda. Dado que llevamos a cabo dos operaciones que queremos que se vean como una (es decir, que la operación sea atómica), las ejecutamos en una transacción de Firestore.
A continuación, actualizaremos el método editTask
para usar Firestore. Dentro del controlador de diálogo de cierre, debemos cambiar las siguientes líneas de código:
src/app/app.component.ts
...
dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
if (!result) {
return;
}
if (result.delete) {
this.store.collection(list).doc(task.id).delete();
} else {
this.store.collection(list).doc(task.id).update(task);
}
});
...
Accedemos al documento objetivo correspondiente a la tarea que manipulamos con el SDK de Firestore y lo borramos o actualizamos.
Por último, debemos actualizar el método para crear tareas nuevas. Reemplaza this.todo.push('task')
por: this.store.collection('todo').add(result.task)
.
Observa que ahora nuestras colecciones no son arreglos, sino observables. A fin de poder visualizarlos, debemos actualizar la plantilla de AppComponent
. Solo reemplaza cada acceso de las propiedades todo
, inProgress
y done
con todo | async
, inProgress | async
y done | async
, respectivamente.
La canalización asíncrona se suscribe automáticamente a los elementos observables asociados con las colecciones. Cuando los observables emiten un valor nuevo, Angular ejecuta automáticamente la detección de cambios y procesa el arreglo emitido.
Por ejemplo, veamos los cambios que debemos realizar en el grupo todo
:
src/app/app.component.html
<mat-card
cdkDropList
id="todo"
#todoList="cdkDropList"
[cdkDropListData]="todo | async"
[cdkDropListConnectedTo]="[doneList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="(todo | async)?.length === 0">Empty list</p>
<app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo | async" cdkDrag [task]="task"></app-task>
</mat-card>
Cuando pasamos los datos a la directiva cdkDropList
, aplicamos el canal asíncrono. Es lo mismo dentro de la directiva *ngIf
, pero ten en cuenta que también usamos el encadenamiento opcional (también conocido como operador de navegación segura en Angular) cuando se accede a la propiedad length
para garantizar que no se muestre un error de tiempo de ejecución si todo | async
no es null
o undefined
.
Ahora, cuando crees una tarea nueva en la interfaz de usuario y abras Firestore, deberías ver algo como lo siguiente:
12. Mejora las actualizaciones optimistas
En la aplicación, actualmente estamos realizando actualizaciones optimistas. Tenemos nuestra fuente de información en Firestore, pero, al mismo tiempo, tenemos copias locales de las tareas. Cuando se emiten cualquiera de los objetos observables asociados con las colecciones, obtenemos un arreglo de tareas. Cuando una acción del usuario muta el estado, primero actualizamos los valores locales y, luego, propagamos el cambio a Firestore.
Cuando trasladamos una tarea de un natación a otra, invocamos transferArrayItem,
, que opera en instancias locales de los arreglos que representan las tareas de cada fila. El SDK de Firebase trata a estos arreglos como inmutables, lo que significa que la próxima vez que Angular ejecute la detección de cambios, obtendremos nuevas instancias de ellos, lo que renderizará el estado anterior antes de transferir la tarea.
Al mismo tiempo, activamos una actualización de Firestore y el SDK de Firebase activa una actualización con los valores correctos, por lo que en unos pocos milisegundos la interfaz de usuario tendrá el estado correcto. Esto hace que la tarea que acabamos de transferir pase de la primera lista a la siguiente. Puede verlo en el siguiente GIF:
La manera correcta de resolver este problema varía de una aplicación a otra, pero en todos los casos debemos asegurarnos de mantener un estado coherente hasta que se actualicen nuestros datos.
Podemos aprovechar BehaviorSubject
, que une el observador original que recibimos de valueChanges
. De forma interna, BehaviorSubject
mantiene un arreglo mutable que conserva la actualización de transferArrayItem
.
Para implementar una corrección, lo único que debemos hacer es actualizar AppComponent
:
src/app/app.component.ts
...
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { BehaviorSubject } from 'rxjs';
const getObservable = (collection: AngularFirestoreCollection<Task>) => {
const subject = new BehaviorSubject<Task[]>([]);
collection.valueChanges({ idField: 'id' }).subscribe((val: Task[]) => {
subject.next(val);
});
return subject;
};
@Component(...)
export class AppComponent {
todo = getObservable(this.store.collection('todo')) as Observable<Task[]>;
inProgress = getObservable(this.store.collection('inProgress')) as Observable<Task[]>;
done = getObservable(this.store.collection('done')) as Observable<Task[]>;
...
}
Todo lo que hacemos en el fragmento anterior es crear un BehaviorSubject
, que emite un valor cada vez que cambia el observable asociado con la colección.
Todo funciona como se espera, ya que el BehaviorSubject
reutiliza el array en las invocaciones de detección de cambios y solo se actualiza cuando obtenemos un valor nuevo de Firestore.
13. Implementa la aplicación
Lo único que debemos hacer para implementar nuestra app es ejecutar lo siguiente:
ng deploy
Este comando hará lo siguiente:
- Compila tu app con su configuración de producción aplicando optimizaciones en el tiempo de compilación.
- Implementa tu app en Firebase Hosting.
- Genera una URL para que puedas obtener una vista previa del resultado.
14. Felicitaciones
¡Felicitaciones! Creaste correctamente un panel kanban con Angular y Firebase.
Creó una interfaz de usuario con tres columnas que representan el estado de las diferentes tareas. Con el CDK de Angular, implementaste la acción de arrastrar y soltar de las tareas en las columnas. Luego, usando material de Angular, creó un formulario para crear tareas nuevas y editar las existentes. A continuación, aprendiste a usar @angular/fire
y se movió todo el estado de la aplicación a Firestore. Por último, implementaste tu aplicación en Firebase Hosting.
¿Qué sigue?
Recuerda que implementamos la aplicación con configuraciones de prueba. Antes de implementar tu app en producción, asegúrate de configurar los permisos correctos. Aquí puedes obtener información acerca de cómo hacerlo.
Actualmente, no conservamos el orden de las tareas individuales en una línea específica. Para implementar esto, puedes usar un campo de pedido en el documento de tareas y ordenarlo en función de él.
Además, creamos el kanban para un solo usuario, lo que significa que tenemos un solo kanban para cualquier persona que abra la app. A fin de implementar paneles separados para diferentes usuarios de tu app, deberás cambiar la estructura de tu base de datos. Obtén más información sobre las prácticas recomendadas de Firestore aquí.