angular / components

Component infrastructure and Material Design components for Angular
https://material.angular.io
MIT License
24.22k stars 6.69k forks source link

[Table] Add a full-featured but limited table component and array-based data source #5889

Open andrewseguin opened 6 years ago

andrewseguin commented 6 years ago

Introduce a new table component that would use <mat-table> and come with built-in features including sorting, pagination, selection, etc. Would accept an array-based data input so users do not need to create a data source to use the table.

This would allow people to quickly use a table but would limit their ability to customize behavior and would be very opinionated on user experience.

Example concept:

<mat-simple-table #simpleTable [data]="_peopleDatabase.data" pageSize="25">
  <mat-simple-column headerText="User ID" property="id"></mat-simple-column>
  <mat-simple-column headerText="Name" property="name"></mat-simple-column>
  <mat-simple-column headerText="Progress" property="progress"></mat-simple-column>
  <mat-simple-column headerText="Color" property="color"></mat-simple-column>
</mat-simple-table>

Notes:

andrewseguin commented 5 years ago

Prototype https://stackblitz.com/edit/angular-5xthcl?file=app%2Ftable-simple-column-example.html

kussmaul commented 4 years ago

TL;DR: This prototype was incredibly helpful as I've tried to share column definitions between tables. Is there an angular8 version? I'm trying to adapt it, without success.

DETAIL: Here is my component (details removed for brevity):

@Component({
  selector: 'app-abstract-list-column-select',
  template: `
    <ng-container matColumnDef>
      <th mat-header-cell *matHeaderCellDef>HEADER</th>
      <th mat-footer-cell *matFooterCellDef>FOOTER</th>
      <td mat-cell        *matCellDef="let row">CELL</td>
    </ng-container>`
})
export class AbstractListColumnSelectComponent implements OnInit {
  // NOTE: order: constructor(), static @ViewChild, @Input, OnInit(), non-static @ViewChild

  @Input()
  get name() { return this._name; }
  set name(name : string) {
    console.log('name=', name, this.columnDef); this._name = name;
    if (this.columnDef) { this.columnDef.name = name; }
  }
  _name : string = 'select_default';

  @ViewChild(MatColumnDef, { static : true })
  get columnDef() { return this._columnDef; }
  set columnDef(columnDef) {
    console.log('cdef=', columnDef); this._columnDef = columnDef;
    if (this.table) { this.table.addColumnDef(this.columnDef); }
  }
  _columnDef ! : MatColumnDef;

  constructor(readonly table : MatTable<any>) { console.log('new', table); }

  ngOnInit() {
    console.log('OnInit()', this, this.table, this.columnDef);
    if (this.table && this.columnDef) {
      this.table.addColumnDef(this.columnDef);
    }
  }
  // TODO: add ngOnDestroy()
}

Here is the (start of) my code for the table using the component, where tableColumns includes "select" but not "selectN".

    <table mat-table matSort [dataSource]="dataSource">
      <tr mat-header-row *matHeaderRowDef="tableColumns; sticky: true"></tr>
      <tr mat-footer-row *matFooterRowDef="tableColumns; sticky: true"></tr>
      <tr mat-row        *matRowDef="let row; columns: tableColumns;"></tr>

      <app-abstract-list-column-select name="selectN"></app-abstract-list-column-select>
      <ng-container matColumnDef="select">
        <th mat-header-cell *matHeaderCellDef>HEADER</th>
        <th mat-footer-cell *matFooterCellDef>FOOTER</th>
        <td mat-cell *matCellDef="let row">CELL</td>
      </ng-container>

As written, it runs and I see the expected column. In the debugger, matTable.columnDefsByName shows both "select" and "selectN", and both have templates in value.cell, value.headerCell, and value.footerCell. However, if I swap "selectN" and "select" (to replace the original column with my component), I get an error:

ERROR TypeError: Cannot read property 'template' of undefined
    at MatHeaderRowDef.extractCellTemplate (table.js:357)
    at table.js:1975
    at Function.from (<anonymous>)
    at MatTable._getCellTemplates (table.js:1965)
    at MatTable._renderRow (table.js:1920)
    at table.js:1779
    at Array.forEach (<anonymous>)
    at MatTable._forceRenderHeaderRows (table.js:1774)
    at MatTable.ngAfterContentChecked (table.js:1251)
    at callProviderLifecycles (core.js:32324)

