Compila una aplicación web con Angular y Firebase

1. Introducción

Última actualización: 11 de septiembre de 2020

Qué compilarás

En este codelab, compilaremos un tablero Kanban web con Angular y Firebase. El resultado final será una app con tres categorías de tareas: pendientes, en curso y completadas. Con la función de arrastrar y soltar, podremos crear, borrar y mover tareas de una categoría a otra.

Desarrollaremos la interfaz de usuario con Angular y usaremos Firestore como nuestro almacenamiento persistente. Al final del codelab, implementaremos la app en Firebase Hosting con la CLI de Angular.

b23bd3732d0206b.png

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 a través de un solo comando.

Requisitos

Para este codelab, suponemos que ya tienes una Cuenta de Google y conocimientos básicos de Angular y su CLI.

Comencemos.

2. Crea un proyecto nuevo

Primero, 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

Abre http://localhost:4200. Allí deberías ver un resultado similar al siguiente:

5ede7bc5b1109bf3.png

En tu editor, abre src/app/app.component.html y borra todo su contenido. Cuando regreses a http://localhost:4200, debería aparecer una página en blanco.

3. Cómo agregar Material y el CDK

Angular incluye la implementación de componentes de la interfaz de usuario que cumplen con los requisitos de Material Design como parte del paquete @angular/material. Una de las dependencias de @angular/material es el Kit de Desarrollo de Componentes (CDK). El CDK proporciona primitivas, como utilidades de a11y (accesibilidad), la función de arrastrar y soltar y superposiciones. Distribuimos el CDK en el paquete @angular/cdk.

Para agregar material a tu app, ejecuta lo siguiente:

ng add @angular/material

Si deseas usar los estilos de tipografía global de material y configurar las animaciones del navegador para Angular Material, este comando te pide que elijas un tema. Elige "Índigo/Rosa" para obtener el mismo resultado que en este codelab y responde "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 de este módulo.

Primero, agregaremos una barra de herramientas y un ícono a AppComponent. Abre app.component.html y agrega el siguiente código 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 observas tu consola ahora, verás 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 { }

Ya que usamos el ícono y la barra de herramientas de Angular Material, debemos importar los módulos correspondientes en AppModule.

En la pantalla, deberías ver lo siguiente:

a39cf8f8428a03bc.png

¡Nada mal! Y con solo 4 líneas de HTML y dos importaciones.

4. Cómo visualizar tareas

El siguiente paso será crear un componente que usaremos para visualizar las tareas en el tablero Kanban.

Ve al directorio src/app y ejecuta el siguiente comando de la CLI:

ng generate component task

Este comando genera el código TaskComponent y agrega su declaración al código AppModule. Dentro del directorio task, crea un archivo llamado task.ts. Usaremos este archivo para definir la interfaz de las tareas en el tablero Kanban. Cada tarea tendrá los campos id, title y description opcionales de tipo string:

src/app/task/task.ts

export interface Task {
  id?: string;
  title: string;
  description: string;
}

Ahora, actualicemos task.component.ts. Queremos que el código TaskComponent acepte como entrada un objeto de tipo Task y que pueda emitir los resultados "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>();
}

Edita la 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>

Observa que ahora se arrojan algunos errores:

'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 AppComponent y las visualizaremos a través de 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:

d96fccd13c63ceb1.png

5. Cómo implementar la función de arrastrar y soltar para tareas

Ahora viene la parte divertida. Crearemos tres swimlanes para los tres estados diferentes en los que podrían estar las tareas y, con el CDK de Angular, implementaremos la función de arrastrar y soltar.

En app.component.html, quita el componente app-task con la directiva *ngFor en la parte superior y reemplázalo por lo siguiente:

src/app/app.component.html

<div class="content-wrapper">
  <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>
</div>

Hay mucho que analizar aquí. 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 un elemento div que une los tres swimlanes con el nombre de clase "container-wrapper". Cada swimlane tiene el nombre de clase "container" y un título dentro de una etiqueta h2.

