Angular-ui.router: Update URL without view refresh
Asked Answered
B

4

24

I have an Angular SPA that presents a variety of recommendation lists, and a Google Map of locations, based on different cuts of some restaurant data (see m.amsterdamfoodie.nl). I want each of these lists to have their own URL. In order for Google to crawl the different lists I use <a> tags for the offcanvas navigation.

At present the <a> tag causes a view refresh, which is very noticeable with the map.

  • I can prevent this using ng-click and $event.preventDefault() (see code snippets below), but then I need to implement a means of updating the browser URL.
  • But in trying Angular's $state or the browser's history.pushstate, I end up triggering state changes and the view refresh...!

My question is therefore how can I update a model and the URL, but without refreshing the view? (See also Angular/UI-Router - How Can I Update The URL Without Refreshing Everything?)

I have experimented with a lot of approaches and currently have this html

<a href="criteria/price/1" class="btn btn-default" ng-click="main.action($event)">Budget</a>

In the controller:

this.action = ($event) ->
    $event.preventDefault()
    params = $event.target.href.match(/criteria\/(.*)\/(.*)$/)

    # seems to cause a view refresh
    # history.pushState({}, "page 2", "criteria/"+params[1]+"/"+params[2]);

    # seems to cause a view refresh
    # $state.transitionTo 'criteria', {criteria:params[1], q:params[2]}, {inherit:false}

    updateModel(...)

And, what is I think is happening is that I am triggering the $stateProvider code:

angular.module 'afmnewApp'
.config ($stateProvider) ->
  $stateProvider
  .state 'main',
    url: '/'
    templateUrl: 'app/main/main.html'
    controller: 'MainCtrl'
    controllerAs: 'main'
  .state 'criteria',
    url: '/criteria/:criteria/:q'
    templateUrl: 'app/main/main.html'
    controller: 'MainCtrl'
    controllerAs: 'main'

One possible clue is that with the code below if I load e.g. http://afmnew.herokuapp.com/criteria/cuisine/italian then the view refreshes as you navigate, whereas if I load http://afmnew.herokuapp.com/ there are no refreshes, but no URL updates instead. I don't understand why that is happening at all.

Borlow answered 14/12, 2014 at 7:11 Comment(4)
I haven't studied this article in any depth yet, but you may find it interesting. weluse.de/blog/angularjs-seo-finally-a-piece-of-cake.htmlGoldfilled
Unfortunately this is about pre-rendering, whereas my question is about navigating a SPA in an SEO-friendly mannerBorlow
Have you seen ui-sref? also reloadOnSearch parameter in the state config?Disproportionate
reloadOnSearch looks like it might help, but is it part of ui.router?Borlow
R
11

Based on our previous discussions, I want to give you some idea, how to use UI-Router here. I believe, I understand your challenge properly... There is a working example. If this not fully suites, please take it as some inspiration

DISCLAIMER: With a plunker, I was not able to achieve this: http://m.amsterdamfoodie.nl/, but the principle should be in that example similar

So, there is a state definition (we have only two states)

  $stateProvider
    .state('main', {
        url: '/',
        views: {
          '@' : {
            templateUrl: 'tpl.layout.html',
            controller: 'MainCtrl',
          },
          'right@main' : { templateUrl: 'tpl.right.html',}, 
          'map@main' : {
            templateUrl: 'tpl.map.html',
            controller: 'MapCtrl',
          },
          'list@main' : {
            templateUrl: 'tpl.list.html',
            controller: 'ListCtrl',
          },
        },
      })
    .state('main.criteria', {
        url: '^/criteria/:criteria/:value',
        views: {
          'map' : {
            templateUrl: 'tpl.map.html',
            controller: 'MapCtrl',
          },
          'list' : {
            templateUrl: 'tpl.list.html',
            controller: 'ListCtrl',
          },
        },
      })
}];

This would be our main tpl.layout.html

<div>

  <section class="main">

    <section class="map">
      <div ui-view="map"></div>
    </section>

    <section class="list">
      <div ui-view="list"></div>
    </section>

  </section>

  <section class="right">
    <div ui-view="right"></div>
  </section>

</div>

As we can see, the main state does target these nested views of the main state: 'viewName@main', e.g. 'right@main'

