Angular - custom *ngIf directive

In this article, I'll show how to create a custom structural directive that works similarly to Angular's ngIf. The goal is to create a directive that checks if a current user has required permissions by passing them to the directive. The directive should also allow passing else template reference whenever the directive condition fails.

Setup

If unsure how to set up Angular with Jest please refer to the article: barcioch.pro/angular-with-jest-setup

Permissions

Define available permissions in the Permission enum.

// permission.enum.ts

export enum Permission {
  login = 'login',
  dashboard = 'dashboard',
  orders = 'orders',
  users = 'users',
  admin = 'admin',
}

Create a simple service for permission validation. The service takes as a first argument in the constructor the current users' permissions.

// permission.service.ts

import { Injectable } from '@angular/core';
import { Permission } from './permission.enum';

@Injectable()
export class PermissionService {
  constructor(private readonly permissions: Permission[]) {
  }

  hasPermission(permission: Permission): boolean {
    return this.permissions.includes(permission);
  }
}

Directive

Start with creating an empty directive with required dependencies.

// has-permission.directive.ts

import { Directive, TemplateRef, ViewContainerRef } from '@angular/core';
import { PermissionService } from './permission.service';

@Directive({
  selector: '[hasPermission]',
})
export class HasPermissionDirective {
  constructor(
    private readonly viewContainer: ViewContainerRef,
    private readonly templateRef: TemplateRef<unknown>,
    private readonly permissionService: PermissionService
  ) {
  }
}

There are 3 dependencies required for this directive to work:

  • ViewContainerRef - a reference to the parent view

  • TemplateRef<unknown> - a reference to the element's view that the directive is connected to

  • PermissionService - service for checking users' permissions

The user needs to pass permission directly to the directive in the following manner:

// component
...
readonly permissions = Permission;
...
<!-- view -->

<div *hasPermission="permissions.login"></div>

To handle directive inputs use @Input() decorator. The input methods should be prefixed with directive selector (hasPermission in this case). The default input should be named exactly as the directive selector.

private hasCurrentPermission = false;

@Input() set hasPermission(permission: Permission) {
  this.hasCurrentPermission = this.permissionService.hasPermission(permission);
  this.displayTemplate();
}

private displayTemplate(): void {
  this.viewContainer.clear();

  if (this.hasCurrentPermission) {
    this.viewContainer.createEmbeddedView(this.templateRef);
  }
}

I created a setter hasPermission. It uses the PermissionService to check the permission and stores the result in the private property hasCurrentPermission. Next, the displayTemplate method is called. First, it clears the current view (removes the content) this.viewContainer.clear(). Then, it checks the hasCurrentPermission property and if it's true then it renders the element that the directive is attached to this.viewContainer.createEmbeddedView(this.templateRef).

Next, let's support the else keyword. The else should point to the ng-template reference.

<div *hasPermission="permissions.login; else noPermission"></div>

<ng-template #noPermission>
  <span>Access denied</span>
</ng-template>

To handle the else functionality we need to add the new input called hasPermissionElse. It's argument's type is the TemplateRef because we will be passing the template reference. Note, that the name consists of two parts:

  • prefix: directive selector hasPermission

  • suffix: the name used in the view else

private elseTemplateRef: TemplateRef<unknown>;

@Input() set hasPermissionElse(templateRef: TemplateRef<unknown>) {
  this.elseTemplateRef = templateRef;
  this.displayTemplate();
}

private displayTemplate(): void {
  this.viewContainer.clear();

  if (this.hasCurrentPermission) {
    this.viewContainer.createEmbeddedView(this.templateRef);
    return;
  }

  if (this.elseTemplateRef) {
    this.viewContainer.createEmbeddedView(this.elseTemplateRef);
  }
}

The hasPermissionElse setter takes and stores the else template reference and then it calls displayTemplate() method. The displayTemplate method has the following modifications:

  • added return to this.hasCurrentPermission condition check

  • added this.elseTemplateRef condition check. If the user has no permission and the else template reference was passed we render this template

The whole directive looks as follows:

// has-permission.directive.ts

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
import { PermissionService } from './permission.service';
import { Permission } from './permission.enum';