Ahora analicemos la estructura del primer swimlane:

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 el carril como mat-card, que utiliza la directiva cdkDropList. Usamos un mat-card debido a los estilos de este componente. Luego, el código cdkDropList nos permitirá soltar tareas dentro del elemento. También configuramos las siguientes dos entradas:

  • cdkDropListData: Es la entrada de la lista desplegable que nos permite especificar el array de datos.
  • cdkDropListConnectedTo: Hace referencia a los otros códigos cdkDropList a los que está conectado el cdkDropList actual. Cuando configuramos esta entrada, especificamos en qué otras listas podemos soltar elementos.

Además, queremos controlar la acción de soltar usando el resultado de cdkDropListDropped. Una vez que cdkDropList arroje este resultado, usaremos el método drop dentro de AppComponent y pasaremos el evento actual como argumento.

Observa que también especificamos un código id para usar como identificador de este contenedor y un nombre class a fin de poder aplicar un estilo. Ahora veamos 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. Observa que, aquí, controlamos el resultado de edit que declaramos originalmente a través del método editTask 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, iteraremos la lista todo como lo hicimos antes y pasaremos la entrada task. Sin embargo, esta vez, también agregaremos la directiva cdkDrag. que permite arrastrar las tareas individuales.

Para que todo esto funcione, debemos actualizar el código app.module.ts y, luego, 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 arrays 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[]>): 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
    );
  }
}

Observa que, en el método drop, primero verificamos que se encuentre en la misma lista de la que proviene la tarea. En ese caso, regresamos de inmediato. De lo contrario, transferimos la tarea actual al swimlane de destino.

El resultado debería ser este:

460f86bcd10454cf.png

En este punto, ya deberías poder pasar elementos de una lista a otra.

6. Cómo crear tareas nuevas

Ahora implementemos una funcionalidad para crear tareas nuevas. Para ello, actualicemos 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>

Crearemos primero un elemento div alrededor del código container-wrapper y agregaremos un botón con un ícono de material "add" junto a la etiqueta "Agregar tarea". Necesitamos el wrapper adicional para poner el botón en la parte superior de la lista de swimlanes. Más adelante colocaremos los botones uno al lado del otro usando el flexbox. Ya que este botón usa el componente de botón de material, debemos importar el módulo correspondiente en el código 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 AppComponent. Usaremos un diálogo de Material. En el 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.

Observemos la implementación en la parte superior 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);
      });
  }
}

Declararemos un constructor en el que ingresaremos la clase MatDialog. Dentro de newTask, hacemos lo siguiente:

  • Abre un diálogo nuevo con el código TaskDialogComponent, que definiremos más adelante.
  • Especifica que queremos que el diálogo tenga un ancho de 270px..
  • Pasa una tarea vacía al diálogo como datos. En TaskDialogComponent, podremos obtener una referencia a este objeto de datos.
  • Nos suscribimos al evento de cierre y agregamos la tarea del objeto result al array todo.

Para asegurarte de que esto funcione, primero debes importar MatDialogModule en 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 elemento 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 código 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 cambie el contenido de los campos correspondientes, usamos la vinculación de datos bidireccional con ngModel.

Cuando el usuario haga clic en el botón Aceptar, se mostrará de forma automática el resultado { task: data.task }, que es la tarea que cambiamos 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 código TaskDialogComponent, insertaremos una referencia al diálogo para poder cerrarlo y, luego, el valor del proveedor asociado con el token MAT_DIALOG_DATA. Este es el objeto de datos que pasamos al método abierto en el código 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 presione el botón Cancelar, se restablecerán las propiedades de this.data.task posiblemente modificadas y se cerrará el diálogo, que pasará this.data como resultado.

Hay dos tipos a los que hicimos referencia, pero que aún 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 { }

Ahora, cuando hagas clic en el botón "Agregar tarea", deberías ver la siguiente interfaz de usuario:

33bcb987fade2a87.png

7. Cómo mejorar los estilos de la app

Para que la aplicación sea más atractiva, ajustaremos un poco sus estilos y así mejoraremos su diseño. Queremos posicionar los swimlanes uno junto al otro. También queremos realizar algunos ajustes menores en el botón "Agregar tarea" y la etiqueta de 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 y establecimos su ancho en 1400px y su margen en auto. Luego, con flexbox, pusimos los swimlanes uno al lado del otro. 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:

69225f0b1aa5cb50.png

