PatrickJS / NG6-starter

:ng: An AngularJS Starter repo for AngularJS + ES6 + Webpack
https://angularclass.github.io/NG6-starter
Apache License 2.0
1.91k stars 1.35k forks source link

How to use resolve #12

Open santios opened 9 years ago

santios commented 9 years ago

Guys, with this syntax:

$stateProvider
        .state('home', {
            url: '/',
            template: '<home></home>'
        });

It's possible to use resolve? Or do we need a controller property in the state in order to use it?

Thank you.

PatrickJS commented 9 years ago

what do you mean by resolve? are you talking about

$stateProvider
        .state('home', {
            url: '/',
            template: '<home></home>',
            resolve: {
              yourData: function(yourService) {
                return yourService.getAsyncPromise()
              }
            }
        });
santios commented 9 years ago

@gdi2290 Yes, How is 'yourData' injected in the controller if we are rendering the component using template. For resolve to work do we need controller: 'HomeCtrl' and templateUrl: 'home.html', don't we? Sorry if I'm missing something.

PatrickJS commented 9 years ago

@santios here's an example client/app/components/home/home.js

import angular from 'angular';
import uiRouter from 'angular-ui-router';
import homeComponent from './home.component'; 

let homeModule = angular.module('home', [
    uiRouter
])
.config(($stateProvider, $urlRouterProvider)=>{
    $urlRouterProvider.otherwise('/');

    $stateProvider
        .state('home', {
            url: '/',
            template: '<home></home>',
            resolve: {
              'yourData': (yourService) => {
                return yourService.getAsyncPromise()
              }
            }
        });
})
.directive('home', homeComponent);

export default homeModule;

client/app/components/home/home.controller.js

class HomeController {
    constructor(yourData) {
        console.log(yourData)
        this.name = 'home';
    }
}

HomeController.$inject = ['yourData'];
export default HomeController;
santios commented 9 years ago

@gdi2290 Thank you for your answer but this is throwing and error: "Unknown provider: myDataProvider"

I think the problem is that you can't inject dependencies to this controller, as the controller is being used inside a directive definition, and we are rendering the component directly in the template option of the state (template: '<home></home>').

import template from './home.html';
import controller from './home.controller';
import './home.styl';

let homeComponent = function(){
    return {
        template,
        controller, //this is home controller, how will resolve inject the data here?
        restrict: 'E',
        controllerAs: 'vm',
        scope: {},
        bindToController: true
    };
};

I think we need a folder called pages, where we use components. For example:

$stateProvider
        .state('home', {
            url: '/',
            templateUrl: 'page.html',
            controller: PageController,
            resolve: {
                myData: function(){
                    return promise;
                }               
            }
        });

And inside page.html we can use the home component and posible pass the resolved data:

<home myData='ctrl.myData'></home>

What do you think?

PatrickJS commented 9 years ago
$stateProvider
        .state('home', {
            url: '/',
            templateUrl: 'page.html',
            controller: PageController,
            resolve: {
                myData: function(){
                    return promise;
                }               
            }
        });

you need to inject your service and return either an object or a promise and this won't work when dealing with homeComponent since it's a directive

martinmicunda commented 9 years ago

@gdi2290 I have exactly same issue with resolve as @santios so what is the proper solution for this problem?

PatrickJS commented 9 years ago

@martinmicunda this is solved in angular2 but you would handle it like this in a directive

import template from './home.html';
import controller from './home.controller';
import './home.styl';

let homeComponent = function(){
    return {
        template,
        controller, //this is home controller, how will resolve inject the data here?
        restrict: 'E',
        controllerAs: 'vm',
        scope: {},
        bindToController: true
    };
};

home.controller.js


class HomeController {
    constructor(YourService) {
        console.log(YourService);
        this.yourData = [];

        // resolve data
        YourService.getAsyncPromise().then(res => {
          this.yourData = res.yourData;
        });

        this.name = 'home';
    }
}

HomeController.$inject = ['YourService'];
export default HomeController;

the resolve is a way for us to synchronously load our data before we load our template. With the directive we don't really have that luxury without wrapping the directive

martinmicunda commented 9 years ago

@gdi2290 yeah that's what I start doing but then I got more complex example with onEnter and that doesn't work either...

    $stateProvider
        .state('employees.add', {
            url: '/add',
            onEnter: function($stateParams, $state, $modal) {
                $modal.open({
                    template: template,
                    resolve: {
                        languages: LanguageResource => LanguageResource.getList(),
                        positions: PositionResource => PositionResource.getList({lang: 'en'}), 
                        roles: RoleResource => RoleResource.getList({lang: 'en'}) 
                    },
                    controller: 'EmployeesAddController',
                    controllerAs: 'vm',
                    size: 'lg'
                }).result.finally(function() {
                        $state.go('employees');
                    });
            }
        });
