Dan McCoy
Dan McCoy

Reputation: 405

Knockout: changing options in select list without clearing value in view-model

I have a Knockout JS based problem with some cascading options lists and switching out the underlying "active" object that they relate to.

I have created a jsfiddle to demonstrate the problem.

I have a UI in which the users are editing a main "header" record and adding/removing/editing child records. There is a central area for editing child records. The idea is to click a child record in a table and this become the record being edited in the middle area.

The problem I have is due to the fact that the list of things in the second drop-down changes depending on the first. This is fine until the active record changes. If the category changes because the active record changes then the list of "things" also changes. At this point, the chosen "thing" (2nd drop-down) on the new active child record is cleared.

I'm assuming the value on the new active record changes, but is cleared because it doesn't appear in the old list (if the category changed). The list of items themselves is then changed (including the appropriate value), but the value has already gone from the view model by this point.

(I realize that's quite a long-winded explanation, hopefuly the jsfiddle makes it clear)

How can I change the list of items in a drop-down AND the selected value in the view model without losing the selected value along the way?

HTML:

<label>Some header field</label>
<input type="text" id="txtSomeHeaderField" data-bind="value: HeaderField" />

<fieldset>
    <legend>Active Child Record</legend>

    <label>Category</label>
    <select id="ddlCategory" data-bind="options: categories, value: ActiveChildRecord().Category, optionsCaption:'Select category...'" ></select>

    <label>Things</label>
    <select id="ddlThings" data-bind="options: ThingsList, value: ActiveChildRecord().Favorite, optionsCaption:'Select favorite thing...'" ></select>
</fieldset>

<button data-bind="click: AddChildRecord" >Add a child record</button>

<table id="tblChildRecords" border>
    <thead>
        <tr>
            <th>Category</th>
            <th>Favorite Thing</th>
        </tr>
    </thead>
    <tbody data-bind="foreach: ChildRecords">
        <tr data-bind="click: ChildRecordClicked, css: {activeRow: ActiveChildRecord() === $data}" >
            <td data-bind="text: Category"></td>
            <td data-bind="text: Favorite"></td>
        </tr>
    </tbody>
</table>

<p>Steps to reproduce problem:</p>
<ol>
    <li>Click "Add child record"</li>
    <li>Click on that row to make it the "active" record</li>
    <li>Choose category "Pets" and thing "Dog"</li>
    <li>Click "Add child record"</li>
    <li>Click on the new row to make it the "active" record</li>
    <li>Choose category "Colours" and thing "Blue"</li>
    <li>Now click back on the first row... <strong>"Dog" disappears!</strong></li>
</ol>

Javascript:

var categories = ["Pets", "Colours", "Foods"];

var MyViewModel = function(){
    var _this = this;

    this.HeaderField = ko.observable("this value is unimportant");
    this.ChildRecords = ko.observableArray([]);
    this.ActiveChildRecord = ko.observable({ Category: ko.observable("-"), Favorite: ko.observable("-")});    
    this.ThingsList = ko.observableArray();

    this.AddChildRecord = function(){
        _this.ChildRecords.push({ Category: ko.observable("-"), Favorite: ko.observable("-")});
    }

    this.ChildRecordClicked = function(childRecord){
        _this.ActiveChildRecord(childRecord);
    }

    this.RefreshThingsList = ko.computed(function(){
        var strActiveCategory = _this.ActiveChildRecord().Category();
        switch(strActiveCategory){
            case "Pets": _this.ThingsList(["Dog", "Cat", "Fish"]); break;      
            case "Colours": _this.ThingsList(["Red", "Green", "Blue", "Orange"]); break;
            case "Foods": _this.ThingsList(["Apple", "Orange", "Strawberry"]); break;
        }        

    });
}

ko.applyBindings(MyViewModel);

Upvotes: 4

Views: 4392

Answers (3)

JotaBe
JotaBe

Reputation: 39025

I've used a totally different approach, using subscriptions to updates lists and values, and an special observable to hold the edited record.

<fieldset>
    <legend>Active Child Record</legend>
    <label>Category</label>
    <select id="ddlCategory" 
       data-bind="options: categories, value: category, 
                  optionsCaption:'Select category...'" ></select>
    <label>Things</label>
    <select id="ddlThings" 
       data-bind="options: things, value: thing, 
                  optionsCaption:'Select favorite thing...'" ></select>
</fieldset>

<button data-bind="click: AddChildRecord" >Add a child record</button>

<table id="tblChildRecords" border>
    <thead>
        <tr>
            <th>Category</th>
            <th>Favorite Thing</th>
        </tr>
    </thead>
    <tbody data-bind="foreach: childRecords">
        <tr data-bind="click: ChildRecordClicked, 
                css: {activeRow: editedRecord() === $data}" >
            <td data-bind="text: category"></td>
            <td data-bind="text: thing"></td>
        </tr>
    </tbody>
</table>

JavaScript:

var categories = ["Pets", "Colours", "Foods"];

var MyViewModel = function(){
    var _this = this;

    this.categories = ko.observableArray(["Pets","Colours","Foods"]);
    this.category = ko.observable();
    this.category.subscribe(function(newCategory){
        _this.refreshThings(newCategory);
        if(editedRecord()) {
            editedRecord().category(newCategory);
        }
    });

    this.things = ko.observableArray([]);
    this.thing = ko.observable();
    _this.refreshThings = function(newCategory){
        switch(newCategory){
            case "Pets": _this.things(["Dog", "Cat", "Fish"]); break;      
            case "Colours": _this.things(["Red", "Green", "Blue", "Orange"]); break;
            case "Foods": _this.things(["Apple", "Orange", "Strawberry"]); break;
        }        
    };
    this.thing.subscribe(function(newThing){
        if(editedRecord()) {
            editedRecord().thing(newThing);
        }
    });

    this.childRecords = ko.observableArray([]);
    this.editedRecord = ko.observable();

    this.AddChildRecord = function(){
        var newRecord = {
            category: ko.observable(),
            thing: ko.observable()
        };
        _this.childRecords.push(newRecord);
        _this.editedRecord(newRecord);
        _this.category('');
        _this.thing('');
    }

    this.ChildRecordClicked = function(childRecord){
        _this.editedRecord(null);
        _this.category(childRecord.category())
        _this.thing(childRecord.thing())
        _this.editedRecord(childRecord);
    }    

}

ko.applyBindings(MyViewModel);

Several notes:

  • a new observable, named 'editedRecord' is used. This can hold the value of the currently edited record (either new, either selected by clicking it), or a null value, if nothing should be edited (this value is set in AddChildRecord and ChildrecordClicked, to vaoid changes while the lists are updated)
  • there is an array of categories, a category observable, and a subscription that updates the list of things as well as the category property of the edited record, if present
  • there is an array of things, a thing observable, and a subscription that updates the thing property of the edited record, if present
  • the addChildRecord, creates a new, empty record, and set it as the edited record. Besides initializes both lists of categories and things
  • the childRecordClick sets the clicked record as edited record

As you can see, with this technique the bindings remain very simple, and you have full control of what's ging on in each moment.

You can use techniques similar to this one to allow cancelling of the edition and things like that. In fact, I usually edit the record in a different place, and add it, or apply its changes, once the user accepts them, allowing him to cancel.

This is your modified fiddle.

Finally, if you want to keep the slashes on unedited records, make this change:

this.AddChildRecord = function(){
    _this.editedRecord(null);
    var newRecord = {
        category: ko.observable("-"),
        thing: ko.observable("-")
    };
    _this.childRecords.push(newRecord);
    _this.category('');
    _this.thing('');
    _this.editedRecord(newRecord);
}

included in this version of the fiddle, but it would be better if you applied an style so that the table cell has a minimun height, and keep it empty, like in the previous version.

Upvotes: 2

Kyle Magilavy
Kyle Magilavy

Reputation: 787

Knockout's valueAllowUnset binding might be a cleaner approach.

http://jsfiddle.net/5mpwx501/8/

<select id="ddlCategory" data-bind="options: categories, value: ActiveChildRecord().Category, valueAllowUnset: true, optionsCaption:'Select category...'" ></select>
<select id="ddlThings" data-bind="options: ThingsList, value: ActiveChildRecord().Favorite, valueAllowUnset: true, optionsCaption:'Select favorite thing...'" ></select>

@super cool is 100% correct, but the reason it is undefined is that the ActiveChildRecord changes when you click the pet row, but this computed function has not yet executed so you've got a small timeframe where Dog is the Favorite, but the options are still Colours. Since Dog is not an option, the dropdown will set the Favorite property on your ActiveChildRecord to undefined.

I would use the valueAllowUnset binding. Basically it tells the dropdown that if there is no match, don't set my value to undefined, but rather wait because the options might be updating.

A nice side effect of using this binding is that when you add a new child record it doesn't copy the previous row. It naturally resets the selection for you.

Upvotes: 6

super cool
super cool

Reputation: 6045

well i made a small modification in your fiddle which worked perfectly .

View Model:

this.RefreshThingsList = ko.computed(function(){
        var store= ActiveChildRecord().Favorite();
        var strActiveCategory = _this.ActiveChildRecord().Category();
        switch(strActiveCategory){
            case "Pets": _this.ThingsList(["Dog", "Cat", "Fish"]); break;      
            case "Colours": _this.ThingsList(["Red", "Green", "Blue", "Orange"]); break;
            case "Foods": _this.ThingsList(["Apple", "Orange", "Strawberry"]); break;
        }      
        alert(ActiveChildRecord().Favorite()); // debug here you get undefined on your step 7 so store the value upfront and use it .
       ActiveChildRecord().Favorite(store);
    });

working fiddle here

Just in case you looking for something other than this let us know .

Upvotes: 1

Related Questions