Angular Material editable table using FormArray
Asked Answered
S

5

48

I'm trying to build an inline editable table using the latest material+cdk for angular.

Question

How can I make mat-table use [formGroupName] so that the form fields can be referenced by its correct form path?

This is what I got so far: Complete StackBlitz example

Template

<form [formGroup]="form">
  <h1>Works</h1>
  <div formArrayName="dates" *ngFor="let date of rows.controls; let i = index;">
    <div [formGroupName]="i">
      <input type="date" formControlName="from" placeholder="From date">
      <input type="date" formControlName="to" placeholder="To date">
    </div>
  </div>


  <h1>Wont work</h1>
  <table mat-table [dataSource]="dataSource" formArrayName="dates">
    <!-- Row definitions -->
    <tr mat-header-row *matHeaderRowDef="displayColumns"></tr>
    <tr mat-row *matRowDef="let row; let i = index; columns: displayColumns;" [formGroupName]="i"></tr>

    <!-- Column definitions -->
    <ng-container matColumnDef="from">
      <th mat-header-cell *matHeaderCellDef> From </th>
      <td mat-cell *matCellDef="let row"> 
        <input type="date" formControlName="from" placeholder="From date">
      </td>
    </ng-container>

    <ng-container matColumnDef="to">
      <th mat-header-cell *matHeaderCellDef> To </th>
      <td mat-cell *matCellDef="let row">
        <input type="date" formControlName="to" placeholder="To date">
      </td>
    </ng-container>
  </table>
  <button type="button" (click)="addRow()">Add row</button>
</form>

Component

export class AppComponent implements  OnInit  {
  data: TableData[] = [ { from: new Date(), to: new Date() } ];
  dataSource = new BehaviorSubject<AbstractControl[]>([]);
  displayColumns = ['from', 'to'];
  rows: FormArray = this.fb.array([]);
  form: FormGroup = this.fb.group({ 'dates': this.rows });

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.data.forEach((d: TableData) => this.addRow(d, false));
    this.updateView();
  }

  emptyTable() {
    while (this.rows.length !== 0) {
      this.rows.removeAt(0);
    }
  }

  addRow(d?: TableData, noUpdate?: boolean) {
    const row = this.fb.group({
      'from'   : [d && d.from ? d.from : null, []],
      'to'     : [d && d.to   ? d.to   : null, []]
    });
    this.rows.push(row);
    if (!noUpdate) { this.updateView(); }
  }

  updateView() {
    this.dataSource.next(this.rows.controls);
  }
}

Problem

This wont work. Console yields

ERROR Error: Cannot find control with path: 'dates -> from'

It seems as if the [formGroupName]="i" has no effect, cause the path should be dates -> 0 -> from when using a formArray.

My current workaround: For this problem, I've bypassed the internal path lookup (formControlName="from") and use the form control directly: [formControl]="row.get('from')", but I would like to know how I can (or at least why I cannot) use the Reactive Form preferred way.

Any tips are welcome. Thank you.


Since I think this is a bug, I've registered an issue with the angular/material2 github repo.

Surtout answered 3/7, 2018 at 8:42 Comment(0)
E
35

I would use the index which we can get within matCellDef binding:

*matCellDef="let row; let index = index" [formGroupName]="index"

Forked Stackblitz

For solving problems with sorting and filtering take a look at this answer Angular Material Table Sorting with reactive formarray

Elohim answered 15/7, 2018 at 11:46 Comment(9)
Wont that produce the index from the columns and not the rows?Ezaria
You can check it by printing index stackblitz.com/edit/…Elohim
Seemingly works fine. Strange, if this is intentional, it is not intuitive. But thank you.Ezaria
this generates problems when using pagination and filtering, I'm struggling with that right now... Pagination is easy to solve, but not filteringArhna
@Arhna how did you solve pagination when using index as formGroupName. I'm facing the same problem.Permenter
This works fine if you dont have sort or pagination... Pagination you can solve because you know how many items you are showing at the moment and which page index too and like that you can recalculate the right index but with sort... not reallyPolytrophic
@Arhna , how have you solved the filtering problem?Dispersal
@Arhna I added info about filteringElohim
For newer versions of Material, you will need to change let index = index to let index = dataIndexInsistency
G
26

here is the sample code

