12

With ui-router, I add all resolve logic in state function like this;

    //my-ctrl.js
    var MyCtrl = function($scope, customers) {
      $scope.customers = customers;
    }

    //routing.js
    $stateProvider.state('customers.show', {
      url: '/customers/:id',
      template: template,
      controller: 'MyCtrl',
      resolve: {   // <-- I feel this must define as like controller
        customers: function(Customer, $stateParams) {
          return Customer.get($stateParams.id);
        }
      }
    });

However IMO, resolve object must belong to a controller, and it's easy to read and maintain if it is defined within a controller file.

    //my-ctrl.js
    var MyCtrl = function($scope, customers) {
      $scope.customers = customers;
    }
    MyCtrl.resolve = {
      customers: function(Customer, $stateParams) {
        return Customer.get($stateParams.id);
      };
    };

    //routing.js
    $stateProvider.state('customers.show', {
      url: '/customers/:id',
      template: template,
      controller: 'MyCtrl',
      resolve: 'MyCtrl.resolve'   //<--- Error: 'invocables' must be an object.
    });

However, When I define it as MyCtrl.resolve, because of IIFE, I get the following error.

Failed to instantiate module due to: ReferenceError: MyCtrl is not defined

When I define that one as string 'MyCtrl.resolve', I get this

Error: 'invocables' must be an object.

I see that controller is defined as string, so I think it's also possible to provide the value as string by using a decorator or something.

Has anyone done this approach? So that I can keep my routings.js clean and putting relevant info. in a relevant file?

allenhwkim
  • 27,270
  • 18
  • 89
  • 122
  • Like mentioned earlier, controller isn't available yet at that point. The design pattern I use is having a `Resolver` service. Using `this.self.name` in the resolving functions scope (where you make your `Customer.get()` request), you can get the name of the state. Then, doing something like `return Resolver.prepare( this.self.name )`, you can extract all logic into `Resolver`, and reduce your code to a single (and equal) line in your state declarations. – Maurits Moeys Jan 25 '17 at 13:47

5 Answers5

8

It sounds like a neat way to build the resolve, but I just don't think you can do it.

Aside from the fact that "resolve" requires an object, it is defined in a phase where all you have available are providers. At this time, the controller doesn't even exist yet.

Even worse, though, the "resolve" is meant to define inputs to the controller, itself. To define the resolve in the controller, then expect it to be evaluated before the controller is created is a circular dependency.

In the past, I have defined resolve functions outside of the $stateProvider definition, at least allowing them to be reused. I never tried to get any fancier than that.

var customerResolve = ['Customer', '$stateParams',
    function(Customer, $stateParams) {
        return Customer.get($stateParams.id);
    }
];

// ....

$stateProvider.state('customers.show', {
  url: '/customers/:id',
  template: template,
  controller: 'MyCtrl',
  resolve: {
    customers: customerResolve
  }
});
Jeff Fairley
  • 8,071
  • 7
  • 46
  • 55
  • config state, it's not required to have full controller object, as you see controller is just a string. – allenhwkim Jun 12 '15 at 16:50
  • 2
    It's true, but at runtime, in order for Angular to evaluate the resolve, it will need to create the controller. It cannot create the controller until it has evaluated the resolve. – Jeff Fairley Jun 12 '15 at 16:53
4

This question is about features of ui-router package. By default ui-router doesn't support strings for resolve parameter. But if you look at the source code of ui-router you will see, that it's possible to implement this functionality without making direct changes to it's code.

Now, I will show the logic behind suggested method and it's implementation

Analyzing the code

First let's take a look at $state.transitionTo function angular-ui-router/src/urlRouter.js. Inside that function we will see this code

  for (var l = keep; l < toPath.length; l++, state = toPath[l]) {
    locals = toLocals[l] = inherit(locals);
    resolved = resolveState(state, toParams, state === to, resolved, locals, options);
  }

Obviously this is where "resolve" parameters are resolved for every parent state. Next, let's take a look at resolveState function at the same file. We will find this line there:

dst.resolve = $resolve.resolve(state.resolve, locals, dst.resolve, state);
var promises = [dst.resolve.then(function (globals) {
    dst.globals = globals;
})];

This is specifically where promises for resolve parameters are retrieved. What's good for use, the function that does this is taken out to a separate service. This means we can hook and alter it's behavior with decorator.

For reference the implementation of $resolve is in angular-ui-router/src/resolve.js file

Implementing the hook

The signature for resolve function of $resolve is

