Likert visualisations (Part 1 - @ngrx Actions and Reducers)

Posted on 2017-08-18 09:10:54

This is the first of a 2 part tutorial on creating a client side application for visualising the results of Likert Surveys. The application will be built using Angular, D3.js for the visualisations and ngrx for state management. This tutorial will cover the set up of the angular application using angular-cli and how to use the ngrx store for state management.

The completed application can be seen in action here and the source code is available on GitHub.

Setting up the project

To generate the new project install @angular/cli globally with npm and then run:

ng new -ng4 --style scss likert

This creates a new project using angular version 4, and scss for styling. I have used bootstrap fir styling in my application. To do the same run npm install --save bootstrap@4.0.0-beta in your newly created project directory. You then need to add the bootstrap styles and scripts to your .angular-cli.json file. The updated part of that file looks like the following:

{
 ...
  "apps": [
    {
      ...
      "styles": [
        "../node_modules/bootstrap/scss/bootstrap.scss",
        "styles.scss"
      ],
      "scripts": [
        "../node_modules/tether/dist/js/tether.min.js",
        "../node_modules/popper.js/dist/umd/popper.min.js",
        "../node_modules/jquery/dist/jquery.min.js",
        "../node_modules/bootstrap/dist/js/bootstrap.min.js"
      ],
      ...
  ],
    ...
}

Setting up the @ngrx library

One of the main issues developers seem to have with AngularJS is how to store and maintain the application state across different views. The newer Angular versions solve this issue by using RxJS Observables. The ngrx library which I have used for this project takes this state management a step further. To install this library run:

npm install --save @ngrx/core @ngrx/store@2.2.3

Creating actions and reducers

@ngrx uses a redux pattern where you have a persistent state, reducers which are pure functions that act on that state and a set of actions which are called on the reducer. There is a nice introduction to the @ngrx/store on wisdom of jim. It is good practice to create a new directory within src/app to contain the state, actions and reducers which I have called state-management and then create actions and reducers sub-directories.

mkdir state-management && cd state-management && mkdir actions && mkdir reducers

I also create a models directory for the application. The data model for our likert responses will be simple. There will be an array of response objects, each with an id, question text and array of response numbers which will represent the number of people who responded with that corresponding answer. So our models/response.ts file will look like this:

export interface Response {
    id: number;
    question: string;
    responses: number[];
};

Let’s create our state actions next. These actions are classes which implement the @ngrx/store Action class and contain a type which is a string that describes what the action does and an optional payload which is sent to the reducer. In a larger application you could have a number of action files each acting on different states but in this case we will only have one file;state-management/actions/main-actions.ts which is as follows:

import { Action } from "@ngrx/store";
import { Response } from "../../models/response";

export const INCREASE_NUMBER_RESPONSES = "INCREASE_NUMBER_RESPONSES";
export const DECREASE_NUMBER_RESPONSES = "DECREASE_NUMBER_RESPONSES";
export const ADD_RESPONSE              = "ADD_RESPONSE";
export const UPDATE_RESPONSE           = "UPDATE_RESPONSE";
export const REMOVE_RESPONSE           = "REMOVE_RESPONSE";
export const UPDATE_LABELS             = "UPDATE_LABELS";
export const UPDATE_CHART_TYPE         = "UPDATE_CHART_TYPE";

export class IncreaseNumberResponses implements Action {
    readonly type = "INCREASE_NUMBER_RESPONSES";
}

export class DecreaseNumberResponses implements Action {
    readonly type = "DECREASE_NUMBER_RESPONSES";
}

export class AddResponse implements Action {
    readonly type = "ADD_RESPONSE";
}

export class UpdateResponse implements Action {
    readonly type = "UPDATE_RESPONSE";
    constructor(public payload: Response) {}
}

export class RemoveResponse implements Action {
    readonly type = "REMOVE_RESPONSE";
    constructor(public payload: Response) {}
}

export class UpdateLabels implements Action {
    readonly type = "UPDATE_LABELS";
    constructor(public payload: string[]) {}
}

export class UpdateType implements Action {
    readonly type = "UPDATE_CHART_TYPE";
    constructor(public payload: string) {}
}

export type Actions
    = IncreaseNumberResponses
    | DecreaseNumberResponses
    | AddResponse
    | UpdateResponse
    | RemoveResponse
    | UpdateLabels
    | UpdateType

The reducer function takes the state and an action as arguments and will contain a switch statement that checks the action type and should have a case corresponding to each of the actions defined above. The reducer file should also contain an initial state and a type definition of the state and can contain some selector functions which extract a specific item from the state. The state-management/reducers/main-reducer.ts looks like this for our application:

import { Response } from "../../models/response";
import * as MainActions from "../actions/main-actions";

export interface State {
    responses: Response[];
    numberResponses: number;
    labels: string[];
    chartType: string;
}

export const initialState: State = {
    responses: [],
    numberResponses: 4,
    labels: ["","","",""],
    chartType: "bubbles"
}

var id: number = 0;

export function reducer(state = initialState, action: MainActions.Actions): State {

    switch (action.type) {
        case MainActions.INCREASE_NUMBER_RESPONSES: {
            return {
                numberResponses: state.numberResponses + 1,
                responses: state.responses.map(function(r){
                    return Object.assign({}, r, {responses: [...r.responses, 0]});
                }),
                labels: [...state.labels, ""],
                chartType: state.chartType
            };
        }
        case MainActions.DECREASE_NUMBER_RESPONSES: {
            return {
                numberResponses: state.numberResponses - 1,
                responses: state.responses.map(function(r){
                    return Object.assign({}, r, {responses: [...r.responses].slice(0,-1)});
                }),
                labels: [...state.labels].slice(0,-1),
                chartType: state.chartType
            };
        }
        case MainActions.ADD_RESPONSE: {
            id++;
            var emptyResponse: Response = {
                question: "",
                id: id,
                responses: Array.apply(null, {length: state.numberResponses}).map(function(){ return 0; })
            };
            return {
                numberResponses: state.numberResponses,
                responses: [...state.responses, Object.assign({}, emptyResponse)],
                labels: [...state.labels],
                chartType: state.chartType
            };
        }
        case MainActions.UPDATE_RESPONSE: {
            return {
                numberResponses: state.numberResponses,
                responses: state.responses.map(function(r){
                    return r.id != action.payload.id ? Object.assign({}, r) : action.payload;
                }),
                labels: [...state.labels],
                chartType: state.chartType
            };
        }
        case MainActions.REMOVE_RESPONSE: {
            return {
                numberResponses: state.numberResponses,
                responses: state.responses.filter(function(r){
                    return r.id != action.payload.id;
                }),
                labels: [...state.labels],
                chartType: state.chartType
            };
        }
        case MainActions.UPDATE_LABELS: {
            return {
                numberResponses: state.numberResponses,
                responses: [...state.responses],
                labels: action.payload,
                chartType: state.chartType
            };
        }
        case MainActions.UPDATE_CHART_TYPE: {
            return {
                numberResponses: state.numberResponses,
                responses: [...state.responses],
                labels: [...state.labels],
                chartType: action.payload
            };
        }
        default: {
            return state;
        }
    }

};

export const getLabels = (state: State) => state.labels;

export const getNumberResponses = (state: State) => state.numberResponses;

export const getChartType = (state: State) => state.chartType;

export const getResponses = (state: State) => state.responses;

We also need to create an reducer index file which will export our state functionality for use in the rest of the application. It will expose each of the states (in our case just main) and selector functions for those states. The state-management/reducers/index.ts file:

import { ActionReducerMap, createSelector, createFeatureSelector } from '@ngrx/store';

import * as fromMain from "./main-reducer";

export interface State {
    main: fromMain.State;
}

export const reducers: ActionReducerMap<State> = {
    main: fromMain.reducer
};

export const getMainState = (state: State) => state.main;

export const getResponses = createSelector(
    getMainState,
    fromMain.getResponses
);

export const getNumberResponses = createSelector(
    getMainState,
    fromMain.getNumberResponses
);

export const getLabels = createSelector(
    getMainState,
    fromMain.getLabels
);

export const getChartType = createSelector(
    getMainState,
    fromMain.getChartType
);

In part 2 we will see how to create a form component that interacts with the reducer we have just created.


Comments