//You can now create a bindingProvider that uses something different than data-bind attributes
ko.customBindingProvider = function(bindingObject) {
this.bindingObject = bindingObject;
//determine if an element has any bindings
this.nodeHasBindings = function(node) {
return node.getAttribute ? node.getAttribute("data-class") : false;
};
//return the bindings given a node and the bindingContext
this.getBindings = function(node, bindingContext) {
var i, j, bindingAccessor, binding,
result = {},
classes = node.getAttribute("data-class");
if (classes) {
classes = classes.split(' ');
//evaluate each class, build a single object to return
for (i = 0, j = classes.length; i < j; i++) {
bindingAccessor = this.bindingObject[classes[i]];
if (bindingAccessor) {
binding = typeof bindingAccessor == "function" ? bindingAccessor.call(bindingContext.$data) : bindingAccessor;
ko.utils.extend(result, binding);
}
}
}
return result;
};
};
/*
This is the definition for an extended observable called a "protectedObservable"
-this would generally belong in a separate library
-the idea is that you can still use it just like any observable, but it has more features
-some of the extra features that it provides
-when someone writes to it, the value is cached until you call its "commit" method
-a "reset" method will update the bound fields back to the original value
-the temporary value is exposed, in case you need to send it to the server
-a dirtyFlag is exposed using a dependentObservable that checks the original vs. temp value
-now you can bind to your observable and any of its sub-observables (temp, isDirty, commit, reset)
-this post explains this idea in more detail: http://www.knockmeout.net/2011/03/guard-your-model-accept-or-cancel-edits.html
*/
ko.protectedObservable = function(initalValue) {
//private variables
var _temp = ko.observable(initalValue),
_actual = ko.observable(initalValue);
//what we actually return is a writeable dependentObservable, so we can intercept writes to it
var result = ko.dependentObservable({
read: function() {
return _actual();
},
write: function(newValue) {
_temp(newValue);
}
});
//expose the temporary value
result.temp = _temp;
//expose a flag to indicate that the values are different
result.isDirty = ko.dependentObservable(function() {
return _temp() !== _actual();
});
//commit the temporary value to our observable
result.commit = function() {
_actual(_temp());
};
//notify subscribers to update their value with the original
result.reset = function() {
_actual.valueHasMutated();
_temp(_actual());
};
return result;
};
//This is a simple custom binding that will fade in or out an element based on the truthiness of the value passed to it.
//This would likely belong in a separate library
ko.bindingHandlers.showVisible = {
update: function(element, valueAccessor) {
var value = ko.utils.unwrapObservable(valueAccessor());
if (value) {
$(element).show(500);
} else {
$(element).hide();
}
}
}
//this is our constructor function for a Product object
function Product(product, selectedProduct) {
this.id = product.id;
//these editable fields use the protectedObservables that we defined above
this.name = ko.protectedObservable(product.name);
this.description = ko.protectedObservable(product.description);
//we passed in the viewModel's selectedProduct observable, so that we can mark ourself as the selected product
//this makes it easy to bind our item against select (data-bind="click: select") rather than using anonymous functions
this.select = function() {
selectedProduct(this);
};
//indicates whether I am the selected product.
this.isSelected = ko.dependentObservable(function() {
return this === selectedProduct();
}, this);
//some extra observables to track whether it has been saved and any messages returned by the server
this.saveStatus = ko.observable();
this.saveMessage = ko.observable();
//this dependentObservable will fire any time that one of our items becomes dirty (or not dirty)
this.isDirty = ko.dependentObservable(function() {
return this.name.isDirty() || this.description.isDirty();
}, this);
//when dirtyFlag fires, clear out save fields, since we now need to save it again
this.isDirty.subscribe(function(newValue) {
if (newValue) {
this.saveStatus(null);
this.saveMessage(null);
}
}, this);
}
//Save the data back to the server
Product.prototype.save = function() {
var self = ko.utils.unwrapObservable(this);
$.ajax({
type: 'POST',
url: '/echo/json/',
data: {
json: ko.toJSON({id: self.id, name: self.name.temp(), description: self.name.temp()}),
delay: .3
},
context: self,
success: function(data) {
this.name.commit();
this.description.commit();
this.saveStatus("success");
this.saveMessage("Saved!");
},
error: function(request, error) {
this.saveStatus("error");
this.saveMessage("Error: " + error);
},
dataType: 'json'
});
};
//If the user cancels, then reset our fields.
Product.prototype.cancel = function() {
var self = ko.utils.unwrapObservable(this);
self.name.reset();
self.description.reset();
}
//this is the simple view model that we start with
var viewModel = {
products: ko.observableArray(),
selectedProduct: ko.observable()
};
//this is the start up for our application
$(function() {
//data to be returned by AJAX request (faking it using jsFiddle's echo service)
var fakeData = {
products: [
{ id: 1, name: "One", description: "This is product one" },
{ id: 2, name: "Two", description: "This is product two" },
{ id: 3, name: "Three", description: "This is product three" },
{ id: 4, name: "Four", description: "This is product four" },
{ id: 5, name: "Five", description: "This is product five" },
{ id: 6, name: "Six", description: "This is product six" }
]
};
//our binding object
var bindings = {
products: { foreach: viewModel.products },
nameText: function() { return { text: this.name }; },
selectLink: function() { return { visible: !this.isSelected(), click: this.select }; },
selectSpan: function() { return { visible: this.isSelected }; },
editor: { visible: viewModel.selectedProduct, with: viewModel.selectedProduct },
nameVal: function() { return { value: this.name }; },
descVal: function() { return { value: this.description }; },
keydown: { valueUpdate: 'afterkeydown' },
dirty: function() { return { enable: this.isDirty } },
save: function() { return { click: this.save } },
cancel: function() { return { click: this.cancel } },
saveStatus: function() { return { showVisible: this.saveMessage, text: this.saveMessage, attr: { 'class': this.saveStatus } }; }
};
//set ko's current bindingProvider equal to our new binding provider
ko.bindingProvider.instance = new ko.customBindingProvider(bindings);
//get our data from the server
$.ajax({
type: 'POST', //echo only works with post
url: '/echo/json/',
data: {
json: ko.toJSON(fakeData),
delay: .1
},
success: function(data) {
//loop through the products returned and send them through the Product contructor function
viewModel.products(ko.utils.arrayMap(data.products, function(product) {
return new Product(product, viewModel.selectedProduct);
}));
//finally apply bindings against the UI
ko.applyBindings(viewModel);
},
dataType: 'json'
});
});
<div id="list">
<h2>Product</h2>
<ul data-class="products">
<li>
<a href="#" data-class="nameText selectLink"></a>
<span class="selected" data-class="nameText selectSpan"></span>
</li>
</ul>
</div>
<div id="editor" style="display: none" data-class="editor">
<div>
Name<br/>
<input data-class="nameVal keydown" />
</div>
<div>
Description<br/>
<input data-class="descVal keydown" />
</div>
<div>
<button data-class="dirty save">Save</button>
<button data-class="dirty cancel">Cancel</button>
<span data-class="saveStatus"></span>
</div>
</div>
input { margin-bottom: 10px; }
h2 { font-size: 1.25em; font-weight: bold; }
.error { color: red }
.success { color: green }
.selected { color: blue; font-weight: bold; }
#list {
width: 100px;
height: 150px;
border: solid 1px black;
padding: 5px;
float: left;
}
#editor {
height: 150px;
border: solid 1px black;
padding: 5px;
margin-left: 120px;
}