Criando um componente de tabela - Angular 2+

Neste tutorial alguns recursos como <ng-template> e <ng-container> são explorados com o objetivo de criar um componente que renderiza uma tabela de dados.

Criando um componente de tabela - Angular 2+

Com certeza um dos recursos mais utilizados para os desenvolvedores web é boa e velha tabela de dados. Quando comecei no mundo Angular tive um pouco de dificuldade quando me deparei com a construção de um componente de tabela, então vou compartilhar uma das formas que aprendi para criar este componente. Com certeza alguns recursos que serão abordados neste texto poderão ser utilizados em outros lugares do seu projeto.

Iniciando a construção do componente tabela

Inicialmente vamos estruturar um componente com a premissa de que os dados passados para a tabela terão a seguinte estrutura: { id: number, nome: string }

import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-tabela',
  templateUrl: 'tabela.component.html',
})
export class TabelaComponent {
  @Input() dados: Array<{ id: number, nome: string }> | null = null;
}
tabela.component.ts
<table>
    <tr *ngFor="let dadosLinha of dados">
        <td>{{dadosLinha.id}}</td>
        <td>{{dadosLinha.nome}}</td>
    </tr>
</table>
tabela.component.html
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TabelaComponent } from './tabela.component';

@NgModule({
  declarations: [TabelaComponent],
  exports: [TabelaComponent],
  imports: [CommonModule],
})
export class TabelaModule {

}
tabela.module.ts

O código ainda está bem simples mas já é um ponto de partida. Para testarmos o componente no AppComponent, por exemplo, vamos importar o módulo TabelaModule dentro do AppModule conforme abaixo:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { TabelaModule } from './tabela/tabela.module';

@NgModule({
  imports:      [ BrowserModule, FormsModule, TabelaModule ],
  declarations: [ AppComponent ],
  bootstrap:    [ AppComponent ]
})
export class AppModule { }
app.module.ts

Uma vez importado o TabelaModule no AppModule podemos utilizar o app-tabela dentro do AppComponent:

import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent  {
  dados: Array<{ id: number, nome: string }> = [];

  constructor() {
    this.dados = [{
      "id": 442,
      "nome": "Myrtle"
    }, {
      "id": 376,
      "nome": "Georgette"
    }, {
      "id": 882,
      "nome": "Manning"
    }, {
      "id": 414,
      "nome": "Essie"
    }, {
      "id": 466,
      "nome": "Augusta"
    }, {
      "id": 315,
      "nome": "Mueller"
    }, {
      "id": 344,
      "nome": "Walter"
    }];
  };
}
app.template.ts

Aqui vale uma observação, este site (https://www.json-generator.com/) ajuda a gerar um JSON com dados aleatórios para efetuar seus testes no formato que você configurar.

<app-tabela [dados]="dados"></app-tabela>
app.component.html
Resultado da renderização da tabela conforme código acima
Resultado da renderização da tabela conforme código acima

Agora que o código está funcionando vamos focar no problema das colunas. Até este momento seguimos a premissa de que a estrutura dos dados recebidos na tabela terão apenas duas informações:

  • id
  • nome

Como a ideia é que este componente possa ser utilizado para qualquer matriz de dados, vamos evoluir um pouco mais essa questão.

Parametrizando as colunas

Podemos imaginar algumas formas de deixar a renderização das colunas de forma dinâmica. Uma das possíveis soluções é supor que cada item dentro do array tenha exatamente a mesma estrutura, então sempre que o app-tabela receber um novo dado dentro do @Input() dados podemos pegar o primeiro item e verificar quais são suas propriedades através do Object.getOwnPropertyNames(obj), veja no código abaixo:

import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-tabela',
  templateUrl: 'tabela.component.html',
})
export class TabelaComponent implements OnChanges {
  colunas: Array<string> = [];
  @Input() dados: Array<any> | null = null;

