Como criar um aplicativo da Web com o Angular e o Firebase

1. Introdução

Última atualização: 11/09/2020

O que você vai criar

Neste codelab, vamos criar um quadro Kanban na Web com o Angular e o Firebase. Nosso app final vai ter três categorias de tarefas: backlog, em andamento e concluída. Poderemos criar, excluir e transferir tarefas de uma categoria para outra usando o recurso de arrastar e soltar.

Vamos desenvolver a interface do usuário usando o Angular e usar o Firestore como armazenamento permanente. Ao final do codelab, vamos implantar o app no Firebase Hosting usando a CLI do Angular.

b23bd3732d0206b.png

O que você vai aprender

  • Como usar o material do Angular e o CDK.
  • Como adicionar a integração do Firebase ao app do Angular.
  • Como manter os dados permanentes no Firestore.
  • Como implantar o app no Firebase Hosting usando a CLI do Angular com um único comando.

O que é necessário

Neste codelab, presumimos que você tenha uma Conta do Google e uma compreensão básica do Angular e da CLI do Angular.

Vamos começar.

2. Como criar um novo projeto

Primeiro, vamos criar um novo espaço de trabalho do Angular:

ng new kanban-fire
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS

Esta etapa pode levar alguns minutos. A CLI do Angular cria a estrutura do projeto e instala todas as dependências. Quando o processo de instalação terminar, acesse o diretório kanban-fire e inicie o servidor de desenvolvimento da CLI do Angular:

ng serve

Abra http://localhost:4200. Uma saída parecida com esta será mostrada:

5ede7bc5b1109bf3.png

No editor, abra src/app/app.component.html e exclua todo o conteúdo. Quando você voltar para http://localhost:4200, vai ver uma página em branco.

3. Como adicionar o Material Design e o CDK

O Angular vem com uma implementação de componentes da interface do usuário compatíveis com o Material Design como parte do pacote @angular/material. Uma das dependências do @angular/material é o Kit de desenvolvimento de componentes ou CDK, na sigla em inglês. O CDK oferece elementos primitivos, como utilitários de acessibilidade, recursos de arrastar e soltar e sobreposição. Distribuímos o CDK no pacote @angular/cdk.

Para adicionar o Material Design à execução do app:

ng add @angular/material

Esse comando pede que você escolha um tema se quiser usar os estilos de tipografia globais do Material Design e configurar as animações do navegador para o Material Design do Angular. Escolha "Indigo/Pink" para gerar o mesmo resultado deste codelab e responda "Yes" (Sim) para as duas últimas perguntas.

O comando ng add instala o @angular/material, as dependências dele e importa o BrowserAnimationsModule para o AppModule. Na próxima etapa, vamos começar a usar os componentes oferecidos por este módulo.

Primeiro, vamos adicionar uma barra de ferramentas e um ícone ao AppComponent. Abra app.component.html e adicione a seguinte marcação:

src/app/app.component.html

<mat-toolbar color="primary">
  <mat-icon>local_fire_department</mat-icon>
  <span>Kanban Fire</span>
</mat-toolbar>

Aqui, adicionamos uma barra de ferramentas usando a cor principal do tema do Material Design. Dentro dela, usamos o ícone local_fire_depeartment ao lado do rótulo "Kanban Fire". Se você observar o console agora, vai ver que o Angular gera alguns erros. Para corrigir os erros, adicione as seguintes importações ao 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 { }

Como usamos a barra de ferramentas e o ícone do Material Design do Angular, precisamos importar os módulos correspondentes para o AppModule.

Você vai ver o seguinte na tela:

a39cf8f8428a03bc.png

Nada mal com apenas quatro linhas de HTML e duas importações.

4. Como visualizar tarefas

Na próxima etapa, vamos criar um componente que pode ser usado para visualizar as tarefas no quadro Kanban.

Acesse o diretório src/app e execute o seguinte comando da CLI:

ng generate component task

Esse comando gera o TaskComponent e adiciona a declaração ao AppModule. No diretório task, crie um arquivo chamado task.ts. Vamos usar esse arquivo para definir a interface das tarefas no quadro Kanban. Cada tarefa terá os campos opcionais id, title e description, todos do tipo string:

src/app/task/task.ts

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

Agora, vamos atualizar task.component.ts. Queremos que o TaskComponent aceite como entrada um objeto do tipo Task e que emita as saídas "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>();
}

Edite o modelo do TaskComponent. Abra task.component.html e substitua o conteúdo pelo seguinte 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>

Observe que agora há erros no console:

'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

No modelo acima, usamos o componente mat-card do @angular/material, mas não importamos o módulo correspondente para o app. Para corrigir o erro acima, precisamos importar o MatCardModule para o AppModule:

src/app/app.module.ts

...
import { MatCardModule } from '@angular/material/card';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatCardModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Em seguida, vamos criar algumas tarefas no AppComponent, que podem ser visualizadas usando o TaskComponent.

No AppComponent, defina uma matriz chamada todo. Dentro dela, adicione duas tarefas:

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!'
    }
  ];
}

Agora, na parte inferior de app.component.html, adicione a seguinte diretiva *ngFor:

src/app/app.component.html

<app-task *ngFor="let task of todo" [task]="task"></app-task>

Ao abrir o navegador, você verá o seguinte:

d96fccd13c63ceb1.png

5. Como implementar o recurso de arrastar e soltar tarefas

Chegou a hora da diversão! Vamos criar três raias para os três estados em que as tarefas podem estar. Também vamos implementar uma função de arrastar e soltar usando o CDK do Angular.

Em app.component.html, remova o componente app-task com a diretiva *ngFor na parte superior e substitua por:

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>

Muita coisa está acontecendo aqui. Vamos dar uma olhada em cada parte do snippet. Esta é a estrutura de nível superior do modelo:

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>

Aqui, criamos um div que une as três raias, com o nome de classe "container-wrapper". Cada raia tem um nome de classe "container" e um título dentro de uma tag h2.

Agora vamos analisar a estrutura da primeira raia:

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

Primeiro, definimos a raia como um mat-card, que usa a diretiva cdkDropList. Usamos um mat-card devido aos estilos que esse componente oferece. Mais tarde, o cdkDropList permitirá soltar tarefas dentro do elemento. Também definimos as duas entradas a seguir:

  • cdkDropListData: entrada da lista suspensa que permite especificar a matriz de dados
  • cdkDropListConnectedTo: referências às outras cdkDropLists às quais a cdkDropList atual está conectada. Ao configurar essa entrada, especificamos em quais outras listas podemos soltar os itens

Além disso, queremos processar o evento de soltar usando a saída cdkDropListDropped. Depois que a cdkDropList emitir essa saída, invocaremos o método drop declarado no AppComponent e transmitiremos o evento atual como um argumento.

Também especificamos um id a ser usado como identificador desse contêiner e um nome de class para definir um estilo para ele. Agora vamos analisar o conteúdo filho do mat-card. Temos estes dois elementos:

  • Um parágrafo que usamos para mostrar o texto "Lista vazia" quando não há itens na lista todo.
  • O componente app-task. Processamos a saída edit que declaramos originalmente chamando o método editTask com o nome da lista e o objeto $event. Isso nos ajuda a substituir a tarefa editada na lista correta. Em seguida, iteramos a lista todo como fizemos acima e transmitimos a entrada task. No entanto, desta vez, também adicionamos a diretiva cdkDrag. Ela torna as tarefas arrastáveis.

Para que tudo isso funcione, precisamos atualizar o app.module.ts e incluir uma importação para o DragDropModule:

src/app/app.module.ts

...
import { DragDropModule } from '@angular/cdk/drag-drop';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    DragDropModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Também precisamos declarar as matrizes inProgress e done, junto com os métodos editTask e 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
    );
  }
}

No método drop, primeiro conferimos se estamos soltando na mesma lista da tarefa de origem. Se for o caso, vamos retornar imediatamente. Caso contrário, a tarefa atual será transferida para a raia de destino.

O resultado será:

460f86bcd10454cf.png

Neste ponto, você já pode transferir itens entre as duas listas.

6. Como criar novas tarefas

Agora vamos implementar uma função para criar novas tarefas. Para isso, vamos atualizar o modelo do 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>

Criamos um elemento div de nível superior ao redor do container-wrapper e adicionamos um botão com um ícone do material "add" ao lado do rótulo "Add Task" (Adicionar tarefa). Precisamos do wrapper extra para posicionar o botão na parte superior da lista de raias. Depois, colocaremos um ao lado do outro usando a flexbox. Como esse botão usa o componente do Material Design, precisamos importar o módulo correspondente para o AppModule:

src/app/app.module.ts

...
import { MatButtonModule } from '@angular/material/button';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatButtonModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Agora, vamos implementar a função para adicionar tarefas ao AppComponent. Usaremos uma caixa de diálogo do Material Design. Nela, há um formulário com dois campos: título e descrição. Quando o usuário clica no botão "Add Task" (Adicionar tarefa), a caixa de diálogo é aberta. Quando ele envia o formulário, adicionamos a tarefa recém-criada à lista todo.

Vamos ver a implementação de alto nível dessa funcionalidade no 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 um construtor em que injetamos a classe MatDialog. Dentro da newTask:

  • Abrimos uma nova caixa de diálogo usando o TaskDialogComponent, que será definido em breve.
  • Especificamos que queremos que a caixa de diálogo tenha 270px. de largura.
  • Transmitimos uma tarefa vazia para a caixa de diálogo na forma de dados. No TaskDialogComponent, é possível gerar uma referência a esse objeto de dados.
  • Fazemos a inscrição no evento de encerramento e adicionamos a tarefa do objeto result à matriz todo.

Para garantir o funcionamento dessas etapas, primeiro precisamos importar o MatDialogModule para o AppModule:

src/app/app.module.ts

...
import { MatDialogModule } from '@angular/material/dialog';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatDialogModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Agora, vamos criar o TaskDialogComponent. Navegue até o diretório src/app e execute:

ng generate component task-dialog

Para implementar a funcionalidade dele, primeiro abra src/app/task-dialog/task-dialog.component.html e substitua o conteúdo por:

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>

No modelo acima, criamos um formulário com dois campos para title e description. Usamos a diretiva cdkFocusInput para focar automaticamente a entrada title quando o usuário abrir a caixa de diálogo.

Dentro do modelo, mencionamos a propriedade data do componente. Ela será o mesmo data transmitido ao método open da dialog no AppComponent. Para atualizar o título e a descrição da tarefa quando o usuário mudar o conteúdo dos campos correspondentes, usamos a vinculação de dados bidirecional com ngModel.

Quando o usuário clica no botão OK, retornamos automaticamente o resultado { task: data.task }, que é a tarefa que mudamos usando os campos do formulário no modelo acima.

Agora, vamos implementar o controlador do 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);
  }
}

No TaskDialogComponent, injetamos uma referência à caixa de diálogo para fechá-la, bem como o valor do provedor associado ao token MAT_DIALOG_DATA. Esse é o objeto de dados que transmitimos ao método aberto no AppComponent acima. Também declaramos a propriedade particular backupTask, que é uma cópia da tarefa transmitida com o objeto de dados.

Quando o usuário pressiona o botão para cancelar, restauramos as propriedades que podem ter sido mudadas de this.data.task e fechamos a caixa de diálogo, transmitindo this.data como resultado.

Há dois tipos que mencionamos, mas não declaramos ainda: TaskDialogData e TaskDialogResult. Dentro de src/app/task-dialog/task-dialog.component.ts, adicione as seguintes declarações na parte inferior do arquivo:

src/app/task-dialog/task-dialog.component.ts

...
export interface TaskDialogData {
  task: Partial<Task>;
  enableDelete: boolean;
}

export interface TaskDialogResult {
  task: Task;
  delete?: boolean;
}

A última coisa que precisamos fazer para que a funcionalidade fique pronta é importar alguns módulos para o 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 { }

Ao clicar no botão "Add Task" (Adicionar tarefa), você verá a seguinte interface do usuário:

33bcb987fade2a87.png

7. Como melhorar os estilos do app

Para tornar o visual do app mais atrativo, vamos melhorar o layout fazendo ajustes nos estilos. Queremos posicionar as raias uma ao lado da outra. Também queremos fazer pequenos ajustes no botão "Add Task" (Adicionar tarefa) e no rótulo da lista vazia.

