Tech: Create a reactive Master-Detail template with Angular Router + Material

A step-by-step guide to build an awesome master-detail component for Angular apps.

Image for post
Image for post

1. Prerequisites

2. Install dependencies and setup new project

ng new master-detail-democd master-detail-demong add @angular/material
ng g c employee-container    // containerng g c employee-list         // summary viewng g c employee-detail       // detail view
// employee-list.tsexport const EmployeeList = [{id: 0, firstName: "John", lastName: "Smith", age: 34, title: "Business Analysis"},{id: 1, firstName: "Britney", lastName: "Spears", age: 35, title: "Singer"},{id: 2, firstName: "Chris", lastName: "Evans", age: 20, title: "Captain America"},{id: 3, firstName: "Frank", lastName: "Conners", age: 28, title: "Software Engineer"},{id: 4, firstName: "Jasmine", lastName: "Banks", age: 31, title: "QA"}];
// app.module.tsimport { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { MatSidenavModule } from '@angular/material/sidenav';
@NgModule({
...
imports: [
...
BrowserAnimationsModule,
MatSidenavModule
],
...
})
export class AppModule {}
// app.routes.tsexport const route = [{
path: "employees",
component: EmployeeContainerComponent,
children: [
{
path: ":id",
component: EmployeeDetailComponent
}]
},
{ path: "**", redirectTo: "employees" }
];
// app.module.ts
import { Route, RouterModule, Router } from "@angular/router";
import { route } from "./app.routes";
@NgModule({
...
imports: [
...
BrowserAnimationsModule,
MatSidenavModule,
RouterModule.forRoot(route)
],
...
})
export class AppModule {}
// app.component.ts@Component({
selector: "master-detail-demo",
template: `
<router-outlet></router-outlet>
`
})
export class AppComponent {}

3. Build “list” component

// employee-list.component.tsimport { EmployeeList } from "../employee-list";@Component({
selector: "app-employee-list",
template: `
<h3>List of employees</h3>
<div *ngFor="let e of employees">
<a href="#" (click)="onclick(e.id); (false)">
<h5>{{ e.firstName }} {{ e.lastName }}</h5>
</a>
</div>
`
})
export class EmployeeListComponent {
@Output()
rowClick = new EventEmitter();
employees = EmployeeList; onclick(id: number) {
this.rowClick.next(id);
}
}

4. Build “detail” component

// employee-detail.component.tsimport { ActivatedRoute } from "@angular/router";
import { map } from "rxjs/operators";
import { EmployeeList } from "../employee-list";
@Component({
selector: "app-employee-detail",
template: `
<table style="width:100%" *ngIf="(employee$ | async) as e">
<tr>
<td>Emloyee Id</td>
<td>{{ e.id }}</td>
</tr>
<tr>
<td>First Name</td>
<td>{{ e.firstName }}</td>
</tr>
<tr>
<td>Last Name</td>
<td>{{ e.lastName }}</td>
</tr>
<tr>
<td>Age</td>
<td>{{ e.age }}</td>
</tr>
<tr>
<td>Title</td>
<td>{{ e.title }}</td>
</tr>
</table>
`
})
export class EmployeeDetailComponent { constructor(private route: ActivatedRoute) {} get employee$() {
return this.route.params.pipe(
map(({ id }) => EmployeeList[+id])
);
}
}

5. Build container component

// employee-container.component.tsimport { Observable } from "rxjs";
import { ActivatedRoute, Router, NavigationEnd } from "@angular/router";
import { filter, switchMap, map, tap } from "rxjs/operators";

@Component({
selector: "app-employee-container",
template: `
<mat-drawer-container class="example-container">
<mat-drawer mode="side" position="end"
[opened]="showSideNav$ | async">
<a style="float:right"
href="#"
(click)="closeDetails(); (false)">
x Close
</a>
<router-outlet></router-outlet>
</mat-drawer>
<mat-drawer-content>
<app-employee-list(rowClick)="onRowClick($event)">
</app-employee-list>
</mat-drawer-content>
</mat-drawer-container>
`,
styles: ["mat-drawer { width: 50vw;}"]
})
export class EmployeeContainerComponent { showSideNav$: Observable<boolean>; constructor(private route: ActivatedRoute,
private router: Router) {
this.onShowSideNav();
}
private onRowClick(id: any) {
this.router.navigate(["employees", id]);
}
private closeDetails() {
this.router.navigate(["."], {relativeTo: this.route.parent});
}
private onShowSideNav() {
this.showSideNav$ = this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
switchMap(_ => {
return this.route.firstChild ?
this.route.firstChild.params :
of(false);
}),
map(params => !!params)
);
}
}
Image for post
Image for post

… everything works great, doesn’t it? But there’s a small problem

Image for post
Image for post
// employee-container.component.ts [edited]...export class EmployeeContainerComponent {  showSideNav$: Observable<boolean>;  constructor(private route: ActivatedRoute, 
private router: Router) {
this.onShowSideNav();
}
... private onShowSideNav() { // check if there's an id in URL
const initParams$ =
of(this.route.firstChild ?
this.route.firstChild.params : null
);
// still subscribe to router's event
const params$ = this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
switchMap(_ => {
return this.route.firstChild ?
this.route.firstChild.params : of(false);
}),
map(params => !!params)
);

// merge 2 Observables together
this.showSideNav$ = merge(initParams$, params$).pipe(
map(data => !!data)
);

}
}
Image for post
Image for post

7. Summary

Written by

Front End architect, opensource contributor and investment enthusiast. New content posted every week.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store