5

I have a single-page AngularJS application composed of multiple modules, whose purpose is to provide the user with a collaborative pad (main widget) and other related widgets (other connected users, pad metadatas, etc.).

I chose to split the application as follow:

  • 1 module hosting a service, responsible for exposing initialization methods for the pad component
  • N modules hosting custom directives (and their controller), corresponding to the different widgets in the application
  • 1 module responsible for gathering parameters and initializing the pad component

Let's simplify this by assuming I have only 1 widget, whose sole goal is to display a status message to the user: "authenticating", "authenticated", "error" or "ready".

I chose to use a subscribe/notify pattern with the service to let the widget be notified of a change in the shared component's state.

The service:

angular.module("app.core").factory("padService", padService);
function padService() {
    // Callback registration and notification code omitted
    return {
        initialize: function (authToken) { ... },
        onAuthenticated: function (callback) { ... },
        onReady: function (callback) { ... },
        onError: function (callback) { ... }
    };
}

The widget:

angular.module("app.widget").directive("widget", widget);
function widget() {
    return {
        templateUrl: 'app/widget.html',
        restrict: 'E',
        controller: widgetController
    };
}
function widgetController($scope, padService) {
    $scope.message = "authenticating";
    padService.onAuthenticated(function (user) {
        $scope.message = "authenticated";
        // Do other stuff related to user authentication event
    });
    padService.onReady(function (padInstance) {
        $scope.message = "ready";
        // Do other stuff related to pad readiness event
    });
    padService.onError(function (error) {
        $scope.message = "error";
        // Do other stuff related to error event
    });
}

Now the "initializer module", in its simplest form, gathers an authentication token authToken from the URL fragment (similar to OAuth2) and simply calls padService.initialize(authToken);. Note that it could as well be a dedicated authentication popup, that's why it resides in its own module.

My problem is that I don't know where to put that piece of code. All the places I tried resulted in being placed too early in the angular bootstrap process and/or not updating the widget:

