icon-search
angular generalizing components

Generalizing Components in Angular

Paweł Kozioł 30.09.2016

Components in Angular 2 allow to design maintainable applications where business logic is encapsulated and reusable. However sometimes we would like to have even more reusability. We would like to use components as templates with some other component inside.

In object oriented programming we can use inheritance to express some common functionality in base classes and extend it in subclasses. However extending Angular 2 components is problematic, because it does not support decorators and annotations inheritance. Therefore much code would need to be copied in subclasses and the benefits of inheritance would be reduced. Besides, if we want to combine behaviour of multiple subcomponents, it is better to use composition than inheritance.
In this approach the generic component is composed of subcomponents and they are responsible for specific aspects of its behaviour.

Example use of a generic component
Example use of a generic component

A good example is a modal dialog. It implements some generic functionality of displaying content and handling events which is common regardless of the content of the dialog. We can reuse such generic modal dialog to display simple Yes/No confirmation dialog, more complicated input forms with many text fields or even a Javascript SVG graphics editor.

In Sparkbit we use such generic components to implement modal dialogs, context menus, flyouts, tab components and lists.

Transclusion

Angular 2 has the feature named transclusion or content projection exactly for this.
Here is our implementation of the generic dialog component.

<div class="generic-modal__wrapper">
  <div class="generic-modal__title">
    <h2>{{title}}</h2>
    <button class="generic-modal__close" title="Close" (click)="onClose()"></button>
  </div>
  <div class="generic-modal__content-container">
    <ng-content></ng-content>
  </div>
</div>

The dialog has a title, a closing button and some CSS classes that handle the display. The thing that makes it generic is the ng-content tag. This is where some other components or any HTML code can be inserted to the dialog.

This is what the component code looks like:

import {Subject} from "rxjs/Rx";

@Component({
    selector: "generic-modal",
    templateUrl: "generic-modal.html"
})
export class GenericModalComponent {
    @Input() public showObservable: Subject<boolean>;
    @Input() public title: string;
    
    //some logic to focus on the first focusable element inside the dialog
    
    public onClose() {
        this.showObservable.next(false);
    }
}

Among its inputs is an Rx.Subject to communicate the fact that the dialog was closed to the component that displays it. One neat thing we implemented is to trigger focus on the first focusable element inside the dialog such as an anchor, input, textarea, button, etc. This allows keyboard navigation in our application and is an important requirement since our app needs to be fully keyboard accessible. This is one example of a generic functionality that can be implemented once and then reused in all places where a generic dialog is needed.
The details about how we implemented it are a good material for a next blog post.

Now let’s have a look at a use of the generic dialog – the CommentDialogComponent.
The HTML template looks as following:

<generic-modal [showObservable]="showModalObservable" title="{{userComment}}">
  <div>{{commentHeader}}</div>
  <form [ngFormModel]="commentForm" (ngSubmit)="submitComment(commentForm.value)">
    <textarea class="comment__text-area " ngControl="comment" placeholder="{{commentPlaceholder}}"></textarea>
    <div class="comment__buttons">
      <button class="comment__btn-cancel" (click)="cancel($event)" title="{{cancel}}">
        {{cancel}}
      </button>
      <button class="comment__btn-save" type="submit" [disabled]="!isDataValid(commentForm.value)" title="{{save}}">
        {{addComment}}
      </button>
    </div>
  </form>
</generic-modal>

The showObservable and title inputs are passed to the generic modal component and inside is the content to be displayed inside the dialog.

Inside the CommentDialogComponent we have to remember to declare the usage of the GenericModalComponent directive.

@Component({
  selector: "comment-dialog",
    templateUrl: "comment-dialog.html",
    directives: [FORM_DIRECTIVES, GenericModalComponent]
})
export class CommentDialogComponent {
//comment dialog specific logic goes here
}

It is also possible to make the Angular component even more flexible, by inserting more than one ng-content tag. We can build a generic modal dialog that has separate slots for the title, header and contents.
This is how the HTML template of such component looks like:

<div class="generic-modal__wrapper">
  <div class="generic-modal__title">
    <ng-content select="modal-title"></ng-content>
    <button class="generic-modal__close" title="Close" (click)="onClose()"></button>
  </div>
  <div class="generic-modal__content-container">
    <div class="generic-modal__header">
      <ng-content select="modal-header"></ng-content>
    </div>
    <div>
      <ng-content select="modal-content"></ng-content>
    </div>
  </div>
</div>

And we use it this way:

<generic-modal [showObservable]="showModalObservable">
  <modal-title>
    <img src="title_icon.png">
        {{title}}
  </modal-title>
  <modal-header>
    <div>{{commentHeader}}</div>
  </modal-header>
  <modal-content>
    <form [ngFormModel]="commentForm" (ngSubmit)="submitComment(commentForm.value)">
      <textarea class="comment__text-area " ngControl="comment" placeholder="{{commentPlaceholder}}">
      </textarea>
      <div class="comment__buttons">
        <button class="comment__btn-cancel" (click)="cancel($event)" title="{{cancel}}">
          {{cancel}}
        </button>
        <button class="comment__btn-save" type="submit" [disabled]="!isDataValid(commentForm.value)" title="{{save}}">
          {{addComment}}
        </button>
      </div>
    </form>
  </modal-content>
</generic-modal>

Limitations of ng-content

At the beginning we mentioned that in Sparkbit we built generic list components. In fact collections are the best use case for generic components, as almost every application uses collections of some kind. These can be lists of users, lists of orders, lists of items in the shopping cart, etc. Every list has some common set of features such as sorting, filtering and paging. They only differ with the type of items that are displayed in the list. It would be inconvenient to maintain the list logic in every component that wanted to display its contents in the form of a list.