In Html:

    <form [formGroup]="tableForm">

    <mat-table formArrayName="users" [dataSource]="dataSource">

      <ng-container cdkColumnDef="position">
        <mat-header-cell *cdkHeaderCellDef> No. </mat-header-cell>
        <mat-cell *cdkCellDef="let row let rowIndex = index"  [formGroupName]="rowIndex"> 
          <input type="text" size="2" formControlName="position"> </mat-cell>
      </ng-container>


      <ng-container cdkColumnDef="name">
        <mat-header-cell *cdkHeaderCellDef> Name </mat-header-cell>
        <mat-cell *cdkCellDef="let row let rowIndex = index"  [formGroupName]="rowIndex"> 
          <input type="text" size="7" formControlName="name">
        </mat-cell>
      </ng-container>

        <ng-container cdkColumnDef="weight">
        <mat-header-cell *cdkHeaderCellDef> Weight </mat-header-cell>
        <mat-cell *cdkCellDef="let row let rowIndex = index"  [formGroupName]="rowIndex"> 
          <input type="text" size="3" formControlName="weight">
        </mat-cell>
      </ng-container>

        <ng-container cdkColumnDef="symbol">
        <mat-header-cell *cdkHeaderCellDef> Symbol </mat-header-cell>
        <mat-cell *cdkCellDef="let row let rowIndex = index"  [formGroupName]="rowIndex"> 
          <input type="text" size="2" formControlName="symbol">
        </mat-cell>
      </ng-container>

      <!-- Header and Row Declarations -->
      <mat-header-row *cdkHeaderRowDef="displayedColumns"></mat-header-row>
      <mat-row *cdkRowDef="let row; columns: displayedColumns;"></mat-row>
    </mat-table>
    </form>