@Directive({
  selector: '[hasPermission]',
})
export class HasPermissionDirective {
  private elseTemplateRef: TemplateRef<unknown>;
  private hasCurrentPermission = false;

  constructor(
    private readonly viewContainer: ViewContainerRef,
    private readonly templateRef: TemplateRef<unknown>,
    private readonly permissionService: PermissionService
  ) {
  }

  @Input() set hasPermissionElse(templateRef: TemplateRef<unknown>) {
    this.elseTemplateRef = templateRef;
    this.displayTemplate();
  }

  @Input() set hasPermission(permission: Permission) {
    this.hasCurrentPermission = this.permissionService.hasPermission(permission);
    this.displayTemplate();
  }

  private displayTemplate(): void {
    this.viewContainer.clear();

    if (this.hasCurrentPermission) {
      this.viewContainer.createEmbeddedView(this.templateRef);
      return;
    }

    if (this.elseTemplateRef) {
      this.viewContainer.createEmbeddedView(this.elseTemplateRef);
    }
  }
}

Testing

A simple test that checks all possible combinations of directive inputs.

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { Permission } from './permission.enum';
import { CommonModule } from '@angular/common';
import { HasPermissionModule } from './has-permission.module';
import { PermissionService } from './permission.service';
import { By } from '@angular/platform-browser';

describe('HasPermissionDirective', () => {
  let fixture: ComponentFixture<TestComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        CommonModule,
        HasPermissionModule
      ],
      declarations: [TestComponent],
      providers: [
        {
          provide: PermissionService,
          useValue: new PermissionService([Permission.dashboard, Permission.users])
        }
      ]
    });

    fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges()
  });

  describe('when the test component is initialized', () => {
    describe('and user has set "dashboard" and "users" permissions', () => {
      it('should not display "orders" element', () => {
        const element = fixture.debugElement.query(By.css('#orders'));
        expect(element).toBeFalsy();
      });

      it('should display "dashboard" element', () => {
        const element = fixture.debugElement.query(By.css('#dashboard'));
        expect(element).toBeTruthy();
      });

      it('should not display "admin" element', () => {
        const element = fixture.debugElement.query(By.css('#admin'));
        expect(element).toBeFalsy();
      });

      it('should display "no-access-admin" element', () => {
        const element = fixture.debugElement.query(By.css('#no-access-admin'));
        expect(element).toBeTruthy();
      });

      it('should display "no-access-dashboard" element', () => {
        const element = fixture.debugElement.query(By.css('#admin'));
        expect(element).toBeFalsy();
      });


      it('should display "users" element', () => {
        const element = fixture.debugElement.query(By.css('#users'));
        expect(element).toBeTruthy();
      });

      it('should display "no-access-users" element', () => {
        const element = fixture.debugElement.query(By.css('#no-access-users'));
        expect(element).toBeFalsy();
      });

    });
  });
});


@Component({
  selector: 'app-test',
  template: `
    <div id="orders" *hasPermission="permissions.orders"></div>

    <div id="dashboard" *hasPermission="permissions.dashboard"></div>

    <div id="admin" *hasPermission="permissions.admin; else noAccessAdmin"></div>

    <ng-template #noAccessAdmin>
      <div id="no-access-admin"></div>
    </ng-template>

    <div id="users" *hasPermission="permissions.users; else noAccessUsers"></div>

    <ng-template #noAccessUsers>
      <div id="no-access-users"></div>
    </ng-template>
  `,
})
export class TestComponent {
  readonly permissions = Permission;
}

Run jest

npx jest

and you should see all tests passed

PASS  src/app/permissions/has-permission.spec.ts
  HasPermissionDirective
    when the test component is initialized
      and user has set "dashboard" and "users" permissions
        ✓ should not display "orders" element (52 ms)
        ✓ should display "dashboard" element (8 ms)
        ✓ should not display "admin" element (6 ms)
        ✓ should display "no-access-admin" element (6 ms)
        ✓ should display "no-access-dashboard" element (5 ms)
        ✓ should display "users" element (5 ms)
        ✓ should display "no-access-users" element (4 ms)

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
Snapshots:   0 total
Time:        0.836 s, estimated 2 s

Source code

gitlab.com/barcioch-blog-examples/008-angul..