Building Pluggable Components in AngularJS


Although ng-boilerplate or angular-seed talks about the best-practice directory structure to ensure code re-usability and maximum scalability but sometimes having a separate directory per feature seems an ideal choice because it allows us to integrate loosely coupled components together and makes it easy to unplug it anytime without much hassle. The approach I’m proposing here may not be the best practice but it solves the real problem for us.

In our application, we’ve couple of icons (tools) grouped by a controller and whenever we wish to move any icon/tool from one controller to another we’d to migrate the respective code base also which was quite time consuming and risky (sometimes it breaks the feature during migration).

What we wanted to create was a pluggable component. By just including the JS file of the component (similar to Polymer Import) and placing an element anywhere should make the feature available.

So, I came up with the following directory structure:
app/scripts
└── features
│   ├── home
│   │   ├── home.js
│   │   └── home.tpl.html
│   └── profile
│   │   ├── profile.js
│   │   └── profile.tpl.html

Creating a Component

In angular, writing a custom directive is the best option to create a component. So lets begin with how we can use a particular component once it is ready.

<!-- index.html -->
<div ng-controller="OneCtrl">
    <div pc-home></div>
</div>

As I said earlier, in order to make it as pluggable as possible we’ll create a separate module for it so whenever required the module will be injected as a dependency. So below is the blueprint of the component:

// home.js
angular.module('tools.home', [])
  .directive('pcHome', function() {
    return {
      restrict: 'EA',
      replace: true,
      scope: {},
      controller: function($scope, $element, $attrs) {

      },
      template: '',
      link: function(scope, element, attrs) {

      }
    }
  });

We restricted the component to be used as an Element or Attribute, set an isolate scope for it so that it does not interfere with its parent scope (OneCtrl in this case – Prototypal Inheritance will not work either). The controller option will allow us to write all the logic related to the component and finally a template/templateUrl will replace the existing element on which the directive was placed.

We also want to activate a single tool at a time so in order to maintain the state of the tool, we’ll create a service just to share data between multiple components. openPanel property handles the state of the tool that is currently in use or selected.

App.factory('StateService', function() {
  return {
    openPanel: ''
  };
});

We’re going to use a button as a tool handler replacing the div[pc-home]. You can also use templateUrl in case your template is fat. As you can notice, we’ve injected StateService as a dependency for the component which then bound to $scope inside controller and later used within the template to toggle a CSS class.

angular.module('tools.home', [])
  .directive('pcHome', function(StateService) {
    return {
      restrict: 'EA',
      replace: true,
      scope: {},
      controller: function($scope, $element, $attrs) {
        $scope.stateService = StateService;
      },
      template: '<button class="btn home" ng-click="toggle()" ng-class="{true: \'btn-primary\', false: \'btn-success\'}[stateService.openPanel == \'home\']">Home</button>',
      link: function(scope, element, attrs) {

      }
    }
  });

Lets define toggle() method to perform some kind of action once the button is clicked. We’re just updating the property value of the StateService here. You can go and check the demo – click Home button and see it toggles its state when clicked. But as of now, the button does not do anything except toggling the background color.

angular.module('tools.home', [])
  .directive('pcHome', function(StateService) {
    return {
      restrict: 'EA',
      replace: true,
      scope: {},
      controller: function($scope, $element, $attrs) {
        $scope.stateService = StateService;
        
        $scope.toggle = function() {
          if (StateService.openPanel === 'home') {
            StateService.openPanel = '';
          } else {
            StateService.openPanel = 'home';                
          }
        };
      },
      template: '<button class="btn home" ng-click="toggle()" ng-class="{true: \'btn-primary\', false: \'btn-success\'}[stateService.openPanel == \'home\']">Home</button>',
      link: function(scope, element, attrs) {

      }
    }
  });

Final Trick

It is sometimes important to position an element relative to <body> not its parent. And thats why we can not merge the panel that we want to open ( when the button turns blue) inside the template. For that, we’ll have to add the panel markup dynamically inside index.html so that its available only if the component is in use or plugged. Lets put the panel markup in separate file named home.tpl.html.

<!-- home.tpl.html -->
<div class='home-template' ng-show="stateService.openPanel == 'home'">
  <ul>
    <li ng-repeat="choice in choices" ng-bind-template="{{$index + 1}}. {{choice}}"></li>
  </ul>
</div>

And finally we’ll change the link function where we’re simply injecting a <div> tag with ng-include attribute on it which allows us to include the template in <body> after getting compiled by $compile service with the same scope. The shared scope lets us use any method/property defined in our controller within the template as well which obviates the need for a separate controller specifically for home.tpl.html.

angular.module('tools.home', [])
  .directive('pcHome', function(StateService, $compile) {
    return {
      restrict: 'EA',
      replace: true,
      scope: {},
      controller: function($scope, $element, $attrs) {
        $scope.stateService = StateService;
        
        $scope.toggle = function() {
          if (StateService.openPanel === 'home') {
            StateService.openPanel = '';
          } else {
            StateService.openPanel = 'home';                
          }
        };

        $scope.choices = [
         'Share whats new...',
         'You may know',
         'Updates from followers',
         'Few posts from communities',
         'Scrolling now for more updates',
         'Loading...'
        ];
      },
      template: '<button class="btn home" ng-click="toggle()" ng-class="{true: \'btn-primary\', false: \'btn-success\'}[stateService.openPanel == \'home\']">Home</button>',
      link: function(scope, element, attrs) {
        angular.element(document.getElementsByTagName('body')).append(
          $compile('<div ng-include="\'home.tpl.html\'"></div>')(scope)
        );
      }
    }
  });

Word of Wisdom

By design the ng-include directive creates a new scope and in our case it will become a child scope of the original scope we compiled the template with. So beware of initializing a model inside the template itself using ng-model or ng-init. Is there a fix for this?

Yes! suppose you want to use ng-model="search" within the template then use $parent with it. i.e. ng-model="$parent.search". This will automatically bind the search model on the controller’s scope not the one created by ng-include.

The End

Last but not the least, whenever we need this component in our application we just have to resolve the dependency by introducing App.tools in main application module and you are free to use the component anywhere within the application without breaking a thing. Try moving components around.

var App = angular.module('App', ['App.tools']);
angular.module('App.tools', ['tools.home', 'tools.profile']);

In case you want to unplug the component then just delete the features/home directory, unreference the home.js and <div pc-home></div> from index.html, remove the module tools.home, and you are DONE.

Advertisements

2 thoughts on “Building Pluggable Components in AngularJS

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s