Building a Configurator in Angular

Introduction

Modl Buildr is a configurator that allows users to select and configure a product of choice. It was inspired by a desktop configurator application and the question, “How can this problem be solved better in the browser?.”

As with many of our projects, we used AngularJS. Built as a framework for enhancing and augmenting the HTML language, Angular provides developers with a toolset supporting web applications consisting of dynamic, data driven content. Angular’s primary components include services for the isolation of business logic, routing for application architecture, controllers for marshaling data and directives for concise templates and view. Modl Buildr is created on these four core concepts.

We introduced Modl Buildr in a previous blog post.

If you prefer a video exclamation to text, the video below explains how it works in considerable detail.

For text, read on.

Routing

Client side routing is needed in most non-trivial Angular applications. The router allows users to navigate between features of the application without the need for additional server round-trips. When establishing the routes of the application Modl Buildr takes advantage of the syntax and creates a declarative road map of the application’s “pages”. The state declarations that are added highlight what routes are available and their associated models, views and controllers. By utilizing the resolve mechanism it is immediately clear what pieces of data are needed in order for any given route to function properly. This also removes the need for the controller to communicate with an API service or contain promise handling boilerplate.

Controllers and Services

Connecting the models to the view are various combinations of controllers and services. The router takes advantage of custom services to allow the creation of clean, simple and maintainable controllers. Consider the following code used to connect the landing page’s view to its model.

Controller:

(function () {
   'use strict';
   angular.module('app.home', [])
       .controller('HomeCtrl', function ($state, featuredProducts, popularProducts, productDataService) {
           var hctrl = this;
           hctrl.featured = featuredProducts;
           hctrl.popular = popularProducts;
           hctrl.selectProduct = selectProduct;

           //Prefetch for next route in navigation
           productDataService.getProductList();

           function selectProduct(selectedProduct) {
               $state.go('configure', {productId: selectedProduct.qsBaseID});
           }
       });
}());

Services:

that.getFeaturedList = function () {
   return that.getProductList().then(function (list) {
       return _.where(list, { Featured: true });
   });
};

that.getPopularList = function () {
   return that.getProductList().then(function (list) {
       return _.where(list, {Popular: true});
   });
};

The code responsible for obtaining and shaping the data is isolated nicely in a service, consumed by the router, and made available to the controller. Meanwhile, the controller simply passes the data to the view. This MVVM (MVC, MVW*) pattern is seen throughout the application.

Not all of the controllers and services are this simple. Some data bindings require constant updating in the form functions. For example, as a user configures a product there is a significant amount of calculation that occurs to determine if the product’s configuration is still valid. The result of this calculation is bound to the view for the user to see. Best practices encourage a clean separation of controllers and services while allowing the service to take responsibility for complex computation. The following code snippet demonstrates how Modl Buildr achieves this separation while maintaining two way data binding.

(function () {
   'use strict';
   angular.module('app.configure')
       .controller('ConfigureCtrl', function ($scope, $stateParams, $state,
                    productDataService, productStateService,appStateService) {
           var confCtrl = this;

           //the result of execution valid changes with every user input
           confCtrl.valid = valid;
           confCtrl.reset = reset;
           confCtrl.goToSummary = goToSummary;
           confCtrl.optionDisplay = optionDisplay;

           //Guard against extra API calls by only updating when the user’s selections have changed
           $scope.$watchCollection(function () {
               return confCtrl.selections;
           }, function (n, o) {
               if (n && n._seriesId) {
                  productDataService.getValidity(n).then(function () {
                       updateAll();
                   });
               }
           });

           function goToSummary() {
               var modelNumber = productStateService.productOptions.BaseLabel;
               modelNumber += _.map(productStateService.levelsToBuildModelNumber(), function (level) {
                   return productStateService.selections[level.Tag];
               }).join('');
               $state.go("summary", {productId: productStateService.productOptions.qsBaseID, number: modelNumber});
           }

           //This is called after every change to the user options
           function updateAll() {
               confCtrl.levels = productStateService.levelsToConfigure();
               confCtrl.modelDisplayOptions = productStateService.levelsToBuildModelNumber();
               confCtrl.valid = productStateService.valid;
               confCtrl.selections = productStateService.selections;
               confCtrl.productInfo = productStateService.productOptions;
           }
       });
}());

In the example above productInfo (the model number) and selections (the user’s choice of options) are bound to the view. The data in productInfo is a result of a function whose input is the selections. This approach not only provides a simple set of bindings, but also matches the application’s primary use case perfectly. The primary use case of the application is to select from a set of options and generate the corresponding model number. The challenge with this solution is that the server acts as a block box while performing the calculation. Fortunately the binding function is designed in such a way that it doesn’t matter where the complex calculation takes place or what it does. As the selections are updated the controller makes a service call and updates the bindings based upon the updated data that’s returned. The services handle the question of what to do and isolates the controller from any complexities or potential changes to this process.

Directives

There are several elements that appear on multiple screens of the application. These include simple pieces of functionality such as a hover text explaining why a button is disabled to custom widgets such as the product tiles. While controllers and services are great for laying out the big picture. Directives shine when used to create reusable and named components.

Modl Buildr contains a custom product tile directive. A reusable tile with a card-like appearance is created by combining a bootstrap panel, an image and a styled definition list. This tile is used to present products to the user. By specifying a few custom properties the same tile directive can be used across multiple situations.

Jade Template:

div.panel.panel-default.products-container(ng-click="click()",
   ng-class="{'product-tile': configure==='false', 'configure-tile': configure==='true'}")
   div.panel-heading
       | {{product.BaseLabel}}
       i.fa.fa-2x.fa-star.pull-right.featured(ng-show="product.Featured")
   div.panel-body.product-details
       img(ng-src="{{product.Image}}")
       hr
       dl.dl-horizontal.centered-block
           dt Name:
           dd {{product.BaseDesc}}
           dt Mfr:
           dd {{product.Manufacturer}}
           dt Item Type:
           dd {{product.ItemType}}
           dt Subtype:
           dd {{product.ItemSubType1}}

Javascript:

(function () {
   'use strict';
   angular.module('app.tile', [])
       .directive('descriptionTile', function () {
           return {
               restrict: 'E',
               templateUrl: "app/tile/descriptionTile.html",
               scope: {
                   product: '=info',
                   click: '&',
                   configure: '@'
               }
           };
       });
}());

With this definition and an ng-repeat these tiles are easily stamped out in a grid formation. This directive is also used on subsequent screens to display the product being configured.

Another helpful directive is the model number directive. The model number at the top of the configure and summary screens is generated by the users actions. Rather than complicate the main page controllers with the logic for displaying this interactive control a directive was created to isolate this concern. Now a model number can be displayed anywhere in the application by adding the following line of HTML (Jade) in the desired location:

model-number(product-name='{{cfg.productInfo.BaseLabel}}',
    current-selections='cfg.modelDisplayOptions',
    scroll-target='scroll-container')
// (the above is Jade, http://jade-lang.com/

Conclusions

Angular turned out to be a good fit for this application, yielding a working solution with a modest amount of code. Moreover, the application works quite nicely in a browser, at least as nicely as the native desktop application we were inspired by. The browser environment in 2015 is completely suitable for complex rules-based configurators.

 

 

One thought on “Building a Configurator in Angular”

Comments are closed.