Angular framework allows us to create component dynamically, But not directives, I posted an article earlier on how to add directives dynamically

In this post, I am going to walk through an advanced use-case where we need to instantiate and attach directives dynamically.

Products Inline editable swimlane/ngx-datatable

Let’s take a situation where we have an inline editable data-table to update product details like description, category, price, and inventory.

The business requirement is to have a visual indicator for an editable field and when the user clicks table cell has a pencil icon, we need to show appropriate form field. It may be an input field or a select field.

Here are the individual column use-case

  • For the description column, when users click the table cell, we should show an input field and it should accept only alphanumeric with space.
  • For Category column, when user click table cell, we should show select field with all options
  • For Price column, when user click table cell, we should show an input field and it should accept only in currency format
  • For the Inventory column, when users click the table cell, we should show an input field and it should accept only numbers.

In our requirement, we have to show an input field for 3 columns, but character restriction in the input field is different.

This is an ideal situation where we can attach directives to the same input component dynamically based on the column configuration. It’s would be easy to test, scale and modular.

Here is the approach I followed to solve this challenge

  1. Created one directive per requirement ( numberOnly, currency, alphaNumeric )
  2. Create a service to instantiate directive instance with class name and arguments.
  3. Attach directives based on the column configuration, when the input element is added into the DOM.
  4. Unbind events in directive when a component is destroyed.

Step 1: Create directives for NumberOnly, Currency & AlphaNumeric

Here is the number-only directive, it will prevent the user from input text other than numbers.

// file-name : number-only.directive.ts
import { Directive, ElementRef, Renderer2, OnDestroy } from '@angular/core';

@Directive({ selector: '[appNumberOnly]' })
export class NumberOnlyDirective implements OnDestroy {

    private inputUnListner = () => { };

    constructor(public elementRef: ElementRef, public renderer: Renderer2) {
        this.bindEvent();
    }

    private bindEvent() {
        const inputElement = this.elementRef.nativeElement;
        this.inputUnListner =
            this.renderer.listen(inputElement, 'input', (event: KeyboardEvent) => {
                event.preventDefault();
                const targetElement = event.target as HTMLInputElement;
                targetElement.value = targetElement.value.replace(/\D/g, '');
            });

    }

    ngOnDestroy() {
        this.inputUnListner();
    }

}

Here is the alphanumeric directive, it will prevent the user input from special characters.

// file-name : alphanumberic.directive.ts
import { Directive, OnDestroy, ElementRef, Renderer2 } from '@angular/core';

@Directive({ selector: '[appAlphaNumeric]' })
export class AlphaNumericDirective implements OnDestroy {
    private unlisten = () => { };

    constructor(public elementRef: ElementRef, public renderer: Renderer2) {
        this.bindEvent();
    }

    private bindEvent() {
        const inputElement = this.elementRef.nativeElement;
        this.unlisten =
            this.renderer.listen(inputElement, 'input', (event: KeyboardEvent) => {
                event.preventDefault();
                const targetElement = event.target as HTMLInputElement;
                // RegEx to replace all special char except alphanumeric with space
                targetElement.value = targetElement.value.replace(/[^A-Z0-9 ]+/ig, '');
            });

    }

    ngOnDestroy() {
        this.unlisten();
    }
}

Here is the currency directive, it will prevent the user from input special characters except ( “$, . & 0-9 “)

// file-name : currency.directive.ts
import { Directive, ElementRef, Renderer2, OnDestroy } from '@angular/core';

@Directive({ selector: '[appCurrency]' })
export class CurrencyDirective implements OnDestroy {
    private inputUnListner = () => { };

    constructor(public elementRef: ElementRef, public renderer: Renderer2) {
        this.bindEvent();
    }

    private bindEvent() {
        const inputElement = this.elementRef.nativeElement;
        this.inputUnListner =
            this.renderer.listen(inputElement, 'input', (event: KeyboardEvent) => {
                event.preventDefault();
                const targetElement = event.target as HTMLInputElement;
                this.formatCurrency(targetElement);
            });

    }

    private formatNumber(value: string): string {
        return value.replace(/\D/g, '').replace(/\B(?=(\d{3})+(?!\d))/g, ',');
    }

