A plugin API for your Angular app

You might wonder do I really need plugins for my Angular app?

When building Angular-Gantt we had the same thoughts. But we run into problems when our library got bigger and bigger. Not everyone needs all the functionality and certain users would like to have custom specific adjustments.

We decided to refactor the code to a small core library and implemented all other functionality as plugins. This step allowed us to decouple functionality easier. Optimize performance by removing unneeded features and offer the possibility to implement custom behavior.

In this tutorial, I would like to show you how to implement your own plugin API. The code and idea are based on the solution of ng-Grid.

The idea

The main idea behind the plugin API is to expose events and methods which can be used by a plugin.

Events

Events are sent by your app to the plugin. They act as hooks which the plugin can use to execute own functionality. Every event has a name and a feature name. The feature name is used to group multiple events by feature.

Example:
Feature name: form
Event name: submitted

Usage (by the plugin):

api.form.on.submitted(function() {
    // Event handler code
});

The app first needs to register possible events with the API. This ensures that plugins can only listen to registered events or otherwise receives an exception. The app can register new events with:

api.registerEvent(featureName, name);

Methods

Every API needs public methods. Those methods can be registered by the app and will then be exposed trough the API.
API methods have a name, a feature name and a callback function. Similar as with the event feature name. The callback function is the internal app method which implements the functionality exposed by the API.

Example:
Feature name: form
Event name: reset

Usage (by the plugin):

api.form.reset();

The app can register a method with:

api.registerMethod(featureName, name, appMethodToCall);

The sample app

Before we dive into coding our API I would like to introduce you to the sample app. It will help to understand how to use the API from the app and plugin perspective.

The sample app is very simple. It has an input text field, a submit button and a reset button. The HTML is defined as:

<html ng-app="myapp">
<body ng-controller="MainCtrl">
    <input type="text" ng-model="message" />
    <button ng-click="submit($event)">Submit</button>
    <button ng-click="clear($event)">Clear</button>
</body>
</html>

The controller code defines two functions for the submit button and clear button. Additionally we expose those functions in our API so they can be used by plugins. Each function fires an event so plugins are informed when a user submits the form.

var app = angular.module('myapp', ['angular-api']);
app.controller('MainCtrl', ['$scope', '$log', 'Api', function ($scope, $log, Api) {
    var submitForm = function(message) {
        $log.info('Submit: ' + $scope.message);
        $scope.api.form.raise.submitted(message);
    };

    var clearForm = function(message) {
        $scope.message = '';
        $scope.api.form.raise.cleared();
    };

    $scope.submit = function($event) {
        submitForm($scope.message);
    };

    $scope.clear = function($event) {
        clearForm();
    };

    $scope.api = new Api(this, $scope.$id);
    $scope.api.registerEvent('form', 'submitted');
    $scope.api.registerMethod('form', 'submit', submitForm);
    $scope.api.registerEvent('form', 'cleared');
    $scope.api.registerMethod('form', 'clear', clearForm);
}]);

As you can see, after the user submits the form the message is written to the console.

We would like now to show an additional alert box when the user submitted the form. After the alert box is closed the message shall be deleted from the input text field. In order to do this, we write a plugin and use the API.

The code is simple. We define a directive which receives a reference to the API using a scope variable. The directive listens to the form submit event. In case the event is fired the directive shows an alert box and clears the form by calling the API clear function.

angular.module('plugin-alert', []).directive('alert', [ function () {
    return {
        scope: {api: '=?'},
        link: function(scope, element, attrs) {
            scope.api.form.on.submitted(scope, function(message) {
                alert(message);
                scope.api.form.clear();
            });
        }
    };
}]);

After we have written our plugin we need to add it to the HTML:

...
<button ng-click="clear($event)">Clear</button>
<!-- Plugin //-->
<alert api="api"></alert>
<!-- Plugin //-->

And to the Angular app module dependencies:

var app = angular.module('myapp', ['angular-api', 'plugin-alert']);

This is it! As an exercise, you can write your own plugin and run both plugins together. It would even be possible to expose an API from a plugin so that another plugin could use it. Endless possibilities :)

The complete code of the sample app can be downloaded from my Github repo: Schweigi/angular-api-demo.