santios commented 9 years ago

@martinmicunda You won't be able to use much more than the template and controller option inside the state object. If you really want to do this, you should create a pages folder ( that will bring up some duplication) and create there a plain controller with a view using the components, something like this:

pages/main/

 main.controller.js
 main.js
 main.html

Inside main.html:

<home></home>

And in main.js you can use the normal resolve with all the options you are used too.

eshcharc commented 9 years ago

There is this solution that keeps both resolve and component in tact:

in home.js

$stateProvider
        .state('home', {
            url: '/',
            controller: function($scope, yourData) {
                this.yourData = yourData;
            },
            controllerAs: 'homeState',
            template: '<home your-data="homeState.yourData"></home>',
            resolve: {
                yourData: function() {
                    return 42;
                }
            }
        });

in home.component.js

let homeComponent = function(){
    return {
        template,
        controller,
        restrict: 'E',
        controllerAs: 'vm',
        scope: {
            yourData: '='
        },
        bindToController: true
    };
};

and in home.controller.js

class HomeController {
    constructor(){
        this.name = 'home';
        this.data = this.yourData;
    }
}

A little bit of boilerplate (that can be customized in the generator's templates) and an additional controller for each component generated, but that does the trick.

santios commented 9 years ago

@eshcharc That's clever, thank you for sharing.

eshcharc commented 9 years ago

@santios If you have a spare time, please open a PR.

PatrickJS commented 9 years ago

to resolve you need to

this works since the template won't load until resolve is finished then it's only a problem of passing the data to the directive

gad2103 commented 8 years ago

+1. I lost a good few hours of my life trying to fix this in a more elegant way and the page solution feels more palatable than the everything is a directive approach.

uriklar commented 8 years ago

Hi @eshcharc (Ma Kore? :) I've tried your solution 1 for 1, no typos or anything, and for some reason my controller doesn't receive the props i'm binding in the template. Any idea why?

uriklar commented 8 years ago

Ok Ok got it! Not sure why, but my component needed to look a little bit different then your's:

let categoryComponent = {
  restrict: 'E',
  template,
  controller,
  controllerAs: 'vm',
  bindings: {
    categoryData: '='
  }
};
eshcharc commented 8 years ago

Glad you could solve that. Next time, don't esitate to call. Since I started using RxJs I find it rare that I inject to component. I rather subscribe to the proper stream. Try that, it'll change your programmatic life...

uriklar commented 8 years ago

In due time :-) thanks!

fesor commented 8 years ago

@eshcharc can you provide an example?

eshcharc commented 8 years ago

It's not about an example. You will need to read and see what RxJs is all about. The thing is that you set your model as a stream and register for changes in your componet. This is quite out of scope here.

fesor commented 8 years ago

@eshcharc I know about reactive programming, I only doesn't thought about representing state as data stream (in angular components context)

eshcharc commented 8 years ago

State and data manipulation is best achieved with Scan operator.

aneurysmjs commented 8 years ago

@eshcharc your solution is totally insane!!!! really helped me a lot :) thanks for share it, really appreciate it.

@santios @gdi2290 according to the angularjs 1.5.0-rc.0 docs this is how you resolve data for a component:

var myMod = angular.module('myMod', ['ngRoute']);

myMod.component('home', {
  template: '<h1>Home</h1><p>Hello, {{ home.user.name }} !</p>',
  bindings: {user: '='}
});

myMod.config(function($routeProvider) {
  $routeProvider.when('/', {
    template: '<home user="$resolve.user"></home>',
    resolve: {user: function($http) { return $http.get('...'); }}
  });
});

hope it works for somebody

julius-retzer commented 8 years ago

@blackendstudios but that is ngRoute, this project uses ui-router

aneurysmjs commented 8 years ago

@wormyy yeah yeah,I know, is for illustration porpuses, I that's way I said "@santios @gdi2290 according to the angularjs 1.5.0-rc.0 docs this is how you resolve data for a component"

ranbuch commented 8 years ago

Sorry guys, I still don't get it.

After running "gulp component --name admin" in my CMD I got this in my admin.component.js:

import template from './admin.html';
import controller from './admin.controller';
import './admin.styl';

let adminComponent = {
  restrict: 'E',
  bindings: {},
  template,
  controller,
  controllerAs: 'vm'
};