    private formatCurrency(inputElement: HTMLInputElement) {
        let inputValue = inputElement.value;
        if (inputValue === '') {
            return;
        }
        const originalLength = inputValue.length;
        let cursorPosition = inputElement.selectionStart;

        if (inputValue.indexOf('.') >= 0) {
            const decimalPosition = inputValue.indexOf('.');
            let beforeDecimal = inputValue.substring(0, decimalPosition);
            let afterDecimal = inputValue.substring(decimalPosition);

            beforeDecimal = this.formatNumber(beforeDecimal);
            afterDecimal = this.formatNumber(afterDecimal);

            afterDecimal = afterDecimal.substring(0, 2);
            inputValue = '$' + beforeDecimal + '.' + afterDecimal;
        } else {
            inputValue = this.formatNumber(inputValue);
            inputValue = '$' + inputValue;
        }
        inputElement.value = inputValue;
        cursorPosition = inputValue.length - originalLength + cursorPosition;
        inputElement.setSelectionRange(cursorPosition, cursorPosition);
    }

    ngOnDestroy() {
        this.inputUnListner();
    }
}

Step 2: Service to instantiate directive by class name

// file-name : directives.factory.ts

import { Injectable } from '@angular/core';
import { AlphaNumericDirective } from './directives/alphanumberic.directive';
import { CurrencyDirective } from './directives/currency.directive';
import { NumberOnlyDirective } from './directives/number-only.directive';

@Injectable()
export class DirectivesFactory {

    public entryDirectives = {
        AlphaNumericDirective,
        CurrencyDirective,
        NumberOnlyDirective
    };

    constructor() { }

    createInstance(name: string, props) {
        let directiveInstance;
        try {
            directiveInstance = new this.entryDirectives[name](...props);
        } catch (error) {
            console.error(`Please declar Class ${name} in entryDirectives`);
        }
        return directiveInstance;
    }
}

In my service, I maintain list of directives in entryDirectives object. In createInstance method, I lookup entryDirectives object with class name as key, and if I found, then using new keyword I am creating an instance with constructor arguments and returning it.

Step 3: Attach directives to input components on demand.

In the component view, I have div to show the text and input field to capture user-entered value. I have to show an input element when the user clicks the div tag. I assigned #textboxRef as a template variable for the input element.

*ngIf will create input element and attach to the DOM when inputVisibility model is true.

Here is the input component template code

table-cell.component.html

<!-- Text input field -->
<ng-template #toggleinput>
    <div tabindex="0" class="editable" (click)="toggleInput()" [hidden]="inputVisiblity">
        {{row[column.prop]}}
        <i class="material-icons">
            edit
        </i>
    </div>
    <input autofocus #textboxRef (blur)="updateValue($event)" *ngIf="inputVisiblity" type="text" [value]="row[column?.prop]" />
</ng-template>

Here is a data-table column configuration object. I introduced a key “restrict” and values are an array of directive names. In this way, I can pass the column object to my component and attach the directive dynamically.

columns = [
    {
      prop: 'desc',
      title: 'Description',
      type: 'text',
      restrict: [AlphaNumericDirective]
    },
    {
      prop: 'price',
      title: 'Price',
      type: 'text',
      restrict: [CurrencyDirective]
    },
    {
      prop: 'inventory',
      title: 'Inventory',
      type: 'text',
      restrict: [NumberOnlyDirective]
    }
  ];

In my component, I am using @Viewchild decorator to identify when the input element is added to DOM.

When the input element is available in the DOM I will pass a class name to service and get the instance of the directive.

table-cell.component.ts

@ViewChild('textboxRef', { static: false })
    public set textboxRef(value: ElementRef) {
        this.attachDirective(value);
    }

private directiveInstances = [];

constructor(private directivesFactory: DirectivesFactory, private renderer: Renderer2) { }

public attachDirective(inputElement: ElementRef) {
        if (!inputElement || !this.column.restrict) {
            return;
        }
        /**
         * create new instance of directives using directivesFactory and stores in an array
         */
        this.directiveInstances = this.column.restrict.map((directive) => {
            return this.directivesFactory.createInstance(directive.name, [inputElement, this.renderer]);
        });

    }

To create an instance of my directives, i need couple of arguments for my constructor. The first one is Input ElementRef and the Second one is Renderer.

Directives will bind events when we create a new instance of Directives. We need to have a local copy of the directive instance to unbind the event during the destroy phase.

Step 4: Unbind events in directive when a component is destroyed.

ngOnDestroy() {
        if (this.directiveInstances.length === 0) {
            return;
        }

        this.directiveInstances.forEach((directiveInstance) => {
            if (directiveInstance && typeof directiveInstance.ngOnDestroy === 'function') {
                directiveInstance.ngOnDestroy();
            }
        });
    }

Since we are handling the life cycle of directives explicitly, it’s our responsibility to clean up objects or free the resources.

In our directives, we were binding events using the renderer listen method. We should unbind the event.

Finally, I put together everything in the app-root component.

app.component.html

