17
votes

Even now in version 7.2 of Angular Material, I can't seem to find examples on how to use rowspan on mat-table and keep the component functionality.

This is how far (short?) I've got:

https://stackblitz.com/edit/angular-wudscb

The example in the Stackblitz above is "almost" what I am looking for, but I am not being able to see how to finish it.

...
===============================================
||     ||            ||            ||  row1  ||
||  1  ||  Hydrogen  ||   1.0079   ||========||
||     ||            ||            ||  row2  ||
===============================================
||     ||            ||            ||  row1  ||
||     ||            ||            ||========||
||  2  ||   Helium   ||   4.0026   ||  row2  ||
||     ||            ||            ||========||
||     ||            ||            ||  row3  ||
===============================================
||     ||            ||            ||  row1  ||
||  3  ||  Lithium   ||   6.941    ||========||
||     ||            ||            ||  row2  ||
===============================================
...

An example using other metadata format can be found in:

https://stackblitz.com/edit/angular-lnahlh

Following my Stackblitz (the first link), my questions are:

Am I too far of achieving this rowspan shim/hack?

How do I loop the rows based on the lenght of the row['descriptions'] size?

What If I had another array property inside the object? Could I iterate and generate the columns/rows/rowspan with its size, so it would become more generic?

I'm trying to find a generic solution for the community.

5
@MaihanNijat that example that is found on a Github issue open in material layout works in a very different way. You can find in the sabe Github issue a question about how to do it when the object dataSource has an array inside it how it would be solved. - BBacon
Can you edit your question and add expected output? - Just code
@Justcode edited. - BBacon
Rather than use a method getRowSpan(), I would recommend you add a data field in your object. Data binding to a method is expensive in iteration. - wannadream

5 Answers

18
votes

Well, it seems that material table has no api documentation for it, I could not find any trick to do this too, But we can twick the our data to support this, as per your second example we can reform the data to new json and we can get our expected result.

Step 1 :

    const originalData = [
      { id: 1, name: 'Hydrogen', weight: 1.0079, descriptions: ['row1', 'row2'] },
      { id: 2, name: 'Helium', weight: 4.0026, descriptions: ['row1', 'row2', 'row3'] },
      { id: 3, name: 'Lithium', weight: 6.941, descriptions: ['row1', 'row2'] },
      { id: 4, name: 'Beryllium', weight: 9.0122, descriptions: ['row1', 'row2', 'row3'] },
      { id: 5, name: 'Boron', weight: 10.811, descriptions: ['row1'] },
      { id: 6, name: 'Carbon', weight: 12.0107, descriptions: ['row1', 'row2', 'row3'] },
      { id: 7, name: 'Nitrogen', weight: 14.0067, descriptions: ['row1'] },
      { id: 8, name: 'Oxygen', weight: 15.9994, descriptions: ['row1'] },
      { id: 9, name: 'Fluorine', weight: 18.9984, descriptions: ['row1', 'row2', 'row3'] },
      { id: 10, name: 'Neon', weight: 20.1797, descriptions: ['row1', 'row2', 'row3'] },
    ]; //original data

    const DATA = originalData.reduce((current, next) => {
      next.descriptions.forEach(b => {
        current.push({ id: next.id, name: next.name, weight: next.weight, descriptions: b })
      });
      return current;
    }, []);//iterating over each one and adding as the description 
    console.log(DATA)

    const ELEMENT_DATA: PeriodicElement[] = DATA; //adding data to the element data

Step 2

this will be as it is as your second stackblitz link.

 getRowSpan(col, index) {    
    return this.spans[index] && this.spans[index][col];
  }

Step 3

as it is as per your second link

  constructor() {
    this.cacheSpan('Priority', d => d.id);
    this.cacheSpan('Name', d => d.name);
    this.cacheSpan('Weight', d => d.weight);
  }

  /**
   * Evaluated and store an evaluation of the rowspan for each row.
   * The key determines the column it affects, and the accessor determines the
   * value that should be checked for spanning.
   */
  cacheSpan(key, accessor) {
    for (let i = 0; i < DATA.length;) {
      let currentValue = accessor(DATA[i]);
      let count = 1;

      // Iterate through the remaining rows to see how many match
      // the current value as retrieved through the accessor.
      for (let j = i + 1; j < DATA.length; j++) {
        if (currentValue != accessor(DATA[j])) {
          break;
        }

        count++;
      }

      if (!this.spans[i]) {
        this.spans[i] = {};
      }

      // Store the number of similar values that were found (the span)
      // and skip i to the next unique row.
      this.spans[i][key] = count;
      i += count;
    }
  }

