Tuesday, March 25, 2014

Accessing AngularJS services from outside AngularJS

If you read an Angular book or read a howto, you will think that Angular is the greatest discovery since fire. Everything is neatly stacked in modules, controllers, templates, directives, factories, etc. The problem comes when you want to use some code of your own, using simple Javascript that does specific work, and then you want to link it nicely with AngularJS. It is not always easy. My example concerns the simple display of a dialog which edits an object. I want it to work on every page, so I added it to the general layout template. The layout does not have a controller. Even if I add it, the dialog engine I have been using was buggy and I've decided to just use jQuery.dialog.

So here is my conundrum: How to load the content of a dialog from an Angular template, display it with jQuery.dialog, load the information with jQuery.get, then bind its input elements to an Angular scope object. I've tried the obvious: just load the template in the dialog and expect Angular to notice a new DOM element was added and parse it and work its magic. It didn't work. Why can't I just call an angular.refresh(elem); function and get it over with, I thought. There are several other solutions. One is to not create the content dynamically at all, just add it to the layout, mark it with ng-controller="something" and then, in the controller, save the object you are interested in or the scope as some sort of globally accessible object that you instantiate from jQuery.get. The dialog would just move the element around, afterwards. That means you need to create a controller, maybe in another file, to be nice, then load it into your page. Another is to create some sort of directive or script tag that loads the Angular template dynamically and to hope it works.

Long story short, none of these solutions appealed to me. I wanted a simple refresh(elem) function. And there is one. It is called angular.injector. You call it with the names of the modules you need to load ('ng' one of them and usually the main application module the second). The result is a function that can use invoke to get the same results as a controller constructor. And that is saying something: if you can do the work that the controller does in your block of code, you don't need a zillion controllers making your life miserable, nor do you need to mark the HTML uselessly for very simple functionality.

Without further due, here is a function that takes as parameters an element and a data object. The function will force angular to compile said element like it was part of the angular main application, then bind to the main scope the properties of the data object:
function angularCompile(elem, data) {
    // create an injector
    var $injector = angular.injector(['ng','app']);
            
    // use the type inference to auto inject arguments, or use implicit injection
    $injector.invoke(function($rootScope, $compile, $document){
        var compiled = $compile(elem || $document);
        compiled($rootScope);
        if (data) {
            for (var k in data) {
                if (data.hasOwnProperty(k)) {
                    $rootScope[k]=data[k];
                }
            }
        }
           $rootScope.$digest();
    });
}

Example usage:
angularCompile(dialog[0],{editedObject: obj}); // will take the jQuery dialog element, compile it, and add to the scope the editedObject property with the value of obj.

Full code:
OpenTranslationDialog=function(Rule, onProceed, onCancel) {
  jQuery.ajax({
          type: 'GET',
          url: '/Content/ng-templates/dialogs/Translation.html',
          data: Rule,
          success: function(data) {
            var dialog=jQuery('<div></div>')
              .html(data)
              .dialog({
                resizable:true,
                width:700,
                modal:true,
                    buttons: {
                      "Save": function() {
                    var url='/some/api/url';
                    jQuery.ajax({
                        type:'PUT',
                        url:url,
                        data:Rule,
                        success:function() {
                          if (onProceed) onProceed();
                              $(this).dialog( "close" );
                        },
                        error:function() {
                          alert('There was an error saving the rule');
                        }
                      });
                      },
                      Cancel: function() {
                    if (onCancel) onCancel();
                          $(this).dialog( "close" );
                      }
                    }
              });

            angularCompile(dialog[0],{Rule:Rule});
          },
          error:function() {
              alert('There was an error getting the dialog template');
                  }
      });
}

Before you take my word on it, though, beware: I am an Angular noob and my desire here was to hack away at it in order to merge my own code with the nice structured code of my colleagues, who now hate me. Although they liked angular.injector when I showed it to them :)

3 comments:

Anonymous said...

Just for the future readers I think you should call out that it is a really, really bad idea to pollute $rootScope with random content from any given out of band service that would utilize this code.

Siderite said...

I warned people that I am a noob using a brute force solution to a specific problem. It doesn't get more honest than this.

I agree with you that the $rootScope is not there to get polluted by all kind of random data, but on the other hand the biggest strength of Angular (that of magically compiling the HTML markup) seems to me to also be its biggest weakness, since it must do three passes to do the work of only one binding, but then forces me to go through hoops to "compile" dynamic content such as in this post.

Wouldn't it be better to be able to specify what and when I want to run through the AngularJS engine?

This post was designed to help people pass the hoops part and just implement what they need, regardless of "good practice". (even if in my inebriated state I can hardly understand what I meant in this post :) to begin with)

Siderite said...

On the other hand, it would be interesting if I could also dynamically assign a sub-scope to the content.