An NgRx based State Machine for Angular Components - Part 3

As mentioned in the second part of this article, we initially used object literals to specify the state machine setup for a given component but this object was not easy to structure and difficult to read. To improve the setup and readability we set up a fluent style builder.

// Instead of this ....
const componentStates = {
name: 'userApproval',
states: {
[actionTypes.loadUnapprovedUsers]: {
[ComponentStates.Idle]: { to: ComponentStates.Processing, action: PassthroughAction }
},
[actionTypes.loadUnapprovedUsersSuccess]: {
[ComponentStates.Processing]: { to: ComponentStates.Idle, action: PassthroughAction }
},
[actionTypes.loadUnapprovedUsersError]: {
[ComponentStates.Processing]: { to: ComponentStates.Idle }
}
}
}
// With the fluent builder, component states can be specified like this ...
const componentStates = this._componentStateBuilder
.create('userApproval')
.forAction(actionTypes.loadUnapprovedUsers)
.fromState(ComponentStates.Idle)
.toState(ComponentStates.Processing
.passThrough()
.forAction(actionTypes.loadUnapprovedUsersSuccess)
.fromState(ComponentStates.Processing)
.toState(ComponentStates.Idle)
.passThrough()
.forAction(actionTypes.loadUnapprovedUsersError)
.fromState(ComponentStates.Processing)
.toState(ComponentStates.Idle)
.terminate()
// and the fluent builder code ...
import { forOwn } from 'lodash';
import { Injectable } from '@angular/core';
import { PassthroughAction } from '../../core/state/component-state.actions';
import { ComponentStates } from './component.states';
import { Guard } from '../../shared/guard';
export interface ComponentState {
id?: string | number;
name: string;
disableWhenProcessing: boolean;
showProgressBar: boolean;
states: { [action: string]: ActionState };
}
export interface ActionState {
[state: string]: Transition;
}
export interface Transition {
to?: ComponentStates;
action?: Function;
terminate?: boolean;
}
@Injectable()
export class ComponentStateBuilder {
private _componentStates: ComponentState;
private _currentAction: string;
private _currentFromState: ComponentStates;
public create(componentName: string) {
Guard.notNothing(componentName, 'componentName');
this._currentAction = undefined;
this._currentFromState = undefined;
this._componentStates = { name: componentName, disableWhenProcessing: false, showProgressBar: true, states: {} };
return this;
}
public withId(id: string | number) {
Guard.notNothing(id, 'id');
this._componentStates.id = id;
return this;
}
public disableWhenProcessing() {
this._componentStates.disableWhenProcessing = true;
return this;
}
public showProgressBar(showProgressbar: boolean) {
this._componentStates.showProgressBar = showProgressbar;
return this;
}
public forAction(actionType: string) {
Guard.notNothing(actionType, 'actionType');
this._componentStates.states[actionType] = {};
this._currentAction = actionType;
return this;
}
public fromState(fromState: ComponentStates) {
Guard.notNothing(fromState, 'fromState');
this._componentStates.states[this._currentAction][fromState] = {};
this._currentFromState = fromState;
return this;
}
public toState(toState: ComponentStates) {
Guard.notNothing(toState, 'toState');
this._componentStates.states[this._currentAction][this._currentFromState].to = toState;
return this;
}
public passThrough() {
this._componentStates.states[this._currentAction][this._currentFromState].action = PassthroughAction;
return this;
}
public terminate() {
this._componentStates.states[this._currentAction][this._currentFromState].terminate = true;
return this;
}
public transformTo(action: Function) {
Guard.notNothing(action, 'action');
this._componentStates.states[this._currentAction][this._currentFromState].action = action;
return this;
}
public build() {
this.validate(this._componentStates.states);
return this._componentStates;
}
public validate(states: any) {
forOwn(states, (value: any, key: string) => {
forOwn(value, (actionValue: any, actionKey: string) => {
if (!actionValue.action && !actionValue.terminate) {
throw new Error(`The component state change for ${key} is missing a passthrough, transform, or terminate flag`);
}
});
});
}
}

Once the component has specified the state transitions that it is interested in the next step is to register these with a component state service.

// the component uses an injected componentStateService to register its state transitions
this._componentStateService.addComponentStates(componentStates);
// the component state service is reasonably simple and just maintains a lookup object of all the registered
// state transitions, and also has methods for cleaning these up when components are destroyed
@Injectable()
export class ComponentStateService {
public componentStates: any = {};
constructor(private _store: Store<RootState>) {
Guard.notNothing(_store, '_store');
}
public addComponentStates(componentStateData: any) {
this.deleteComponentState(componentStateData.name);
forOwn(componentStateData.states, (value: any, key: string) => {
if (!this.componentStates[key]) {
this.componentStates[key] = {};
}
if (!this.componentStates[key][componentStateData.name]) {
if (componentStateData.id) {
value.id = componentStateData.id;
}
this.componentStates[key][componentStateData.name] = value;
}
});
}
public removeComponentStates(componentName: string) {
forOwn(this.componentStates, (value: any, key: string) => {
if (value[componentName]) {
delete this.componentStates[key][componentName];
}
if (isEmpty(this.componentStates[key])) {
delete this.componentStates[key];
}
});
this.deleteComponentState(componentName);
}
public updateComponentState(componentName: string, uiState: ComponentStates) {
this._store.dispatch(new UpdateComponentStateAction({ componentName, uiState }));
}
public deleteComponentState(componentName: string) {
this._store.dispatch(new DeleteComponentStateAction({ componentName }));
}
}
The above service still does some restructuring of the state data into a more machine readable format. This is left over from when we were specifying the state date as object literals in the components. It would now be possible to have the fluent builder structure the object in the correct format, and the service would then be even simpler, just inserting the state data as received. For reasons of maintaining backwards compatibility with some older components we haven't got around to doing this yet.

In the fourth and final article in this series I will show how the central state machine handles the flow of actions based on the registered component states.

Comments