Si bien mejoramos de manera significativa los estilos de nuestra app, aún tenemos el siguiente problema cuando cambiamos de tarea:

f9aae712027624af.png

Cuando comenzamos a arrastrar la tarea "Buy milk", vemos dos tarjetas para la misma tarea: la que arrastramos y la otra en el swimlane. 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, la función de arrastrar y soltar del CDK de Angular lo clona 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. El CDK agregará esto al marcador de posición.

Además, cuando soltamos un elemento, el CDK agrega la clase cdk-drag-animating. Para mostrar una animación fluida en lugar de ajustar el elemento directamente, definiremos una transición con una duración de 250ms.

También queremos realizar algunos ajustes menores en los estilos de las tareas. En task.component.css, vamos a configurar cómo se muestra el elemento del host en block y estableceremos algunos márgenes:

src/app/task/task.component.css

:host {
  display: block;
}

.item {
  margin-bottom: 10px;
  cursor: pointer;
}

8. Cómo editar y borrar tareas existentes

Para editar y quitar tareas existentes, volveremos a usar la mayoría de las funcionalidades que ya implementamos. Cuando el usuario haga doble clic en una tarea, abriremos TaskDialogComponent y propagaremos los dos campos en el formulario con title y description de la tarea.

También le agregaremos un botón para borrar el TaskDialogComponent. Cuando el usuario haga clic en él, se pasará una instrucción de borrar, que terminará en AppComponent.

Solo debemos realizar un cambio en la plantilla en TaskDialogComponent:

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 material de borrar. Cuando el usuario haga clic en él, se cerrará el diálogo y, como resultado, se pasará el literal de objeto { task: data.task, delete: true }. Además, observa que hacemos que el botón sea circular con mat-fab, configuramos su color para que sea el principal y lo mostraremos solo cuando los datos del diálogo tengan habilitada la función de borrar.

El resto de la implementación de las funciones de editar y borrar se encuentra en 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 de cadena literal con valores correspondientes a las propiedades asociadas con los swimlanes individuales.
  • La tarea actual que queremos editar.

En el cuerpo del método, primero abrimos una instancia de 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 al establecer la propiedad enableDelete en true.

Cuando obtenemos el resultado del diálogo, tenemos dos situaciones:

  • Cuando la marca delete se establece en true (es decir, cuando el usuario presiona el botón de borrar), se quita la tarea de la lista correspondiente.
  • La alternativa es reemplazar la tarea en el índice determinado por la tarea que obtuvimos del resultado del diálogo.

9. Cómo crear un proyecto nuevo de Firebase

Ahora, creemos un nuevo proyecto de Firebase.

10. Cómo agregar Firebase al proyecto

En esta sección, integraremos nuestro proyecto con 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 lugar 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 lo siguiente:

9ba88c0d52d18d0.png

Mientras tanto, se abrirá una ventana del 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".

1e4a08b5a2462956.png

Luego, crea una base de datos en modo de prueba:

ac1181b2c32049f9.png

Por último, selecciona una región:

34bb94cc542a0597.png

Lo único que falta ahora es agregar la configuración de Firebase al entorno. Puedes encontrar la configuración de tu proyecto en Firebase console.

  • Haz clic en el ícono de ajustes junto a Descripción general del proyecto.
  • Selecciona Configuración del proyecto.

c8253a20031de8a9.png

En "Tus apps", selecciona una "Aplicación web":

428a1abcd0f90b23.png

A continuación, registra tu aplicación y asegúrate de habilitar "Firebase Hosting":

586e44cb27dd8f39.png

Después de hacer clic en "Registrar app", puedes copiar tu configuración en src/environments/environment.ts:

e30f142d79cecf8f.png

Así debería verse tu archivo de configuración cuando termines:

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. Cómo mover los datos a Firestore

Ahora que configuramos el SDK de Firebase, usa @angular/fire para mover los datos a Firestore. Primero, importa los módulos que necesitas 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 {}

Ya que usaremos Firestore, debemos insertar AngularFirestore en el constructor de 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 los swimlanes:

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. Ten en cuenta que valueChanges devuelve un observable en lugar de un array y que especificamos que el campo de ID de los documentos de esta colección debe llamarse id para que coincida con el nombre que usamos en la interfaz de Task. El observable que devuelve valueChanges emite una colección de tareas cada vez que cambia.

