Angular - custom *ngIf directive
Table of contents
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 viewTemplateRef<unknown>
- a reference to the element's view that the directive is connected toPermissionService
- 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
tothis.hasCurrentPermission
condition checkadded
this.elseTemplateRef
condition check. If the user has no permission and theelse
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