23
loading...
This website collects cookies to deliver better user experience
*ngIf
. Using *ngIf
works when the amount of conditional template changes are small, but what if the view has a lot changes, or if the number of conditions to evaluate increases? Managing the correct view only by using *ngIf
becomes difficult. These types of scenarios are where dynamic components are helpful. Angular has the mechanics to load components at runtime so you can dynamically display content. npx @angular/cli@13 new dynamic-components --routing --style=scss --inline-template --inline-style --skip-tests
dynamic-components
with a working application skeleton. All CLI commands in the rest of this post should be run inside the project directory.ng add @angular/material@13 --theme=custom --typography=true --animations=true
ng run
or npm start
in a second terminal so you can view the changes as you progress through this post. Home
component that contains the application's default view, a Menu
component to handle logging in, and a Profile
component to display your name after authentication by running the following code.ng generate component home
ng generate component menu
ng generate component profile
src/app/app-routing.module.ts
file to add HomeComponent
as a default route, as shown below.const routes: Routes = [
{ path: '', component: HomeComponent }
];
src/app/app.module.ts
. We need to add some modules for the Material components we'll use.NgModule
imports
array, add the following Angular Material component modules: MatToolbarModule
from @angular/material/toolbar
MatIconModule
from @angular/material/icon
MatButtonModule
from @angular/material/button
MatMenuModule
from @angular/material/menu
src/app/app.component.ts
and replace the entire component with the following code.@Component({
selector: 'app-root',
template: `
<mat-toolbar color="primary" class="toolbar">
<h1>My favorite work app</h1>
<div>
<app-profile></app-profile>
<app-menu></app-menu>
</div>
</mat-toolbar>
<router-outlet></router-outlet>
`,
styles: [`
.toolbar { display: flex; justify-content: space-between; }
`]
})
export class AppComponent { }
Profile
and Menu
components. We'll update the template of those components in a bit. Below the toolbar, the <router-outlet></router-outlet>
shows the view for the current route. You should see the output of the Home
component when you serve the app.src/app/home/home.component.ts
, which is your welcome landing page. Feel free to change the template and styles to whatever suits you. @Component({
selector: 'app-home',
template: `
<div class="welcome">
<h2 class="mat-display-1">Welcome! Log in to get started.</h2>
<img src="assets/welcome.svg" alt="welcome illustration" />
</div> `,
styles: [`
.welcome {
display: flex;
flex-direction: column;
align-items: center;
h2 { margin: 3rem; }
img { width: 40%; }
}
`]
})
export class HomeComponent { }
Protected
, to hold the view guarded by authentication. We can pass in the parameters for routing, creating the default component, and lazy-loading, by running the following command.ng generate module protected --routing --route=protected --module=app
DynamicDirective
, and a component to house the dynamic component view and orchestrate the loading called DepartmentComponent
.ng generate component protected/department
ng generate directive protected/department/dynamic
DynamicComponent
. We're being a little tricky for the CLI here, so we need to rename the interface after we generate it manually. First, create the interface by running the following command.ng generate interface protected/department/dynamic --type=component
src/app/protected/department/dynamic.component.ts
file and rename the interface from Dynamic
to DynamicComponent
to help us better keep track of what the interface provides.Clawesome
, Pawesome
, and Smiley
.ng generate component protected/department/clawesome --flat
ng generate component protected/department/pawesome --flat
ng generate component protected/department/smiley --flat
Protected
module set up. The default view in this module shows ProtectedComponent
, which displays a task list and the DepartmentComponent
dynamic component loader. First, we'll import the Material component modules, then update the Protected
component template and styles, and populate the task list.src/app/protected/protected.module.ts
and add the following Material component modules to the imports array:MatCardModule
from @angular/material/card
MatListModule
from @angular/material/list
src/app/protected/protected.component.ts
. First, we'll set up the tasks. Create a public array for task items in the component and set the values to whatever you want. Here's my task list.public tasks: string[] = [
'Respond to that one email',
'Look into the thing',
'Reply to their inquiry',
'Set up the automation'
];
ProtectedComponent
's template, we'll use Material's List component. Update the inline template and styles code to look like the following.@Component({
selector: 'app-protected',
template: `
<div class="dashboard">
<main>
<h2>My tasks</h2>
<mat-selection-list #todo class="task-list">
<mat-list-option *ngFor="let task of tasks">
{{task}}
</mat-list-option>
</mat-selection-list>
</main>
<app-department></app-department>
</div>
`,
styles: [`
.dashboard {
margin-top: 2rem; display: flex;
main {
width: 75%;
h2 { text-align: center; }
.task-list { width: 80%; margin: auto; }
mat-selection-list { max-width: 800px; }
}
}
`]
})
Protected
module as part of the URL.localhost:4200/protected
Department
component is the container for the dynamic components, and controls which component to show. The Department
component HTML template contains an ng-template
element with a helper directive to identify where to add the dynamic component to the view.ViewContainerRef
API to make working with dynamic components more straightforward. We could use Angular Component Development Kit (CDK) Portals instead since it has extra helper functionality, but let's take the updated API out for a spin. 😁DynamicComponent
interface. Open each dynamic component file, Clawesome
, Pawesome
, and Smiley
, and implement the DynamicComponent
interface to the class. The interface is empty now, but we'll add members later. Feel free to remove the OnInit
lifecycle hook too. The Clawesome
component class looks like the following example, and the Pawesome
and Smiley
component classes should look similar.export class ClawesomeComponent implements DynamicComponent {
// ...remaining scaffolded code here
}
src/app/protected/department/dynamic.directive.ts
to inject the ViewContainerRef
. Your code will look like the following.@Directive({
selector: '[appDynamic]'
})
export class DynamicDirective {
constructor(public viewContainerRef: ViewContainerRef) { }
}
src/app/protected/department/department.component.ts
. First, we'll update the template and styles. Update the inline template to include the template reference with the Dynamic
directive. I added text, so my template and styles look like the following.@Component({
selector: 'app-department',
template: `
<h3 class="mat-headline">Relax, you got this</h3>
<ng-template appDynamic></ng-template>
`,
styles: [`
h3 { text-align: center; }
`]
})
ViewChild
decorator on the Dynamic
directive to access where to insert the component. When creating the component, we pass in the component Type
. Copy the following class code and replace your DepartmentComponent
class code.export class DepartmentComponent implements OnInit, OnDestroy {
@ViewChild(DynamicDirective, {static: true}) private dynamicHost!: DynamicDirective;
private interval: number|undefined;
private currentIndex = 1;
private messages: { type: Type<DynamicComponent> }[] = [
{ type: ClawesomeComponent },
{ type: PawesomeComponent },
{ type: SmileyComponent }
];
public ngOnInit(): void {
this.loadComponent();
this.rotateMessages();
}
public ngOnDestroy(): void {
clearInterval(this.interval);
}
private loadComponent(): void {
if (this.messages.length === 0) return;
this.currentIndex = (this.currentIndex + 1) % this.messages.length;
const message = this.messages[this.currentIndex];
const viewContainerRef = this.dynamicHost.viewContainerRef;
viewContainerRef.clear();
const componentRef = viewContainerRef.createComponent<DynamicComponent>(message.type);
}
private rotateMessages(): void {
this.interval = window.setInterval(() => {
this.loadComponent();
}, 10000);
}
}
loadComponent
method in a little more detail. First, we make sure we're rotating through the messages sequentially by keeping track of where in the array we are, then clearing out the previous component. To dynamically load the component, we use the directive as an anchor and create the component into its position in the DOM. The createComponent
method requires the component type, not the instance. We use the base interface as a generic type for all the components, and use concrete component type in the method parameter.okta register
to sign up for a new account. If you already have an account, run okta login
. Then, run okta apps create
. Select the default app name, or change it as you see fit. Choose Single-Page App and press Enter.http://localhost:4200
. You will see output like the following when it’s finished:Okta application configuration:
Issuer: https://dev-133337.okta.com/oauth2/default
Client ID: 0oab8eb55Kb9jdMIr5d6
NOTE: You can also use the Okta Admin Console to create your app. See Create an Angular App for more information.
Issuer
and the Client ID
. You will need them in the following steps.npm install @okta/okta-angular@4 @okta/[email protected] --save
srcs/app/app.module.ts
and create an OktaAuth
instance by adding the following before the NgModule
and replacing the placeholders with the Issuer
and Client ID
from earlier.import { OKTA_CONFIG, OktaAuthModule } from '@okta/okta-angular';
import { OktaAuth } from '@okta/okta-auth-js';
const oktaAuth = new OktaAuth({
issuer: 'https://{yourOktaDomain}/oauth2/default',
clientId: '{yourClientID}',
redirectUri: window.location.origin + '/login/callback'
});
OktaAuthModule
to the imports
array and configure the provider for the OKTA_CONFIG
token, as shown below.@NgModule({
...
imports: [
...,
OktaAuthModule
],
providers: [
{ provide: OKTA_CONFIG, useValue: { oktaAuth } }
],
...
})
src/app/app-routing.module.ts
and add the following to your routes array.{ path: 'login/callback', component: OktaCallbackComponent }
Protected
component route to authenticated users. Okta has a guard we can use. Open src/app/protected/protected-routing.module.ts
to add a canActivate
guard to the default route. Your routes array will look like the code snippet below.const routes: Routes = [{ path: '', component: ProtectedComponent, canActivate: [OktaAuthGuard] }];
src/app/menu/menu.component.ts
to add a menu with login and logout buttons. We'll use some Okta-provided code to log in, log out, and identify the authenticated state. Update the component code to match the code below.@Component({
selector: 'app-menu',
template: `
<button mat-icon-button aria-label="Button to open menu" [matMenuTriggerFor]="menu">
<mat-icon>menu</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item *ngIf="!isAuthenticated" (click)="login()">
<mat-icon>login</mat-icon> <span>Login</span>
</button>
<button mat-menu-item *ngIf="isAuthenticated" (click)="logout()">
<mat-icon>logout</mat-icon> <span>Logout</span>
</button>
</mat-menu>
`
})
export class MenuComponent implements OnInit, OnDestroy {
public isAuthenticated = false;
private _destroySub$ = new Subject<void>();
constructor(private _oktaAuth: OktaAuth, private _authStateService: OktaAuthStateService, private _router: Router) { }
public ngOnInit(): void {
this._authStateService.authState$.pipe(
filter((s: AuthState) => !!s),
map((s: AuthState) => s.isAuthenticated ?? false),
distinctUntilChanged(),
takeUntil(this._destroySub$)
).subscribe(
(authenticated: boolean) => this.isAuthenticated = authenticated
);
}
public ngOnDestroy(): void {
this._destroySub.next();
}
public async login(): Promise<void> {
await this._oktaAuth.signInWithRedirect().then(
_ => this._router.navigate(['/protected'])
);
}
public async logout(): Promise<void> {
await this._oktaAuth.signOut();
}
}
src/app/profile/profile.component.ts
. Okta's auth state has user info. Notice it's also available through a claim. Replace the Profile
component code with the following.@Component({
selector: 'app-profile',
template: `
<ng-container *ngIf="name$ | async as name ">
<span class="mat-body-1">{{name}}</span>
</ng-container>
`})
export class ProfileComponent {
public name$: Observable<string> = this._authStateService.authState$.pipe(
filter((s: AuthState) => !!s && !!s.isAuthenticated),
map((s: AuthState) => s.idToken?.claims.name ?? '')
);
constructor(private _authStateService: OktaAuthStateService) { }
}
ng generate interface message
src/app/message.ts
and replace the contents with the following code.export type MessageType = 'Pawesome' | 'Clawesome' | 'Smiley';
export interface MessageData {
url: string;
content?: any;
}
export class MessageItem {
constructor(public type: MessageType, public data: MessageData) { }
}
DynamicComponent
. Since all the dynamic components have some data, we need to update the DynamicComponent
interface to reflect this shared property that all the components will implement.src/app/protected/department/dynamic.component.ts
and add a property named data
of type MessageData
to it. The interface now looks like the following.export interface DynamicComponent {
data: MessageData;
}
src/app/protected/department/clawesome.component.ts
. This component's data has an URL to an image and string content. Update the component to the following.@Component({
selector: 'app-clawesome',
template: `
<mat-card class="card">
<img mat-card-image src="{{data.url}}" alt="Photo of a clawesome creature" >
<mat-card-content>
<p>{{data.content}}</p>
</mat-card-content>
</mat-card>
`,
styles: [` .card { max-width: 300px; } `]
})
export class ClawesomeComponent implements DynamicComponent {
@Input() data!: MessageData;
}
src/app/protected/department/pawesome.component.ts
. In addition to the URL, content contains the properties name
and about
. Update the component to the following.@Component({
selector: 'app-pawesome',
template: `
<mat-card class="card">
<mat-card-header>
<mat-card-title>{{data.content.name}}</mat-card-title>
<mat-card-subtitle>Good doggo</mat-card-subtitle>
</mat-card-header>
<img mat-card-image src="{{data.url}}" alt="Photo of a pawesome creature" >
<mat-card-content>
<p> {{data.content.about}} </p>
</mat-card-content>
</mat-card>
`,
styles: [` .card { max-width: 300px; } `]
})
export class PawesomeComponent implements DynamicComponent {
@Input() data!: MessageData;
}
src/app/protected/department/smiley.component.ts
. The only data in this message type is the URL. Update the component to the following.@Component({
selector: 'app-smiley',
template: `
<mat-card class="card">
<img mat-card-image src="{{data.url}}" alt="Photo of a smiley creature" >
<mat-card-content>
<p>SMILE!</p>
</mat-card-content>
</mat-card> `,
styles: [` .card { max-width: 300px; } `]
})
export class SmileyComponent implements DynamicComponent {
@Input() public data!: MessageData;
}
1
. We'll label the department names as either 1
or 2
for ease of coding.2
, but you can also edit the department value between logging in.user.profile.department
. Your inputs should look like the image below.ng generate service message
MessageService
returns messages the company wants to display to its users. All messages have a URL, and some have additional content to display. Open src/app/message.service.ts
and add the following code to fake out message responses as a private class property.private messages: MessageItem[] = [
{
type: 'Clawesome',
data: {
url: 'https://images.pexels.com/photos/2558605/pexels-photo-2558605.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
content: 'Cat ipsum dolor sit amet, meow for can opener to feed me',
}
},
{
type: 'Clawesome',
data: {
url: 'https://images.pexels.com/photos/1560424/pexels-photo-1560424.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
content: 'Cat ipsum dolor sit amet, find dog bed and sleep all day',
}
},
{
type: 'Clawesome',
data: {
url: 'https://images.pexels.com/photos/3687957/pexels-photo-3687957.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
content: 'Cat ipsum dolor sit amet, too cute for human to get mad'
}
},
{
type: 'Pawesome',
data: {
url: 'https://images.pexels.com/photos/97082/weimaraner-puppy-dog-snout-97082.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
content: {
name: 'Sammy',
about: 'Snuggly cloud borker'
}
}
},
{
type: 'Pawesome',
data: {
url: 'https://images.pexels.com/photos/825949/pexels-photo-825949.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
content: {
name: 'Pittunia',
about: 'Maximum adorable shooberino'
}
}
},
{
type: 'Pawesome',
data: {
url: 'https://images.pexels.com/photos/406014/pexels-photo-406014.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
content: {
name: 'Bay',
about: 'Long snoot for pats'
}
}
},
{
type: 'Smiley',
data: {
url: 'https://images.pexels.com/photos/2168831/pexels-photo-2168831.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940'
}
},
{
type: 'Smiley',
data: {
url: 'https://cdn.pixabay.com/photo/2017/06/17/13/11/axolotl-2412189_960_720.jpg'
}
}
];
departmentMapping
property and update the constructor for the service as shown below.private departmentMapping: Map<number, MessageType[]> = new Map<number, MessageType[]>();
constructor() {
this.departmentMapping.set(1, ['Smiley']);
this.departmentMapping.set(2, ['Pawesome', 'Clawesome']);
}
public getMessages(department: number): MessageItem[] {
const messageTypes = this.departmentMapping.get(department) ?? [];
return this.messages.filter(m => messageTypes.includes(m.type));
}
getMessages
method. We'll access the claim through the ID token from Okta's auth state subject. Even though we're in a guarded route, we'll still add the safety measures to verify the user authentication, and to return a default value if the claim isn't on the ID token for some reason. Open src/app/protected/protected.component.ts
and update to the following code.export class ProtectedComponent implements OnInit {
// ... task list property here don't delete
public messages: MessageItem[] = [];
constructor(private _authStateService: OktaAuthStateService, private _messageService: MessageService) { }
public ngOnInit(): void {
this._authStateService.authState$.pipe(
filter((s: AuthState) => !!s && !!s.isAuthenticated),
map((s: AuthState) => +s.idToken?.claims['department'] ?? 0),
take(1)
).subscribe(
(d: number) => this.messages = this._messageService.getMessages(d)
);
}
}
ProtectedComponent
, update the <app-department>
element to pass in messages
as an input property. You'll see an error in the IDE since we haven't created the input property in the Department component yet. The element in the inline template will look like the code below.<app-department [messages]="messages"></app-department>
src/app/protected/department/department.component.ts
. Replace the hardcoded private messages
property into a public input property like the code snippet below.@Input() public messages: MessageItem[] = [];
loadComponent
method expects a component type. We'll add a factory method to return the component type to create by matching the MessageType
to the component type like the following example.private componentTypeFactory(type: MessageType): Type<DynamicComponent> {
let comp: Type<DynamicComponent>;
if (type === 'Pawesome') {
comp = PawesomeComponent;
} else if (type === 'Clawesome') {
comp = ClawesomeComponent;
} else {
comp = SmileyComponent;
}
return comp;
}
loadComponent
method to use the factory method. We also have message data to pass into the components, although the dynamic components can't support the input property. Update the code and add the new line of code to pass data to the components like the code block below.const componentRef = viewContainerRef.createComponent<DynamicComponent>(this.componentTypeFactory(message.type));
componentRef.instance.data = message.data;