Developing with AngularJS - Part III: Services

AngularJS This is the 3rd article in a series on my experience developing with AngularJS. I used AngularJS for several months to create a "My Dashboard" feature for a client and learned a whole bunch of Angular goodness along the way. For previous articles, please see Part I: The Basics and Part II: Dialogs and Data.

Angular offers several ways to interact with data from the server. The easiest way is to use the $resource factory, which lets you interact with RESTful server-side data sources. When we started the My Dashboard project, we were hoping to interact with a REST API, but soon found out that it didn't have all the data we needed. Rather than loading the page and then making another request to get its data, we decided to embed the JSON in the page. For communication back to the server, we used our tried-and-true Ajax solution: DWR.

In Angular-speak, services are singletons that carry out specific tasks common to web apps. In other words, they're any $name object that can be injected into a controller or directive. However, as a Java Developer, I tend to think of services as objects that communicate with the server. Angular's documentation on Creating Services shows you various options for registering services. I used the angular.Module api method.

When I last worked on the project, there were only two services in My Dashboard: Widget and Preferences.

Widget Service

The Widget service is used to retrieve the visible widgets for the user. It has two functions that are exposed to controllers: getUserWidgets(type) and getHiddenWidgets(type). The former function is used at the top of WidgetController, while the latter is used for the configuration dialog mentioned in the previous article.

The code for this service is in services.js. The bulk of the logic is in its filterData() function, where it goes through a 4-step process:

  1. Get all the widgets by type, ensuring they're unique.
  2. Remove the widgets that are hidden by the user's preferences.
  3. Build an array that's ordered by user's preferences.
  4. Add any new widgets that aren't hidden or ordered.

The code for the Widget object is as follows:

angular.module('dashboard.services', []).
    factory('Widget',function ($filter, Preferences) {
        var filter = $filter('filter');
        var unique = $filter('unique');

        function filterData(array, query) {
            // get all possible widgets for a particular type
            var data = filter(array, query);
            data = unique(data);

            // remove widgets that are hidden by users preference
            var hidden = Preferences.getHiddenWidgets(query.type);
            for (var i = 0; i < hidden.length; i++) {
                var w = filter(data, {id: hidden[i]});
                $.each(w, function (index, item) {
                    var itemId = item.id;
                    if (hidden.indexOf(itemId) > -1) {
                        data.splice(data.indexOf(item), 1);
                    }
                });
            }

            // build an array that's ordered by users preference
            var ordered = [];
            var visible = Preferences.getUserWidgets(query.type);
            for (var j = 0; j < visible.length; j++) {
                var v = filter(data, {id: visible[j]});
                $.each(v, function (index, item) {
                    var itemId = item.id;
                    if (visible.indexOf(itemId) > -1) {
                        ordered.push(item)
                    }
                });
            }

            // loop through data again and add any new widgets not in ordered
            $.each(data, function (index, item) {
                if (ordered.indexOf(item) === -1) {
                    ordered.push(item);
                }
            });

            return ordered;
        }

        return {
            getUserWidgets: function (type) {
                return filterData(widgetData, {type: type})
            },

            getHiddenWidgets: function (type) {
                var hidden = Preferences.getHiddenWidgets(type);
                var widgetsForType = filter(widgetData, {type: type});
                widgetsForType = unique(widgetsForType);
                var widgets = [];
                for (var j = 0; j < hidden.length; j++) {
                    var v = filter(widgetsForType, {id: hidden[j]});
                    $.each(v, function (index, item) {
                        if (widgetsForType.indexOf(item) > -1) {
                            widgets.push(item)
                        }
                    });
                }
                return widgets;
            }
        }
    })

Once you have a service configured like this, you can inject it by name. For example, WidgetController has Widget injected into its constructor:

function WidgetController($dialog, $scope, Widget, Preferences) {

Preferences Service

The Preferences service is used to get and save user preferences. It's pretty straightforward and the bulk of its code is interacting with DWR. This service has 5 methods:

  1. getHiddenWidgets(type) - used by Widget service
  2. getUserWidgets(type) - used by Widget service
  3. saveBarOrder(bars) - called from WidgetController
  4. saveWidgetOrder(type, widgets) - called from WidgetController
  5. saveWidgetPreferences(type, widgets) - called from WidgetController

First, let's take a look at the save*Order() functions. There are two parts of the page that use the ui-sortable directive to initialize drag-and-drop functionality. The first is on the main <ul> that holds the 3 bars on the left.

<ul class="widgets" ui-sortable="{handle:'.heading', update: updateBars}">

The "update" property in the configuration JSON indicates which method to call in the controller. Similarly, the tasks and summary items call an updateOrder function.

<ul class="summary-items" ng-model="summaryWidgets" ui-sortable="{update: updateOrder}">
...
<ul class="task-items" ng-model="taskWidgets" ui-sortable="{update: updateOrder}">

These functions are in WidgetController and build an array of widget ids to pass to the Preferences service.

$scope.updateBars = function(event, ui) {
    var bars = [];
    $.each($(ui.item).parent().children(), function (index, item) {
        bars.push(item.id.substring(0, item.id.indexOf('-')))
    });
    Preferences.saveBarOrder(bars);
};

$scope.updateOrder = function(event, ui) {
    var parentId = $(ui.item).parent().parent().attr('id');
    var type = parentId.substring(0, parentId.indexOf('-'));
    var items = [];
    $.each($(ui.item).parent().children(), function (index, item) {
        items.push(item.id.substring(item.id.indexOf('-') + 1))
    });
    Preferences.saveWidgetOrder(type, {items: items});
};

The bar order is used when the page is loaded. The following scriptlet code exists at the bottom of the app's page, in its $(document).ready:

<%  String barOrder = user.getDashboardBarSortOrder();
    if (barOrder != null) { %>
    sortBars(['<%= barOrder %>']);
<% } %>

The sortBars() function is in a dashboard.js file (where we put all non-Angular functions):

function sortBars(barOrder) {
    // Sort bars according to user preferences
    $.each(barOrder, function(index, item) {
        var bar = $('#' + item + '-bar');
        if (bar.index() !== index) {
            if (index === 0) {
                bar.insertBefore($('.widgets>li:first-child'));
            } else if (index === (barOrder.length - 1)) {
                bar.insertAfter($('.widgets>li:last-child'));
            } else {
                bar.insertBefore($('.widgets>li:eq(' + index + ')'));
            }
        }
    });
}

Now that you've seen where Preferences is called from, let's take a look at the code for the service.

The checks for undefined and uniqueness in the code below shouldn't be necessary, but I prefer defensive coding.
factory('Preferences', function ($filter) {
    var unique = $filter('unique');

    return {
        // Get in-page variable: hiddenWidgets
        getHiddenWidgets: function (type) {
            var items = hiddenWidgets[type];
            return (angular.isUndefined(items) ? [] : unique(items));
        },

        // Get in-page variable: userWidgets
        getUserWidgets: function (type) {
            var items = userWidgets[type];
            return (angular.isUndefined(items) ? [] : unique(items));
        },

        // Save main bar (task, summary, chart) order
        saveBarOrder: function (bars) {
            DWRFacade.saveDashboardBarSortOrder(bars, {
                errorHandler: function (errorString) {
                    alert(errorString);
                }
            })
        },

        // Save order of widgets from sortable
        saveWidgetOrder: function (type, widgets) {
            userWidgets[type] = widgets.items;
            DWRFacade.saveDashboardWidgetPreference(type, widgets, {
                errorHandler: function (errorString) {
                    alert(errorString);
                }
            });
        },

        // Save hidden and visible (and order) widgets from config dialog
        saveWidgetPreferences: function (type, widgets) {
            // widgets is a map of hidden and visible
            var hiddenIds = [];
            $.each(widgets.hidden, function (index, item) {
                hiddenIds.push(item.id);
            });
            var visibleIds = [];
            $.each(widgets.items, function (index, item) {
                visibleIds.push(item.id);
            });
            var preferences = {
                hidden: hiddenIds,
                items: visibleIds
            };
            // reset local variables in page
            hiddenWidgets[type] = hiddenIds;
            userWidgets[type] = visibleIds;
            DWRFacade.saveDashboardWidgetPreference(type, preferences, {
                errorHandler: function (errorString) {
                    alert(errorString);
                }
            });
        }
    }
})

Using $http and Receiving Data

In this particular application, we didn't do any reading from the server with Angular. We simply wrote preferences to the server, and updated embedded variables when data changed. Real-time functionality of the app wouldn't be noticeable if a write failed.

In my current Angular project, it's more of a full-blown application that does as much reading as writing. For this, I've found it useful to either 1) pass in callbacks to services or 2) use Angular's event system to publish/subscribe to events.