Abra src/app/app.component.css e adicione os seguintes estilos à 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;
}

No snippet acima, ajustamos o layout da barra de ferramentas e do rótulo dela. Para o conteúdo ficar alinhado na horizontal, defina a largura como 1400px e a margem como auto. Em seguida, usando o flexbox, colocamos as raias uma ao lado da outra e, por fim, fazemos alguns ajustes na visualização de tarefas e listas vazias.

Depois que o app for recarregado, você verá a seguinte interface do usuário:

69225f0b1aa5cb50.png

Já aprimoramos os estilos do app de forma significativa, mas ainda há um problema irritante quando movemos tarefas:

f9aae712027624af.png

Quando começamos a arrastar a tarefa "Buy milk" (Comprar leite), vemos dois cards para a mesma tarefa: o que estamos arrastando e o outro na raia. O CDK do Angular fornece nomes de classes CSS que podemos usar para corrigir esse problema.

Adicione as seguintes substituições de estilo à parte inferior de src/app/app.component.css:

src/app/app.component.css

.cdk-drag-animating {
  transition: transform 250ms;
}

.cdk-drag-placeholder {
  opacity: 0;
}

Enquanto arrastamos um elemento, o recurso de arrastar e soltar do CDK do Angular clona e insere esse elemento na posição em que o original será solto. Para o elemento não ficar visível, definimos a propriedade de opacidade na classe cdk-drag-placeholder, que o CDK adiciona ao marcador de posição.

Além disso, quando soltamos um elemento, o CDK adiciona a classe cdk-drag-animating. Para mostrar uma animação suave, em vez de ajustar o elemento diretamente, definimos uma transição com duração de 250ms.

Também queremos fazer alguns pequenos ajustes nos estilos das tarefas. Em task.component.css, vamos definir a exibição do elemento host como block e definir algumas margens:

src/app/task/task.component.css

:host {
  display: block;
}

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

8. Como editar e excluir tarefas existentes

Para editar e remover as tarefas existentes, reutilizamos a maioria das funcionalidades já implementadas. Quando o usuário clica duas vezes em uma tarefa, abrimos o TaskDialogComponent e preenchemos os dois campos do formulário com o title e a description da tarefa.

Também adicionamos um botão de exclusão no TaskDialogComponent. Quando o usuário clicar nele, será transmitida uma instrução de exclusão, que termina no AppComponent.

A única mudança que precisamos fazer no TaskDialogComponent é no modelo:

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>

Esse botão mostra o ícone do material de exclusão. Quando o usuário clicar nele, fecharemos a caixa de diálogo e transmitiremos o literal de objeto { task: data.task, delete: true } como resultado. Observe também que tornamos o botão circular usando mat-fab, definimos a cor como principal e o mostramos somente quando os dados da caixa de diálogo têm a exclusão ativada.

O restante da implementação da funcionalidade de editar e excluir é feito no AppComponent. Substitua o método editTask pelo seguinte:

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;
      }
    });
  }
  ...
}

Vamos analisar os argumentos do método editTask:

  • Uma lista do tipo 'done' | 'todo' | 'inProgress',, que é um tipo de união de literal de string com valores correspondentes às propriedades associadas às raias.
  • A tarefa atual que queremos editar.

No corpo do método, primeiro abrimos uma instância do TaskDialogComponent. Como data, transmitimos um literal de objeto, que especifica a tarefa que queremos editar e também ativa o botão de edição no formulário ao definir a propriedade enableDelete como true.

Quando recebemos o resultado da caixa de diálogo, temos dois cenários:

  • Quando a sinalização delete é definida como true (ou seja, quando o usuário pressiona o botão de exclusão), removemos a tarefa da lista correspondente.
  • Como alternativa, podemos substituir a tarefa do índice em questão pela tarefa recebida do resultado da caixa de diálogo.

9. Como criar um novo projeto do Firebase

Agora, vamos criar um novo projeto do Firebase.

10. Como adicionar o Firebase ao projeto

Nesta seção, vamos integrar nosso projeto ao Firebase. A equipe do Firebase oferece o pacote @angular/fire, que oferece integração entre as duas tecnologias. Para adicionar o suporte do Firebase ao seu app, abra o diretório raiz do espaço de trabalho e execute:

ng add @angular/fire

Esse comando instala o pacote @angular/fire e faz algumas perguntas. No seu terminal, você verá algo parecido com isto:

9ba88c0d52d18d0.png

Enquanto isso, a instalação abre uma janela do navegador para você fazer a autenticação com sua conta do Firebase. Por fim, você precisa escolher um projeto do Firebase e criar alguns arquivos no disco.

Em seguida, precisamos criar um banco de dados do Firestore. Em "Cloud Firestore", clique em "Create Database" (Criar banco de dados).

1e4a08b5a2462956.png

Depois disso, crie um banco de dados no modo de teste:

ac1181b2c32049f9.png

Por último, selecione uma região:

34bb94cc542a0597.png

Agora só falta adicionar a configuração do Firebase ao seu ambiente. A configuração do projeto está disponível no Console do Firebase.

  • Clique no ícone de engrenagem ao lado de "Project Overview" (Visão geral do projeto).
  • Escolha "Project Settings" (Configurações do projeto).

c8253a20031de8a9.png

Em "Your apps" (Seus apps), selecione "Web app" (App da Web):

428a1abcd0f90b23.png

Em seguida, registre seu aplicativo e ative o "Firebase Hosting":

586e44cb27dd8f39.png

Depois de clicar em "Register app" (Registrar app), você pode copiar sua configuração para src/environments/environment.ts:

e30f142d79cecf8f.png

No fim, o arquivo de configuração ficará assim:

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. Como mover os dados para o Firestore

Agora que o SDK do Firebase foi configurado, vamos usar @angular/fire para mover nossos dados para o Firestore. Primeiro, vamos importar os módulos necessários no 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 {}

Como vamos usar o Firestore, precisamos injetar AngularFirestore no construtor do AppComponent:

src/app/app.component.ts

...
import { AngularFirestore } from '@angular/fire/firestore';

@Component({...})
export class AppComponent {
  ...
  constructor(private dialog: MatDialog, private store: AngularFirestore) {}
  ...
}

Em seguida, atualizamos a forma como inicializamos as matrizes da raia:

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[]>;
  ...
}

Aqui, usamos AngularFirestore para receber o conteúdo da coleção diretamente do banco de dados. Observe que valueChanges retorna um elemento observável (link em inglês) em vez de uma matriz e que também especificamos que o campo "id" dos documentos nessa coleção precisa ser chamado de id para corresponder ao nome que usamos na interface da Task. O elemento observável retornado por valueChanges emite um conjunto de tarefas sempre que muda.

Como estamos trabalhando com observáveis em vez de matrizes, precisamos atualizar a maneira como adicionamos, removemos e editamos tarefas, além da funcionalidade de mover tarefas entre raias. Em vez de modificar as matrizes na memória, vamos usar o SDK do Firebase para atualizar os dados no banco de dados.

Primeiro, vamos analisar como seria a reordenação. Substitua o método drop em src/app/app.component.ts por:

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
  );
}

No snippet acima, o novo código está destacado. Para mover uma tarefa da raia atual para a de destino, removemos a tarefa da primeira coleção e a adicionamos à segunda. Como realizamos duas operações como uma (ou seja, a operação é atômica), executamos essas operações em uma transação do Firestore.

Agora, vamos atualizar o método editTask para usar o Firestore. Dentro do gerenciador de caixas de diálogo de fechamento, precisamos mudar as seguintes linhas 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);
  }
});
...

Acessamos o documento de destino correspondente à tarefa que manipulamos usando o SDK do Firestore e o excluímos ou atualizamos.

Por fim, precisamos atualizar o método para criar novas tarefas. Substitua this.todo.push('task') por this.store.collection('todo').add(result.task).

Agora, nossas coleções não são matrizes, mas elementos observáveis. Para visualizá-los, precisamos atualizar o modelo do AppComponent. Basta substituir todos os acessos das propriedades todo, inProgress e done por todo | async, inProgress | async e done | async, respectivamente.

O pipeline assíncrono (link em inglês) se inscreve automaticamente nos elementos observáveis associados às coleções. Quando os elementos observáveis emitem um novo valor, o Angular executa automaticamente a detecção de mudanças e processa a matriz emitida.