  // Sempre que o @Input() dados for
  // informado no <app-tabela [dados]="..."
  // este método será ativado:
  ngOnChanges(changes: SimpleChanges) {
    if (changes.dados) {
      const primeiroItem = this.dados[0];
      this.colunas = Object.getOwnPropertyNames(primeiroItem);

      console.log(this.colunas);
    }
  }
}
tabela.component.tsc

E agora adicionamos um loop for no <td>:

<table>
  <tr *ngFor="let dadosLinha of dados">
    <td *ngFor="let coluna of colunas">
      {{dadosLinha[coluna]}}
    </td>
  </tr>
</table>
tabela.component.html

Observação: podemos acessar a propriedades de um objeto através dos colchetes, como fizemos no tabela.component.html logo acima.

console.log(objeto.nome);

// ou

console.log(objeto['nome']);

O resultado final é exatamente como o anterior, porém agora as colunas são renderizadas dinâmicamente:

Renderização dos dados no componente app-tabela
Renderização dos dados no componente app-tabela

Nesta solução consideramos como premissa que cada item dentro do array dados tem exatamente a mesma estrutura. Caso esta condição não seja satisfeita, conforme abaixo, o resultado não será como nós esperamos e a coluna age (exemplo abaixo) não será renderizada pois não consta no primeiro item do array.

this.dados = [{
  "id": 442,
  "nome": "Myrtle"
}, {
  "id": 376,
  "nome": "Georgette",
  "age": 30,
}, {
  "id": 882,
  "nome": "Manning",
  "age": 14,
}];

Antes de pensarmos em uma solução para o problema acima, vale destacar um outro ponto que ainda não foi comentado, eventualmente o desenvolvedor que irá utilizar seu app-tabela precisará renderizar uma coluna como um link, uma imagem, alterar a cor da célula dependendo de um valor, etc. Nesta estrutura não conseguimos chegar no resultado abaixo por exemplo:

<table>
  <tr>
    <td>442</td>
    <td>Myrtle</td>
    <!-- Deixar uma coluna como um link: -->
    <td><a href="/teste?id=442">editar</a></td>
  </tr>
</table>

Permitindo que o usuário customize as colunas

Para que possamos permitir uma customização dentro do nosso <td> que está dentro do app-tabela, podemos utilizar um ng-container e "recheá-lo" com o que o usuário passar.

A primeira coisa é que o desenvolvedor que está utilizando o app-tabela deverá indicar como as informações serão renderizadas para cada coluna. Para isto ele deve passar um <ng-template> para cada coluna conforme abaixo:

<app-tabela [dados]="dados">
     <!--
         dadosLinha é injetado pelo app-tabela a cada
         loop do array dados.
         Pegamos essa referência na variável let-item e 
         assim conseguimos acesso as propriedades.

         #coluna -> serve como um identificador para
         que possamos pegar essas referências dentro do
         app-tabela
     -->
    <ng-template #coluna let-item="dadosLinha">
        <a [attr.href]="'teste/' + item.id">{{item.id}}</a>
    </ng-template>

    <ng-template #coluna let-item="dadosLinha">
        <img
          [attr.alt]="item.nome"
          [src]="'https://via.placeholder.com/100x60?text=' + item.nome"
        />
    </ng-template>
</app-tabela>
app.component.html

Já no nosso componente, app-tabela, precisamos pegar as referências destes <ng-template>. Para pegar o que é passado dentro do componente, <app-tabela>aqui é dentro do componente!</app-tabela>, podemos utilizar o @ContentChildren conforme o código abaixo:

import {
  Component,
  ContentChildren,
  Input,
  QueryList,
  TemplateRef } from '@angular/core';

@Component({
  selector: 'app-tabela',
  templateUrl: 'tabela.component.html',
})
export class TabelaComponent {
  @ContentChildren('coluna') colunas: QueryList<TemplateRef<any>>;
  @Input() dados: Array<any> | null = null;
}
tabela.component.ts

