Implementing an editor that allows users to accept or cancel their changes is a common task in Knockout.js. Previously, I suggested the idea of a protectedObservable
that is an extended observable with the ability to commit and reset the value being edited. Since Knockout 2.0, I would probably now implement that functionality using either extenders or by augmenting the .fn
object of the core types. However, I now use a different pattern.
I described this pattern in the Twin Cities Code Camp presentation here. This technique allows you to easily copy, commit, and revert changes to an entire model. There are two rules that make this pattern work:
The creation of observables and computeds needs to be separate from actually populating them with values.
The format of the data that you put in should be the same as the format that you are able to get out. Typically this means that calling
ko.toJS
on your model should result in an object that you could send back through the function described in step 1 to re-populate the values.
Separate creation and initialization
The idea is that we can apply fresh data to our object at anytime, so our constructor function should just create our structure, while a separate method handles setting the values.
1 2 3 4 5 6 7 8 9 10 11 |
|
Our update
function (call it whatever you like init
, initialize
, hydrate
, etc.), needs to handle populating the values of all of the observables given a plain JavaScript object. It can also handle supplying default values.
Data in matches data out
To make this pattern work, we need to be able to put a plain JavaScript object in and get the equivalent object out. Ideally, simply calling ko.toJS
on our model, will give us the plain object that we need. It is okay, if there are some additional computeds/properties on the object, but it needs to be appropriate for sending through the update
function again.
A technique that I use frequently to “hide” data that I won’t want when I turn the model into a plain JavaScript object or to JSON is to use a “sub-observable”. So, if we had a computed observable to show a formatted version of the price that we don’t want in our output, we could specify it like:
1 2 3 4 5 6 7 |
|
Since observables are functions and functions are objects that can have their own properties, it is perfectly valid to create a formatted
computed off of the price
observable. In the markup, we can bind against price.formatted
. However, when calling ko.toJS
or ko.toJSON
, the formatted
sub-observable will disappear, as we will simply be left with a price
property and value.
Reverting changes
Now that we have these pieces in place, it is easy to create an editor that allows reverting changes. In the event that a user chooses to cancel their editing, all we need to do is send the original data back through our update
function and we will be back to where we started.
We will need to track the original data, so that it is available to repopulate the model. A good place to cache this data is in the update
function itself. It is useful to make sure that the data is “hidden” as well, so multiple edits don’t result in recursive versions of the old data being kept around. If we were not using the prototype, then we could just use a local variable to store the data, but since we are placing our shared functions on the prototype in these samples, we need to find another way to hide this data. A simple solution is to create an empty cache
function and store our data as property off of it. This will prevent calls to ko.toJS
or ko.toJSON
from capturing this data.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
In this scenario, we will let edits persist to our observables and in the event that a user chooses to cancel, we will refresh our model with the original data.
Committing changes
When a user accepts the data, we need to make sure that we update the cached data with the current state of the model. For example, we could simply add a commit
or accept
function to our prototype that does:
1 2 3 |
|
We now have the latest data cached, so the next time that a user cancels it will be reverted back to this updated state.
Copying/cloning
Sometimes in an editor, we would not want the currently edited observables to affect the rest of the UI until the user chooses to accept the changes. In this case, we can use these same methods to create a copy of the item for editing and then apply it back to the original. Here is how our overall view model might look:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
So, we make edits to a copy of the original and on an accept we apply those edits back to the original. We can use ko.toJS
to get a plain JavaScript object and feed it into our update
function to apply the changes. In this scenario, the revertItem
function can simply throw away the copied item and clear our the selected value.
Link to sample on jsFiddle.net
If you were going to reuse this technique often, then you could even create an extension to an observableArray that adds the appropriate observables and functions off of the observableArray itself like in this sample.
Updating from server
Our update
function is also a handy way to apply updates from the server to our existing view model. In our case, we would likely need to add an id
to the Item
objects, so that we can identify which object needs updating. This works very well with something like SignalR or socket.io to keep various clients in sync with each other. This is an easier pattern than trying to swap items in an array or update specific properties one by one.
Summary
Editors with accept/cancel options are a common scenario in a Knockout.js application. By separating the creation of observables/computeds from poplating their values, we can commit, reset, and update models by feeding a version of the data through our update
function. When you think in terms of the plain JS object that you can get out of or put into a model, it makes many of these scenarios easy to handle.