Also the subview, main.criteria does inject into layout views.

Its url starts with a sign ^ (url : '^/criteria/:criteria/:value'), which allows to have / slash for main and not doubled slash for child

And also there are controllers, they are here a bit naive, but they should show, that on the background could be real data load (based on criteria).

The most important stuff here is, that the PARENT MainCtrl creates the $scope.Model = {}. This property will be (thanks to inheritance) shared among parent and children. That's why this all will work:

app.controller('MainCtrl', function($scope)
{
  $scope.Model = {};
  $scope.Model.data = ['Rest1', 'Rest2', 'Rest3', 'Rest4', 'Rest5'];  
  $scope.Model.randOrd = function (){ return (Math.round(Math.random())-0.5); };
})
.controller('ListCtrl', function($scope, $stateParams)
{
  $scope.Model.list = []
  $scope.Model.data
    .sort( $scope.Model.randOrd )
    .forEach(function(i) {$scope.Model.list.push(i + " - " + $stateParams.value || "root")})
  $scope.Model.selected = $scope.Model.list[0];
  $scope.Model.select = function(index){
    $scope.Model.selected = $scope.Model.list[index];  
  }
})

This should get some idea how we can use the features provided for us by UI-Router:

Check the above extract here, in the working example

Extend: new plunker here

If we do not want to have map view to be recreated, we can just omit that form the child state def:

.state('main.criteria', {
    url: '^/criteria/:criteria/:value',
    views: {
      // 'map' : {
      //  templateUrl: 'tpl.map.html',
      //  controller: 'MapCtrl',
      //},
      'list' : {
        templateUrl: 'tpl.list.html',
        controller: 'ListCtrl',
      },
    },
  })

Now our map VIEW will be just recieving changes in the model (could be watched) but view and controller won't be rerendered

ALSO, there is another plunker http://plnkr.co/edit/y0GzHv?p=preview which uses the controllerAs

.state('main', {
    url: '/',
    views: {
      '@' : {
        templateUrl: 'tpl.layout.html',
        controller: 'MainCtrl',
        controllerAs: 'main',        // here
      },
      ...
    },
  })
.state('main.criteria', {
    url: '^/criteria/:criteria/:value',
    views: {
      'list' : {
        templateUrl: 'tpl.list.html',
        controller: 'ListCtrl',
        controllerAs: 'list',      // here
      },
    },
  })

and that could be used like this:

<h4>{{main.hello()}}</h4>
<h4>{{list.hello()}}</h4>

The last plunker is here

Ruminate answered 17/12, 2014 at 18:33 Comment(9)
Quick question: is it vital to create the Model in MainCtrl - I have been using a shared Factory so far?Borlow
Simon, please, that does not seem to me serious. I want to help, but you are giving me link to version with minified Javascript? that does not work. You should see that I wanted help. So try to adjust, extend my plunker to reproduce the issue. I can also re-engeneer your life app, but... (that's a bit over Q&A on SO)... Anyhow, I wanted to give you a hint as promised. I created pretty sophisticated plunker. If you want me to do more, please, make it easy for me. I understand if you do not need my assitence any more... Does it make sense?Megan
OK, forgive the previous question. My main interest is not to refresh the map, but to have the controller delete and recreate markers as the model changes (caused by clicks on the 'right' panel). I modified your plnkr plnkr.co/edit/9atRT1?p=preview and added a console.log message each time the MapCtrl is loaded, which is equivalent to the view reloading (I think), and it fires all the time. That's a concern for me, but it may be due to the fact that I am keeping the model in a Factory (my first comment above)Borlow
Will come to you later... promiseMegan
I'm currently working on the idea that I need to get the map itself into the main view, but have the markers created by a subviewBorlow
I am really sorry, to not reply now, will be available later today... That Idea seems to me correct. Will updated plunker, which won't be using map on child...Megan
I have updated my plnkr so that it emulates what I want, and have been able to implement that on my site :-) Only thing I have lost now is the controllerAs which just seemed to be blocking everything on my siteBorlow
OK, I am in. So the plunker is still plnkr.co/edit/9atRT1?p=preview ? what should I check? I am fully ready to assistMegan
Let us continue this discussion in chat.Borlow
B
16

