Search code examples
javascriptrazorknockout.jsko.observablearray

Knockout bindings not working as expected for manipulating observable array


We have a view using Razor and Knockout.js that displays a form. Part of the form asks the user to enter a list of values, and we're using a ko.observablearray to keep track of them. This list is represented as a bunch of text boxes, one per value, with a "Delete" button next to each box and a single "Add" button underneath all of them. It works similarly to the demo project at http://learn.knockoutjs.com/#/?tutorial=collections.

Our form is acting unexpectedly in two ways:

  1. When a delete button is clicked, it removes all values from the ko.observablearray, not just the one corresponding to what was clicked.
  2. When the "Submit" button for the overall form is clicked, it adds a new element to the ko.observablearray instead of submitting the form to our server.

Why are we seeing this behavior? (I know that these are two separate issues, but I'm not sure if they're caused by the same underlying problem or not, which is why I'm posting them in one question.)

Here is our Razor view:

@model OurProject.Models.Input.InputModel
@{
    ViewBag.Title = "Input";
}

<h2>Inputs</h2>

<div id="inputForm">
    <!-- snip - lots of input elements to fill in that are bound to KO -->

    <div>
        @Html.LabelFor(model => model.POSTransactionCodes)
    </div>
    <div>
        <span class="help-block">Separate values by commas.</span>
    </div>
    <div>
        <ul data-bind="foreach: POSTransactionCodes">
            <li><input data-bind="value: $data" /> <a href="#" data-bind="click: $root.removePOSTransactionCode">Delete</a></li>
        </ul>
        <button data-bind="click: addPOSTransactionCode">Add another POS Transaction Code</button>

        @Html.ValidationMessageFor(model => model.POSTransactionCodes, null, new { @class = "help-inline" })
    </div>
    <!-- snip - more input elements -->

    <button data-bind="click: save">Submit</button>
</div>

<script type="text/javascript" src='~/Scripts/jquery-1.8.2.min.js'></script>
<script type="text/javascript" src='~/Scripts/knockout-2.1.0.js'></script>
<script type="text/javascript" src='~/Scripts/OP/OP.js'></script>
<script type="text/javascript" src='~/Scripts/OP/Input/OP.Input.Input.Form.js'></script>
<script type="text/javascript" src='~/Scripts/OP/Input/OP.Input.Input.Data.js'></script>
<script type="text/javascript">
    var elementToBindTo = $("#inputForm")[0];
    OP.Input.Input.Form.init(elementToBindTo);
</script>

Here is our main piece of Knockout code, OP.Input.Input.Form.js:

extend(OP, 'OP.Input.Input.Form');
OP.Input.Input.Form = function (jQuery) {
    //The ViewModel for the page
    var ViewModel = function () {
        var self = this;

        //Fields
        /* snip - lots of ko.observables() */
        self.POSTransactionCodes = ko.observableArray([]); //is a list of transaction codes
        /* snip - lots of ko.observables() */

        //Set up with initial data
        self.initialize = function () {
            var c = function (data, status, response) {
                if (status === "success") {
                    /* snip - lots of ko.observables() */
                    ko.utils.arrayPushAll(self.POSTransactionCodes, data.POSTransactionCodes);
                    self.POSTransactionCodes.valueHasMutated();
                    /* snip - lots of ko.observables() */
                } else {

                }
            };
            OP.Input.Input.Data.GetInput(c);
        }

        //When saving, submit data to server
        self.save = function (model) {
            var c = function (data, status, response) {
                if (status === "success") {
                    //After succesfully submitting input data, go to /Input/Submitted
                    //in order to let MVC determine where to send the user next
                    window.location.href = "~/Input/Submitted";
                } else {
                }
            };
            OP.Input.Input.Data.SaveInput(model, c);
        }

        //Modifying POSTransactionCodes array
        self.removePOSTransactionCode = function (POScode) {
            self.POSTransactionCodes.remove(POScode)
        }

        self.addPOSTransactionCode = function () {
            self.POSTransactionCodes.push("");
        }
    };

    //Connect KO form to HTML
    return {
        init: function (elToBind) {
            var model = new ViewModel();
            ko.applyBindings(model, elToBind);
            model.initialize();
        }
    };
} ($);

Here is OP.Input.Input.Data.js:

extend(OP, 'OP.Input.Input.Data');
OP.Input.Input.Data = {
    GetInput: function (callback) {
        $.get("/API/Input/InputAPI/GetInputModel", callback);
    },
    SaveInput: function (input, callback) {
        $.ajax({
            url: "/API/Input/InputAPI/SaveInput",
            type: "post",
            data: input,
            complete: callback
        });
    }
};

Solution

  • You need to be pushing a new ViewModel into your observable array. Which will contain observable properties.

    So to do this I created a new view model called TransactionCodeView

    var TransactionCodeView = function() {
      var self = this;
      self.code = ko.observable("");    
    };
    

    Then when the user clicks "Add another POS Transaction Code":

    self.addPOSTransactionCode = function () {
        self.POSTransactionCodes.push(new TransactionCodeView());
    }
    

    The only other thing changed was in the HTML binding:

    <li><input data-bind="value: code" /> <a href="#" data-bind="click: $root.removePOSTransactionCode">Delete</a></li>
    

    Because code is the observable property in the new viewmodel we bind the input value to that.

    Take a look at this jsfiddle. I haven't tested the submit functionality for obvious reasons ;-)