The API code

Let's first define a factory which is used by the app to create a new API object. This API object can then be passed to the plugins. In a second step, we add the functionality to register events and methods.

angular.module('myapp').factory('MyAppAPI', ['$q', '$rootScope', function($q, $rootScope) {
    var Api = function Api(appInstance, apiId) {
        this.app = appInstance;
        this.apiId = apiId;
        this.eventListeners = [];
    }
    
    return Api;
}

As you see the API object contains a reference to the app, a unique api id, and a list of event listeners.

The unique api id is used to generate unique Angular broadcast events in case multiple API instances do exist.

Events

The app needs to register all events which are going to be  raised. The register event call also generates the code for the Angular broadcast event. Additionally it provides an event listener method which can be used by the plugin.

Api.prototype.registerEvent = function(featureName, eventName) {

Inside the register function, we need to make sure the feature object path (e.g. form.on) exist. Based on this object path we will then add the event trigger and listener methods.

    var self = this;
    if (!self[featureName]) {
        // Create feature object path
        self[featureName] = {};
    }

    var feature = self[featureName];
    if (!feature.on) {
        feature.on = {};
        feature.raise = {};
    }

Then we define the event which is used by Angular for the event broadcast.

    var eventId = 'event:api:' + this.apiId + ':' + featureName + ':' + eventName;

Next, we create the raise method so that our app can trigger the event (e.g. ui.form.raise.submitted()). The trigger method also attaches all its function arguments to the event. This allows the app to add additional arguments to the events.

    feature.raise[eventName] = function() {
        $rootScope.$emit.apply($rootScope [eventId].concat(Array.prototype.slice.call(arguments)));
    };

The most complicated part is the event listener method. The plugins use it to register a listener for a specific event. The heavy lifting is done by the method registerEventWithAngular which adds the listener as Angular broadcast receiver. More on it later.

    feature.on[eventName] = function(scope, handler, _this) {
        var deregAngularOn = registerEventWithAngular(eventId, handler, self.app, _this);

We create a new listener and add it to the list of all event listeners. The API uses this list to remove all listeners in case the underlying scope is destroyed. If we wouldn't do that a memory leak will occur because the JS garbage collector is still holding a valid reference to the scope.

        var listener = {
            handler: handler,
            dereg: deregAngularOn,
            eventId: eventId,
            scope: scope,
            _this: _this
        };
        self.eventListeners.push(listener);

The remove listener function is not only used in case the scope is destroyed but also returned to the caller (plugin). The plugin can then use this function to unregister any event it subscribed too.

        var removeListener = function() {
            listener.dereg();
            var index = self.listeners.indexOf(listener);
            self.listeners.splice(index, 1);
        };

        scope.$on('$destroy', function() {
            removeListener();
        });

        return removeListener;
        };
    };

As written before the registerEventWithAngular does the heavy lifting by adding the plugin event handler as an Angular broadcast listener. Every time we send an Angular broadcast this event handler is called. A check  is done if the eventId is equal and if so calls the plugin event handler.

function registerEventWithAngular(eventId, handler, gantt, _this) {
    return $rootScope.$on(eventId, function() {
        var args = Array.prototype.slice.call(arguments);
        args.splice(0, 1); //remove evt argument
        handler.apply(_this ? _this : app.api, args);
    });
}

Methods

In order to register new API methods, we will now implement the registerMethod functionality.

Similar as with the event registration we first make sure the feature object path (e.g. form) does exist.

Api.prototype.registerMethod = function(featureName, methodName, callBackFn, _this) {
    if (!this[featureName]) {
        this[featureName] = {};
    }

The we add the method to the public API methods. The register method accepts a _this parameter which is used as the base object on which we call the specified method. In case _this was not specified we use the app object as the base object.

    var feature = this[featureName];
    feature[methodName] = function() {
        callBackFn.apply(_this || this.app, arguments);
    };
};

With the registerMethod method implemented, we have all the basic functionality for our API. The complete code is available here: api.js.

Next steps

The Github repo does not only contain the sample app but also the complete API code. Additionally there is an API function which can be used to suppress API events in case you need to disable them temporary. I encourage you to take a look and play around with it!