Step 4

Using index to pass down to rowspan and hiding the rows where it doesn't needed

    <ng-container matColumnDef="id">
        <th mat-header-cell *matHeaderCellDef> Priority </th>
        <td mat-cell *matCellDef="let data;let i = dataIndex" [attr.rowspan]="getRowSpan('Priority',i)" [style.display]="getRowSpan('Priority', i) ? '' : 'none'">
         {{ data.id }} </td>
    </ng-container>

    <ng-container matColumnDef="name">
        <th mat-header-cell *matHeaderCellDef> Name </th>
        <td mat-cell *matCellDef="let data;let i = dataIndex" [attr.rowspan]="getRowSpan('Name',i)" [style.display]="getRowSpan('Name', i) ? '' : 'none'">
         {{ data.name }} </td>
    </ng-container>

    <ng-container matColumnDef="weight">
        <th mat-header-cell *matHeaderCellDef> Weight </th>
        <td mat-cell *matCellDef="let data;let i = dataIndex" [attr.rowspan]="getRowSpan('Weight',i)" [style.display]="getRowSpan('Weight', i) ? '' : 'none'">
         {{ data.weight }} </td>
    </ng-container>

    <ng-container matColumnDef="descriptions">
        <th mat-header-cell *matHeaderCellDef> Descriptions </th>
        <td mat-cell *matCellDef="let data"> {{ data.descriptions }} </td>
    </ng-container>

    <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
    <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> 


</table>

Here is the demo

4
votes

As I was not happy with the answers presented (especially with the cacheSpan implementation).

I came up with something more convenient IMHO, that goes something like:

export class TableBasicExample {
  displayedColumns = ['priority', 'status', 'dateCreated', 'testNumber', 'testCurrency', 'testTime'];
  dataSource = DATA;

  spans = {};

  constructor() {
    this.spans = Object.assign({}, {
      priority: this.spanDeep(['priority'], DATA),
      status: this.spanDeep(['priority', 'status'], DATA),
      dateCreated: this.spanDeep(['priority', 'status', 'dateCreated'], DATA)
    });
  }

  spanDeep(paths: string[] | null, data: any[]) {
    if (!paths.length) {
      return [...data]
        .fill(0)
        .fill(data.length, 0, 1);
    }

    const copyPaths = [...paths];
    const path = copyPaths.shift();

    const uniq = uniqWith(data, (a, b) => get(a, path) === get(b, path))
      .map(item => get(item, path));

    return uniq
      .map(uniqItem => this.spanDeep(copyPaths, data.filter(item => uniqItem === get(item, path))))
      .flat(paths.length);
  }

  getRowSpan(path, idx) {
    return this.spans[path][idx];
  }
};

Working example may be found here: https://stackblitz.com/edit/angular-lnahlh-hw2d3b

1
votes

We must say how many rows there, but some rows has same id and we will sort and merge td if them used same id. But for your data, it is say some rows there, and descriptions is array and split able. For this way JS can't know how many <tr> should be there.

2 ways for you: 1- Format your data, keep one description each row, same as the example data in second href, [{id, name, weight, countdescriptions, description},...], and use [attr.rowspan]='data.countdescriptions' instead [attr.rowspan]='getRowSpan(data.id)'. 2- Update content format, like <ul><li *ngFor... in the description <td>, and remove the [attr.rowspan] attribute.

1
votes

a way simpler approach would be to use ng-containers

html template would be something like this, ex: app.component.html

<table mat-table [dataSource]="dataSource" class="mat-elevation-z8" multiTemplateDataRows>

<ng-container matColumnDef="id">
    <th mat-header-cell *matHeaderCellDef> Priority </th>
       <ng-container *matCellDef="let data">
          <td mat-cell *ngIf="data.id" [attr.rowspan]="rowSpanData[data.id]">
            {{ data.id }} 
          </td>
       </ng-container>
</ng-container>

<ng-container matColumnDef="name">
    <th mat-header-cell *matHeaderCellDef> Name </th>
    <ng-container *matCellDef="let data">
      <td mat-cell *ngIf="data.name" [attr.rowspan]="rowSpanData[data.id]">
       {{ data.name }} 
      </td>
    </ng-container>
</ng-container>

<ng-container matColumnDef="weight">
    <th mat-header-cell *matHeaderCellDef> Weight </th>
    <ng-container *matCellDef="let data">
      <td mat-cell *ngIf="data.name" [attr.rowspan]="rowSpanData[data.id]">
       {{ data.weight }} 
      </td>
    </ng-container>