<h2>Products Editable Datatable</h2>
<ngx-datatable class="material" [rows]="rows" [columns]="columns" headerHeight="50" limit="5" columnMode="force"
  footerHeight="50" rowHeight="auto">

  <ngx-datatable-column *ngFor="let column of columns" [name]="column.title" [prop]="column.prop">
    
    <ng-template ngx-datatable-cell-template let-row="row">
      <app-table-cell [column]="column" [row]="row" (itemChange)="handleItemChange($event)"></app-table-cell>
    </ng-template>

  </ngx-datatable-column>

</ngx-datatable>

For this example, I used inline edit swimlane/ngx-datatable

app-table-cell is responsible to rendering each cell in the datatable based on Inputs “column” configuration and “row” data.

app-table-cell.component.html

<ng-container [ngSwitch]="column?.type">
    <ng-container *ngSwitchCase="'text'">
        <ng-container *ngTemplateOutlet="toggleinput;context: contentRef"></ng-container>
    </ng-container>
    <ng-container *ngSwitchCase="'select'">
        <ng-container *ngTemplateOutlet="dropdown;context: contentRef"></ng-container>
    </ng-container>
    <ng-container *ngSwitchDefault>
        <ng-container *ngTemplateOutlet="default;context: contentRef"></ng-container>
    </ng-container>
</ng-container>

<!-- Non editable field -->
<ng-template #default let-value>
    {{value}}
</ng-template>

<!-- Text input field -->
<ng-template #toggleinput>
    <div tabindex="0" class="editable" (click)="toggleInput()" [hidden]="inputVisiblity">
        {{row[column.prop]}}
        <i class="material-icons">
            edit
        </i>
    </div>
    <input autofocus #textboxRef (blur)="updateValue($event)" *ngIf="inputVisiblity" type="text" [value]="row[column?.prop]" />
</ng-template>

<!-- Select field -->
<ng-template #dropdown let-options="options">
    <div tabindex="0" class="editable" (click)="toggleInput()" [hidden]="inputVisiblity">
        {{row[column.prop]}}
        <i class="material-icons">
            edit
        </i>
    </div>
    <select [ngModel]="row[column.prop]" *ngIf="inputVisiblity" (change)="updateValue($event)">
        <option *ngFor="let option of options" [value]="option.value">{{option.label}}</option>
    </select>
</ng-template>

Column object type property value is helping to render appropriate component and possible values are “text” / “select”.

When there is no type is specified, the row value will be displayed in a div tag.

app.component.ts

import { Component } from '@angular/core';
import { CurrencyDirective } from './shared/directives/currency.directive';
import { NumberOnlyDirective } from './shared/directives/number-only.directive';
import { ItemChangeAction } from './shared/table-cell/item-change-action.interface';
import { AlphaNumericDirective } from './shared/directives/alphanumberic.directive';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'angular-dynamic-directives';
  rows = [
    { id: 123, name: 'Gerber', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 },
    { id: 124, name: 'Iris', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 },
    { id: 125, name: 'Curcuma', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 },
    { id: 126, name: 'Carnation', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 },
    { id: 127, name: 'Astilbe', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 },
    { id: 128, name: 'Peonis', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 },
    { id: 129, name: 'Scabiosa', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 },
    { id: 130, name: 'Ranunculus', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 },
    { id: 131, name: 'Scilla', desc: 'Beautiful Single Flower', category: 'Flowers', price: '$5.00', inventory: 100 }
  ];

  columns = [
    {
      prop: 'name',
      title: 'Name'
    },
    {
      prop: 'desc',
      title: 'Description',
      type: 'text',
      restrict: [AlphaNumericDirective]
    },
    {
      prop: 'category',
      title: 'Category',
      type: 'select',
      options: [{
        label: 'Flowers', value: 'Flowers'
      },
      {
        label: 'Vegetables', value: 'Vegetables'
      }
      ]
    },
    {
      prop: 'price',
      title: 'Price',
      type: 'text',
      restrict: [CurrencyDirective]
    },
    {
      prop: 'inventory',
      title: 'Inventory',
      type: 'text',
      restrict: [NumberOnlyDirective]
    }
  ];

  public handleItemChange($event: ItemChangeAction) {
    this.rows = this.rows.map((row) => {
      if (row.id === $event.item.id) {
        return $event.item;
      }
      return row;
    });

  }
}

app component typescript file has column configuration and row data and event handler to handle when values are changes in the editable field.

You can see the code live in action here https://www.upliftskills.us/tutorials/angular-dynamic-directives/index.html and as usual, you can download this full code from my git repo https://github.com/smartrack/angular-dynamic-directives

Summary

I hope this post might have given you an idea of how to handle complex User Interface with dynamic directives.


Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.