this.resolve = function (invocables, locals, parent, self) {

Where "invocables" is the object from our declaration of state. So we need to check if "invocables" is string. And if it is we will get a controller function by string and invoke function after "." character

//1.1 Main hook for $resolve
$provide.decorator('$resolve', ['$delegate', '$window', function ($delegate, $window){ 
  var service = $delegate; 



  var oldResolve = service.resolve;
  service.resolve = function(invocables, locals, parent, self){
     if (typeof(invocables) == 'string') {
       var resolveStrs = invocables.split('.');

       var controllerName = resolveStrs[0];
       var methodName = resolveStrs[1];

       //By default the $controller service saves controller functions on window objec
       var controllerFunc = $window[controllerName];
       var controllerResolveObj = controllerFunc[methodName]();

       return oldResolve.apply(this, [controllerResolveObj, locals, parent, self]);

     } else {
       return oldResolve.apply(this, [invocables, locals, parent, self]);
     }
  };

  return $delegate;
}]);

EDIT:

You can also override $controllerProvider with provider like this:

app.provider("$controller", function () {

}

This way it becomes possible to add a new function getConstructor, that will return controller constructor by name. And so you will avoid using $window object in the hook:

$provide.decorator('$resolve', ['$delegate', function ($delegate){ 
    var service = $delegate; 

    var oldResolve = service.resolve;
    service.resolve = function(invocables, locals, parent, self){
       if (typeof(invocables) == 'string') {
         var resolveStrs = invocables.split('.');

         var controllerName = resolveStrs[0];
         var methodName = resolveStrs[1];

         var controllerFunc = $controllerProvider.getConstructor(controllerName);
         var controllerResolveObj = controllerFunc[methodName]();

         return oldResolve.apply(this, [controllerResolveObj, locals, parent, self]);

       } else {
         return oldResolve.apply(this, [invocables, locals, parent, self]);
       }
    }; 

Full code demonstrating this method http://plnkr.co/edit/f3dCSLn14pkul7BzrMvH?p=preview

Dmitry Tolmachov
  • 395
  • 2
  • 11
  • I like it except you get controller from `$window` instead of `$controller`. Is it possible to make it use `$controller`? No matter what I will give you the bounty since you did the most effort to solve the problem. – allenhwkim Jun 22 '15 at 15:33
  • Unfortunately it near to impossible. $controller instantiates controller and tries to resolve dependencies. But because we have custom dependencies(resolve parameters) $controller will say it does not know "customers" parameter. It could be possible to decorate $controllerProvider service and override it's register function. This would give a way to get a list of controller's functions. But current angular do not allow this nowadays. Angular exposes result of $get function instead of actual service. So "register" function is left out of view – Dmitry Tolmachov Jun 22 '15 at 15:58
  • The next option was to alter $injector. But again, angular doesnt allow to decorate this service and simple overriding of it's methods gives nothing. – Dmitry Tolmachov Jun 22 '15 at 15:58
  • Ok, it seems you can override $controllerProvider with provider function. This way you can add a necessary "getConstructor" to it and avoid using $window object to get controller constructor. I updated the plunker with these new findings – Dmitry Tolmachov Jun 25 '15 at 09:45
  • I don't recommend implementing this workaround. UI-Router 1.0 no longer uses the `$resolve` service, so this workaround will have a limited life span. – Chris T Jan 14 '16 at 15:52
2

You need to make sure the controller is within the same closure as the state config. This doesn't mean they need to be defined in the same file.

So instead of a string, use a the static property of the controller:

resolve: MyCtrl.resolve,

Update

Then for your Controller file:

var MyCtrl;
(function(MyCtrl, yourModule) {

    MyCtrl = function() { // your contructor function}
    MyCtrl.resolve = { // your resolve object }

    yourModule.controller('MyCtrl', MyCtrl);

})(MyCtrl, yourModule)

And then when you define your states in another file, that is included or concatenated or required after the controller file:

(function(MyCtrl, yourModule) {

    configStates.$inject = ['$stateProvider'];
    function configStates($stateProvider) {

        // state config has access to MyCtrl.resolve
        $stateProvider.state('customers.show', {
            url: '/customers/:id',
            template: template,
            controller: 'MyCtrl',
            resolve: MyCtrl.resolve
        });
    }

    yourModule.config(configStates);

})(MyCtrl, yourModule);

For production code you will still want to wrap all these IIFEs within another IIFEs. Gulp or Grunt can do this for you.

Martin
  • 15,820
  • 4
  • 47
  • 56
  • how do I make the controller be in the same closure when each file is using IIFE? – allenhwkim Jun 15 '15 at 17:42
  • Add the IIFE around your concatenated js at build time. Uglify has an option to wrap your code in a closure, an IIFE. This way your code will be within the same IIFE. – Martin Jun 15 '15 at 17:56
  • That will work in production, but not in development mode, which does not run any uglifier. – allenhwkim Jun 16 '15 at 15:15
  • I updated my original answer with a solution that should work with or without a build process. – Martin Jun 16 '15 at 15:32
0

If the intention is to have the resolver in the same file as the controller, the simplest way to do so is to declare the resolver at the controller file as a function:

//my-ctrl.js
var MyCtrl = function($scope, customers) {
  $scope.customers = customers;
}
var resolverMyCtrl_customers = (['Customer','$stateParams', function(Customer, $stateParams) {
    return Customer.get($stateParams.id);
}]);

//routing.js
$stateProvider.state('customers.show', {
  url: '/customers/:id',
  template: template,
  controller: 'MyCtrl',
  resolve: resolverMyCtrl_customers
});
xabedev
  • 31
  • 3
0

This should work.

//my-ctrl.js
var MyCtrl = function($scope, customer) {
    $scope.customer = customer;
};

//routing.js
$stateProvider
    .state('customers.show', {
        url: '/customers/:id',
        template: template,
        resolve: { 
            customer: function(CustomerService, $stateParams){
                return CustomerService.get($stateParams.id)
            } 
        },
        controller: 'MyCtrl'
});


//service.js
function CustomerService() {
    var _customers = {};

    this.get = function (id) {
        return _customers[id];
    };
}
Adam
  • 1
  • 1