Ya que trabajamos con elementos observables en lugar de arrays, debemos actualizar la forma en que agregamos, quitamos y editamos tareas, así como la funcionalidad para mover tareas entre swimlanes. En lugar de cambiar los arrays en la memoria, usaremos el SDK de Firebase para actualizar los datos de la base de datos.

Primero, 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, el código nuevo está destacado. Para mover una tarea del carril actual al carril objetivo, la quitaremos de la primera colección y la agregaremos a la segunda. Ya que realizamos dos operaciones que deseamos que sean una sola (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 se borra o se actualiza.

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 las selecciones no son arrays, sino observables. Para 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.

El canal asíncrono se suscribe de forma automática a los elementos observables asociados con las selecciones. Cuando los observables arrojan un valor nuevo, Angular ejecuta automáticamente la detección de cambios y procesa el array arrojado.

Por ejemplo, veamos los cambios que debemos realizar en el swimlane 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 observa que allí también usamos el encadenamiento opcional (también llamado operador de navegación segura en Angular) cuando se accede a la propiedad length a fin de garantizar que no se muestre un error en el tiempo de ejecución si todo | async no es null ni undefined.

Ahora, cuando crees una tarea nueva en la interfaz de usuario y abras Firestore, deberías ver algo como lo siguiente:

dd7ee20c0a10ebe2.png

12. Cómo mejorar las actualizaciones optimistas

En la aplicación, ahora estamos realizando actualizaciones optimistas. Tenemos la fuente de información en Firestore; pero, también tenemos copias locales de las tareas. Cuando cualquiera de los observables asociados con las colecciones arroja algo, obtenemos un array de tareas. Cuando una acción del usuario cambia el estado, primero actualizamos los valores locales y después propagamos el cambio a Firestore.

Para mover una tarea de un swimlane a otro, se usa transferArrayItem,. Este opera en instancias locales de los arrays que representan las tareas de cada swimlane. El SDK de Firebase trata a estos arrays como inmutables, lo que significa que la próxima vez que Angular ejecute la detección de cambios, se obtendrán instancias nuevas de los arrays. De este modo, se renderizará el estado anterior antes de mover la tarea.

Al mismo tiempo, se activa una actualización de Firestore y el SDK de Firebase activa una actualización con los valores correctos. Eso hará que en unos milisegundos la interfaz de usuario tenga el estado correcto. Entonces la tarea que acabamos de mover pasará de la primera lista a la siguiente. Puedes ver esto en el siguiente GIF:

70b946eebfa6f316.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 los datos.

Podemos aprovechar BehaviorSubject, que une el observador original que recibimos de valueChanges. De forma automática, BehaviorSubject mantiene un array 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 hicimos en el fragmento anterior fue crear un BehaviorSubject. Este arroja un valor cada vez que cambia el observable asociado con la selección.

Todo funciona como se esperaba, ya que BehaviorSubject reutiliza el array en las funciones 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:

  1. Compilará tu app con su configuración de producción aplicando optimizaciones en el tiempo de compilación.
  2. Implementa tu app en Firebase Hosting.
  3. Generará una URL para que puedas obtener una vista previa del resultado.

14. Felicitaciones

¡Felicitaciones! Compilaste un tablero Kanban con Firebase y Angular.

Creaste una interfaz de usuario con tres columnas que representan el estado de las diferentes tareas. Implementaste las funciones de arrastrar y soltar en las columnas con el CDK de Angular. Luego, compilaste un formulario para crear tareas nuevas y editar tareas existentes con material de Angular. Después aprendiste a usar @angular/fire y a mover 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. Puedes obtener información acerca de cómo hacerlo aquí.

Actualmente, no conservamos el orden de las tareas individuales en un swimlane específico. Para implementar esto, puedes usar un campo de orden en el documento de tareas y ordenarlo en función de él.

Además, compilamos el tablero Kanban para un solo usuario. Esto significa que tenemos un solo tablero Kanban para cualquier usuario que abra la app. Si deseas implementar diferentes tableros para diferentes usuarios de tu app, deberás cambiar la estructura de la base de datos. Obtén información sobre las prácticas recomendadas de Firestore aquí.