and if I look at matTable.columndefsByName, "select" (my new template) is missing value.cell, value.headerCell, and value.footerCell, which (I assume) causes the error message.

I would appreciate any suggestions.

kussmaul commented 3 years ago

FYI, I finally got this to work, by explicitly connecting the columnDef to its children. I don't know if this happens later, or if it should happen sooner but doesn't for some reason.

  @ViewChild(    MatColumnDef, { static : true })     columnDef ! :     MatColumnDef;
  @ViewChild(      MatCellDef, { static : true })       cellDef ! :       MatCellDef;
  @ViewChild(MatHeaderCellDef, { static : true }) headerCellDef ! : MatHeaderCellDef;
  @ViewChild(MatFooterCellDef, { static : true }) footerCellDef ! : MatFooterCellDef;

  ngOnInit() {
    if (this.table && this.columnDef) {
      this.columnDef.cell       = this.cellDef;
      this.columnDef.headerCell = this.headerCellDef;
      this.columnDef.footerCell = this.footerCellDef;
      this.table.addColumnDef(this.columnDef);
    }
  }
mykolav commented 2 years ago

Thank you, @kussmaul!

Here's what I cobbled together from your code and @andrewseguin's prototype.

@Component({
  selector: 'app-column-template',
  template: `
    <ng-container matColumnDef>
      <th mat-header-cell *matHeaderCellDef>{{ label || capitalize(name) }}</th>
      <td mat-cell *matCellDef="let row">
        <ng-container *ngTemplateOutlet="cellTemplate; context: { $implicit: row }"></ng-container>
      </td>
    </ng-container>
  `,
  // eslint-disable-next-line @angular-eslint/no-host-metadata-property
  host: {
    class: 'column-template cdk-visually-hidden',
    '[attr.ariaHidden]': 'true',
  },
})
export class ColumnTemplateComponent implements OnDestroy, OnInit {
  @Input() name = '';
  @Input() label: string | null = null;
  @Input() align: 'before' | 'after' = 'before';

  constructor(@Optional() public table: MatTable<unknown>) {}

  @ViewChild(MatColumnDef, { static: true }) columnDef!: MatColumnDef;
  @ViewChild(MatCellDef, { static: true }) cellDef!: MatCellDef;
  @ViewChild(MatHeaderCellDef, { static: true }) headerCellDef!: MatHeaderCellDef;
  @ViewChild(MatFooterCellDef, { static: true }) footerCellDef!: MatFooterCellDef;

  @ContentChild('cell', { static: false })
  cellTemplate: TemplateRef<unknown> | null = null;

  ngOnInit(): void {
    if (this.table && this.columnDef) {
      this.columnDef.name = this.name;
      this.columnDef.cell = this.cellDef;
      this.columnDef.headerCell = this.headerCellDef;
      this.columnDef.footerCell = this.footerCellDef;
      this.table.addColumnDef(this.columnDef);
    }
  }

  ngOnDestroy(): void {
    if (this.table) {
      this.table.removeColumnDef(this.columnDef);
    }
  }

  capitalize(value: string): string {
    return value.charAt(0).toUpperCase() + value.slice(1);
  }
}
export type CellValueNeededFn = (data: Record<string, unknown>, name: string) => string;

@Component({
  selector: 'app-column',
  template: `
    <ng-container matColumnDef>
      <th mat-header-cell *matHeaderCellDef>{{ label || capitalize(name) }}</th>
      <td mat-cell *matCellDef="let row">{{ getCellValue(row) }}</td>
    </ng-container>
  `,
  // eslint-disable-next-line @angular-eslint/no-host-metadata-property
  host: {
    class: 'column cdk-visually-hidden',
    '[attr.ariaHidden]': 'true',
  },
})
export class ColumnComponent implements OnDestroy, OnInit {
  @Input() name = '';
  @Input() label: string | null = null;
  @Input() align: 'before' | 'after' = 'before';
  @Input() cellValueNeeded: CellValueNeededFn | null = null;