The first method is the easiest, and likely the most familiar to JavaScript developers. For example, here's the controller code to remove a profile picture:

Profile.removePhoto($scope.user, function (data) {
    // close the dialog
    $scope.close('avatar');
    // success message using toastr: http://bit.ly/14Uisgm
    Flash.pop({type: 'success', body: 'Your profile picture was removed.'});
})

And the Profile.removePhoto() method:

removePhoto: function (user, callback) {
    $http.post('/profile/removePhoto', user).success(function (response) {
        return callback(response);
    });
}

The second, event-driven method works equally as well, but can easily suffer from typos in event names.

// controller calling code
Profile.getUser();

// service code
getUser: function () {
    $http.get('/profile').success(function (data) {
        if (data.username) {
            $log.info('Profile for ' + data.username + ' retrieved!');
            $rootScope.$broadcast('event:profile', data);
        }
    });
}

// controller receiving code
$rootScope.$on('event:profile', function (event, data) {
    $scope.user = data;
});

I like both methods, but the event-driven one seems like it could offer more extensibility in the future.

Summary

Using in-page variables and DWR doesn't seem to be recommended by the Angular Team. However, it worked well for us and seems like a good way to construct Angular services. Even if a REST API becomes available to get all the data, I think using in-page variables to minimize requests is a good idea.

When retrieving data, you can use callbacks or Angular's pub/sub event system ($broadcast and $on) to get data in your controllers. If you want to learn more about this technique, see Eric Terpstra's Communicating with $broadcast. In his article, Eric mentions Thomas Burleson's pub/sub module that acts as a message queue. If you've used Thomas's MessageQueue (or something similar) with Angular, I'd love to hear about your experience.

In the next article, I'll talk about how we redesigned My Dashboard and used CSS3 and JavaScript to implement new ideas.

Posted in The Web at Jun 25 2013, 07:03:26 AM MDT 10 Comments
Comments:

Hi Matt,

Did you use AngularJs with Grails or Java for this project? I used it in my last project with Java but I am trying to start a Grails project and I'm interested in using Angularjs if it could be leveraged.

Thanks

Posted by Juan C on July 30, 2013 at 07:32 AM MDT #

Juan - I used Java for this project, and DWR for all the remote communication. I also completed a project in June using AngularJS and Grails. Everything worked quite nicely together.

Posted by Matt Raible on July 30, 2013 at 07:33 AM MDT #

Looking forward to read the next article !! this is cool stuff!! I'm not sure if the DWR is the best approach though, I would use the resolve function to pass the initial to be display in the page instead of in page variables.

Posted by Ricardo on September 02, 2013 at 10:40 AM MDT #

Hi Matt, will you publish the source code?

Posted by Francisco Philip on September 03, 2013 at 01:24 PM MDT #

Francisco - the source code is part of a client's project. Therefore, I won't be publishing it.

Posted by Matt Raible on September 03, 2013 at 01:26 PM MDT #

Matt - thank you for the good insight. I agree that the solution using DWR appears to be viable.

Best regards,
Guntram

Posted by Guntram on September 06, 2013 at 12:19 PM MDT #

Hi Matt,

Nice article.

Any idea on the timescales for Part 4...?

Best Regards,

Frank.

Posted by Frank Cobbinah on September 24, 2013 at 01:43 AM MDT #

[Trackback] Welcome to the final article in a series on my experience developing with AngularJS . I learned its concepts, beat my head against-the-wall with and finally tamed it enough to create a "My Dashboard" feature for a client. For previous articles, please...

Posted by Raible Designs on September 24, 2013 at 07:02 PM MDT #

Hi, great series, but where is your next article after part 3? thanks

Posted by Nathan on March 14, 2014 at 06:42 AM MDT #

Nathan, you can find it at Developing with AngularJS - Part IV: Making it Pop.

Posted by Matt Raible on March 14, 2014 at 06:43 AM MDT #

Post a Comment:
  • HTML Syntax: Allowed