export default adminComponent;

and this in my admin.js:

import angular from 'angular';

import uiRouter from 'angular-ui-router';

import adminComponent from './admin.component';

import {default as AdminController} from './admin.controller';

let adminModule = angular.module('admin', [
    uiRouter
]).component('admin', adminComponent);

export default adminModule;

For triggering resolve in ui-router I need to change admin.js to this:

import angular from 'angular';
import uiRouter from 'angular-ui-router';
import adminComponent from './admin.component';
import {default as AdminController} from './admin.controller';

let adminModule = angular.module('admin', [
    uiRouter
])

    .config(($stateProvider, $urlRouterProvider) => {
        "ngInject";

        $urlRouterProvider.otherwise('/');

        $stateProvider
            .state('admin', {
                url: '/admin',
                template: '<admin></admin>',
                controller: AdminController,
                controllerAs: 'vm',
                resolve: {
                    retailersList: ['RestManager', 'AuthManager', (rest, auth) => {
                        if (auth.isLogin() && auth.isAdmin())
                            return rest.getRetailersList();
                        return [];
                    }]
                }
            });
    })

    .component('admin', adminComponent);

export default adminModule;

but now my admin.controller.js file is getting invoked twice and that can't be good!

In the second invocation I'm getting an error: Unknown provider: retailersListProvider <- retailersList

This is my admin.controller.js file:

let vm = null;

class AdminController {
    constructor(retailersList) {
        vm = this;

        vm.retailersList = retailersList;
    }
}

AdminController.$inject = ['retailersList'];

export default AdminController;

I'm sure I can have all kind of workarounds but what is the best practice?

Thank you.

ranbuch commented 8 years ago

O.K. I got it: All I needed to do is replace the templates in the admin.js file and the admin.components.js file like this:

in the admin.js file switch this line:

template: template,

with this line:

template: '<admin></admin>',

and in the admin.components.js file switch this line:

template: '<admin></admin>',

to this line:

template: template,

Also add

import template from './admin.html';

to the top of the admin.js file and delete the same line from admin.component.js file.

fesor commented 8 years ago

but now my admin.controller.js file is getting invoked twice and that can't be good!

What do you expected? You have one controller in state definition and one in component.

what is the best practice?

Well... ok. First of all, data should be passed to component via bindings. (also all your components (i.e. custom elements) should have prefix.)

import template from './admin.html';
import './admin.styl';

// I like to make all components controllers private
// but you chose how to work with them
class MyAdminComponent {
    // this is instead of watchers in controllers
    set list(list) {
        this._list = list;
        this.reactOnListChanges();
    }

    reactOnListChanges() {
       // do stuff...
    }
}

export default {
  restrict: 'E',
  bindings: {
      list: '='
  },
  template,
  controllerAs: 'vm'
};

This is our component. All state which it need will be passed from above via bindings. And this state will be prepared in our route resolvers. One note, consider to move all your resolvers to separate resolver-services.

export function retailersListResolver(rest, auth) {
    "ngInject";
    if (auth.isLogin() && auth.isAdmin()) {
        return rest.getRetailersList();
    }

    return [];
}

// admin.js or somewhere else

import * as resolvers from './resolvers';