Por exemplo, vamos analisar as mudanças que precisamos fazer na raia 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>

Quando transmitimos os dados para a diretiva cdkDropList, aplicamos o pipeline assíncrono. Ele é o mesmo da diretiva *ngIf, mas nela também usamos encadeamento opcional (também conhecido como operador de navegação segura no Angular) ao acessar a propriedade length. O objetivo disso é garantir que não haverá erro de tempo de execução se todo | async não for null ou undefined.

Agora, ao criar uma nova tarefa na interface do usuário e abrir o Firestore, você verá algo parecido com isto:

dd7ee20c0a10ebe2.png

12. Como melhorar as atualizações otimistas

No momento, estamos realizando atualizações otimistas. Temos nossa fonte de informações confiáveis no Firestore, mas também temos cópias locais das tarefas. Quando qualquer um dos elementos observáveis associados às coleções é emitido, recebemos uma matriz de tarefas. Quando uma ação do usuário muda o estado, primeiro atualizamos os valores locais e, depois, propagamos a mudança para o Firestore.

Quando movemos uma tarefa de uma raia a outra, invocamos transferArrayItem,, que opera nas instâncias locais das matrizes que representam as tarefas em cada raia. O SDK do Firebase trata essas matrizes como imutáveis. Isso significa que, na próxima vez que o Angular executar a detecção de mudanças, vamos receber novas instâncias delas, o que renderizará o estado anterior antes da transferência da tarefa.

Ao mesmo tempo, acionamos uma atualização do Firestore, e o SDK do Firebase aciona uma atualização com os valores corretos. Dessa maneira, em alguns milissegundos, a interface do usuário vai chegar ao estado correto. Assim, a tarefa que acabamos de transferir vai da primeira lista para a próxima. Você pode ver bem isso no GIF abaixo:

70b946eebfa6f316.gif

A forma correta de resolver esse problema varia de um aplicativo para outro. No entanto, em todos os casos, precisamos garantir a consistência do estado até que nossos dados sejam atualizados.

Podemos usar o BehaviorSubject (link em inglês), que une o observador original que recebemos de valueChanges. Internamente, o BehaviorSubject contém uma matriz mutável que mantém a atualização do transferArrayItem.

Para implementar uma correção, basta atualizar o 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[]>;
...
}

No snippet acima, criamos um BehaviorSubject, que emite um valor toda vez que o elemento observável associado à coleção muda.

Tudo funciona conforme o esperado, porque o BehaviorSubject reutiliza a matriz nas invocações de detecção de mudanças e só é atualizado quando recebemos um novo valor do Firestore.

13. Como implantar o aplicativo

Para implantar o app, basta executar:

ng deploy

Esse comando vai:

  1. Criar seu app com a configuração de produção, aplicando otimizações no tempo de compilação.
  2. Implantar seu app no Firebase Hosting.
  3. Gerar um URL para que você possa visualizar o resultado.

14. Parabéns

Parabéns! Você criou um quadro Kanban com o Angular e o Firebase.

Você criou uma interface do usuário com três colunas que representam o status de tarefas. Usando o CDK do Angular, você implementou a função de arrastar e soltar tarefas nas colunas. Em seguida, usando o Material Design do Angular, você criou um formulário para criar novas tarefas e editar as atuais. Depois, você aprendeu a usar @angular/fire e moveu todo o estado do aplicativo para o Firestore. Por fim, você implantou seu aplicativo no Firebase Hosting.

Qual é a próxima etapa?

Lembre-se de que implantamos o aplicativo usando configurações de teste. Antes de implantar o app no ambiente de produção, configure as permissões corretas. Aprenda a fazer isso aqui.

No momento, não preservamos a ordem das tarefas em determinada raia. Para implementar isso, use um campo de ordem no documento da tarefa e faça a classificação com base nele.

Além disso, criamos o quadro Kanban para apenas um usuário, o que significa que temos um único quadro para qualquer pessoa que abra o app. Para implementar quadros separados para diferentes usuários do seu app, você precisará mudar a estrutura do banco de dados. Saiba mais sobre as práticas recomendadas do Firestore aqui.