Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

AngularJS controllers, design pattern for a DRY code

I have created a full example for the purpose of describing this issue. My actual application is even bigger than the presented demo and there are more services and directives operated by every controller. This leads to even more code repetition. I tried to put some code comments for clarifications, PLUNKER: http://plnkr.co/edit/781Phn?p=preview

Repetitive part:

routerApp.controller('page1Ctrl', function(pageFactory) {
  var vm = this;

  // page dependent
  vm.name = 'theOne';
  vm.service = 'oneService';
  vm.seriesLabels = ['One1', 'Two1', 'Three1'];

  // these variables are declared in all pages
  // directive variables,
  vm.date = {
    date: new Date(),
    dateOptions: {
      formatYear: 'yy',
      startingDay: 1
    },
    format: 'dd-MMMM-yyyy',
    opened: false
  };

  vm.open = function($event) {
    vm.date.opened = true;
  };

  // dataservice
  vm.data = []; // the structure can be different but still similar enough
  vm.update = function() {
      vm.data = pageFactory.get(vm.service);
    }

  //default call
  vm.update();   
})

Basically I moved all the logic I could to factories and directives. But now in every controller that uses certain directive I need, for example, a field that keeps the value that directive is modifying. And it's settings. Later I need similar field to keep the data that comes from dataservice, and the call itself (method) is the same as well.

This leads to a lot of repetition.


Graphically I see the current example to look like this:

The current design

While I believe the proper design should look more like this:

The expected design


I tried to find some solution here, but none seem to be confirmed. What I have found:

  1. AngularJS DRY controller structure, suggesting I pass the $scope or vm and decorate it with extra methods and fields. But many sources say it is dirty solution.
  2. What's the recommended way to extend AngularJS controllers? using angular.extend, but this have problems when using controller as syntax.
  3. And then I have found also the answer (in the link above):

You don't extend controllers. If they perform the same basic functions then those functions need to be moved to a service. That service can be injected into your controllers.

And even when I did there is still a lot of repetition. Or is it the way it just has to be? Like John Papa sais (http://www.johnpapa.net/angular-app-structuring-guidelines/):

Try to stay DRY (Don't Repeat Yourself) or T-DRY

Did you face a similar issue? What are the options?

like image 460
Atais Avatar asked Nov 25 '15 14:11

Atais


3 Answers

From a over all design perspective I don't see much of a difference between decorating a controller and extending a controller. In the end these are both a form of mixins and not inheritance. So it really comes down to what you are most comfortable working with. One of the big design decisions comes down to not just how to pass in functionality to just all of the controllers, but how to also pass in functionality to say 2 out of the 3 controllers also.

Factory Decorator

One way to do this, as you mention, is to pass your $scope or vm into a factory, that decorates your controller with extra methods and fields. I don't see this as a dirty solution, but I can understand why some people would want to separate factories from their $scope in order to separate concerns of their code. If you need to add in additional functionality to the 2 out of 3 scenario, you can pass in additional factories. I made a plunker example of this.

dataservice.js

routerApp.factory('pageFactory', function() {

    return {
      setup: setup
    }

    function setup(vm, name, service, seriesLabels) {
      // page dependent
      vm.name = name;
      vm.service = service;
      vm.seriesLabels = seriesLabels;

      // these variables are declared in all pages
      // directive variables,
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = get(vm.service);
      }

      //default call
      vm.update();
    }

});

page1.js

routerApp.controller('page1Ctrl', function(pageFactory) {
    var vm = this;
    pageFactory.setup(vm, 'theOne', 'oneService', ['One1', 'Two1', 'Three1']);
})

Extending controller

Another solution you mention is extending a controller. This is doable by creating a super controller that you mix in to the controller in use. If you need to add additional functionality to a specific controller, you can just mix in other super controllers with specific functionality. Here is a plunker example.

ParentPage

routerApp.controller('parentPageCtrl', function(vm, pageFactory) {

    setup()

    function setup() {

      // these variables are declared in all pages
      // directive variables,
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = pageFactory.get(vm.service);
      }

      //default call
      vm.update();
    }

})

page1.js

routerApp.controller('page1Ctrl', function($controller) {
    var vm = this;
    // page dependent
    vm.name = 'theOne';
    vm.service = 'oneService';
    vm.seriesLabels = ['One1', 'Two1', 'Three1'];
    angular.extend(this, $controller('parentPageCtrl', {vm: vm}));
})

Nested States UI-Router

Since you are using ui-router, you can also achieve similar results by nesting states. One caveat to this is that the $scope is not passed from parent to child controller. So instead you have to add the duplicate code in the $rootScope. I use this when there are functions I want to pass through out the whole program, such as a function to test if we are on a mobile phone, that is not dependent on any controllers. Here is a plunker example.

like image 130
jjbskir Avatar answered Nov 16 '22 16:11

jjbskir


You can reduce a lot of your boilerplate by using a directive. I've created a simple one to replace all of your controllers. You just pass in the page-specific data through properties, and they will get bound to your scope.

routerApp.directive('pageDir', function() {
  return {
    restrict: 'E',
    scope: {},
    controller: function(pageFactory) {
      vm = this;
      vm.date = {
        date: moment().startOf('month').valueOf(),
        dateOptions: {
          formatYear: 'yy',
          startingDay: 1
        },
        format: 'dd-MMMM-yyyy',
        opened: false
      };

      vm.open = function($event) {
        vm.date.opened = true;
      };

      // dataservice
      vm.data = []; // the structure can be different but still similar enough
      vm.update = function() {
        vm.data = pageFactory.get(vm.service);
      };

      vm.update();
    },
    controllerAs: 'vm',
    bindToController: {
      name: '@',
      service: '@',
      seriesLabels: '='
    },
    templateUrl: 'page.html',
    replace: true
  }
});

As you can see it's not much different than your controllers. The difference is that to use them, you'll use the directive in your route's template property to initialize it. Like so:

    .state('state1', {
        url: '/state1',
        template: '<page-dir ' +
          'name="theOne" ' +
          'service="oneService" ' +
          'series-labels="[\'One1\', \'Two1\', \'Three1\']"' +
          '></page-dir>'
    })

And that's pretty much it. I forked your Plunk to demonstrate. http://plnkr.co/edit/NEqXeD?p=preview

EDIT: Forgot to add that you can also style the directive as you wish. Forgot to add that to the Plunk when I was removing redundant code.

like image 38
kevrom Avatar answered Nov 16 '22 15:11

kevrom


I can't respond in comment but here what i will do :

I will have A ConfigFactory holding a map of page dependent variables :

{
  theOne:{
      name: 'theOne',
      service: 'oneService',
      seriesLabels: ['One1', 'Two1', 'Three1']
  },
  ...
}

Then i will have a LogicFactory with a newInstance() method to get a proper object each time i need it. The logicFactory will get all the data / method shared betwwen controllers. To this LogicFactory, i will give the view-specific data. and the view will have to bind to this Factory.

And to retrieve the view-specific data i will pass the key of my configuration map in the router.

so let say the router give you #current=theOne, i will do in the controller :

var specificData = ServiceConfig.get($location.search().current);
this.logic = LogicFactory.newInstance(specificData);

Hope it help

I retouch your example, here is the result : http://plnkr.co/edit/ORzbSka8YXZUV6JNtexk?p=preview

Edit: Just to say this way, you can load the specific configuration from a remote server serving you the specific-view data

like image 2
Igloob Avatar answered Nov 16 '22 16:11

Igloob