Compilar una aplicación web con Angular y Firebase

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.

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 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:

5ede7bc5b1109bf3.png

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:

a39cf8f8428a03bc.png

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:

d96fccd13c63ceb1.png

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 otros cdkDropList a los que está conectado el cdkDropList 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 salida edit que declaramos originalmente llamando al 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, iteramos la lista todo como lo hicimos antes y pasamos la entrada task. Sin embargo, esta vez, también agregaremos la directiva cdkDrag. 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:

460f86bcd10454cf.png

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 array todo.

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&quot, deberías ver la siguiente interfaz de usuario:

33bcb987fade2a87.png

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:

69225f0b1aa5cb50.png

Si bien mejoramos significativamente los estilos de nuestra app, aún tenemos un problema molesto cuando cambiamos de tarea:

f9aae712027624af.png

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 en true (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.

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:

9ba88c0d52d18d0.png

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.

1e4a08b5a246296.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 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.

c8253a20031de8a9.png

En "Tus apps", selecciona una app 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&quot, puedes copiar tu configuración en src/environments/environment.ts:

e30f142d79cecf8f.png

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:

dd7ee20c0a10ebe2.png

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:

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 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:

  1. Compila 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. 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í.