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: https://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
hasPermissionsuffix: 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
returntothis.hasCurrentPermissioncondition checkadded
this.elseTemplateRefcondition check. If the user has no permission and theelsetemplate 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
https://gitlab.com/barcioch-blog-examples/008-angular-custom-ngif