Por fim, no template precisamos fazer um loop em cima do colunas e passar como referência o item (dadosLinha) do array dados:

<table>
  <tr *ngFor="let dadosLinha of dados">
    <td *ngFor="let coluna of colunas">
      <!-- *ngTemplateOutlet -> indica qual o template
           que será utilizado para renderizar a informação.
           Logo, coluna é um <ng-template> que pegamos com
           o @ContentChildren
      -->
      <ng-container
        *ngTemplateOutlet="coluna; context: { dadosLinha }">
      </ng-container>
    </td>
  </tr>
</table>
tabela.component.html

Basicamente dentro do componente nós construímos nossa estrutura básica e deixamos um <ng-container> onde o usuário do componente pode customizar. Fazendo uma analogia, funciona como um carro com motor flex, a estrutura do carro está pronta para uso (componente) mas quem decide qual combustível (etanol ou gasolina) utilizar é você (desenvolvedor).

Renderização dos dados com o app-tabela em conjunto com o ng-template
Renderização dos dados com o app-tabela em conjunto com o ng-template

Veja que agora o usuário passa uma lista de <ng-template> que o componente app-tabela utiliza para renderizar o conteúdo dentro do <td>...</td>. Com isto o componente torna-se mais flexível para atender outras demandas.

Perceba que este recurso pode ser utilizado em vários lugares para deixar os componentes mais flexíveis. Eu mesmo já utilizei este recurso em um Popover, onde eu criei um formato padrão de exibição do popover mas permitindo que o desenvolvedor possa customizá-lo através de um <ng-template>.

Tratando o cabeçalho

Até este momento não comentamos sobre o cabeçalho da tabela, mas podemos seguir a mesma lógica criada logo acima:

<app-tabela [dados]="dados">
    <!-- 
         Adicionarmos um #header para identificar
         quais ng-template correspondem ao cabeçalho
    -->
    <ng-template #header>Link</ng-template>
    <ng-template #coluna let-item="dadosLinha">
        <a [attr.href]="'teste/' + item.id">{{item.id}}</a>
    </ng-template>

    <ng-template #header>Nome</ng-template>
    <ng-template #coluna let-item="dadosLinha">
        <img
          [attr.alt]="item.nome"
          [src]="'https://via.placeholder.com/100x60?text=' + item.nome"
        />
    </ng-template>
</app-tabela>
app.component.html
import {
  Component,
  ContentChildren,
  Input,
  QueryList,
  TemplateRef } from '@angular/core';

@Component({
  selector: 'app-tabela',
  templateUrl: 'tabela.component.html',
})
export class TabelaComponent {
  @ContentChildren('coluna') colunas: QueryList<TemplateRef<any>>;
  @ContentChildren('header') headers: QueryList<TemplateRef<any>>;
  @Input() dados: Array<any> | null = null;
}
tabela.component.ts
<table>
  <tr>
    <th *ngFor="let header of headers">
      <ng-container
        *ngTemplateOutlet="header">
      </ng-container>
    </th>
  </tr>
  <tr *ngFor="let dadosLinha of dados">
    <td *ngFor="let coluna of colunas">
      <ng-container
        *ngTemplateOutlet="coluna; context: { dadosLinha }">
      </ng-container>
    </td>
  </tr>
</table>
tabela.component.html
Renderização dos cabeçalhos
Renderização dos cabeçalhos

Considerações

Veja que para construir este componente acabamos passando pelo @ContentChildren, <ng-template>e <ng-container>. Como já dito no início deste texto, com certeza estes recursos poderão ser aproveitados em outros lugares do seu projeto. Abaixo vou deixar um link para o código fonte completo no Stackblitz e alguns links legais para uma posterior leitura.

Angular Ivy (forked) - StackBlitz
Exemplo de como construir um componente de tabela em Angular 2+
Exemplo da implementação

Links interessantes: