Angular 6: HTML table create dynamic columns and rows

Joining my two comments, I created this stackblitz

I used material table because I’m lazy to formated a table. As commented, the only thing we need to use a mat-table is put as dataSource the controls of a Form Array

dataSource = this.myformArray.controls;

The columns of the table becomes like

<ng-container matColumnDef="surname">
    <th mat-header-cell *matHeaderCellDef> Surname </th>
        <td mat-cell *matCellDef="let element">
       <input arrow-div [formControl]="element.get('surname')">
       </td>
  </ng-container>

Yes, simple using [formControl]=element.get(‘nameOfField’)

The funny work is make that arrrows keys work to move between “cells”.
I use a directive. But as I hate create a directive with @Output() I use a auxiliar service.

If we not use a service, our .html looks like

<input arrow-div [formControl]="element.get('id')" (arrowEvent)="move($event)">
<input arrow-div [formControl]="element.get('name')" (arrowEvent)="move($event)">
<input arrow-div [formControl]="element.get('surname')" (arrowEvent)="move($event)">
  ...

If we used a service our html become more transparent

<input arrow-div [formControl]="element.get('id')" >
<input arrow-div [formControl]="element.get('name')" >
<input arrow-div [formControl]="element.get('surname')" >
...

And in the app we subscribe to the service.

The service is simple

export class KeyBoardService {
  keyBoard:Subject<any>=new Subject<any>();
  sendMessage(message:any)
  {
    this.keyBoard.next(message)
  }
}

just a Subject and a method to send the value to subject.

The directive only listen if a arrow key is down and send the key sender. Well, I send a object of type {element:…,acction:..} to send more information.

export class ArrowDivDirective {
  constructor( private keyboardService:KeyBoardService,public element:ElementRef){}

  //@Output() arrowEvent:EventEmitter<any>=new EventEmitter();
   

  @HostListener('keydown', ['$event']) onKeyUp(e) {
    switch (e.keyCode)
    {
      case 38:
        this.keyboardService.sendMessage({element:this.element,action:'UP'})
        break;
      case 37:
        if (this.element.nativeElement.selectionStart<=0)
        {
        this.keyboardService.sendMessage({element:this.element,action:'LEFT'})
        e.preventDefault();
        }
        break;
      case 40:
        this.keyboardService.sendMessage({element:this.element,action:'DOWN'})
        break;
      case 39:
        if (this.element.nativeElement.selectionStart>=this.element.nativeElement.value.length)
        {
        this.keyboardService.sendMessage({element:this.element,action:'RIGTH'})
        e.preventDefault();
        }
        break;
    }
  }
}

Well, I take account when you’re at first or at init of the input to send or not the key when we click lfet and right arrow.

The app.component only has to subscribe to the service and use ViewChildren to store all the inputs. be carefully! the order of the viewchildren in a mat-table goes from top to down and to left to rigth

@ViewChildren(ArrowDivDirective) inputs:QueryList<ArrowDivDirective>

  constructor(private keyboardService:KeyBoardService){}
  ngOnInit()
  {
    this.keyboardService.keyBoard.subscribe(res=>{
      this.move(res)
    })
  }
  move(object)
  {
    const inputToArray=this.inputs.toArray()
    const rows=this.dataSource.length
    const cols=this.displayedColumns.length
    let index=inputToArray.findIndex(x=>x.element===object.element)
    switch (object.action)
    {
      case "UP":
        index--;
        break;
      case "DOWN":
        index++;
        break;
      case "LEFT":
        if (index-rows>=0)
          index-=rows;
        else
        {
          let rowActual=index%rows;
          if (rowActual>0)
            index=(rowActual-1)+(cols-1)*rows;
        }
        break;
      case "RIGTH":
      console.log(index+rows,inputToArray.length)
        if (index+rows<inputToArray.length)
          index+=rows;
        else
        {
          let rowActual=index%rows;
          if (rowActual<rows-1)
            index=(rowActual+1);

        }
        break;
    }
    if (index>=0 && index<this.inputs.length)
    {
      inputToArray[index].element.nativeElement.focus();
    }
  }

*UPDATE If we want to add dinamically columns add new two variables (plus the “displayedColumns”

displayedColumns: string[] = ['name','surname','delete'];
displayedHead:string[]=['Name','Surname']
displayedFields:string[] = ['name','surname'];

And our table becomes like

<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
  <!-- All columns -->
   <ng-container *ngFor="let col of displayedFields;let i=index" [matColumnDef]="col">
      <th mat-header-cell *matHeaderCellDef> {{displayedHead[i]}} </th>
      <td mat-cell *matCellDef="let element">
        <input arrow-div [formControl]="element.get(col)">
      </td>
    </ng-container>
    <!---column delete-->
  <ng-container matColumnDef="delete">
    <th mat-header-cell *matHeaderCellDef></th>
    <td mat-cell *matCellDef="let element;let i=index;">
        <button arrow-div mat-button (click)="delete(i)">delete</button>
    </td>
  </ng-container>

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

A new function to add a column must add a FormControl to each FormGroup of the array, actualize the variables displayedColumns,displayedHead and displayedFields

addColumn()
  {
    let newField="Column"+(this.displayedFields.length+1)

    this.myformArray.controls.forEach((group:FormGroup)=>{
      group.addControl(newField,new FormControl())
    })
    this.displayedHead.push(newField)
    this.dataSource = [...this.myformArray.controls];
    this.displayedFields.push(newField);
    this.displayedColumns=[...this.displayedFields,"delete"];
  }

In this another stackblitz I add this functionality (also how delete a row and how create a new row)

Update 2 answer to how not “hardcode” the formArray really it’s all inside.
If we imagine that we has an array like

ELEMENT_DATA: any[] = [ { name: '1', surname: 'one' }, { name: '2', surname: 'two' }, { name: '3', surname: 'three' }, ]; )

We need use the array to give values to displayedHead, displayedFields and displayedColumns:

displayedHead:string[]=Object.keys(this.ELEMENT_DATA[0]).map(x=>x.substring(0,1).toUpperCase()+x.substring(1))
  displayedFields:string[] = Object.keys(this.ELEMENT_DATA[0]);
  displayedColumns:string[]=[...this.displayedFields,'delete']

To initialize the FormArray we are going to improve the function “add” to allow pass as argument an object to give value to the form

  add(data:any=null)
  {
    const newGroup=new FormGroup({});
    this.displayedFields.forEach(x=>{
      //see that if data!=null we create the FormControl with the value
      //of data[x]
      newGroup.addControl(x,new FormControl(data?data[x]:null))
    })
    this.myformArray.push(newGroup)

    this.dataSource = [...this.myformArray.controls];
  }

At least create a function initArray

  initArray(elements:any[]){
    elements.forEach(x=>{
      this.add(x);
    })
  }

And call it in ngOnInit

this.init(this.ELEMENT_DATA)

Well, if we don’t has the array in a variable -usually we get the value form a service-, we need put all this in the subscribe function to the service

this.myserviceData.getData().subscribe(res=>{
displayedHead:string[]=Object.keys(res[0]).map(x=>x.substring(0,1).toUpperCase()+x.substring(1))
      displayedFields:string[] = Object.keys(res);
      displayedColumns:string[]=[...this.displayedFields,'delete']
      this.initArray(res)
})

Update allow user to change name of the columns

In a table, the “head” of each columns can be anything. So we can defined one variable

  columnSelect = -1;

And we are create a more complex header

  <th mat-header-cell *matHeaderCellDef>
     <span [style.display]="columnSelect!=i?'inline':'none'" (click)="selectColumn(i,columnName)">{{displayedHead[i]}} </span>
     <input #columnName [style.display]="columnSelect==i?'inline':'none'" [ngModel]="displayedHead[i]" (blur)="changeColumnName(i,columnName.value)"/>
     </th>

See that the header or is an input or is an span (if the “selectColumn” is equal to the column. remember that the columns are numerated from 0 -this is the reason because if selectColumn=-1 there’re no column selected.

We use a template reference variable “#columnName” to pass the value to the function (blur) and when (click) the span. this allow us create two functions

  selectColumn(index: number, inputField: any) {
    this.columnSelect = index; //give value to columnSelect
    setTimeout(() => {          //in a setTimeout we make a "focus" to the input
      inputField.focus();
    });
  }

It’s neccesary make the focus inside a setTimeout to allow Angular repaint the header and then make the focus. This is the reason also we use [style.display] and not *ngIf. (if we use *ngIf, the value “inputField” was null)

The function to change the name is a bit more complex

  changeColumnName(index, columnTitle) {
    const oldName = this.displayedFields[index];
    const columnName = columnTitle.replace(/ /g, "").toLowerCase();
    if (columnName != oldName) {
      this.myformArray.controls.forEach((group:FormGroup)=>{
          group.addControl(columnName,new FormControl(group.value[oldName]))
          group.removeControl(oldName)
      })
      this.displayedHead.splice(index, 1, columnTitle);
      this.displayedColumns.splice(index, 1, columnName);
      this.displayedFields.splice(index, 1, columnName);
    }
    this.columnSelect = -1;
  }

Basicaly we add a new formControl to the array and remove the older. I choose that the name of the “field” was the Title to lower case after remove all the spaces.

The new stackblitz

Leave a Comment