This is an example of the way to go if I understand correctly:

$state.go('my.state', {id:data.id}, {notify:false, reload:false});
//And to remove the id from the url:
$state.go('my.state', {id:undefined}, {notify:false, reload:false});

From user l-liava-l in the issue https://github.com/angular-ui/ui-router/issues/64

You can check the $state API here: http://angular-ui.github.io/ui-router/site/#/api/ui.router.state.$state

Bonina answered 12/6, 2015 at 9:40 Comment(4)
awesome thx John - fyi this doesn't work on the old router - it worked like a charm after I upgraded to angular-ui-router/0.2.18 (was on 0.2.13)Latium
No problem. It seems that there are some "secondary" problems with this, you can check in the github issue I point in the answer. But if I am not mistaken it should have been addressed in 1.x releases of UI-RouterBonina
This works just fine clicking the link, but if I go forward/back on browser, it reloads the viewKorenblat
Yeah, that is one of the problems I was referring to in my comment. I didn't have the chance of checking if that still happens in the 1.0 alpha version (the most recent version as for today)... is that the case?Bonina
R
11

Based on our previous discussions, I want to give you some idea, how to use UI-Router here. I believe, I understand your challenge properly... There is a working example. If this not fully suites, please take it as some inspiration

DISCLAIMER: With a plunker, I was not able to achieve this: http://m.amsterdamfoodie.nl/, but the principle should be in that example similar

So, there is a state definition (we have only two states)

  $stateProvider
    .state('main', {
        url: '/',
        views: {
          '@' : {
            templateUrl: 'tpl.layout.html',
            controller: 'MainCtrl',
          },
          'right@main' : { templateUrl: 'tpl.right.html',}, 
          'map@main' : {
            templateUrl: 'tpl.map.html',
            controller: 'MapCtrl',
          },
          'list@main' : {
            templateUrl: 'tpl.list.html',
            controller: 'ListCtrl',
          },
        },
      })
    .state('main.criteria', {
        url: '^/criteria/:criteria/:value',
        views: {
          'map' : {
            templateUrl: 'tpl.map.html',
            controller: 'MapCtrl',
          },
          'list' : {
            templateUrl: 'tpl.list.html',
            controller: 'ListCtrl',
          },
        },
      })
}];

This would be our main tpl.layout.html

<div>

  <section class="main">

    <section class="map">
      <div ui-view="map"></div>
    </section>

    <section class="list">
      <div ui-view="list"></div>
    </section>

  </section>

  <section class="right">
    <div ui-view="right"></div>
  </section>

</div>

As we can see, the main state does target these nested views of the main state: 'viewName@main', e.g. 'right@main'

Also the subview, main.criteria does inject into layout views.

Its url starts with a sign ^ (url : '^/criteria/:criteria/:value'), which allows to have / slash for main and not doubled slash for child

And also there are controllers, they are here a bit naive, but they should show, that on the background could be real data load (based on criteria).

The most important stuff here is, that the PARENT MainCtrl creates the $scope.Model = {}. This property will be (thanks to inheritance) shared among parent and children. That's why this all will work:

app.controller('MainCtrl', function($scope)
{
  $scope.Model = {};
  $scope.Model.data = ['Rest1', 'Rest2', 'Rest3', 'Rest4', 'Rest5'];  
  $scope.Model.randOrd = function (){ return (Math.round(Math.random())-0.5); };
})
.controller('ListCtrl', function($scope, $stateParams)
{
  $scope.Model.list = []
  $scope.Model.data
    .sort( $scope.Model.randOrd )
    .forEach(function(i) {$scope.Model.list.push(i + " - " + $stateParams.value || "root")})
  $scope.Model.selected = $scope.Model.list[0];
  $scope.Model.select = function(index){
    $scope.Model.selected = $scope.Model.list[index];  
  }
})

This should get some idea how we can use the features provided for us by UI-Router:

Check the above extract here, in the working example

Extend: new plunker here

If we do not want to have map view to be recreated, we can just omit that form the child state def:

.state('main.criteria', {
    url: '^/criteria/:criteria/:value',
    views: {
      // 'map' : {
      //  templateUrl: 'tpl.map.html',
      //  controller: 'MapCtrl',
      //},
      'list' : {
        templateUrl: 'tpl.list.html',
        controller: 'ListCtrl',
      },
    },
  })