  constructor(@Optional() public table: MatTable<unknown>) {}

  @ViewChild(MatColumnDef, { static: true }) columnDef!: MatColumnDef;
  @ViewChild(MatCellDef, { static: true }) cellDef!: MatCellDef;
  @ViewChild(MatHeaderCellDef, { static: true }) headerCellDef!: MatHeaderCellDef;
  @ViewChild(MatFooterCellDef, { static: true }) footerCellDef!: MatFooterCellDef;

  @ContentChild('cell', { static: false })
  cellTemplate: TemplateRef<unknown> | null = null;

  ngOnInit(): void {
    if (this.table && this.columnDef) {
      this.columnDef.name = this.name;
      this.columnDef.cell = this.cellDef;
      this.columnDef.headerCell = this.headerCellDef;
      this.columnDef.footerCell = this.footerCellDef;
      this.table.addColumnDef(this.columnDef);
    }
  }

  ngOnDestroy(): void {
    if (this.table) {
      this.table.removeColumnDef(this.columnDef);
    }
  }

  capitalize(value: string): string {
    return value.charAt(0).toUpperCase() + value.slice(1);
  }

  getCellValue(row: Record<string, unknown>): unknown {
    return this.cellValueNeeded ? this.cellValueNeeded(row, this.name) : row[this.name];
  }
}

An attempt to build ColumnComponent based on ColumnTemplateComponent for me ends up in the familiar

Error: Could not find column with id "...".
     at getTableUnknownColumnError (table.js:1078) [angular]
     blah-blah-blah...
b-johnse commented 2 years ago

@mykolav here's an updated version as a angular 14 standalone component that I have working:

import { CommonModule } from '@angular/common';
import { Component, HostBinding, Input, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core';
import { MatSortModule } from '@angular/material/sort';
import {
  MatCellDef,
  MatColumnDef,
  MatFooterCellDef,
  MatHeaderCellDef,
  MatTable,
  MatTableModule,
} from '@angular/material/table';

@Component({
  standalone: true,
  imports: [MatTableModule, CommonModule, MatSortModule],
  selector: 'evt-table-column-currency',
  template: `
    <ng-container matColumnDef>
      <th mat-header-cell *matHeaderCellDef>
        <span mat-sort-header> {{ label ?? capitalize(prop) }}</span>
      </th>
      <td mat-cell *matCellDef="let row">{{ row[prop] | currency }}</td>
    </ng-container>
  `,
})
export class TableCurrencyColumnComponent implements OnDestroy, OnInit {
  @Input() prop = '';
  @Input() label: string | undefined;

  constructor(@Optional() public table: MatTable<unknown>) {}

  @HostBinding('attr.ariaHidden') ariaHidden!: true;
  @HostBinding('class') classes!: 'column-template cdk-visually-hidden';

  @ViewChild(MatColumnDef, { static: true }) columnDef!: MatColumnDef;
  @ViewChild(MatCellDef, { static: true }) cellDef!: MatCellDef;
  @ViewChild(MatHeaderCellDef, { static: true }) headerCellDef!: MatHeaderCellDef;
  @ViewChild(MatFooterCellDef, { static: true }) footerCellDef!: MatFooterCellDef;

  ngOnInit(): void {
    if (this.table && this.columnDef) {
      this.columnDef.name = this.prop;
      this.columnDef.cell = this.cellDef;
      this.columnDef.headerCell = this.headerCellDef;
      this.columnDef.footerCell = this.footerCellDef;
      this.table.addColumnDef(this.columnDef);
    }
  }

  ngOnDestroy(): void {
    if (this.table) {
      this.table.removeColumnDef(this.columnDef);
    }
  }

  capitalize(value: string): string {
    return value.charAt(0).toUpperCase() + value.slice(1);
  }
}
egpotter commented 1 year ago

Just made it work now. Thanks a lot for posting your solution.

@b-johnse How exactly is youR template where you instantiate the component. Trying your solution but I am definitely making something wrong. I still get the "Could not find column with id" error.