Developing with AngularJS - Part III: Services
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:
- Get all the widgets by type, ensuring they're unique.
- Remove the widgets that are hidden by the user's preferences.
- Build an array that's ordered by user's preferences.
- 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:
- getHiddenWidgets(type) - used by Widget service
- getUserWidgets(type) - used by Widget service
- saveBarOrder(bars) - called from WidgetController
- saveWidgetOrder(type, widgets) - called from WidgetController
- 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.
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.
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 01:32 PM MDT #
Posted by Matt Raible on July 30, 2013 at 01:33 PM MDT #
Posted by Ricardo on September 02, 2013 at 04:40 PM MDT #
Posted by Francisco Philip on September 03, 2013 at 07:24 PM MDT #
Posted by Matt Raible on September 03, 2013 at 07: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 06: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 07:43 AM MDT #
Posted by Raible Designs on September 25, 2013 at 01:02 AM MDT #
Posted by Nathan on March 14, 2014 at 12:42 PM MDT #
Posted by Matt Raible on March 14, 2014 at 12:43 PM MDT #