Building a visual feedback service for angular http calls with material

Update: I improved this here. Read on for educational purposes, but make sure to read the article linked as well since it makes the whole code prettier and more architecturally neat.

Building web applications using angular is fun, we all know that. With angular material, it’s also pretty to look at. So let’s build a service that gives us some convenience regarding visual feedback for users while performing http requests.

Angular Material has a cool progress spinner component and it also includes toasty notifications that pop from the bottom which they call snack bar.

When performing simple CRUD operations on an arbitrary data structure, wouldn’t it be nice to give a generic feedback system that can be used while developing? Every time we have an http request, it gives feedback, in the form of a progress spinner and optionally also with these little pop up notifications that tell us if everything went right or if problems occurred.

Plan with tests and implement

We need a service that wraps the MdSnackBar Service provided by the MaterialModule. This service can then also manage any spinner components that can register as a listener to this service and get notifications of any ongoing http calls so that they show visual feedback using the spinner. So we create these two:

  • VFeedbackService
  • WorkingSpinnerComponent

First let’s look at the VFeedbackService or rather its spec since we do this test driven:

let snackSpy = jasmine.createSpyObj('snackBar', ['open']);

//...
//....beforeEach, TestBed configurations etc
//...

it('should let listeners subscribe', function () {
        expect(service._listeners.length).toBe(0);
        service.addListener({
            onLoading: () => {
            }, onLoadingComplete: () => {
            }
        });
        expect(service._listeners.length).toBe(1);
    });

    it('should remove listeners on removal', function () {
        expect(service._listeners.length).toBe(0);
        let listener = {
            onLoading: () => {
            }, onLoadingComplete: () => {
            }
        };
        service.addListener(listener);
        service.addListener(listener);
        service.addListener(listener);
        expect(service._listeners.length).toBe(3);
        service.removeListener(listener);
        expect(service._listeners.length).toBe(0);
    });

    it('should notify spinners of processing events and completion', fakeAsync(() => {

        let listenerSpy = jasmine.createSpyObj('listenerSpy', ['onLoading', 'onLoadingComplete']);
        service.addListener(listenerSpy);

        let obs = new Observable(subscribe => {
            subscribe.next('foobar');
            subscribe.complete();
        });
        service.spinUntilCompleted(obs);
        //wait one async cycle
        tick();
        expect(listenerSpy.onLoading).toHaveBeenCalledTimes(1);

        expect(listenerSpy.onLoadingComplete).toHaveBeenCalledTimes(1);

    }));

    it('should not notify spinners if observable has not been completed', fakeAsync(() => {
        let listenerSpy = jasmine.createSpyObj('listenerSpy', ['onLoading', 'onLoadingComplete']);
        service.addListener(listenerSpy);

        let obs = new Observable(subscribe => {
            subscribe.next('foobar');
        });
        service.spinUntilCompleted(obs);
        //wait one async cycle
        tick();
        expect(listenerSpy.onLoadingComplete).toHaveBeenCalledTimes(0);
    }));

    it('should call MdSnackBar on successful response from observable', done => {
        service.showMessageOnAnswer('hooray!', null, new Observable(sub => {
            sub.next('fake server response');
            sub.complete();
        }))
        //inside observable subscription of service
            .subscribe(next => {
                expect(snackSpy.open).toHaveBeenCalledWith('hooray!', undefined, {duration: 1500})
            }, null, done);
    });

    it('should call MdSnackBar  with error on failed response from observable', done => {
        service.showMessageOnAnswer('hooray!', 'fail :(', new Observable(sub => {
            sub.error();
            sub.complete();
        }))
        //inside observable subscription of service
            .subscribe(next => {
                expect(snackSpy.open).toHaveBeenCalledWith('fail :(', undefined, {duration: 1500});
            }, null, done);
    });

 

As is visible from the specs, the service manages a list of listeners and offers http services to hand over their observables so that the service gives feedback once they succeeded or failed.

The tests use angulars async helper functions to properly test all the observables and such. They mock away the SnackBar service as well as any listener components that subscribe to the service

Now that we have the spec, we can simply implement the service. It’s straight forward from the specs but I’ll share it anyways

import {Injectable} from "@angular/core";
import {MdSnackBar, MdSnackBarRef, SimpleSnackBar} from "@angular/material";
import {Observable} from "rxjs";
import {LoadingEventListener} from "./LoadingEventListener";

@Injectable()
export class VFeedbackService {

    _listeners: LoadingEventListener[] = [];

    constructor(private snackBar: MdSnackBar) {

    }

    public addListener(listener: any) {
        this._listeners.push(listener);
    }

    public removeListener(listener: any) {
        this._listeners = this._listeners.filter(item => item !== listener)
    }

    /**
     *
     * @param forthis: The observable to watch and spin for
     */
    public spinUntilCompleted(forthis: Observable<any>): void {
        //start up all listeners
        this.triggerAllListeners();
        //on end of observable call complete for all listeners
        forthis.subscribe(null, null, () => {
            this.completeAllListeners()
        })

    }

    /**
     *
     * @param successMessage
     * @param failMessage
     * @param observableRequest
     * @param actionText
     */
    public showMessageOnAnswer(successMessage: string, failMessage: string, observableRequest: Observable<any>, actionText?: string): Observable<MdSnackBarRef<SimpleSnackBar>> {
        let obs = new Observable(sub => {
            observableRequest
                .subscribe(
                    null,
                    err => {
                        let snack = this.snackBar.open(failMessage, null, {duration: 1500});
                        sub.next(snack);
                        sub.complete()
                    },
                    () => {
                        sub.next(this.snackBar.open(successMessage, actionText, {duration: 1500}));
                        sub.complete()
                    })
        }).publish();
        obs.connect();
        return obs;
    }

    private triggerAllListeners() {
        this._listeners.forEach((listener) => {
            if (listener) listener.onLoading();
        });
    }

    private completeAllListeners() {
        this._listeners.forEach(listener => {
            if(listener)listener.onLoadingComplete();
        });
    }

}

 

This service is not yet perfect. If you have several http requests running in parallel, the first one that completes stops all listeners and therefore stops them from spinning. This can be averted by keeping track of the number of calls to spinUntilCompleted and only notify all listeners of a stop, once all subscriptions are completed. Feel free to add that to your code if you have the need for it.

Now let’s look at the spinner components. They are so simple, writing tests for them is almost not worth it. I skipped it.

import {Component, OnInit, OnDestroy} from "@angular/core";
import {LoadingEventListener} from "../../services/LoadingEventListener";
import {VFeedbackService} from "../../services/vfeedback.service";
import {_keyValueDiffersFactory} from "@angular/core/src/application_module";

@Component({
    selector: 'app-working-spinner',
    template: `<md-progress-spinner mode="indeterminate" *ngIf="_visible">`,
    styleUrls: ['working-spinner.component.scss']
})
export class WorkingSpinnerComponent extends LoadingEventListener implements OnInit, OnDestroy {

    private _visible: boolean;

    constructor(public vfeedbackService: VFeedbackService) {
        super();
    }

    onLoading() {
        this._visible = true;
    }

    onLoadingComplete() {
        this._visible = false;
    }

    ngOnInit() {
        this.vfeedbackService.addListener(this);
    }

    ngOnDestroy(): void {
        this.vfeedbackService.removeListener(this);
    }
}

Using it in our services

public orderPost(offerId: string, takeaway: boolean): Observable<Order> {

        //http call prep code ...
        //...

        let obs = this.http.post(path, orderBooking, requestOptions)
            .map((response: Response) => {
                if (response.status === 204) {
                    return undefined;
                } else {
                    return response.json();
                }
            })
            .publish(); //ensuring we only have one subscription to pass around, otherwise you get multiple http calls every time
        obs.connect();  

        //here we tell the feedback service what we want. we'd like spinning and toasting
        this.vfeedback.showMessageOnAnswer('Order placed!', 'Oops', obs);
        this.vfeedback.spinUntilCompleted(obs);
        return obs;

    }

As you can see, we just create an http call and then pass it to our service so it can hook into the event process. If we only want spinners, we just use spinUntilCompleted, if we also want a message, we call both methods.

It’s important to note here that failing to ensure you are working with a single subscription (using .share() or .publish() ), the VFeedbackService triggers a secondary http call by subscribing to your observable. So be sure to keep that in mind.

Next steps

Next, it would be nice to hook into the http calls event system to automate the spinner and clean this code out of our services. The http service doesn’t care about the visual feedback, it just does what it has to do. But our spinner should be able to know when http calls are performed and act upon it.

Option A: Wrap http and use the wrapping service instead of http directly. A similar approach has been chosen by Auth0, to attach a jwt token to every http request to ensure its authenticated.

Option B: Hook into the stuff that angulars http uses behind the scences, namely BrowserXhr. I will investigate this in a future post.

Wrapping it up

I showed how to create a service that can quickly be used to allow for user feedback using angular material in angular 2 projects. You can replace my component using an md-progress-spinner by a dancing chicken or whatever you please. Hooking into the BrowserXhr will be explained in a follow up post, once I got the code clean and put a bow tie on it.

Dieser Beitrag wurde unter Software Engineering veröffentlicht. Setze ein Lesezeichen auf den Permalink.

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s