javascriptknockout.jsknockout-components

Async loading a template in a Knockout component


I'm pretty experienced with Knockout but this is my first time using components so I'm really hoping I'm missing something obvious! I'll try and simplify my use case a little to explain my issue.

I have a HTML and JS file called Index. Index.html has the data-bind for the component and Index.js has the ko.components.register call.

Index.html

<div data-bind="component: { name: CurrentComponent }"></div>

Index.js

var vm = require("SectionViewModel");
var CurrentComponent = ko.observable("section");
ko.components.register("section", {
    viewModel: vm.SectionViewModel,
    template: "<h3>Loading...</h3>"
});
ko.applyBindings();

I then have another HTML and JS file - Section.html and SectionViewModel.js. As you can see above, SectionViewModel is what I specify as the view model for the component.

Section.html

<div>
    <span data-bind="text: Section().Name"></span>
</div>

SectionViewModel.js

var SectionViewModel = (function() {
    function SectionViewModel() {
        this.Section = ko.observable();
        $.get("http://apiurl").done(function (data) {
            this.Section(new SectionModel(data.Model)); // my data used by the view model
            ko.components.get("dashboard", function() {
                component.template[0] = data.View; // my html from the api
            });
        });
    }
    return SectionViewModel;
});
exports.SectionViewModel = SectionViewModel;

As part of the constructor in SectionViewModel, I make a call to my API to get all the data needed to populate my view model. This API call also returns the HTML I need to use in my template (which is basically being read from Section.html).

Obviously this constructor isn't called until I've called applyBindings, so when I get into the success handler for my API call, the template on my component is already set to my default text.

What I need to know is, is it possible for me to update this template? I've tried the following in my success handler as shown above:

ko.components.get("section", function(component) {
    component.template[0] = dataFromApi.Html;
});

This does indeed replace my default text with the html returned from my API (as seen in debug tools), but this update isn't reflected in the browser.

So, basically after all that, all I'm really asking is, is there a way to update the content of your components template after binding?

I know an option to solve the above you might think of is to require the template, but I've really simplified the above and in it's full implementation, I'm not able to do this, hence why the HTML is returned by the API.

Any help greatly appreciated! I do have a working solution currently, but I really don't like the way I've had to structure the JS code to get it working so a solution to the above would be the ideal.

Thanks.


Solution

  • You can use a template binding inside your componente.

    The normal use of the template bindign is like this:

    <div data-bind="template: { name: tmplName, data: tmplData }"></div>
    

    You can make both tmplData and tmplName observables, so you can update the bound data, and change the template. The tmplName is the id of an element whose content will be used as template. If you use this syntax you need an element with the required id, so, in your succes handler you can use something like jQuery to create a new element with the appropriate id, and then update the tmplname, so that the template content gets updated.

    *THIS WILL NOT WORK: Another option is to use the template binding in a different way:

    <div data-bind="template: { nodes: tmplNodes, data: tmplData }"></div>
    

    In this case you can supply directly the nodes to the template. I.e. make a tmplNodes observable, which is initialized with your <h3>Loading...</h3> element. And then change it to hold the nodes received from the server.

    because nodesdoesn't support observables:

    nodes — directly pass an array of DOM nodes to use as a template. This should be a non-observable array and note that the elements will be removed from their current parent if they have one. This option is ignored if you have also passed a nonempty value for name.

    So you need to use the first option: create a new element, add it to the document DOM with a known id, and use that id as the template name. DEMO:

    // Simulate service that return HTML
    var dynTemplNumber = 0;
    var getHtml = function() {
        var deferred = $.Deferred();
    	var html = 
        '<div class="c"> \
    	  <h3>Dynamic template ' + dynTemplNumber++ + '</h3> \
    	  Name: <span data-bind="text: name"/> \
        </div>';
        setTimeout(deferred.resolve, 2000, html);
        return deferred.promise();
    };
    
    var Vm = function() {
      self = this;
      self.tmplIdx = 0;
      self.tmplName = ko.observable('tmplA');
      self.tmplData = ko.observable({ name: 'Helmut', surname: 'Kaufmann'});
      self.tmplNames = ko.observableArray(['tmplA','tmplB']);
      self.loading = ko.observable(false);
      self.createNewTemplate = function() {
          // simulate AJAX call to service
          self.loading(true);
          getHtml().then(function(html) {
              var tmplName = 'tmpl' + tmplIdx++;
              var $new = $('<div>');
              $new.attr('id',tmplName);
              $new.html(html);
              $('#tmplContainer').append($new);
              self.tmplNames.push(tmplName);
    	      self.loading(false);
              self.tmplName(tmplName);
          });
      };
      return self;
    };
    
    ko.applyBindings(Vm(), byName);
    div.container { border: solid 1px black; margin: 20px 0;}
    div {padding: 5px; }
    .a { background-color: #FEE;}
    .b { background-color: #EFE;}
    .c { background-color: #EEF;}
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.2.0/knockout-min.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <div id="byName" class="container">
    
      Select template by name: 
      <select data-bind="{options: tmplNames, value: tmplName}"></select>
      <input type="button" value="Add template"
           data-bind="click: createNewTemplate"/>
        <span data-bind="visible: loading">Loading new template...</span>
      <div data-bind="template: {name: tmplName, data: tmplData}"></div>
    
    </div>
    
    <div id="tmplContainer" style="display:none">
      <div id="tmplA">
        <div class="a">
          <h3>Template A</h3>
          <span data-bind="text: name"></span> <span data-bind="text: surname"></span>
        </div>
      </div>
      <div id="tmplB">
        <div class="b">
          <h3>Template B</h3>
          Name: <span data-bind="text: name"/>
        </div>
      </div>
    </div>