However Angular transclusion with ng-content has a serious limitation. It will not work as expected when used inside a component with *ngFor.

If you wanted to implement a list in the following way

<ul>
  <li *ngFor="let item of items">
    <ng-content></ng-content>
  </li>
</ul>

it wouldn’t work, because the contents would be projected into the first with the default selector (here no selector is used).

Repeating templates with ngForTemplate

This is where the ngFor needs to be combined with the ngForTemplate.

Let’s take a look at our generic list implementation.

<div class="generic-list-container">
  <div class="panel panel-default search-and-filter-panel" *ngIf="showFilters || showSorter">
    <list-filter class="list-filter" *ngIf="showFilters"></list-filter>
    <list-sorter class="list-sorter" *ngIf="showSorter"></list-sorter>
    <span class="results-text text-right">
      {{ result.count + " " + genericListCount }}
    </span>
    <span class="filters-toggle-button">
      </span>
  </div>
</div>

<div class="list-panel">
  <div *ngIf="showEmptyMsg">
    {{emptyMsg}}
  </div>

  <ul class="list-group">
    <li *ngFor="let item of result.items" [ngClass]="{'selected': isItemSelected(item)}" (click)="toggleSelection(item)" (keypress)="toggleSelectionUsingKeyboard($event, item)" tabindex="0">
      <template ngFor let-item [ngForOf]="[item]" [ngForTemplate]="itemTmpl">
      </template>
    </li>
  </ul>
  <list-paging class="list-paging" *ngIf="showPaging"></list-paging>
</div>


We use the template and ngForTemplate to repeat the contents of generic list. This way we overcome the limitation of the ng-content tag which can only be repeated once or a fixed number of times using the selectors.

Notice that the list component contains a lot more logic than the simple generic modal component. It implements sorting, filtering, paging and displaying a message, when there are no results to be shown. Detailed parameters passed to the filtering, sorting and paging components are omitted for brevity.

Here is the component code:

import {NgFor, NgIf, NgClass} from "@angular/common";
import {Component, ContentChild, Input, TemplateRef} from "@angular/core";

@Component({
    selector: "<generic-list>",
    templateUrl: "generic-list.html",
    directives: [NgFor, NgIf, NgClass, ListFilterComponent, ListSorterComponent, ListPagingComponent]
})
export class GenericListComponent<T extends {id: string | number}> {
    @Input() public emptyMsg: string;
    @Input() public showFilters = true;
    @Input() public showSorter = true;
    @Input() public showPaging = true;

    @ContentChild(TemplateRef) public itemTmpl: TemplateRef<Element>;

    private items: Array<T>;

    //detailed list implementation
}

Notice the @ContentChild(TemplateRef) annotation which allows to access the contents of the generic-list tag.

We use generics lists in multiple places in our code. A sample usage is:

<generic-list class="generic-list" [emptyMsg]="emptyUserList">
  <user-list-item template="let user" [user]="user"></user-list-item>
</generic-list>

The template directive specifies the name of the variable over which the template will be iterated. So when we are building a list of users, the user will be the name of the one particular list item value object.

Our lists fetch data from the application backend in an asynchronous manner and their data source is an rx.Observable rather than an Array. This is why the list has a private items field. Details of how asynchronous data loading is handled is omitted here to focus on the Angular templates.

Recursive components in Angular

The last topic that we want to cover is how we can implement a component for a tree, which is a recursive data structure.

Let’s jump straight to the HTML code:

<tree-line class="tree" [ngClass]="{'tree--subtree': indent > 0, 'tree--selected': isItemSelected(root)}" [model]="root"
  [expanded]="expanded" [toggleExpanded]="toggleExpanded" (click)="handleClick($event, root)"
  [select]="handleClick"></tree-line>
<div class="tree-child" [ngClass]="{'tree--hidden': !renderChildren()}">
  <tree-tree class="tree-tree" [root]="child" [indent]="indent + 1" [selectionStream]="selectionStream" [selectedItemId]="selectedItemId" [notifyChildSelected]="onChildSelected" *ngFor="let child of root.children">
  </tree-tree>
</div>

The tree component does not use the ng-content or ngForTemplate. Instead it recursively includes its own tree-tree tag.
We use the indent variable to store the indentation level. Our tree also supports subtrees hiding and expanding. The TreeLine component displays the contents of a single tree item.

The typescript code follows below:

@Component({
    selector: "tree",
    templateUrl: "tree.html",
    directives: [NgFor, NgIf, TreeLineComponent, TreeComponent]
})
export class TreeComponent {
    @Input() public root: TreeItem;
    @Input() public indent = 0;
    @Input() public emptyMsg: string;
    @Input() public selectionStream: Subject<TrayItem>;
    @Input() public selectedItemId: string;
    @Input() public notifyChildSelected: () => void;
    public expanded = false;
    
    public renderChildren() {
        return this.expanded && this.root.children && this.root.children.length > 0;
    }

    public toggleExpanded = (event: Event) => {
        this.expanded = !this.expanded;
        event.stopPropagation();
    };

    //implementation details
}

Wrapup

Angular provides the ng-content directive to build generic components with slots where contents can be inserted.
Templates can be used with ngForTemplate for the same purpose as ng-content, however they have the advantage that content can be iterated over to build lists.
And lastly, it is possible to build recursive components by repeating the component tag inside the component template.

comments: 0