Controller code:

    displayedColumns: string[] = ['position', 'name', 'weight', 'symbol'];


     dataSource ;
      tableForm: FormGroup;



     constructor(private formBuilder: FormBuilder){
     this.dataSource = [
      {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'},
      {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'},
      {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'},
      {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'},
      {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'},
      {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'},
      {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'},
      {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'},
      {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'},
      {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'},
    ];
      }

      ngOnInit(){
        this.tableForm= this.formBuilder.group({
            users: this.formBuilder.array([])
        })
        this.setUsersForm();
        this.tableForm.get('users').valueChanges.subscribe(users => {console.log('users', users)});
      }
      private setUsersForm(){
        const userCtrl = this.tableForm.get('users') as FormArray;
        this.dataSource.forEach((user)=>{
          userCtrl.push(this.setUsersFormArray(user))
        })
      };
      private setUsersFormArray(user){


        return this.formBuilder.group({
            position:[user.position],
            name:[user.name],
            weight:[user.weight], 
            symbol:[user.symbol]
        });
      }
Galantine answered 29/11, 2018 at 19:9 Comment(5)
Hi, nice answer, can you please edit your answer to add mat-error implementation?Hypoglycemia
How to implement paginator in this example?Permenter
For pagination :Pagination To paginate the table's data, add a <mat-paginator> after the table. If you are using the MatTableDataSource for your table's data source, simply provide the MatPaginator to your data source. It will automatically listen for page changes made by the user and send the right paged data to the table. check this link for more material.angular.io/components/table/overview#datasourceGalantine
I am getting: Cannot find control with name: '0'Mei
I was missing formArrayName="users". Now I am getting: Error: Cannot find control with path: 'users -> 0'Mei
W
11

A little late to the party but I managed to get it working without relying on the index. This solution also supports filtering etc from the MatTableDataSource.

https://stackblitz.com/edit/angular-material-table-with-form-59imvq

Component

import {
  Component, ElementRef, OnInit
} from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'
import { AlbumService } from './album.service';
import { UserService } from './user.service';
import { Album } from './album.model';
import { User } from './user.model';
import { FormArray, FormGroup, FormBuilder } from '@angular/forms';
import { MatTableDataSource } from '@angular/material';

@Component({
  selector: 'table-form-app',
  templateUrl: 'app.component.html'
})
export class AppComponent implements OnInit {
  form: FormGroup;
  users: User[] = [];
  dataSource: MatTableDataSource<any>;
  displayedColumns = ['id', 'userId', 'title']
  constructor(
    private _albumService: AlbumService,
    private _userService: UserService,
    private _formBuilder: FormBuilder
    ) {}

  ngOnInit() {
    this.form = this._formBuilder.group({
      albums: this._formBuilder.array([])
    });
    this._albumService.getAllAsFormArray().subscribe(albums => {
      this.form.setControl('albums', albums);
      this.dataSource = new MatTableDataSource((this.form.get('albums') as FormArray).controls);
      this.dataSource.filterPredicate = (data: FormGroup, filter: string) => { 
          return Object.values(data.controls).some(x => x.value == filter); 
        };
    });
    this._userService.getAll().subscribe(users => {
      this.users = users;
    })
  }

  get albums(): FormArray {
    return this.form.get('albums') as FormArray;
  }

  // On user change I clear the title of that album 
  onUserChange(event, album: FormGroup) {
    const title = album.get('title');

    title.setValue(null);
    title.markAsUntouched();
    // Notice the ngIf at the title cell definition. The user with id 3 can't set the title of the albums
  }

  applyFilter(filterValue: string) {
    this.dataSource.filter = filterValue.trim().toLowerCase();
  }
}

HTML

<mat-form-field>
  <input matInput (keyup)="applyFilter($event.target.value)" placeholder="Filter">
</mat-form-field>

<form [formGroup]="form" autocomplete="off">
    <mat-table [dataSource]="dataSource">

      <!--- Note that these columns can be defined in any order.
            The actual rendered columns are set as a property on the row definition" -->

      <!-- Id Column -->
      <ng-container matColumnDef="id">
        <mat-header-cell *matHeaderCellDef> Id </mat-header-cell>
        <mat-cell *matCellDef="let element"> {{element.get('id').value}}. </mat-cell>
      </ng-container>

      <!-- User Column -->
      <ng-container matColumnDef="userId">
        <mat-header-cell *matHeaderCellDef> User </mat-header-cell>
        <mat-cell *matCellDef="let element" [formGroup]="element">
          <mat-form-field floatLabel="never">
            <mat-select formControlName="userId" (selectionChange)="onUserChange($event, element)" required>
              <mat-option *ngFor="let user of users" [value]="user.id">
                {{ user.username }}
              </mat-option>
            </mat-select>
          </mat-form-field>
        </mat-cell>
      </ng-container>

      <!-- Title Column -->
      <ng-container matColumnDef="title">
        <mat-header-cell *matHeaderCellDef> Title </mat-header-cell>
        <mat-cell *matCellDef="let element;" [formGroup]="element">
          <mat-form-field floatLabel="never" *ngIf="element.get('userId').value !== 3">
            <input matInput placeholder="Title" formControlName="title" required>
          </mat-form-field>
        </mat-cell>
      </ng-container>

      <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
      <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
    </mat-table>
</form>
<mat-accordion>
  <mat-expansion-panel>
    <mat-expansion-panel-header>
      <mat-panel-title>
        Form value
      </mat-panel-title>
    </mat-expansion-panel-header>
    <code>
      {{form.value | json}}
    </code>
  </mat-expansion-panel>
</mat-accordion>
Wessling answered 20/5, 2019 at 14:0 Comment(4)
Good answer, but matSort does not work i tried all ways and i did a lot search to find solution but i failed can you please help me to apply sorting on the columnsCarberry
Hello Snæbjørn: can you please help me, the sorting does not workCarberry
@MohamadChami the filter works for id and userId. Not sure why it doesn't work for the others. Probably some internal ngForm stuff.Eyde
Hi @Wessling the filter you have applied doesn't work. do you have any working filters over formControls?Grisham
G
2

Create a function that calculates the actual index.

getActualIndex(index : number)    {
    return index + pageSize * pageIndex;
}

You can get the pageSize and pageIndex from the paginator. Then, in the template use this function:

formControlName="getActualIndex(index)"
Giulio answered 24/5, 2020 at 20:19 Comment(0)
P
-1

For matSort to work the type definition is important, at least that's what I found. So with type as any in the code :

dataSource: MatTableDataSource<any>; 

Will not work. There has to be a type defined here to make it work, try to define a interface and pass it in the generics of MatTableDataSource .

Also matColumnDef has to match the property name of the defined type.

Plethora answered 27/7, 2020 at 23:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.