DEV Community

Cover image for Superpowers with Directives and Dependency Injection: Part 6
Armen Vardanyan for This is Angular

Posted on • Edited on

Superpowers with Directives and Dependency Injection: Part 6

Original cover photo by Ivan Bandura on Unsplash.

It's time for the 6th installment of this article series about Angular directives - and their almost magical superpowers.
Today we are going to explore a less popular, but very, very common and valid use case scenario - bringing business logic into the directives.

Introduction

First of all, let's reflect on what we have already done, and understand how today's cases are fundamentally different.

In previous articles, we created directives that did some specific, reusable, and very useful things.

For example, we built a directive that

  • checked for password strength
  • emitted custom events when the host element scrolled into view
  • transported a template view into another component
  • displayed a loader
  • added some default inputs onto existing components

As you can see, there is a common theme for all these cases - all those directives are super-reusable in the sense that they can be dropped in any project and just work; non of them really contain any business logic. But in a lot of cases, with real-life projects, it is the business logic that we want to be reusable and easily shareable. So what scenarios are there?

Using directives to handle permissions

Imagine that our app has permissions to enable or disable certain features. For example, we might have a feature that allows users to edit their profile, but only if they have the right permissions. We have a few PermissionService that has a hasPermission method that takes a permission name and returns a boolean. We could use this service in our component like this:

@Component({
  selector: 'app-profile',
  template: `
    <div *ngIf="hasPermission('edit-profile')">
      <button (click)="editProfile()">Edit profile</button>
    </div>
  `
})
export class ProfileComponent {
  constructor(private permissionService: PermissionService) {}

  hasPermission(permissionName: string) {
    return this.permissionService.hasPermission(permissionName);
  }

  editProfile() {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

But now every time we want to check for this or that permission, we need to inject the service, maybe write a wrapper method, call it from the template, or some other mildly cumbersome way. Also, if the way how we access those permissions changes (for example, the service now has a different API interface), we might face a pretty large refactoring. Also, if were have some sort of state management solution that works with Observables (like NgRx, for instance), our code will become even more complex.

So how do we deal with this?

Well, wouldn't it be nice if we could just do this:

@Component({
  selector: 'app-profile',
  template: `
    <div *hasPermission="'edit-profile'">
      <button (click)="editProfile()">Edit profile</button>
    </div>
  `
})
export class ProfileComponent {
  editProfile() {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

So now we can pass the permission name as an input to the directive, and the directive will handle the rest. Let's see how we can do this.

  • First, we need to create a directive that injects the PermissionService and has an input property that will accept the permission name
  • next, we will add ngIf as a HostDirective so it can handle showing/hiding the template
  • and finally, we will inject the reference to the NgIf directive, and pass the resulting boolean to it

Let's do this:

@Directive({
  selector: '[hasPermission]',
  standalone: true,
  hostDirectives: [NgIf],
})
export class HasPermissionDirective {
  private readonly permissionService = inject(PermissionService);
  private readonly ngIfRef = inject(NgIf);

  @Input() 
  set hasPermission(permissionName: string) {
    // we can use any other approach here
    this.ngIfRef.ngIf = this.permissionService.hasPermission(
      permissionName,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, our directive works almost perfectly. What do we need to add? Of course, a possibility to handle the case when the user does not have the permission. We can do this by adding an else template to our directive:

@Directive({
  selector: '[hasPermission]',
  standalone: true,
  hostDirectives: [NgIf],
})
export class HasPermissionDirective {
  private readonly permissionService = inject(PermissionService);
  private readonly ngIfRef = inject(NgIf);

  @Input() 
  set hasPermission(permissionName: string) {
    this.ngIfRef.ngIf = this.permissionService.hasPermission(
      permissionName,
    );
  }

  // the improtant part
  @Input() 
  set hasPermissionElse(template: TemplateRef<any>) {
    this.ngIfRef.ngIfElse = template;
  }
} 
Enter fullscreen mode Exit fullscreen mode

Now, we essentially just do the business logic and pass the result to the NgIf directive. Here is a working example with a preview:

Using directives to handle shared data in components

Imagine we use some UI library that allows us to show dropdowns:

@Component({
  selector: 'app-profile',
  template: `
    <div>
      <third-party-dropdown
        [items]="dropdownItems"></third-party-dropdown>
    </div>
  `
})
export class ProfileComponent {
  dropdownItems = [
    {
        label: 'Item 1',
        value: 1
    },
    {
        label: 'Item 2',
        value: 2
    }
  ];

}
Enter fullscreen mode Exit fullscreen mode

And in some cases, we need to pass the same list of options to multiple dropdowns. For example, the same list of permissions we encountered earlier in this article could be really useful in lots of places:

@Component({
  selector: 'app-profile',
  template: `
    <div>
      <third-party-dropdown
        [items]="permissions"></third-party-dropdown>
    </div>
    <div>
  `
})
export class SomeComponent {
    permissionService = inject(PermissionService);
    permissions = this.permissionService.getPermissions();
}
Enter fullscreen mode Exit fullscreen mode

And then we could really see this same code in multiple places throughout our app. So how can we make this more reusable? Well, we could create a component that will wrap the dropdown and accept the list of items as an input:

<app-wrapper-around-third-party-dropdown
  [items]="permissions"></app-wrapper-around-third-party-dropdown>
Enter fullscreen mode Exit fullscreen mode

But that would introduce a set of problems:

  • CSS encapsulation
  • Having to pass eventually all of the third-party dropdown's inputs and outputs up and down
  • More complexity in our templates

So what can we do instead? Well, we can create a directive that will inject the list of items into the third-party dropdown:

@Directive({
  selector: 'third-party-dropdown[permissionList]',
  standalone: true,
})
export class PermissionsDropdownDirective implements OnInit {
  thirdPartyDropdown = inject(ThirdPartyDropdown);
  permissionService = inject(PermissionService);

  ngOnInit() {
    this.thirdPartyDropdown.items = 
      this.permissionService.getPermissions();
  }
}
Enter fullscreen mode Exit fullscreen mode

And now we can use it like this:

<third-party-dropdown permissionList></third-party-dropdown>
Enter fullscreen mode Exit fullscreen mode

And that's it! Now our template is simple, the third-party-dropdown is still as it used to be, and we got additional reusable functionality for it.

Here is the example with a preview:

Conclusion

We have explored a huge amount of possibilities that directives give us. While there are plenty more, this much is fairly enough for projects of different sizes, so this article series is nearing its end. In the next and last one, we will talk in detail about directive selectors, what we can use, how can we combine different selectors, and what are some pitfalls or where we cannot use a certain selector. Stay tuned!

Top comments (0)