In this chapter, we complete the router-first architecture implementation on LemonMart by implementing the top two most used features in business applications: master/detail views and data tables. I demonstrate data tables with server-side pagination, highlighting the integration between the frontend and backend using LemonMart and LemonMart Server.
Make sure to have your lemon-mart-server up and running as you implement the recipes outlined in this chapter. Refer to Chapter 10, RESTful APIs and Full-Stack Implementation, for more information.
We leverage the concept of router orchestration to orchestrate how our components load data or render. We use resolve guards to reduce boilerplate code when loading data before navigating to a component. We use auxiliary routes to lay out components through the router configuration. We reuse the same component in multiple contexts.
We then dive into NgRx using the LocalCast Weather app and explore NgRx Data with LemonMart, so you can become familiar with more advanced application architecture concepts in Angular. By the end of this chapter, we will have touched upon most of the major functionality that Angular and Angular Material have to offer.
This chapter covers a lot of ground. It is organized in a recipe format, so you can quickly refer to a particular implementation when you are working on your projects. I cover the architecture, design, and major components of the implementation. I highlight important pieces of code to explain how the solution comes together. Leveraging what you've learned so far, I expect the reader to fill in routine implementation and configuration details. However, you can always refer to the GitHub repo if you get stuck.
In this chapter, you will learn about the following topics:
The most up-to-date versions of the sample code for the book are on GitHub at the repository linked in the following list. The repository contains the final and completed state of the code. You can verify your progress at the end of this chapter by looking for the end-of-chapter snapshot of code under the projects
folder.
To get set up for this chapter's examples based on lemon-mart, do the following:
npm install
on the root folder to install dependenciesprojects/ch12
npx ng serve ch12
npx ng test ch12 --watch=false
npx ng e2e ch12
npx ng build ch12 --prod
Note that the dist/ch12
folder at the root of the repository will contain the compiled result.
To prepare for this chapter's examples based on local-weather-app, implement these steps:
npm install
on the root folder to install dependenciesprojects/ch12
npx ng serve ch12
npx ng test ch12 --watch=false
npx ng e2e ch12
npx ng build ch12 --prod
Remember that the dist/ch12
folder at the root of the repository will contain the compiled result.
Beware that the source code in the book or on GitHub may not always match the code generated by Angular CLI. There may also be slight differences in implementation between the code in the book and what's on GitHub because the ecosystem is ever-evolving. It is natural for the sample code to change over time. Also on GitHub, expect to find corrections, fixes to support newer versions of libraries, or side-by-side implementations of multiple techniques for the reader to observe. The reader is only expected to implement the ideal solution recommended in the book. If you find errors or have questions, please create an issue or submit a pull request on GitHub for the benefit of all readers.
You can read more about updating Angular in the Appendix C, Keeping Angular and Tools Evergreen. You can find this appendix online from https://static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdf or at https://expertlysimple.io/stay-evergreen.
In the next section, we will learn about resolve guards so that we can simplify our code and reduce the amount of boilerplate.
In Chapter 11, Recipes – Reusability, Routing, and Caching, we created a ViewUserComponent
with an editUser
function. We need this functionality later in the chapter when implementing a master/detail view in the system, where a manager can see all users in the system and have the ability to edit them. Before we can enable the editUser
functionality, we need to make sure that the ViewUserComponent
component alongside the ProfileComponent
can load any user given their ID.
Let's start by implementing a resolve guard we can use for both components.
A resolve guard is a type of router guard, as mentioned in Chapter 8, Designing Authentication and Authorization. A resolve guard can load necessary data for a component by reading record IDs from route
parameters, asynchronously load the data, and have it ready by the time the component activates and initializes.
The major advantages of a resolve guard include reusability of the loading logic, a reduction of boilerplate code, and the shedding of dependencies because the component can receive the data it needs without having to import any service:
user.resolve.ts
class under user/user
:
src/app/user/user/user.resolve.ts
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
import { catchError, map } from 'rxjs/operators'
import { transformError } from '../../common/common'
import { IUser, User } from './user'
import { UserService } from './user.service'
@Injectable()
export class UserResolve implements Resolve<IUser> { constructor(private userService: UserService) {}
resolve(route: ActivatedRouteSnapshot) {
return this.userService
.getUser(route.paramMap.get('userId'))
.pipe(map(User.Build), catchError(transformError))
}
}
Note that similar to the updateUser
method in UserService
, we use map(User.Build)
to hydrate the user
object, so it is ready to be used when a component loads data from the route
snapshot, as we'll see next.
user.module.ts
.Next, let's configure the router
and ProfileComponent
to be able to load an existing user.
user-routing.module.ts
to add a new path, profile/:userId
, with a route resolver and the canActivate AuthGuard
:
src/app/user/user-routing.module.ts
...
{
path: 'profile/:userId',
component: ProfileComponent,
resolve: {
user: UserResolve,
},
canActivate: [AuthGuard],
},
...
Remember to provide UserResolve
and AuthGuard
in user.module.ts
.
profile
component to load the data from the route
if it exists:
src/app/user/profile/profile.component.ts
...
constructor(
...
private route: ActivatedRoute
) {
super()
}
ngOnInit() {
this.formGroup = this.buildForm()
if (this.route.snapshot.data.user) {
this.patchUser(this.route.snapshot.data.user)
} else {
this.subs.sink = combineLatest(
[this.loadFromCache(),
this.authService.currentUser$]
)
.pipe(
filter(
([cachedUser, me]) =>
cachedUser != null || me != null
),
tap(
([cachedUser, me]) =>
this.patchUser(cachedUser || me)
)
)
.subscribe()
}
}
We first check to see whether a user is present in the route
snapshot. If so, we call patchUser
to load this user. Otherwise, we fall back to our conditional cache-loading logic.
Note that the patchUser
method also sets the currentUserId
and nameInitialDate$
observables, as well as calling the patchUpdateData
base to update the form data.
You can verify that the resolver is working by navigating to the profile with your user ID. Using the out-of-the-box settings, this URL will look something like http://localhost:5000/user/profile/5da01751da27cc462d265913
.
Now, let's refactor the viewUser
component so that we can reuse it in multiple contexts. User information is displayed in two places in the app as per the mock-ups that were created.
The first place is the Review step of the user profile that we implemented in the previous chapter. The second place is on the user management screen on the /manager/users
route, as follows:
Figure 12.1: Manager user management mock-up
To maximize code reuse, we need to ensure that our shared ViewUser
component can be used in both contexts.
For the Review step of the multi-step input form, we simply bind the current user to it. In the second use case, the component will need to load its own data using a resolve guard, so we don't need to implement additional logic to achieve our goal:
viewUser
component to inject the ActivatedRoute
object and set currentUser$
from the route in ngOnInit()
:
src/app/user/view-user/view-user.component.ts
...
import { ActivatedRoute } from '@angular/router'
export class ViewUserComponent implements OnChanges, OnInit {
...
constructor(
private route: ActivatedRoute, private router: Router
) {}
ngOnInit() {
if (this.route.snapshot.data.user) {
this.currentUser$.next(this.route.snapshot.data.user)
}
}
...
}
ngOnInit
will only fire once when the component is first initialized or has been routed to. In this case, if any data for the route has been resolved, then it'll be pushed to this.currentUser$
with the next()
function.
We now have two independent events to update data; one for ngOnChanges
, which handles updates to the @Input
value and pushes to it to BehaviorSubject currentUser$
if this.user
has been bound to.
To be able to use this component across multiple lazy loaded modules, we must wrap it in its own module:
shared-components.module.ts
under src/app
:
src/app/shared-components.module.ts
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FlexLayoutModule } from '@angular/flex-layout'
import { ReactiveFormsModule } from '@angular/forms'
import { AppMaterialModule } from './app-material.module'
import {
ViewUserComponent
} from './user/view-user/view-user.component'
@NgModule({
imports: [
CommonModule,
ReactiveFormsModule,
FlexLayoutModule,
AppMaterialModule,
],
declarations: [ViewUserComponent],
exports: [ViewUserComponent],
})
export class SharedComponentsModule {}
Ensure that you import the SharedComponentsModule
module into each feature module you intended to use ViewUserComponent
in. In our case, these will be UserModule
and ManagerModule
.
ViewUserComponent
from the User
module declarationsNameInputComponent
in SharedComponentsModule
, and then clean up its other declarationsViewUserComponent
and NameInputComponent
in SharedComponentsModule
as well, such as FieldErrorModule
We now have the key pieces in place to begin implementation of the master/detail view. Let's go over this next.
The true power of router-first architecture comes to fruition with the use of auxiliary routes, where we can influence the layout of components solely through router configuration, allowing for rich scenarios where we can remix the existing components into different layouts. Auxiliary routes are routes that are independent of each other where they can render content in named outlets that have been defined in the markup, such as <router-outlet name="master">
or <router-outlet name="detail">
. Furthermore, auxiliary routes can have their own parameters, browser history, children, and nested auxiliaries.
In the following example, we will implement a basic master/detail view using auxiliary routes:
src/app/manager/user-management/user-management.component.ts
template: `
<div class="horizontal-padding">
<router-outlet name="master"></router-outlet>
<div style="min-height: 10px"></div>
<router-outlet name="detail"></router-outlet>
</div>
`
userTable
component under managermanager-routing.module.ts
to define the auxiliary routes:
src/app/manager/manager-routing.module.ts
...
{
path: 'users',
component: UserManagementComponent,
children: [
{
path: '', component: UserTableComponent,
outlet: 'master'
},
{
path: 'user',
component: ViewUserComponent,
outlet: 'detail',
resolve: {
user: UserResolve,
},
},
],
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
data: {
expectedRole: Role.Manager,
},
},
...
This means that when a user navigates to /manager/users
, they'll see the UserTableComponent
, because it is implemented with the default path.
UserResolve
in manager.module.ts
since viewUser
depends on ituserTable
:
src/app/manager/user-table/user-table.component.html
<a mat-button mat-icon-button [routerLink]="['/manager/users',
{ outlets: { detail: ['user', { userId: row._id}] } }]"
skipLocationChange>
<mat-icon>visibility</mat-icon>
</a>
The skipLocationChange
directive navigates without pushing a new record into history. So if the user views multiple records and hits the Back button, they will be taken back to the previous screen, instead of having to scroll through the records they viewed first.
Imagine that a user clicks on a View detail button like the one defined previously – then, ViewUserComponent
will be rendered for the user with the given userId
. In the next screenshot, you can see what the View Details button will look like after we implement the data table in the next section:
Figure 12.2: View Details button
You can have as many combinations and alternative components defined for the master and detail, allowing for the infinite possibilities of dynamic layouts. However, setting up the routerLink
can be a frustrating experience. Depending on the exact condition, you have to either supply or not supply all or some outlets in the link. For example, for the preceding scenario, if the link was ['/manager/users', { outlets: { master: [''], detail: ['user', {userId: row.id}] } }]
, the route will silently fail to load. Expect these quirks to be ironed out in future Angular releases.
Now that we've completed the implementation of the resolve guard for ViewUserComponent
, you can use Chrome DevTools to see the data being loaded correctly.
Before debugging, ensure that the lemon-mart-server we created in Chapter 10, RESTful APIs and Full-Stack Implementation, is running.
this.currentUser
is assigned, as shown:Figure 12.3: Dev Tools debugging ViewUserComponent
You will observe that this.currentUser
is correctly set without any boilerplate code for loading data inside the ngOnInit
function, showing the true benefit of a resolve guard. ViewUserComponent
is the detail view; now let's implement the master view as a data table with pagination.
We have created the scaffolding to lay out our master/detail view. In the master outlet, we will have a paginated data table of users, so let's implement UserTableComponent
, which will contain a MatTableDataSource
property named dataSource
. We will need to be able to fetch user data in bulk using standard pagination controls such as pageSize
and pagesToSkip
and be able to further narrow down the selection with user-provided searchText
.
Let's start by adding the necessary functionality to the UserService
:
IUsers
interface to describe the data structure of the paginated data:
src/app/user/user/user.service.ts
...
export interface IUsers {
data: IUser[]
total: number
}
UserService
with a getUsers
function:
src/app/user/user/user.service.ts
...
export interface IUserService {
getUser(id: string): Observable<IUser>
updateUser(id: string, user: IUser): Observable<IUser>
getUsers(pageSize: number, searchText: string,
pagesToSkip: number): Observable<IUsers>
}
export class UserService extends CacheService implements IUserService {
...
getUsers
to UserService
:
src/app/user/user/user.service.ts
...
getUsers(
pageSize: number,
searchText = '',
pagesToSkip = 0,
sortColumn = '',
sortDirection: '' | 'asc' | 'desc' = 'asc'
): Observable<IUsers> {
const recordsToSkip = pageSize * pagesToSkip
if (sortColumn) {
sortColumn =
sortDirection === 'desc' ? `-${sortColumn}` : sortColumn
}
return this.httpClient.get<IUsers>(
`${environment.baseUrl}/v2/users`, {
params: {
filter: searchText,
skip: recordsToSkip.toString(),
limit: pageSize.toString(),
sortKey: sortColumn,
},
})
}
...
Note that the sort direction is represented by the keywords asc
for ascending and desc
for descending. When we want to sort a column in ascending order, we pass the column name as a parameter to the server. To sort a column in descending order, we prepend the column name with a minus sign.
UserTable
with pagination, sorting, and filtering:
src/app/manager/user-table/user-table.component.ts
...
@Component({
selector: 'app-user-table',
templateUrl: './user-table.component.html',
styleUrls: ['./user-table.component.css'],
})
export class UserTableComponent implements OnDestroy, AfterViewInit {
displayedColumns = ['name', 'email', 'role', '_id']
items$: Observable<IUser[]>
resultsLength = 0
hasError = false
errorText = ''
private skipLoading = false
private subs = new SubSink()
readonly isLoadingResults$ = new BehaviorSubject(true)
loading$: Observable<boolean>
refresh$ = new Subject()
search = new FormControl('', OptionalTextValidation)
@ViewChild(MatPaginator, { static: false })
paginator: MatPaginator
@ViewChild(MatSort, { static: false }) sort: MatSort
constructor(
private userService: UserService
) {
this.loading$ = this.isLoadingResults$
}
getUsers(
pageSize: number,
searchText: string,
pagesToSkip: number,
sortColumn: string,
sortDirection: SortDirection
): Observable<IUsers> {
return this.userService.getUsers(
pageSize,
searchText,
pagesToSkip,
sortColumn,
sortDirection
)
}
ngOnDestroy(): void {
this.subs.unsubscribe()
}
ngAfterViewInit() {
this.subs.sink = this.sort.sortChange
.subscribe(() => this.paginator.firstPage())
if (this.skipLoading) {
return
}
this.items$ = merge(
this.refresh$,
this.sort.sortChange,
this.paginator.page,
this.search.valueChanges.pipe(debounceTime(1000))
).pipe(
startWith({}),
switchMap(() => {
this.isLoadingResults$.next(true)
return this.getUsers(
this.paginator.pageSize,
this.search.value,
this.paginator.pageIndex,
this.sort.active,
this.sort.direction
)
}),
map((results: { total: number; data: IUser[] }) => {
this.isLoadingResults$.next(false)
this.hasError = false
this.resultsLength = results.total
return results.data
}),
catchError((err) => {
this.isLoadingResults$.next(false)
this.hasError = true
this.errorText = err
return of([])
})
)
this.items$.subscribe()
}
}
We define and initialize various properties to support loading paginated data. items$
stores the user records, displayedColumns
defines the columns of data we intend to display, paginator
and sort
provide pagination and sorting preferences, and search
provides the text we need to filter our results by.
In ngAfterViewInit
, we use the merge
method, as highlighted in the preceding snippet, to listen for changes in pagination, sorting, and filter properties. If one property changes, the whole pipeline is triggered. This is similar to how we implemented the login routine in AuthService
. The pipeline contains a call to this.userService.getUsers
, which will retrieve users based on the pagination, sorting, and filter preferences passed in. Results are then piped into the this.items$
observable, which the data table subscribes to with an async
pipe, so it can display the data.
ManagerMaterialModule
containing the following Material modules:
src/app/manager/manager-material.module.ts
MatTableModule,
MatSortModule,
MatPaginatorModule,
MatProgressSpinnerModule,
MatSlideToggleModule,
manager.module.ts
correctly imports the following:ManageMaterialModule
AppMaterialModule
FormsModule
, ReactiveFormsModule
, and FlexLayoutModule
userTable
:
src/app/manager/user-table/user-table.component.css
.loading-shade {
position: absolute;
top: 0;
left: 0;
bottom: 56px;
right: 0;
background: rgba(0, 0, 0, 0.15);
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.filter-row {
min-height: 64px;
padding: 8px 24px 0;
}
.full-width {
width: 100%;
}
.mat-paginator {
background: transparent;
}
userTable
template:
src/app/manager/user-table/user-table.component.html
<div class="filter-row">
<form style="margin-bottom: 32px">
<div fxLayout="row">
<mat-form-field class="full-width">
<mat-icon matPrefix>search</mat-icon>
<input matInput placeholder="Search" aria-label="Search" [formControl]="search" />
<mat-hint>Search by e-mail or name</mat-hint>
<mat-error *ngIf="search.invalid">
Type more than one character to search
</mat-error>
</mat-form-field>
</div>
</form>
</div>
<div class="mat-elevation-z8">
<div class="loading-shade" *ngIf="loading$ | async as loading">
<mat-spinner *ngIf="loading"></mat-spinner>
<div class="error" *ngIf="hasError">
{{ errorText }}
</div>
</div>
<table mat-table class="full-width" [dataSource]="items$ | async" matSort
matSortActive="name" matSortDirection="asc" matSortDisableClear>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Name </th>
<td mat-cell *matCellDef="let row">
{{ row.fullName }}
</td>
</ng-container>
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header> E-mail </th>
<td mat-cell *matCellDef="let row"> {{ row.email }} </td>
</ng-container>
<ng-container matColumnDef="role">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Role </th>
<td mat-cell *matCellDef="let row"> {{ row.role }} </td>
</ng-container>
<ng-container matColumnDef="_id">
<th mat-header-cell *matHeaderCellDef>View Details
</th>
<td mat-cell *matCellDef="let row" style="margin-right: 8px">
<a mat-button mat-icon-button [routerLink]="[
'/manager/users',
{ outlets: { detail: ['user', { userId: row._id }] } }
]" skipLocationChange>
<mat-icon>visibility</mat-icon>
</a>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"> </tr>
</table>
<mat-toolbar>
<mat-toolbar-row>
<button mat-icon-button (click)="refresh$.next()">
<mat-icon title="Refresh">refresh</mat-icon>
</button>
<span class="flex-spacer"></span>
<mat-paginator [pageSizeOptions]="[5, 10, 25, 100]"
[length]="resultsLength">
</mat-paginator>
</mat-toolbar-row>
</mat-toolbar>
</div>
With just the master view in place, the table is as shown in the following screenshot (make sure you've updated to the latest version of Angular!):
Figure 12.4: User table
If you click on the View icon, ViewUserComponent
will get rendered in the detail outlet, as shown:
Figure 12.5: Master/detail view
In the previous chapter, we implemented the Edit button, passing the userId
to the UserProfile
so that the data can be edited and updated.
ProfileComponent
, edit the user record, and verify that you can update another user's recordThis demonstration of data tables with pagination completes the major functionality of LemonMart for the purpose of this book. Now, before we move on, let's make sure that all of our tests pass.
Let's go over some unit tests for ProfileComponent
and UserTableComponent
to see how we can leverage different techniques to test the components:
ProfileComponent
and identify the use of the authServiceMock
object to provide initial data for the component:
src/app/user/profile/profile.component.spec.ts
describe('ProfileComponent', () => {
let component: ProfileComponent
let fixture: ComponentFixture<ProfileComponent>
let authServiceMock: jasmine.SpyObj<AuthService>
beforeEach(async(() => {
const authServiceSpy = autoSpyObj(
AuthService,
['currentUser$', 'authStatus$'],
ObservablePropertyStrategy.BehaviorSubject
)
TestBed.configureTestingModule({
providers: commonTestingProviders.concat({
provide: AuthService,
useValue: authServiceSpy,
}),
imports: commonTestingModules.concat([
UserMaterialModule,
FieldErrorModule,
LemonRaterModule,
]),
declarations: [ProfileComponent, NameInputComponent, ViewUserComponent],
}).compileComponents()
authServiceMock = injectSpy(AuthService)
fixture = TestBed.createComponent(ProfileComponent)
component = fixture.debugElement.componentInstance
}))
it('should create', () => {
authServiceMock.currentUser$.next(new User())
authServiceMock.authStatus$.next(defaultAuthStatus)
fixture.detectChanges()
expect(component).toBeTruthy()
})
})
Note that instead of using the createComponentMock
function from angular-unit-test-helper
to import NameInputComponent
or ViewUserComponent
, I import their actual implementations. This is because createComponentMock
is not yet sophisticated enough to deal with binding data to child components. In the Further reading section, I've included a blog post by Aiko Klostermann that covers testing Angular components with @Input()
properties.
UserTableComponent
:After fixing up its providers and imports, you will notice that UserTableComponent
is throwing an ExpressionChangedAfterItHasBeenCheckedError
error. This is because the component initialization logic requires dataSource
to be defined. If undefined, the component can't be created. However, we can easily modify component properties in the second beforeEach
method, which executes after TestBed
has injected real, mocked, or fake dependencies into the component class. See the highlighted changes in the following snippet for the test data setup:
src/app/manager/user-table/user-table.component.spec.ts
...
beforeEach(() => {
fixture = TestBed.createComponent(UserTableComponent)
component = fixture.componentInstance
component.items$ = of([new User()])
Object.assign(component, { skipLoading: true })
fixture.detectChanges()
})
...
By now, you may have noticed that just by updating some of our central configuration files, such as commonTestingProviders
and commonTestingModules
, some tests are passing, and the rest of the tests can be resolved by applying the various patterns we have been using throughout the book. For example, user-management.component.spec.ts
uses the common testing modules and providers we have created:
src/app/manager/user-management/user-management.component.spec.ts
providers: commonTestingProviders,
imports: commonTestingModules.concat([ManagerMaterialModule]),
When you are mocking providers, keep in mind what module, component, service, or class is under test and take care only to mock dependencies.
ViewUserComponent
is a special case where we can't use our common testing modules and providers, otherwise, we would end up creating a circular dependency. In this case, manually specify the modules that need to be imported.
With the heavy lifting of the implementation completed, we can now explore alternative architectures, tools, and libraries to better understand the best ways to architect Angular apps for various needs. Next, let's explore NgRx.
As covered in Chapter 1, Introduction to Angular and Its Concepts, the NgRx library brings reactive state management to Angular based on RxJS. State management with NgRx allows developers to write atomic, self-contained, and composable pieces of code, creating actions, reducers, and selectors. This kind of reactive programming allows side effects in state changes to be isolated. In essence, NgRx is an abstraction layer over RxJS to fit the Flux pattern.
There are four major elements of NgRx:
A view (or user interface) displays data from the store by using a selector.
Actions are triggered from a view with the purpose of dispatching them to the store.
Reducers on the store listen for dispatched actions.
Let's revisit the following Flux pattern diagram, which now highlights an Effect:
Figure 12.6: Flux pattern diagram
Let's demonstrate how NgRx works by going over a concrete example. In order to keep it simple, we will be leveraging the LocalCast Weather app.
We will be implementing NgRx to execute the search functionality in the LocalCast Weather app. Consider the following architecture diagram:
Figure 12.7: LocalCast Weather architecture
In order to achieve our implementation, we will use both the NgRx store and effects libraries. NgRx store actions are reflected in the diagram in light gray with a WeatherLoaded
reducer and the app state. At the top, actions are represented as a stream of various data objects either dispatching actions or acting on dispatched actions, enabling us to implement the Flux pattern. The NgRx effects library extends the Flux pattern by isolating side effects in its own model without littering the store with temporary data.
The effects workflow, represented in dark gray, begins with Step 1:
CitySearchComponent
dispatches the search
actionsearch
action appears on the observable @ngrx/action
stream (or data stream)CurrentWeatherEffects
acts on the search
action to perform a searchWeatherService
performs the search to retrieve current weather information from the OpenWeather APIStore actions, represented in light gray, begin with Step A:
CurrentWeatherEffects
dispatches the weatherLoaded
actionweatherLoaded
action appears on the data streamweatherLoaded
reducer acts on the weatherLoaded
actionweatherLoaded
reducer transforms the weather information to be stored as a new statesearch
state, part of the appStore
stateNote that there's a parent-level appStore
state, which contains a child search
state. I intentionally retained this setup to demonstrate how the parent-level state scales as you add different kinds of data elements to the store.
Finally, a view reads from the store, beginning with step a:
CurrentWeather
component subscribes to the selectCurrentWeather
selector using the async
pipeselectCurrentWeather
selector listens for changes to the store.search.current
property in the appStore
stateappStore
state retrieves the persisted dataUsing NgRx, when a user searches for a city, the actions to retrieve, persist, and display that information on the CurrentWeatherComponent
happens automatically via individual composable and immutable elements.
We will be implementing NgRx side by side with BehaviorSubjects
, so you can see the differences in the implementation of the same feature. To do this, we will need a slide toggle to switch between the two strategies:
This section uses the local-weather-app repo. You can find the code samples for this chapter under the projects/ch12
folder.
<mat-slide-toggle>
element on CitySearchComponent
, as shown in the following screenshot:Figure 12.8: LocalCast Weather slide toggle
Ensure that the field is backed by a property on your component named useNgRx
.
doSearch
method to extract the BehaviorSubject
code as its own function named behaviorSubjectBasedSearch
ngRxBasedSearch
:
src/app/city-search/city-search.component.ts
doSearch(searchValue: string) {
const userInput = searchValue.split(',').map((s) => s.trim())
const searchText = userInput[0]
const country = userInput.length > 1 ? userInput[1] : undefined
if (this.useNgRx) {
this.ngRxBasedSearch(searchText, country)
} else {
this.behaviorSubjectBasedSearch(searchText, country)
}
}
We will be dispatching an action from the ngRxBasedSearch
function that you just created.
You may add the NgRx Store package with the following command:
$ npx ng add @ngrx/store
This will create a reducers folder with an index.ts
file in it. Now add the NgRx effects package:
$ npx ng add @ngrx/effects --minimal
We use the --minimal
option here to avoid creating unnecessary boilerplate.
Next, install the NgRx schematics library so you can take advantage of generators to create the boilerplate code for you:
$ npm i -D @ngrx/schematics
Implementing NgRx can be confusing due to its highly decoupled nature, which may necessitate some insight into the inner workings of the library.
The sample project under projects/ch12
configures @ngrx/store-devtools
for debugging.
If you would like to be able to console.log
NgRx actions for debugging or instrumentation during runtime, refer to the Appendix A, Debugging Angular.
Before we can implement effects or reducers, we first need to define the actions our app is going to be able to execute. For LocalCast Weather, there are two types of actions:
search
: Fetches the current weather for the city or zip code that's being searchedweatherLoaded
: Indicates that new current weather information has been fetchedCreate an action named search
by running the following command:
$ npx ng generate @ngrx/schematics:action search --group --creators
Take the default options when prompted.
The --group
option groups actions under a folder named action
. The --creators
option uses creator functions to implement actions and reducers, which is a more familiar and straightforward way to implement these components.
Now, let's implement the two actions using the createAction
function, providing a name and an expected list of input parameters:
src/app/action/search.actions.ts
import { createAction, props, union } from '@ngrx/store'
import { ICurrentWeather } from '../interfaces'
export const SearchActions = {
search: createAction(
'[Search] Search',
props<{ searchText: string; country?: string }>()
),
weatherLoaded: createAction(
'[Search] CurrentWeather loaded',
props<{ current: ICurrentWeather }>()
),
}
const all = union(SearchActions)
export type SearchActions = typeof all
The search action has the name '[Search] Search'
and has searchText
and an optional country
parameter as inputs. The weatherLoaded
action follows a similar pattern. At the end of the file, we create a union type of our actions, so we can group them under one parent type to use in the rest of the application.
Notice that action names are prepended by [Search]
. This is a convention that helps developers visually group related actions together during debugging.
Now that our actions are defined, we can implement the effect to handle the search action and dispatch a weatherLoaded
action.
As mentioned earlier, effects let us change the stored state without necessarily storing the event data that is causing the change. For example, we want our state to only have weather data, not the search text itself. Effects allow us to do this in one step, rather than forcing us to use an intermediate store for the searchText
and a far more complicated chain of events to just turn that into weather data.
Otherwise, we would have to implement a reducer in between, to first store this value in the store, and then later retrieve it from a service and dispatch a weatherLoaded
action. The effect will make it simpler to retrieve data from our service.
Now let's add CurrentWeatherEffects
to our app:
$ npx ng generate @ngrx/schematics:effect currentWeather --module=app.module.ts --root --group --creators
Take the default options when prompted.
You will have a new current-weather.effects.ts
file under the effects
folder.
Once again, --group
is used to group effects under a folder of the same name. --root
registers the effect in app.module.ts
and we use creator functions with the --creators
option.
In the CurrentWeatherEffects
file, start by implementing a private doSearch
method:
src/app/effects/current-weather.effects.ts
private doSearch(action: { searchText: string; country?: string }) {
return this.weatherService.getCurrentWeather(
action.searchText,
action.country
).pipe(
map((weather) =>
SearchActions.weatherLoaded({ current: weather })
),
catchError(() => EMPTY)
)
}
Note that we're choosing to ignore errors thrown with the EMPTY
function. You can surface these errors to the user with a UiService
like the one you've implemented for LemonMart.
This function takes an action with search parameters, calls getCurrentWeather
, and upon receiving a response, dispatches the weatherLoaded
action, passing in the current weather property.
Now let's create the effect itself, so we can trigger the doSearch
function:
src/app/effects/current-weather.effects.ts
getCurrentWeather$ = createEffect(() =>
this.actions$.pipe(
ofType(SearchActions.search),
exhaustMap((action) => this.doSearch(action))
)
)
This is where we tap into the observable action stream, this.actions$
, and listen to actions of the SearchAction.search
type. We then use the exhaustMap
operator to register for the emitted event. Due to its unique nature, exhaustMap
won't allow another search action to be processed until the doSearch
function completes dispatching its weatherLoaded
action.
Confused by all the different kinds of RxJS operators and worried you'll never remember them? See the Appendix B, Angular Cheat Sheet, for a quick reference.
With the weatherLoaded
action triggered, we need a way to ingest the current weather information and store it in our appStore
state. Reducers will help us handle specific actions, creating an isolated and immutable pipeline to store our data in a predictable way.
Let's create a search reducer:
$ npx ng generate @ngrx/schematics:reducer search
--reducers=reducers/index.ts --group --creators
Take the default options. Here, we use --group
to keep files organized under the reducers
folder and --creators
to leverage the creator style of creating NgRx components. We also specify the location of our parent appStore
state at reducers/index.ts
with --reducers
, so our new reducer can be registered with it.
You may observe that reducers.index.ts
has been updated to register the new search.reducer.ts
. Let's implement it step by step.
In the search
state, we will be storing the current weather, so implement the interface to reflect this:
src/app/reducers/search.reducer.ts
export interface State {
current: ICurrentWeather
}
Now let's specify the initialState
. This is similar to how we need to define a default value of a BehaviorSubject
. Refactor the WeatherService
to export a const defaultWeather: ICurrentWeather
object that you can use to initialize BehaviorSubject
and initialState
.
src/app/reducers/search.reducer.ts
export const initialState:
State = { current:
defaultWeather,
}
Finally, implement searchReducer
to handle the weatherLoaded
action using the on
operator:
src/app/reducers/search.reducer.ts
const searchReducer = createReducer(
initialState,
on(SearchActions.weatherLoaded, (state, action) => {
return {
...state,
current: action.current,
}
})
)
We simply register for the weatherLoaded
action and unwrap the data stored in it and pass it into the search
state.
This is, of course, a very simplistic case. However, it is easy to imagine a more complicated scenario, where we may need to flatten or process a piece of data received and store it in an easy-to-consume manner. Isolating such logic in an immutable way is the key value proposition of utilizing a library like NgRx.
We need CurrentWeatherComponent
to register with the appStore
state for updated current weather data.
Start by dependency injecting the appStore
state and registering the selector to pluck current weather from the State
object:
src/app/current-weather/current-weather.component.ts
import * as appStore from '../reducers'
export class CurrentWeatherComponent {
current$: Observable<ICurrentWeather>
constructor(private store: Store<appStore.State>) {
this.current$ =
this.store.pipe(select((state: State) => state.search.current))
}
...
}
We simply listen to state change events that flow through the store. Using the select
function, we can implement an inline select to get the piece of data we need.
We can refactor this a bit and make our selector reusable by using a createSelector
to create a selectCurrentWeather
property on reducers/index.ts
:
src/app/reducers/index.ts
export const selectCurrentWeather = createSelector(
(state: State) => state.search.current,
current => current
)
In addition, since we want to maintain the continued operation of the BehaviorSubject
, we can implement a merge
operator in CurrentWeatherComponent
to listen to both WeatherService
updates and appStore
state updates:
src/app/current-weather/current-weather.component.ts
import * as appStore from '../reducers'
constructor(
private weatherService: WeatherService,
private store: Store<appStore.State>
) {
this.current$ = merge(
this.store.pipe(select(appStore.selectCurrentWeather)),
this.weatherService.currentWeather$
)
}
Now that we are able to listen to store updates, let's implement the final piece of the puzzle: dispatching the search action.
We need to dispatch the search action so that our search effect can fetch current weather data and update the store. Earlier in this chapter, you implemented a stubbed function called ngRxBasedSearch
in the CitySearchComponent
.
Let's implement ngRxBasedSearch
:
src/app/city-search/city-search.component.ts
ngRxBasedSearch(searchText: string, country?: string) {
this.store.dispatch(SearchActions.search({ searchText, country }))
}
Don't forget to inject the appState
store into the component!
And that's it! Now you should be able to run your code and test to see whether it all works.
As you can see, NgRx brings a lot of sophisticated techniques to the table to create ways to make data transformations immutable, well defined, and predictable. However, this comes with considerable implementation overhead. Use your best judgment to determine whether you really need the Flux pattern in your Angular app. Often, the frontend application code can be made much simpler by implementing RESTful APIs that return flat data objects, with complicated data manipulations handled server side.
You can implement unit tests for the weatherLoaded
reducer and the selectCurrentWeather
selector in search.reducer.spec.ts
:
src/app/reducers/search.reducer.spec.ts
import { SearchActions } from '../actions/search.actions'
import { defaultWeather } from '../weather/weather.service'
import { fakeWeather } from '../weather/weather.service.fake'
import { selectCurrentWeather } from './index'
import { initialState, reducer } from './search.reducer'
describe('Search Reducer', () => {
describe('weatherLoaded', () => {
it('should return current weather', () => {
const action = SearchActions.weatherLoaded({ current: fakeWeather })
const result = reducer(initialState, action)
expect(result).toEqual({ current: fakeWeather })
})
})
})
describe('Search Selectors', () => {
it('should selectCurrentWeather', () => {
const expectedWeather = defaultWeather
expect(selectCurrentWeather({ search: { current: defaultWeather }
})).toEqual(
expectedWeather
)
})
})
These unit tests are fairly straightforward and will ensure that no unintentional changes to the data structure can happen within the store.
You need to update the tests for CurrentWeatherComponent
so that we can inject a mock Store
into the component to test the value of the current$
property.
Let's look at the delta of what needs to be added to the spec file to configure the mock store:
src/app/current-weather/current-weather.component.spec.ts
import { MockStore, provideMockStore } from '@ngrx/store/testing'
describe('CurrentWeatherComponent', () => {
...
let store: MockStore<{ search: { current: ICurrentWeather } }>
const initialState = { search: { current: defaultWeather } }
beforeEach(async(() => {
...
TestBed.configureTestingModule({
imports: [AppMaterialModule],
providers: [
...
provideMockStore({ initialState }),
],
}).compileComponents()
...
store = TestBed.inject(Store) as any
}))
...
})
We can now update the 'should get currentWeather from weatherService'
test to see whether CurrentWeatherComponent
works with a mock store:
src/app/current-weather/current-weather.component.spec.ts
it('should get currentWeather from weatherService', (done) => {
// Arrange
store.setState({ search: { current: fakeWeather } })
weatherServiceMock.currentWeather$.next(fakeWeather)
// Act
fixture.detectChanges() // triggers ngOnInit()
// Assert
expect(component.current$).toBeDefined()
component.current$.subscribe(current => {
expect(current.city).toEqual('Bethesda')
expect(current.temperature).toEqual(280.32)
// Assert on DOM
const debugEl = fixture.debugElement
const titleEl: HTMLElement =
debugEl.query(By.css('.mat-title')).nativeElement
expect(titleEl.textContent).toContain('Bethesda')
done()
})
})
The mock store allows us to set the current state of the store, which in turn allows the selector call in the constructor to fire and grab the provided fake weather data.
TestBed is not a hard requirement for writing unit tests in Angular, a topic covered well at https://angular.io/guide/testing. My colleague and reviewer of this book, Brendon Caulkins, contributed a bed-less spec file for this chapter, named current-weather.component.nobed.spec.ts
. He cites significant performance increases when running the tests, with fewer imports and less maintenance, but a higher level of care and expertise required to implement the tests. If you're on a large project, you should seriously consider skipping the TestBed
.
You can find the sample code on GitHub under the projects/ch12
folder.
Go ahead and update the remainder of your tests and do not move on until they all start passing.
If NgRx is a configuration-based framework, NgRx Data is a convention-based sibling of NgRx. NgRx Data automates the creation of stores, effects, actions, reducers, dispatches, and selectors. If most of your application actions are CRUD (Create, Retrieve, Update, and Delete) operations, then NgRx Data can achieve the same result as NgRx with a lot less code needing to be written.
NgRx Data may be a much better introduction to the Flux pattern for you and your team. Then you can go on to NgRx itself.
@ngrx/data
works in tandem with the @ngrx/entity
library. Together they offer a rich feature set, including transactional data management. Read more about it at https://ngrx.io/guide/data.
For this example, we will be switching back over to the LemonMart project.
Add NgRx Data to your project by executing the following commands:
$ npx ng add @ngrx/store --minimal
$ npx ng add @ngrx/effects --minimal
$ npx ng add @ngrx/entity
$ npx ng add @ngrx/data
The sample project under projects/ch12
configures @ngrx/store-devtools
for debugging.
If you would like to be able to console.log
NgRx actions for debugging or instrumentation during runtime, refer to the Appendix A, Debugging Angular.
In LemonMart, we have a great use case for the @ngrx/data
library with the User
class and the UserService
. It neatly represents an entity that could support CRUD operations. With a few modifications and the least amount of effort, you can see the library in action.
This section uses the lemon-mart repo. You can find the code samples for this chapter under the projects/ch12
folder.
User
entity in entity-metadata.ts
:
src/app/entity-metadata.ts
import { EntityMetadataMap } from '@ngrx/data'
const entityMetadata: EntityMetadataMap = {
User: {},
}
export const entityConfig = {
entityMetadata,
}
entityConfig
object is registered with EntityDataModule
:
src/app/app.module.ts
imports: [
...
StoreModule.forRoot({}),
EffectsModule.forRoot([]),
EntityDataModule.forRoot(entityConfig),
]
User
entity service:
src/app/user/user/user.entity.service.ts
import { Injectable } from '@angular/core'
import {
EntityCollectionServiceBase,
EntityCollectionServiceElementsFactory,
} from '@ngrx/data'
import { User } from './user'
@Injectable({ providedIn: 'root' })
export class UserEntityService
extends EntityCollectionServiceBase<User> {
constructor(
serviceElementsFactory: EntityCollectionServiceElementsFactory
) {
super('User', serviceElementsFactory)
}
}
You now have all the basic elements in place to integrate the entity service with a component. In a sense, it is this easy to set up NgRx Data. However, we'll have to customize it somewhat to fit into our existing REST API structure, which will be covered in detail in the next section. If you were to follow the API implementation pattern that NgRx Data expects, then no changes would be necessary.
NgRx Data wants to access the REST API via the /api
path, hosted on the same port as your Angular app. To accomplish this during development, we need to leverage Angular CLI's proxy feature.
Normally, HTTP requests sent to our web server and our API server should have exactly the same URL. However, during development, we usually host both applications on two different ports of http://localhost
. Certain libraries, including NgRx Data, require that HTTP calls be on the same port. This creates a challenge for creating a frictionless development experience. For this reason, Angular CLI ships with a proxy feature with which you can direct the /api
path to a different endpoint on your localhost. This way, you can use one port to serve your web app and your API requests.
proxy.conf.json
file under src
, as shown:If you're working in the lemon-mart-server monorepo, this will be web-app/src
.
proxy.conf.json
{
"/api": {
"target": "http://localhost:3000",
"secure": false,
"pathRewrite": {
"^/api": ""
}
}
}
angular.json
:
angular.json
...
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "lemon-mart:build",
"proxyConfig": "proxy.conf.json"
},
...
}
Now the server that is started when you run npm start
or ng serve
can rewrite the URLs of any call made to the /api
route with http://localhost:3000
. This is the port that lemon-mart-server runs by default.
If your API is running a different port, then use the correct port number and child route.
Next, let's use the UserEntityService
.
We will be updating the User Management master view, so we can optionally use BehaviorSubject
or the UserEntityService
we just created.
user-table.component.ts
, similar to the way we did for LocalCast Weather and NgRx earlier in the chapter: Figure 12.9: UserTableComponent with the NgRx slide toggle
UserTableComponent
and merge its loading observable with the one that's present on the component:
src/app/manager/user-table/user-table.component.ts
useNgRxData = true
readonly isLoadingResults$ = new BehaviorSubject(true) loading$: Observable<boolean>
constructor(
private userService: UserService,
private userEntityService: UserEntityService
) {
this.loading$ = merge(
this.userEntityService.loading$,
this.isLoadingResults$
)
}
Since EntityDataModule
is registered in app.module.ts
at the root of our application, we need to provide UserService
in app.module.ts
as well, so we can consume data from it in UserEntityService
. Even though UserEntityService
is provided in UserModule
, the order of operations within NgRx Data doesn't lend itself to properly working with feature modules. This will probably be fixed at some point.
getUsers
function so there is no need to add the others:
src/app/manager/user-table/user-table.component.ts
getUsers() {
return this.userEntityService.getAll().pipe(
map((value) => {
return { total: value.length, data: value }
})
)
}
add(user: User) {
this.userEntityService.add(user)
}
delete(user: User) {
this.userEntityService.delete(user._id)
}
update(user: User) {
this.userEntityService.update(user)
}
ngAfterViewInit
, refactor the call to this.userService.getUsers
so that it is called from a method named getUsers
this.userEntityService.getAll()
and map out the return value so that it fits the IUsers
interface:
src/app/manager/user-table/user-table.component.ts
...
getUsers(pageSize: number, searchText = '', pagesToSkip = 0)
: Observable<IUsers> {
if (this.useNgRxData) {
return this.userEntityService.getAll().pipe(
map((value) => {
return { total: value.length, data: value }
})
)
} else {
return this.userService.getUsers(
pageSize,
searchText,
pagesToSkip,
sortColumn,
sortDirection
)
}
Now your component can attempt to get data from either source by toggling the slide toggle and entering some new search text. However, our endpoint does not provide the data in the shape that NgRx Data expects, so we need to customize the entity service to overcome this issue.
You can customize the behavior of NgRx Data in numerous places. We are interested in overriding the behavior of the getAll()
function, so the data we're receiving is properly hydrated and the data can be extracted from the item's object.
For this example, we will not attempt to restore the full pagination functionality using NgRx Data. To keep it simple, we focus on just getting an array of data into the data table.
Update User Entity Service to inject UserService
and implement a getAll
function that uses it:
src/app/user/user/user.entity.service.ts
...
getAll(options?: EntityActionOptions): Observable<User[]> {
return this.userService
.getUsers(10)
.pipe(map((users) => users.data.map(User.Build)))
}
...
As you can see, we're iterating through the item's object and hydrating objects with our builder function, thus flattening and transforming Observable<IUsers>
to Observable<User[]>
.
After implementing this change, you should be able to see data flow into the user table as follows:
Figure 12.10: User table with NgRx Data
Note that all seven users are displayed at once, and as magnified in the preceding screenshot, the pagination functionality is not working. However, this implementation is adequate enough to demonstrate what NgRx Data brings to the table.
So, should you implement NgRx Data in your next app? It depends. Since the library is an abstraction layer on top of NgRx, you may find yourself lost and restricted if you don't have a good understanding of the internals of NgRx. However, the library holds a lot of promise for reducing boilerplate code regarding entity data management and CRUD operations. If you're doing lots of CRUD operations in your app, you may save time, but be careful to keep the scope of your implementation only to the areas that need it. Either way, you should keep an eye out for the evolution of this great library.
In this chapter, we completed going over all major Angular app design considerations using router-first architecture, along with our recipes, to implement a line-of-business app with ease. We went over how to edit existing users, leverage a resolve guard to load user data, and hydrate and reuse a component in different contexts.
We implemented a master/detail view using auxiliary routes and demonstrated how to build data tables with pagination. We then learned about NgRx and the @ngrx/data
libraries and their impact on our code base using the local-weather-app and lemon-mart projects.
Overall, by using the router-first design, architecture, and implementation approach, we tackled our application's design with a good high-level understanding of what we wanted to achieve. By identifying code reuse opportunities early on, we were able to optimize our implementation strategy to implement reusable components ahead of time, without running the risk of grossly over-engineering our solution.
In the next chapter, we will set up a highly available infrastructure on AWS to host LemonMart. We will update the project with new scripts to enable no-downtime blue-green deployments. Finally, in the last chapter, we will update LemonMart with Google Analytics and go over advanced Cloud Ops concerns.
Answer the following questions as best as you can to ensure that you've understood the key concepts from this chapter without Googling. Do you need help answering the questions? See Appendix D, Self-Assessment Answers online at https://static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf or visit https://expertlysimple.io/angular-self-assessment.
UserTableComponent
, why do we use readonly isLoadingResults$: BehaviorSubject<Boolean>
over a simple Boolean to drive the loading spinner?