angular.module("app.initializer").run(run);
function run($document, $timeout, tokenService, padService) {
    // This does not work because run() is called before the
    // controllers are initialized (widget does not get notified)
    var authToken = tokenService.getTokenFromUrl();
    padService.initialize(authToken);

    $document.ready(function () {
        // This does not work because angular does not detect
        // changes made to the widget controller's $scope
        var authToken = tokenService.getTokenFromUrl();
        padService.initialize(authToken);

        // This does not work in firefox for some reason (but
        // does in chrome!)... except if I enter debug mode or
        // set the timeout to a longer value, which makes it
        // either really difficult to diagnostic or ugly as hell
        $timeout(function () {
            var authToken = tokenService.getTokenFromUrl();
            padService.initialize(authToken);
        }, 0);
    });
}
6
  • Does the service have knowledge of which controllers will be subscribing to its services? Or does it matter? What does the service need to know from its clients in order to function? Commented Jan 18, 2016 at 17:39
  • You could look at how ui-riouter implements their resolve method github.com/angular-ui/ui-router/blob/master/src/resolve.js Commented Jan 18, 2016 at 17:51
  • The service has no knowledge of its subscribers, it just calls a notify() inner method when an event occurs, which will in turn call each registered callback with some arguments (really this is just a custom implementation of $on and $broadcast to avoid polluting the global event names). The service does not need to know anything about its subscribers to function, but it needs to be initialized with a set of parameters (here an authentication token provided by the app.initializer module). Commented Jan 18, 2016 at 17:53
  • @Daniel_L It seems to be a lot of code for such a trivial need :( Does angular provide no reliable way at all to run some code after everything else has been initialized? Like the run() method, but executing after the controllers? Is it my design which is totally biased, or is no one actually using things like OAuth2 token parsing (to initialize a 3rd party component) in the angular community? Commented Jan 18, 2016 at 18:01
  • @MaximeRossini If you want the code in your controller to execute after something has run (i.e., after a promise is resolved) you'd need a way to either (a) return the constructor instance inside a callback (not possible), or (b) do some complicated low-level modifications to angular's internal $controller service to get your instantiation in a promise chain. It's a "trivial need" but trying to fight async is never easy. Commented Jan 18, 2016 at 18:28

3 Answers 3

2

The controllers are created synchronously (I assume), so there shouldn't be any difficulty to make some code run after that.

That is an erroneous assumption. The AngularJS framework routinely creates and destroys directives and their controllers during the life of the application. ng-repeat, ng-if, ng-include, etc. all create and destroy DOM containing directives. If your "widget" is part of an ng-repeat, its controller gets instantiated multiple times, once for each item in the list that ng-repeat watches.

To retain data that persists throughout the lifetime of an application, keep it in a service. (Or on $rootScope; not recommended but an option.) Controllers can't assume that they have been started during bootstrap. They need to "catch-up" and subscribe to changes.

Keep persistent data in a factory service and provide setter and getter functions.

angular.module("app").factory("padService", function () {
    //Store service status here
    var status = "none-yet";

    function setStatus(s) {
        status = s;
        return status;
    };

    function getStatus() {
        return status;
    };

    return {
        setStatus: setStatus,
        getStatus: getStatus
    };
});

In your "widget", inject the service, subscribe to changes, and "catch-up".

angular.module("app").directive("widget", function() {
    function widgetController($scope, padService) {
        //subscribe with $watch
        $scope.$watch(padService.getStatus, function(newStatus) {
            //catch-up and react to changes
            case (newStatus) {  
                "authenticated":
                     // Do stuff related to authenticated state
                     break;
                "ready":
                     // Do stuff related to pad ready state
                     break;
                "error":
                     // Do stuff related to error state
                     break;
                default:
                     // Do something else
             }
            $scope.message = newStatus;
        };
    };
    return {
            templateUrl: 'app/widget.html',
            restrict: 'E',
            controller: widgetController
    }
});

When the directive first registers the listener using $watch, the AngularJS framework, executes the watch function (in this case padService.getStatus), and executes the listener function. This allows the directive to "catch up" to the current status of the service.

On each digest cycle, the AngularJS framework executes padService.getStatus. If the status has changed, the framework executes the listener function with the new status as the first parameter. This allows the directive to react to changes.

You can not assume that the directive and its controller are created synchronously. But you do know that the service is instantiated and its constructor function executed before it is injected into the controller.

Sign up to request clarification or add additional context in comments.

1 Comment

That makes a lot of sense. The main things I was missing were the $scope.$watch bit, and a step back from my use case to consider the other uses for a directive.
0

Store the status in the service

function padService() {
  var ctx = this;
  ctx.status = 'authenticating';
  return {
      initialize: function (authToken) { ... },
      onAuthenticated: function (callback) { ... },
      onReady: function (callback) { ... },
      onError: function (callback) { ... },
      getStatus: function() { return ctx.status; }
  };
}

In your directive get the status from the service instead of defining it.

function widgetController($scope, padService) {
  $scope.message = padService.getStatus();
  padService.onAuthenticated(function () {
    $scope.message = "authenticated";
  });
  padService.onReady(function () {
    $scope.message = "ready";
  });
  padService.onError(function () {
    $scope.message = "error";
  });
}

There's are a lot of room for improvements here but for a start, the code above allows sharing of the same data throughout the module from the service.

Next thing you might want to do is just have one subscriber method to broadcast changes made to the status to the listeners

5 Comments

If I understand your answer correctly, you mean that I should perform any controller's event subscription code on controller initialization if the associated event has already been triggered when the controller gets created? That would allow the controller to "catch up" the missed events, but it really feels against the DRY principle... whereas all I want to do is ensure the controllers have all been initialized BEFORE the service's initialize() method is run.
pardon me, I might be misinterpreting your statement. You code now performs event subscription on controller init, right? Also do you mean by doing event subscription on controller init is against the DRY principle?
My code performs event subscription on controller init, that's true and (hopefully) not against DRY principle. In your sample though, you added a method to get the service's state from the controller (in addition to the events which also communicate a state change), that is where I see a "repeat". If I wanted to call a web service in the controller when the service becomes ready (instead of displaying a message), I would have to call that method in both the event subscription callback and the controller's initialization code (in the latter case only if the state is ready).
oh ok I see your point, yes the proposed solution is far from ideal, it breaks the DRY principal if you continued its implementation. my point when I mentioned "lots of room for improvement". A complete solution would be to use $on and $broadcast for a subscribe/notify structure
I started with an implementation using $on and $broadcast but switched to a service notification pattern when I realized I started to pollute $rootScope with too many events, and that I could accomplish the same thing with custom service notifications. I fail to see what would be the advantage in using $on and $broadcast and how it would solve anything here? Replacing my service.onXXX(callback) subscriptions with $scope.$on("XXX", callback) wouldn't do much...
0

Adding on for a more complete solution

Service

padService.$inject = ['$rootScope'];
function padService($rootScope) {
  return {
    status: "authenticating",
    initialize: function (authToken) { 
      //Update the status
      $rootScope.$broadcast('STATUS_CHANGED');
    },
    subscribe: function(scope, callback){
      var ctx = this;
      scope.$on('STATUS_CHANGED', function (){
        callback(ctx.status);
      });
    }
  };
}

Controller

function widgetController($scope, padService) {
  $scope.status = padService.status;
  padService.subscribe($scope, function(status){
    $scope.status = status;
  });
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.