angular
   .module('app')
   .service(resolvers); // this will register all your resolvers
   .config(function ($stateProvider) {
        $stateProvider.state('admin', { 
            url: '/admin',
            resolves: {
                 'retailersList': 'retailersListResolver' // service instance
            },
            // we need to aggregate resolved values
            // in uiRouter 0.2.19 this will be done automaticly
            controller: function ($scope, retailersList) {
                 $scope.$resolve = {retailersList};
            },
            // now we will pass data to our component
            template: `<my-admin list="$resolve.retailersList"></my-admin>`,
        }
   });

About uiRouter 0.2.19:

controller: function ($scope, retailersList) {
    $scope.$resolve = {retailersList};
}

You can update uiRouter to latest version (i.e. 0.2.19-dev) to get rid of this. This was implemented 2-3 weeks ago and merged into legacy branch.

Does that helped?

ranbuch commented 8 years ago

That solution looks neat! Thank you.

pibouu commented 8 years ago

@eshcharc Hi, first of all, thanks for your solution I lost a few hours before I found this issue, I'm sorry to bother you but I tried your solution and it doesn't work for me :( I git cloned the project and started fresh ! I added your solution like that with some debugging

$stateProvider
    .state('home', {
      url: '/',
      controller: ($scope, yourData) => {
        console.log("1 " + yourData);
        console.log(this);
        this.yourData = yourData;
        console.log("2 " + this.yourData);
      },
      controllerAs: 'homeState',
      template: '<home your-data="homeState.yourData"></home>',
      resolve: {
        yourData: () => { console.log('resolving'); return 42; }
      }
    });

and in the controller this is undefined.

Am I missing something ?

fesor commented 8 years ago

@pibouu Hi!

Am I missing something ?

Yep, read about arrow functions and lexical this. This will answer your question. Change it like this:

{
    controller: ($scope, yourData) => {
        $scope.$resolve = {yourData};
    },
    // controllerAs: '$ctrl'  - no need for this since we are using scopes
    template: `<my-home your-data="$resolve.yourData"></my-home>`
}

As I said earlier, ngRoute already support automatic population of $resolve scope property, and uiRouter already have it in master branch (0.2.19-dev).

aneurysmjs commented 8 years ago

@fesor you should use array dependency annotation

{
    controller:['$scope', 'yourData', ($scope, yourData) => {
        $scope.$resolve = {yourData};
    }],
    // controllerAs: '$ctrl'  - no need for this since we are using scopes
    template: `<my-home your-data="$resolve.yourData"></my-home>`
}
pibouu commented 8 years ago

@fesor @blackendstudios Thanks for your replies thank you so much ! ! It works fine now !

ps: I used the legacy branch with the 0.2.18 of ui-router.

fesor commented 8 years ago

@blackendstudios I use latest version of uiRouter, so i just don't need controller at all (https://github.com/angular-ui/ui-router/commit/0f6aea62a3e92b99b892aa062d1f3be8e5bafa6a). But yes, you are right.

Blaze34 commented 8 years ago

@fesor 0.2.19-dev not exist. 1.0.0-alpha.4 at that moment

albert5287 commented 8 years ago

Hi guys,

if someone is still interesting, I made it work using ui-router 1.0.0-alpha.5, and following ToddMotto styleguide.

and this is how the code shoud look like

/* ----- todo/todo.component.js ----- */
import template from './todo.html';
import controller from './todo.controller';

const TodoComponent = {
  bindings: {
    todoData: '<'
  },
  controller,
  template: template
};
export default TodoComponent;
/* ----- todo/todo.controller.js ----- */
class TodoController {
  constructor() {
      "ngInject";
      console.log('this is the data from resolve', this.todoData);
  }
}

export default TodoController;
/* ----- todo/index.js ----- */
import angular from 'angular';
import TodoComponent from './todo.component';

const todo = angular
  .module('todo', [])
  .component('todo', TodoComponent)
  .config(($stateProvider, $urlRouterProvider) => {
    "ngInject";
    $stateProvider
      .state('todos', {
        url: '/todos',
        component: 'todo',
        resolve: {
          todoData: PeopleService => PeopleService.getAllPeople();
        }
      });
    $urlRouterProvider.otherwise('/');
  })
  .name;

export default todo;

hope this help.

ttbarnes commented 7 years ago

How can you test state resolves with this approach? I've been trying various approaches and nothing is working. Any help appreciated :)

EG my state resolve:

$stateProvider
    .state('someState', {
      url: '/p/:id',
      resolve: {
        someData: (myService, $stateParams) => {
          return myService.getTheData($stateParams.id);
        }
      },
      controller: ['$scope', 'someData', ($scope, someData) => {
        $scope.profile = someData.data;
      }],

      template: '<some-page profile="someData"></some-page>'
    });

simplified spec:

beforeEach(function(){
  window.module(Services.name);
  window.module(($provide) => {
    $provide.value('profile', { name: 'testing' }  );
  });
});

beforeEach(inject((_$rootScope_, _$q_, _$httpBackend_, myService) => {
  $rootScope = _$rootScope_;
  $q = _$q_;
  deferred = _$q_.defer();
  $httpBackend = _$httpBackend_;
  myService = myService;
  scope = $rootScope.$new();
  scope.vm = $rootScope.$new();
  spyOn(myService, 'getTheData').and.callThrough();
  makeController = () => {
    return new MyController(myService, {$scope: scope, profile: mockProfile });
  };
}));

describe('Controller', () => {

  it('should have a name property', () => {
    // let vm = makeController(myService, {$scope: scope, profile: mockProfile});
    let vm = makeController();
    expect(vm.profile.name).toBeDefined();
  });

});

@gdi2290 any tips?

fesor commented 7 years ago

@ttbarnes #199

You just shouldn't use standalone controllers to bind route to component. This makes things way simpler.

const someState = {
    name: 'someState',
    url: '/p/{id}',
    resolves: {
        // key name will be mapped to component bindings dirrectly. This is important!
        profile: (someService, $stateParams) => someService.getTheData($stateParams.id);
    },
    component: 'somePage'
};

export someState;

Then you could test your components without resolvers:

import myComponentModule from './my-component.js';

describe('Component: somePage component', function () {
  let $componentController;

  beforeEach(module(myComponentModule));
  beforeEach(inject(function(_$componentController_) {
    $componentController = _$componentController_;
  }));

  it('should display profile information', function() {
    // you can just pass whatever you want,
    // it's much more easier than trying to mock something 
    // we don't even depend on. fully isolated test.
    const bindings = {
        profile: {
            name: 'John Doe'
        } 
    };

    const ctrl = $componentController('somePage', null, bindings);
    expect(ctrl.profile.name).toBeDefined();
  });
});

Since you test your components and services in isolation, you don't need to test uiRouter stuff yourself. uiRouter already well tested library. Instead you could write some e2e tests to check that everything is working fine (some tests only).

Hope this will help.

ghost commented 7 years ago

@albert5287 solution seems to work. Anyway I experience an odd behavior. The resolved variable isn't instantly available on my controller. For testing purpose and to sort out async issue I gave it a try using $timeout in resolve route.

Resolve: testResolve: $timeout => $timeout(() => ...., 5000)

Controller: console.log('resolved', this.testResolve) -> display undefined.

However if I put another $timeout within my Controller and set it to 1millisecond, I get the resolved var.

Controller: $timeout(() => console.log('resolved from timeout', this.testResolve), 1); -> displays test data.

Incognito commented 7 years ago

I am confirming what @salacis is seeing in my own code.

ghost commented 7 years ago

@Incognito Thats the usual behaviour of an component. Take a look at this.$onInit = () => {}. Your resolved variables will be there when everything is loaded properly.

Incognito commented 7 years ago

Oh, that works much better. I'm now using this pattern:

 class SomeController {
  constructor(SomeApi, $stateParams) {
     "ngInject";
     this.SomeApi=SomeApi;
     this.someId = +$stateParams.someId;
  }

  $onInit() {
    // Actual logic...
  }
}

Thanks for the tip!

ghost commented 7 years ago

That's not the appropriate way to use it. look at this https://toddmotto.com/angular-1-5-lifecycle-hooks

Incognito commented 7 years ago

After reading the onInit part of code, the specific problem you see with that code was not obvious to me. Is the issue that it contains all logic, or that it is defined at instantiation instead of in the constructor?

fesor commented 7 years ago

@Incognito $onInit method is responsible to component initialization. Constructor should not contain any logic.

By doing this you could create instance of component, pass data to it and then call $onInit method for component initialization. This simplifies testing and extension of components.

Incognito commented 7 years ago

I don't quite get it.

In the Todo example this code exists:

https://github.com/AngularClass/NG6-todomvc-starter/blob/master/src/app/components/todoItem.component.js

export class TodoItemController {
  constructor(todoList) {
    "ngInject";
    this.todoList = todoList;
    this.isEditing = false;
  }

  onDestroyClick() {
    this.todoList.remove(this.task);
  }
// ...

In my example I also inject dependencies via the controller and perform logic inside other methods. In the Todo example it's a method on the controller, and in mine it is a lifecycle hook.

.... is the issue my injection of $stateParams in the constructor? I don't see the Todo example as very different from something such as:

export class TodoItemController {
  constructor(todoList) {
    "ngInject";
    this.todoList = todoList;
    this.isEditing = false;
  }

  $onInit() {
    this.todoList.remove(this.task);
  }

I think I can test both, no?

fesor commented 7 years ago

In the Todo example this code exists:

todoList is dependency of an component. There is no any logic or data retrieval. As for isEditing, it is just initial state of this component. Only component itself responsible for it's initial state (encapsulation and stuff).

is the issue my injection of $stateParams in the constructor?

Yep. By doing this you couple your component to specific route which isn't that flexible. Components != screens. You could have some-kind of top-level components which defines screens, but they should delegate all presentation responsibilities to lower-level components and pass data via binding.

It will be better if you pass all resolved values directly to components binding. UI Router allows that.

I think I can test both, no?

Yes, you can, but when state defined via bindings it's easier.

Incognito commented 7 years ago

Oh I think I get it now. That would let me modify stateful dependencies per instance instead of component instead of the same dependencies every time via ng inject. That's cool.