Search code examples
knockout.jsknockout-3.0knockout-3.2

Firing actions after foreach finishes in knockout.js


I've seen this question and this other one about the topic, but none of them seem to solve my problem.

I'm trying to initialize a javascript plugin (BootStrap javascript tooltip) for an element within each row of a table.

To do it, the table has to be rendered in the DOM. And that's where the problem is. As far as I know, knockout.js doesn't provide a proper way to know when a foreach has render the whole table.

I've tried to solve it by applying the solution provided in the first link I posted, which is by creating a custom binding, but this doesn't work in my case. I guess because the data is being displayed by calling an API from an AJAX call.

Reproduction of the issue

ko.bindingHandlers.doSomething = {
    update: function (element) {
        console.log("table loaded???");

        //applying the tooltip
        $('.actions').tooltip({
            title: 'Actions'
        });
    }
};

function ViewModel(){
    var self = this;
    self.myItems= ko.observableArray(['A', 'B', 'C']);

    //emulating the delay of calling an external API 
    self.getdata = function(){
        setTimeout(function(){
            self.myItems.push('D');
            self.myItems.push('E');
            self.myItems.push('F');
        }, 200);       
    };

    self.addItem = function () {
        this.myItems.push('New item');
    };

    //getting the data on loading    
    self.getdata();
}

ko.applyBindings(new ViewModel());

I would prefer not to use the afterRender callback provided by Knockout.js for foreach cases because that way the plugin would have to be initialized on every iteration rather than just once at the end.

The only solution I found for it was to apply a setTimeout, before applying the tooltip plugin, but it is far from ideal:

ko.bindingHandlers.doSomething = {
    update: function (element) {
        console.log("table loaded???");

        setTimeout(function(){
            //applying the tooltip
            $('.actions').tooltip({
                title: 'Actions'
            });
        }, 2000);
    }
};

The other solution was to call the tooltip plugin after getting the data from the API:

//emulating the delay of calling an external API 
self.getdata = function(){
    setTimeout(function(){
        //simulating 
        $.get(url, function(data){
            self.orders(data);
            self.loading(false);

            $('.actions').tooltip({
                title: 'Actions'
            });
        });
    }, 200);       
};

Is there any better approach to it?


Solution

  • Here is an option that mixes some of what you already know in a different way that might be more palatable.

    http://jsfiddle.net/xveEP/260/

    I first added a variable to the viewmodel called tooltipHandle. This variable will be a reference (or pointer) to any setTimeout calls that are made for applying the tooltips.

    I created a custom binding for the <li> elements rather than for the parent <ul> element. This custom binding first clears out any existing setTimeout instances for adding the tooltip. Then it creates a new setTimeout instance that will run in 5ms. The function in this setTimeout will apply the tooltip to all elements with the .actions class that have not already had the tooltip applied to them.

    In your example, there are 6 total elements added: 3 immediately and 3 200ms later. My binding will execute 6 times, however, it will only apply the tooltips twice: once for the first 3 and once for the second 3. Since I am clearing out the tooltipHandle before it can execute the setTimeout function, it only runs after the 3rd item for each of the 2 sets has finished binding. I added a console.log call in the setTimeout function so that you will see that it is only called 2 times in your previous example.

    In addition the binding will execute when you click the Add button and will only apply the tooltip to the newly added element.

    <ul data-bind="foreach: myItems">
        <li data-bind="text: $data, doSomething2: true" class="actions"></li>
    </ul>
    

    ko.bindingHandlers.doSomething2 = {
        init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
            clearTimeout(bindingContext.$parent.tooltipHandle);
            bindingContext.$parent.tooltipHandle = setTimeout(function() {
                $('.actions:not([data-original-title])').tooltip({
                    title: 'Actions'
                });
                window.console.log('tooltip applied');
            }, 5);
        }
    };
    
    function ViewModel(){
        var self = this;
        self.tooltipHandle = undefined;
        self.myItems= ko.observableArray(['A', 'B', 'C']);
    
        //emulating the delay of calling an external API 
        self.getdata = function(){
            setTimeout(function(){
                self.myItems.push('D');
                self.myItems.push('E');
                self.myItems.push('F');
            }, 200);       
        };
    
        self.addItem = function () {
            this.myItems.push('New item');
        };
    
        //getting the data on loading    
        self.getdata();
    }
    
    ko.applyBindings(new ViewModel());