Search code examples
knockout.jsknockout-mapping-plugin

How to bind observableArray by index to the element


I've been developing a web application that will display a table containing list of candidates. I want to display the candidate collection (get from the server) to the table, but with fixed row count to display, let's say 5 rows. So if the collection only have 2 candidates, the table will be having 5 rows, with the only first 2 rows contains the data, and the remaining 3 are empty rows.

I couldn't use the foreach binding to solve this, so I tried this post.

In that post the objects inside observableArray are not observable, so I tried to make them observable and it still works. But when I try in my code, it throws error in my js:

Unable to process binding "text: function (){return Candidates[0]().CandidateNumber }"
Message: AssignedCandidates[0] is not a function

I'm still not sure what I'm missing. Please help.

This is my js file:

var CandidateViewModel = function (data) {
    var self = this;
    self.CandidateId = ko.observable();
    self.CandidateNumber = ko.observable();
    self.Name = ko.observable();
    self.Status = ko.observable()
    ko.mapping.fromJS(data, {}, self);
}

var mappingCandidateList = {
    Candidates: {
        create: function (options) {
            return new CandidateViewModel(options.data);
        }
    }
}

var CandidateListViewModel = function (data) {
    var self = this;
    self.Candidates = ko.observableArray();
    self.AssignedCount = ko.observable();
    self.ProcessingCount = ko.observable();
    self.RejectingCount = ko.observable();
    self.PassedCount = ko.observable();
    self.FailedCount = ko.observable();
    self.PagingInfo = ko.observable();

    var getData = function (param) {
        $.ajax({
            url: api("Candidate/GetCandidates"),
            data: param,
            type: 'GET',
            dataType: 'JSON'
        }).done(function (data) {
            ko.mapping.fromJS(data, mappingCandidateList, self);
        });
    }
}

ko.applyBindings(new CandidateListViewModel (), document.getElementById('candidate-container'));

and this is the html

<table>
    <thead>
        <tr>
            <th>number</th>
            <th>name</th>
            <th>status</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td data-bind="text: Candidates()[0].CandidateNumber"></td>
            <td data-bind="text: Candidates()[0].Name"></td>
            <td data-bind="text: Candidates()[0].Status"></td>
        </tr>
    </tbody>
</table>

Solution

  • You can use the foreach binding to get your guaranteed 5 rows, you'll just need a computed property that ensures the exact length of 5.

    For example:

    • Define fixed number of rows (NR_OF_ROWS)
    • Create a pureComputed and bind to it using foreach
    • Make the pureComputed return an array of length NR_OF_ROWS
    • Loop over this array and inject a candidate for every index that has one available
    • If there are no more candidates, inject an empty candidate that tells your view what to render.

    var VM = function() {
      const NR_OF_ROWS = 5;
    
      this.candidates = ko.observableArray([
        { id: 1, firstName: "Jane", lastName: "Doe" },
        { id: 2, firstName: "John", lastName: "Doe" }
      ]);
      
      this.displayRows = ko.pureComputed(function() {
        const emptyCandidate = { id: "-", firstName: "-", lastName: "-", empty: true };
        const candidates = this.candidates();
        const rows = [];
        
        for (let i = 0; i < NR_OF_ROWS; i += 1) {
          rows.push(candidates[i] || emptyCandidate);
        }
        
        return rows;
      }, this);
      
      // For demo
      this.newCandidate = {
        firstName: ko.observable(""),
        lastName: ko.observable(""),
        add: function() {
          this.candidates.push({ 
            firstName: this.newCandidate.firstName(), 
            lastName: this.newCandidate.lastName(), 
            id: this.candidates().length + 1 
          })
        }.bind(this)
      }
    }
    
    ko.applyBindings(new VM());
    tr:nth-child(even) { background: #efefef; }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
    
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>First Name</th>
          <th>Last Name</th>
        </tr>
      </thead>
      <tbody data-bind="foreach: displayRows">
        <td data-bind="text: id"></td>
        <td data-bind="text: firstName"></td>
        <td data-bind="text: lastName"></td>
      </tbody>
    </table>
    
    <div data-bind="with: newCandidate">
      <input type="text" placeholder="first name" data-bind="textInput: firstName">
      <input type="text" placeholder="last name" data-bind="textInput: lastName">
      <button data-bind="click: add">add</button>
    </div>


    Edit: if you'd rather have a special view for an empty row, rather than a special viewmodel, you can use templates:

    var VM = function() {
      const NR_OF_ROWS = 5;
    
      this.candidates = ko.observableArray([
        { id: 1, firstName: "Jane", lastName: "Doe" },
        { id: 2, firstName: "John", lastName: "Doe" }
      ]);
      
      this.displayRows = ko.pureComputed(function() {
        const candidates = this.candidates();
        const rows = [];
        
        for (let i = 0; i < NR_OF_ROWS; i += 1) {
          rows.push(candidates[i] || emptyCandidate);
        }
        
        return rows;
      }, this);
      
      // For demo
      this.newCandidate = {
        firstName: ko.observable(""),
        lastName: ko.observable(""),
        add: function() {
          this.candidates.push({ 
            firstName: this.newCandidate.firstName(), 
            lastName: this.newCandidate.lastName(), 
            id: this.candidates().length + 1 
          })
        }.bind(this)
      }
    }
    
    ko.applyBindings(new VM());
    tr:nth-child(even) { background: #efefef; }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
    
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>First Name</th>
          <th>Last Name</th>
        </tr>
      </thead>
      <tbody data-bind="foreach: displayRows">
        <td data-bind="text: id"></td>
        <td data-bind="text: firstName"></td>
        <td data-bind="text: lastName"></td>
      </tbody>
    </table>
    
    <div data-bind="with: newCandidate">
      <input type="text" placeholder="first name" data-bind="textInput: firstName">
      <input type="text" placeholder="last name" data-bind="textInput: lastName">
      <button data-bind="click: add">add</button>
    </div>