Now our map VIEW will be just recieving changes in the model (could be watched) but view and controller won't be rerendered

ALSO, there is another plunker http://plnkr.co/edit/y0GzHv?p=preview which uses the controllerAs

.state('main', {
    url: '/',
    views: {
      '@' : {
        templateUrl: 'tpl.layout.html',
        controller: 'MainCtrl',
        controllerAs: 'main',        // here
      },
      ...
    },
  })
.state('main.criteria', {
    url: '^/criteria/:criteria/:value',
    views: {
      'list' : {
        templateUrl: 'tpl.list.html',
        controller: 'ListCtrl',
        controllerAs: 'list',      // here
      },
    },
  })

and that could be used like this:

<h4>{{main.hello()}}</h4>
<h4>{{list.hello()}}</h4>

The last plunker is here

Ruminate answered 17/12, 2014 at 18:33 Comment(9)
Quick question: is it vital to create the Model in MainCtrl - I have been using a shared Factory so far?Borlow
Simon, please, that does not seem to me serious. I want to help, but you are giving me link to version with minified Javascript? that does not work. You should see that I wanted help. So try to adjust, extend my plunker to reproduce the issue. I can also re-engeneer your life app, but... (that's a bit over Q&A on SO)... Anyhow, I wanted to give you a hint as promised. I created pretty sophisticated plunker. If you want me to do more, please, make it easy for me. I understand if you do not need my assitence any more... Does it make sense?Megan
OK, forgive the previous question. My main interest is not to refresh the map, but to have the controller delete and recreate markers as the model changes (caused by clicks on the 'right' panel). I modified your plnkr plnkr.co/edit/9atRT1?p=preview and added a console.log message each time the MapCtrl is loaded, which is equivalent to the view reloading (I think), and it fires all the time. That's a concern for me, but it may be due to the fact that I am keeping the model in a Factory (my first comment above)Borlow
Will come to you later... promiseMegan
I'm currently working on the idea that I need to get the map itself into the main view, but have the markers created by a subviewBorlow
I am really sorry, to not reply now, will be available later today... That Idea seems to me correct. Will updated plunker, which won't be using map on child...Megan
I have updated my plnkr so that it emulates what I want, and have been able to implement that on my site :-) Only thing I have lost now is the controllerAs which just seemed to be blocking everything on my siteBorlow
OK, I am in. So the plunker is still plnkr.co/edit/9atRT1?p=preview ? what should I check? I am fully ready to assistMegan
Let us continue this discussion in chat.Borlow
A
2

you can use scope inheritance to update url without refreshing view

$stateProvider
            .state('itemList', {
                url: '/itemlist',
                templateUrl: 'Scripts/app/item/ItemListTemplate.html',
                controller: 'ItemListController as itemList'
                //abstract: true //abstract maybe?
            }).state('itemList.itemDetail', {
                url: '/:itemName/:itemID',
                templateUrl: 'Scripts/app/item/ItemDetailTemplate.html',
                controller: 'ItemDetailController as itemDetail',
                resolve: {
                    'CurrentItemID': ['$stateParams',function ($stateParams) {
                        return $stateParams['itemID'];
                    }]
                }
            })

if child view is inside parent view both controllers share same scope. so you can place a dummy (or neccessary) ui-view inside parent view which will be populated by child view.

and insert a

$scope.loadChildData = function(itemID){..blabla..};

function in parent controller which will be called by child controller on controller load. so when a user clicks

<a ui-sref="childState({itemID: 12})">bla</a> 

only child controller and child view will be refreshed. then you can call parent scope function with necessary parameters.

Adonis answered 23/12, 2014 at 15:56 Comment(1)
Thanks. In fact i ended up wrapping the map in a div with a controller but not linked to a view. In not using scope inheritance but have all the key data in a service that the controller can of course accessBorlow
B
0

The short answer ended up being do not put the map inside a view that changes. The accepted answer provides a lot more detail on how to structure a page with sub-views, but the key point is not to make the map part of the view but to connect its behaviour to a view that does change and to use a Controller to update the market icons.

Borlow answered 17/12, 2014 at 13:46 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.