Welcome to Angular challenges #6.
The aim of this series of Angular challenges is to increase your skills by practicing on real life exemples. Moreover you can submit your work though a PR which I or other can review; as you will do on real work project or if you want to contribute to Open Source Software.
The sixth challenge is about managing permissions within an application. Many applications have to handle permissions for different types of users, such as admins, managers… In this challenge, we will need to display or hide certain information and restrict certain route navigation based on a user's permissions. This challenge can be divided into two parts:
- The first part will introduce us to various ways to write a structural directive that modify our HTL to show or hide DOM elements.
- The second part will focus on protecting route navigation: guard. (which will be cover in a following article)
If you haven't done the challenge yet, I invite you to try it first by going to Angular Challenges and coming back afterward to compare your solution with mine. (You can also submit a PR that I'll review)
We begin the challenge with the following application. We have a list of buttons to log in with different users who have different permissions. We also have a list of sentences that we need to display or hide based on the user we logged in with.
User
interface is defined as follow:
export type Role = 'MANAGER' | 'WRITER' | 'READER' | 'CLIENT';
export interface User {
name: string;
isAdmin: boolean;
roles: Role[];
}
The naive way to solve this challenge would be to use ngIf
on each row:
<div *ngIf="user.isAdmin">visible only for super admin</div>
<div *ngIf="!user.isAdmin && user.roles.includes('MANAGER')">
visible if manager
</div>
<div *ngIf="/*Get's complicated*/">visible if manager and/or reader</div>
//...
As we can see, we have to create a complicated condition and this solution is not reusable. It can only get messier and less maintainable as the project grows.
Structural Directives
Angular offers a solution called structural directives to modify the structure of the DOM based on specific conditions. Structural directives allow us to add or remove elements from the DOM. In the previous solution, we used the built-in structural directive ngIf
, which allows us to add or remove elements based on a condition.
Other commonly used built-in structural directives are
NgFor
,NgSwitch
,NgIf
To make our solution more reusable and maintainable, we can create a custom structural directive called hasRole
. This will allow us to simplify our template like this:
<div *hasRoleIsAdmin="true">visible only for super admin</div>
<div *hasRole="'MANAGER'">visible if manager</div>
<div *hasRole="['MANAGER', 'READER']">visible if manager and/or reader</div>
<div *hasRole="['MANAGER', 'WRITER']">visible if manager and/or writer</div>
<div *hasRole="'CLIENT'">visible if client</div>
<div>visible for everyone</div>
👉🏼 Cleaner, Reusable, Maintainable, More readable. 👈🏼
In the following sections, we will see several implementations of the same directive.
Solution #1:
@Directive({
selector: '[hasRole], [hasRoleIsAdmin]',
standalone: true,
providers: [provideDestroyService()],
})
export class HasRoleDirective implements OnInit {
private destroy$ = injectDestroyService();
private templateRef = inject(TemplateRef<unknown>);
private viewContainer = inject(ViewContainerRef);
private store = inject(UserStore);
@Input('hasRole') role: Role | Role[] | undefined = undefined;
@Input('hasRoleIsAdmin') isAdmin = false;
ngOnInit(): void {
if (this.isAdmin) {
this.store.isAdmin$
.pipe(takeUntil(this.destroy$))
.subscribe((isAdmin) =>
isAdmin ? this.addTemplate() : this.clearTemplate()
);
} else if (this.role) {
this.store
.hasAnyRole(this.role)
.pipe(takeUntil(this.destroy$))
.subscribe((hasPermission) =>
hasPermission ? this.addTemplate() : this.clearTemplate()
);
} else {
this.addTemplate();
}
}
private addTemplate() {
this.viewContainer.clear();
this.viewContainer.createEmbeddedView(this.templateRef);
}
private clearTemplate() {
this.viewContainer.clear();
}
}
-
TemplateRef
andViewContainerRef
need to be injected in order to access our DOM elements and manipulate them.TemplateRef
allows us to access the embedded template:<ng-template>
whileViewContainerRef
accesses the view in which our directive has been called.
When we write
<div *ngIf="...">TOTO</div>
, the compiler translates it to<ng-template [ngIf]="..."><div>TOTO</div><ng-template>
- We define two inputs
hasRole
andhasRoleIsAdmin
for all our use cases.
To concatenate inputs inside our directive, we need to prefix our input with our directive selector, this way we will be able to write :
<div*hasRole="'...';isAdmin:true"></div>
.
- All of the logic is created inside our
ngOnInit
hook and we display or hide the embedded template depending on the result of our condition. - To avoid memory leaks, we inject a
DestroyService
and use thetakeUntil
operator to unsubscribe to all of our subscriptions when component is destroyed. To connect our service with the component, we include it in the component provider array withprovideDestroyService()
Issue: In our ngOnInit
hook, we have repeated the same logic twice. We can do improve this by avoiding the repetition.
Solution #2: BehaviorSubject
@Directive({
selector: '[hasRole], [hasRoleIsAdmin]',
standalone: true,
providers: [provideDestroyService()],
})
export class HasRoleDirective implements OnInit {
private destroy$ = injectDestroyService();
private templateRef = inject(TemplateRef<unknown>);
private viewContainer = inject(ViewContainerRef);
private store = inject(UserStore);
private show = new BehaviorSubject<Observable<boolean | undefined>>(
of(undefined)
);
@Input('hasRole') set role(role: Role | Role[] | undefined) {
if (role) {
this.show.next(this.store.hasAnyRole(role));
}
}
@Input('hasRoleIsAdmin') set isAdmin(isAdmin: boolean) {
if (isAdmin) {
this.show.next(this.store.isAdmin$);
}
}
ngOnInit(): void {
this.show
.pipe(
mergeMap((s) => s),
takeUntil(this.destroy$)
)
.subscribe((showTemplate) =>
showTemplate ? this.addTemplate() : this.clearTemplate()
);
}
private addTemplate() {
this.viewContainer.clear();
this.viewContainer.createEmbeddedView(this.templateRef);
}
private clearTemplate() {
this.viewContainer.clear();
}
}
- The logic remains the same, but to combine both subscriptions into one, we use a
BehaviourSubject
to merge our input into a single stream. Now, we only need to subscribe to one observable and we avoid repeat ing ourselves. - To feed our
BehaviourSubject
we use setters on our inputs to work on the incoming values.
Setters are more readable than using
ngOnChange
. However you must usengOnChange
if your changes involve two or more inputs.
Issue: We still need to manually subscribe to our stream. Manual subscription can be error-prone.
Solution #3: Ngrx/Component-store
@Directive({
selector: '[hasRole], [hasRoleIsAdmin]',
standalone: true,
providers: [ComponentStore],
})
export class HasRoleDirective {
private templateRef = inject(TemplateRef<unknown>);
private viewContainer = inject(ViewContainerRef);
private componentStore = inject(ComponentStore);
private store = inject(UserStore);
@Input('hasRole') set role(role: Role | Role[] | undefined) {
if (role) {
this.showTemplate(this.store.hasAnyRole(role));
}
}
@Input('hasRoleIsAdmin') set isAdmin(isAdmin: boolean) {
if (isAdmin) {
this.showTemplate(this.store.isAdmin$);
}
}
private readonly showTemplate = this.componentStore.effect<
boolean | undefined
>(
pipe(
tap((showTemplate) =>
showTemplate ? this.addTemplate() : this.clearTemplate()
)
)
);
private addTemplate() {
this.viewContainer.clear();
this.viewContainer.createEmbeddedView(this.templateRef);
}
private clearTemplate() {
this.viewContainer.clear();
}
}
Using the Ngrx/component-store effect allows us to further reduce the complexity. The nice design of the CS effect is that it can take either a strict value or an Observable as input and handle it in the same way.
But we can further simplify our directive. Since Angular v15, we can take advantage of the hostDirective and reuse code from other directives.
Solution #4: hostDirectives
@Directive({
selector: '[hasRole], [hasRoleIsAdmin]',
standalone: true,
hostDirectives: [NgIf], // 👈🏼 the beauty of Angular v15 is located here
providers: [ComponentStore],
})
export class HasRoleDirective {
private store = inject(UserStore);
private componentStore = inject(ComponentStore);
private ngIf = inject(NgIf, { host: true });
@Input('hasRole') set role(role: Role | Role[] | undefined) {
if (role) {
this.showTemplate(this.store.hasAnyRole(role));
}
}
@Input('hasRoleIsAdmin') set isAdmin(isAdmin: boolean) {
if (isAdmin) {
this.showTemplate(this.store.isAdmin$);
}
}
private readonly showTemplate = this.componentStore.effect<boolean | undefined>(
pipe(
tap((showTemplate) => this.ngIf.ngIf = showTemplate) // 🥰
));
}
- We can now add directives to our host element. In this example, we tied the implementation of
ngIf
with our custom directive by declaringngIf
insidehostDirectives
. We injectngIf
inside our directive to access the internal class properties ofngIf
.
Don't forget to add the
host
meta-property on the inject function to make sure you get thengIf
directive declared on your host element and not one located higher on your component tree.
- In our CS effect
showTemplate
, we set thengIf
class property of thengIf
directive and the built-inNgIf
directive will handle all of the DOM logic for us. This is convenient, handy, and DRY which is less error-prone.
Last but not least, don't forget about a really nice library called RxAngular. (If you don't know it, you should take a look)
Solution #5: RxAngular
@Directive({
selector: '[hasRole], [hasRoleIsAdmin]',
standalone: true,
hostDirectives: [NgIf],
providers: [RxEffects],
})
export class HasRoleDirective {
private store = inject(UserStore);
private rxEffect = inject(RxEffects);
private ngIf = inject(NgIf, { host: true });
private show = new Subject<Observable<boolean | undefined>>();
private show$ = this.show.asObservable().pipe(mergeMap((b) => b));
@Input('hasRole') set role(role: Role | Role[] | undefined) {
if (role) {
this.show.next(this.store.hasAnyRole(role));
}
}
@Input('hasRoleIsAdmin') set isAdmin(isAdmin: boolean) {
if (isAdmin) {
this.show.next(this.store.isAdmin$);
}
}
constructor() {
this.rxEffect.register(this.show$, this.showTemplate);
}
private showTemplate = (showTemplate: boolean | undefined) =>
(this.ngIf.ngIf = showTemplate);
}
You can find the final code in the form of a Pull Request here. (If you want to get it up and running, you can clone the project, check out the solution branch git checkout solution-permissions and run nx serve permissions)
I hope you enjoyed this sixth challenge and learned from it.
If you want to read the second part about protecting your routes with guards, follow this link.
If you found this article useful, please consider supporting my work by giving it some likes ❤️❤️ to help it reach a wider audience. Don't forget to share it with your teammates who might also find it useful. Your support would be greatly appreciated.
👉 Other challenges are waiting for you at Angular challenges. Come and try them. I'll be happy to review you!
Follow me on Twitter or Github to read more about upcoming Challenges! Don't hesitate to ping me if you have more questions.
Top comments (5)
First of all, really good article. One thing I want to check is the use of host = true when injecting the NgIf from the host element. As I understand, host will look up for the token till it reaches the host component that declares the template where the directive is used. For such a use case, wouldn't self = true be more appropriate ?
Self and host will work exactly the same since the directive is applied with the hostDirective attribute. We know for sure that a ngIf is on the host tag. But you can use the self flag to ne more precise, indeed. 👍
Thank you!
I have question about manage permissions. What we would have doing, when we have context permission.
Example: Permissions for current page or projects, when we have different permissions each page or project.
P.S. Sorry for my English
I'm really sorry but I didn't understand the issue. Your permissions are different depending on the context of the page ?
You can still use a structural directive if it's to show or hide element on the page. But you will need to write your own. I can help you if you can create a reproductible example on stackblitz. And if it's to block user to enter some pages, you can use guard (I will write another article on that subject)
If It's something else, you need to explain it a bit more. You can DM me on twitter if you want.
since when an if statement become "complicated condition" ?
both *ngIf and the new @if works pretty well.
stick with "naive"