2014-09-16

AngularJS master-slave select with $location.search() params

In this post we will see how to implement a simple master-slave select controls in AngularJS.

This kind of controls are useful when your users selection depends on the previous select.

For example:
  • continent (Europe, America, Asia, Oceania)
  • country (USA, ...)
  • state (California, ...)
  • etc
The implementation is a bit more complex because I want to support urls that reflect each user input (example: /search -> /search?continent=America -> /search?continent=America&country=USA -> etc). This way the user will be able to share the url of an intermediate state of the application without having to retype all form inputs. Sometimes if you paste the url of a single page application heavily based on AJAX you loose the context.

The key concepts based on this kind of implementation are:
  • $location.search()
  • use reloadOnSearch: false in conjunction on $routeProvider.when. It just updates query params without a reload

Code

In bold the relevant blocks.

Controller

'use strict';

angular
  .module('angularApp', []);
 
  angular.module('angularApp')
  .controller('MainCtrl', ['$scope', '$location', 'queryFactory', function ($scope, $location, queryFactory) {

    var params = $location.search();   // the current params dict

    var initParams = function () {
      /* init controllers conf params */
      angular.forEach(params, function (value, key) {
        if ($scope.conf.search[key]) {
          $scope.conf.search[key].selected = value;
        }
      });
    };

    /* The model configuration */
    $scope.conf = {
      initsearch: 'select1',   // the master of all selects
      search: {
        select1: {
          values: [], selected: undefined, slave: 'select2', updater: queryFactory.getSelect1
        },
        select2: {
          values: [], selected: undefined, slave: undefined, updater: queryFactory.getSelect2
        },
      }
    };

    /* Master select initialization (lookup of options) */  $scope.conf.search[$scope.conf.initsearch].updater($scope.conf.search)
      .success(function(data) {
        $scope.conf.search[$scope.conf.initsearch].values = data;
      });

    /* Init $scope.conf.search depending on search params.
     * */

    initParams();

    // not yet available $watchGroup on angularjs 1.2
    angular.forEach($scope.conf.search, function (value, key) { // jshint ignore:line
      $scope.$watch('conf.search.' + key + '.selected', function(newValue, oldValue) { // jshint ignore:line
        var slave = $scope.conf.search[key].slave;

        params[key] = newValue;
        $location.search(params);

        if (newValue) {
          if ($scope.conf.search[slave]) {
            $scope.conf.search[slave].updater($scope.conf.search)
              .success(function(data) {
                $scope.conf.search[slave].values = data;
              });
          }
          else {
            // slave is undefined
            if ($scope.conf.search[key].selected) {
              // end of chain, do something
              // TODO
            }
          }
        }
        else {
          // removed selected and values
          if ($scope.conf.search[slave]) {
            $scope.conf.search[slave].values = [];
            if ($scope.conf.search[slave].selected) {
              $scope.conf.search[slave].selected = undefined;
            }
          }
        }
      });

    });
  }]);

Template

<!DOCTYPE html>
<html ng-app="angularApp">

  <head>
    <script data-require="angular.js@*" data-semver="1.2.22" src="https://code.angularjs.org/1.2.22/angular.js"></script>
    <link href="style.css" rel="stylesheet" />
    <script src="script.js"></script>
  </head>

  <body>
    <div ng-controller="MainCtrl">
      <h2>Master select widget example with AngularJS</h2>
      Launch in a separate window in order to see the .search() usage: click on the blue button on your top-right of the demo.<br/>
     
      <select ng-model="conf.search.select1.selected" ng-options="item.id as item.title for item in conf.search.select1.values">
        <option value="">--</option>
      </select>

      <select ng-model="conf.search.select2.selected" ng-options="item.id as item.title for item in conf.search.select2.values">
        <option value="">--</option>
      </select>

    </div>
  </body>

</html>

Service (performs $http queries)

angular.module('angularApp')
  .factory('queryFactory', ['$q', function ($q) {
    // Service logic (mock)
    // You should put here your $http calls, for a working example see http://davidemoro.blogspot.it/2014/09/angularjs-how-to-test-http-calls.html


    // Public API here
    return {
      getSelect1: function (conf) {  // jshint ignore:line
        var promise = $q.when([{id: '1', title: '1'}, {id: '2', title: '2'}]);
        promise.success = function(fn) {
          promise.then(function(response) {
            fn(response);
          });
          return promise;
        };

        return promise;
      },
      getSelect2: function (conf) { // jshint ignore:line
        var promise;
        if (conf['select1'].selected === '1') {
            promise = $q.when([{id: '1.1', title: '1.1'}, {id: '1.2', title: '1.2'}]);
        } else {
            promise = $q.when([{id: '2.1', title: '2.1'}, {id: '2.2', title: '2.2'}]);
        }
        promise.success = function(fn) {
          promise.then(function(response) {
            fn(response);
          });
          return promise;
        };

        return promise;
      }
    };
  }]);

How to implement $http tests

You can see an example of tests for a service $http-based decoupled from the controller logic:

Results

Selecting 1 on the first select you'll get 1.X values on the second one and so on

Plunkr demo

I have made a Plunkr demo available at this url:
If you open the Plunkr link in fullscreen mode you'll see the query params will change for each input. If you copy and paste the intermediate url you'll get still a compiled form.

Generic master-slave directive?

I'd like to implement a generic and more reusable master-slave directive. What are your suggestions about the best design strategy and how to build things following the Angular way?

No comments:

Post a Comment