Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

PagerJS how to accomplish "two-way binding" with URL params?

PagerJS can pick up URL parameters and bind them to the model. For instance, in this example from the PagerJS website (see link), when you click on the link, it will navigate to #/user?first=Philip&last=Fry and the data-bound sub-page will appear, displaying "Philip Fry":

<a class="btn" data-bind="page-href: {path: 'user', params: {first: 'Philip', last: 'Fry'}}">Send parameter to page</a>

<div data-bind="page: {id: 'user', params: ['first','last']}" class="well-small">
    <div>
        <span>First name:</span>
        <span data-bind="text: first"></span>
    </div>
    <div>
        <span>Last name:</span>
        <span data-bind="text: last"></span>
    </div>
</div>

This is a one-way binding: if the observable changes, because of user actions on the page, the URL will not be updated.

What's the recommended way of keeping the URL parameters in sync with the observables when using PagerJS?

I'd like to store the user's dynamically created search-criteria, produced by selecting a bunch of UI controls, in the URL parameters so he/she can share the URL with others or bookmark it, all without reloading the page, of course.

like image 785
Slawomir Avatar asked Dec 03 '14 03:12

Slawomir


2 Answers

Disclaimer: I don't know anything about pager.js, but I'm hoping my general knockout experience can still be of help.

Looking at the example, the page binding seems to create observables using initial values from the url. My first instinct would be to extend this binding and make sure a subscription to each of these values updates the URL.

Let's name this binding twoway-page:

ko.bindingHandlers["twoway-page"] = {
  init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
    // Call pager.js' page binding
    ko.bindingHandlers.page.init(element, valueAccessor, allBindings, viewModel, bindingContext);

    // ...
  }
}

And call it on the example's binding:

<div data-bind="twoway-page: {
                  id: 'start', 
                  params: ['first','last']
                 }">

After calling page.init, the page binding has extended the viewmodel, adding the observables defined in the params array to the viewModel object. This means we can subscribe to changes in these observables.

The next challenge is computing the right hash. I looked up how the page-href binding computes its href attribute. Turns out it uses pager.page.path() on an object with a path and params property. E.g.:

var hash = pager.page.path({
  path: "user",
  params: {
    "first": "John",
    "last": "Doe"
  }
});

I tried to construct a similar object in a computed observable.

// ... 
var options = valueAccessor();
var pathObj = ko.computed(function() {

    var result = {
        path: options.id,
        params: {}
    };

    options.params.forEach(function(param) {
        result.params[param] = viewModel[param]();
    });

    return result;
}).extend({ rateLimit: { timeout: 200, method: "notifyWhenChangesStop" } });

I couldn't find a "clean" way to update the hash via a pager.js method, but I did notice that internally, pagerjs uses location.hash = "newhash" to set a value (although there also seems to be a history/html5 alternative...). Anyway, we can subscribe to our observable to update the hash:

// ...
pathObj.subscribe(function(newValue) {
    location.hash = pager.page.path(newValue);
});

Now, instead of the text bindings from the example, we'll use textInput bindings so we can update the values:

<div>
  <span>First name:</span>
  <input type="text" data-bind="textInput: first">
</div>

So, to wrap up: my best guess would be to

  1. Extend an existing pager.js binding
  2. Create subscriptions to all observables that need to be updated in the URL
  3. Automatically update the hash when values change; use a rateLimit extension to prevent an overload of updates

Doing stuff with the location hash is a bit hard to show in a fiddle, so I've recorded a gif of my proof of concept:

live updating the hash using a custom pagerjs binding

The complete custom binding code is:

ko.bindingHandlers["twoway-page"] = {
    init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
        ko.bindingHandlers.page.init(element, valueAccessor, allBindings, viewModel, bindingContext)

        var options = valueAccessor();

        var pathObj = ko.computed(function() {

            var result = {
                path: options.id,
                params: {}
            };

            options.params.forEach(function(param) {
                result.params[param] = viewModel[param]();
            });

            return result;
        }).extend({ rateLimit: { timeout: 200, method: "notifyWhenChangesStop" } });

        pathObj.subscribe(function(newValue) {
            location.hash = pager.page.path(newValue);
        })

        return { controlsDescendantBindings: true }
    }
};
like image 63
user3297291 Avatar answered Oct 03 '22 05:10

user3297291


I can't add a comment so i have to add a new answer. Very nice answer by user3297291, it really helped me a lot. However, some issues arise in real world, e.g.:

  1. deep navigation: pages, subpages, subsubpages, etc. the options.id is not enough, we need the complete path page/subpage/subsubpage (without #!/ )

  2. when sharing some URL params between pages, anytime the param change, each page will trigger a navigation, last one will be displayed. I share same ID params between some pages (productID,etc).

For issue 1, calculate complete path:

...var options = valueAccessor();
var parent = pager.getParentPage(bindingContext);
var fullroute = parent.getFullRoute()();
if(fullroute.length == 0) 
    var path = fullroute.join('/')+options.id;
else 
    var path = fullroute.join('/')+'/'+options.id;

result object turns into:

var result = {
    path: path, 
    params: {}
};

For issue 2, we have to tell them that they can only trigger a navigation when the active page is their own.

Inside the subscribe method, we calculate the activePage path and compares them:

pathObj.subscribe(function(newValue) {
    //calculate activePath
    var activeParent = pager.activePage$().parentPage.getFullRoute()();
    var activeId = pager.activePage$().getCurrentId();
    if(activeParent.length == 0)
        var activePath = activeParent .join('/')+activeId;
    else 
        var activePath = activeParent .join('/')+'/'+activeId;

    if( path == activePath ){
        location.hash = pager.page.path(newValue);
    }
})

Also, be carefull when looping throw params, it can be an array or an object if you have provided default values:

<div data-bind="page: {id: 'search', params: ['product','price']}" class="well">

needs:

options.params.forEach(function(param) {...

Vs

<div data-bind="page: {id: 'search', params: {'product': null,'price': 0}}" class="well">

needs something like:

Object.keys(options.params).forEach(function(key,index) { 
    result.params[key] = viewModel[key](); 
}

Thanks again to user3297291 for your answer, it really made the difference.

like image 41
raguchi Avatar answered Oct 03 '22 05:10

raguchi