</ng-container>

<ng-container matColumnDef="descriptions">
    <th mat-header-cell *matHeaderCellDef> Descriptions </th>
    <td mat-cell *matCellDef="let data"> {{ data.description }} </td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>

JS / TS file content will be

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

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  dataSource = ELEMENT_DATA;
  rowSpanData = ROWSPANDATA;
  displayedColumns = ['id', 'name', 'weight', 'descriptions'];
  constructor() {  }
}

export interface PeriodicElement {
  id: number;
  name: string;
  weight: number;
  descriptions: string[];
}

const originalData = [
  { id: 1, name: 'Hydrogen', weight: 1.0079, descriptions: ['row1', 'row2'] },
  { id: 2, name: 'Helium', weight: 4.0026, descriptions: ['row1', 'row2', 'row3'] },
  { id: 3, name: 'Lithium', weight: 6.941, descriptions: ['row1', 'row2'] },
  { id: 4, name: 'Beryllium', weight: 9.0122, descriptions: ['row1', 'row2', 'row3'] },
  { id: 5, name: 'Boron', weight: 10.811, descriptions: ['row1'] },
  { id: 6, name: 'Carbon', weight: 12.0107, descriptions: ['row1', 'row2', 'row3'] },
  { id: 7, name: 'Nitrogen', weight: 14.0067, descriptions: ['row1'] },
  { id: 8, name: 'Oxygen', weight: 15.9994, descriptions: ['row1'] },
  { id: 9, name: 'Fluorine', weight: 18.9984, descriptions: ['row1', 'row2', 'row3'] },
  { id: 10, name: 'Neon', weight: 20.1797, descriptions: ['row1', 'row2', 'row3'] },
]

let DATA1 = []
const ROWSPANDATA = {}
originalData.forEach(row => {
  ROWSPANDATA[row.id] = row.descriptions.length;
  row.descriptions.forEach((desc, index) => {
    if (index === 0) {
      DATA.push({id: row.id, name: row.name, weight: row.weight, description: 
      desc});
    } else {
     DATA.push({description: desc})
    }
  })
})

const ELEMENT_DATA: PeriodicElement[] = DATA;

just reformat the data to fit in a basic HTML table.

working example here

note: ID field can be duplicated, here i have used ID for simplicity of an answer you can create your own unique key and add it as part of row data and let it not displayed as a table column, that is still fine with angular material table.

-1
votes

There are some tricks to make this work. The other answers already got it, but I'll try a different approach.

Let's say that your data is as it follows (I took it from one of the other answers):

elements = [
    { id: 1, name: 'Hydrogen', weight: 1.0079, descriptions: ['row1', 'row2'] },
    { id: 2, name: 'Helium', weight: 4.0026, descriptions: ['row1', 'row2', 'row3'] },
    { id: 3, name: 'Lithium', weight: 6.941, descriptions: ['row1', 'row2'] }
]

If we would show just one of this elements, we should write:

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>Name</th>
      <th>Weight</th>
      <th>Descriptions</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td rowspan="2">1</td>
      <td rowspan="2">Hidrogen</td>
      <td rowspan="2">1.0079</td>
      <td>row1</td>
    </tr>
    <tr>
      <td>row2</td>
    </tr>
  </tbody>
</table

And this will give us:

enter image description here


Now, if we want to iterate the data, there are some things that we have to change.

Trick 1

If we use the *ngFor inside the tr tag, the table will crash. Instead, we just have to use it inside a ng-container tag.

Trick 2

We have to change the rowspan to [attr.rowspan].

Trick 3

The rows must have a separete iterator, but we already got the first element. So we have to make this separete iteration (with another *ngFor) but just show it if the index is above 0.

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>Name</th>
      <th>Weight</th>
      <th>Descriptions</th>
    </tr>
  </thead>
  <tbody>
    <ng-container *ngFor="let e of elements">
      <tr>
        <td [attr.rowspan]="e.descriptions.length + 1">{{e.id}}</td>
        <td [attr.rowspan]="e.descriptions.length + 1">{{e.name}}</td>
        <td [attr.rowspan]="e.descriptions.length + 1">{{e.weight}}</td>
        <td>{{e.descriptions[0]}}</td>
      </tr>
      <tr *ngFor="let d of e.descriptions; let index = index">
        <td *ngIf="index>0">{{d}}</td>
      </tr>
    </ng-container>
  </tbody>
</table>

And, finally, we have:

enter image description here