Tracking changes to complex viewmodels with Knockout.JS Part 2 – Primitive Arrays
In the first part of this series, I talked about the challenges of tracking changes to complex viewmodels in knockout, using isDirty() (see here and here) and getChanges() methods.
In this second part, I’ll go through how we extended this initial approach so we could track changes to array elements as well as regular observables. If you haven’t already, I suggest you have a read of part one as many of the examples build on code from the first post.
Starting Simple
For the purposes of this post we are only considering ‘Primitive’ arrays… these are arrays of values such as strings and numbers, as opposed to complex objects with properties of their own. Previously we created an extender that allows us to apply change tracking to a given observable, and we’re using the same approach here.
We won’t be re-using the existing extender, but we will use some of the same code for iterating over our model and applying it to our observables. In that vein, here’s a skeleton for our change tracked array extender… it has a similar structure to our previous one:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
ko.extenders.trackArrayChange = function (target, track) { | |
if (track) { | |
target.isDirty = ko.observable(false); | |
target.added = ko.observableArray([]); | |
target.removed = ko.observableArray([]); | |
var addItem = function (item) { | |
//… | |
}; | |
var removeItem = function (item) { | |
//… | |
}; | |
target.getChanges = function () { | |
var result = { | |
added: target.added, | |
removed: target.removed | |
}; | |
return result; | |
}; | |
//…. | |
//…. | |
} | |
} |
You should notice a few differences however:
- Two observable arrays are being exposed in addition to the isDirty() flag – added and removed
- The getChanges() method returns a complex object also containing adds and removes
As this functionality was developed with HTTP PATCH in mind, we’re assuming that we will need to track both the added items and the removed items, so that we can only send the changes back to the server. If you aren’t using PATCH, it can be sufficient just to know that a change has occurred and then save your data by replacing the entire array.
Last points to make – we’re treating any ‘changes’ to existing elements as an add and then a delete… these are just primitive values after all. Also the ordering of the elements is not going to be tracked (although this is possible and will be covered in the next post).
Array subscriptions
Prior to Knockout 3.0, we had to provide alternative methods to the usual push() and pop() so that we could keep track of array elements… subscribing to the observableArray itself would only notify you if the entire array was replaced. As of Knockout 3.0 though, we now have a way to subscribe to array element changes themselves!
We’re using the latest version for this example, but check the links at the bottom of the third post in the series if you are interested in the old version.
Let’s begin to flesh out the skeleton a little more:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//…. | |
//…. | |
target.getChanges = function () { | |
var result = { | |
added: target.added(), | |
removed: target.removed() | |
}; | |
return result; | |
}; | |
target.subscribe(function(changes) { | |
ko.utils.arrayForEach(changes, function (change) { | |
switch (change.status) | |
{ | |
case "added": | |
addItem(change.value); | |
break; | |
case "deleted": | |
removeItem(change.value); | |
break; | |
} | |
}); | |
}, null, "arrayChange"); | |
} |
Now we’ve added an arrayChange subscription, we’ll be notified whenever anyone pops, pushes or even splices our array. In the event of the latter, we’ll receive multiple changes so we have to cater for that eventuality.
We’ve deferred the actual tracking of the changes to private methods, addItem() and removeItem(). The reason for this becomes clear when you consider what you’d expect to happen after performing the following operations:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Initialise array with some data and track changes | |
var trackedArray = ko.observableArray([1,2,3]).extend({trackArrayChanges: true}); | |
trackedArray.getChanges(); | |
// -> { } No changes yet | |
trackedArray.push(4); | |
trackedArray.pop(); | |
trackedArray.getChanges(); | |
// -> { } Changes have negated each other |
In order to achieve this behavior, we first need to check that the item in question has not already been added to one of the lists like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//…. | |
//…. | |
var findItem = function (array, item) { | |
return ko.utils.arrayFirst(array, function (o) { | |
return o === item; | |
}); | |
}; | |
var addItem = function (item) { | |
var previouslyRemoved = findItem(target.removed(), item); | |
if (previouslyRemoved) { | |
target.removed.remove(previouslyRemoved); | |
} else { | |
target.added.push(item); | |
target.isDirty(true); | |
} | |
}; | |
var removeItem = function (item) { | |
var previouslyAdded = findItem(target.added(), item); | |
if (previouslyAdded) { | |
target.added.remove(previouslyAdded); | |
} else { | |
target.removed.push(item); | |
target.isDirty(true); | |
} | |
}; | |
//…. | |
//…. |
Applying this to the view model
A change tracked primitive array is unlikely to be very useful on it’s own, so we need to make sure that we can track changes to an observable array regardless of where it appeared in our view model. Lets revisit the code from our previous sample that traversed the view model and extended all the observables it encountered:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
…. | |
…. | |
var applyChangeTrackingToObservable = function (observable) { | |
// Only apply to basic writeable observables | |
if (observable && !observable.nodeType && !observable.refresh && ko.isObservable(observable)) { | |
if (!observable.isDirty) observable.extend({ trackChange: true }); | |
} | |
}; | |
var applyChangeTracking = function (obj) { | |
var properties = getObjProperties(obj); | |
ko.utils.arrayForEach(properties, function (property) { | |
applyChangeTrackingToObservable(property.value); | |
}); | |
}; | |
…. | |
…. |
In order to properly apply change tracking to our model, we need to detect whether a given observable is in fact an observableArray, and if so then apply the new extender instead of the old one. This is not actually as easy as it sounds… based on the status of this pull request, Knockout seems to provide no mechanism for doing this (please correct me if you know otherwise!).
Luckily, this thread had the answer… we can simply extend the observableArray “prototype” by adding the following line somewhere in global scope:
ko.observableArray.fn.isObservableArray = true;
Assuming that’s in place, our change becomes very simple:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//…. | |
//…. | |
var applyChangeTrackingToObservable = function (observable) { | |
// Only apply to basic writeable observables | |
if (observable && !observable.nodeType && !observable.refresh && ko.isObservable(observable)) { | |
if (observable.isObservableArray) { | |
observable.extend({ trackArrayChange: true }); | |
} | |
else { | |
if (!observable.isDirty) observable.extend({ trackChange: true }); | |
} | |
} | |
}; | |
//…. | |
//…. |
We don’t need to change any of the rest of the wireup code from the first sample, as we are already working through our view model recursively and letting applyChangeTrackingToObservable do it’s thing.
That’s all the code we needed, now we can take it for a spin!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var viewModel = { | |
Name: ko.observable("Pete"), | |
Age: ko.observable(29), | |
Skills: ko.observableArray([ | |
"TDD", "Knockout", "WebForms" | |
}), | |
Occupation: ko.observable("Developer") | |
}; | |
applyChangeTracking(viewModel); | |
viewModel.Occupation("Blogger"); | |
viewModel.Skills.push("Change tracking"); | |
viewModel.Skills.remove("WebForms"); | |
getChangesFromModel(viewModel); | |
/* -> { | |
"Skills": { | |
added: ["Change tracking"], | |
removed: ["WebForms"] | |
}, | |
Occupation: "Blogger" | |
} */ |
Summary
We’ve seen how we can make use of the new arraySubscriptions feature in Knockout 3.0 to get notified about changes to array elements. We made sure that we didn’t get strange results when items were added and then removed again or vice-versa, and then integrated the whole thing into a change tracked viewmodel.
In the third and final post in this series, we’ll go the whole hog and enable change tracking for complex and nested objects within arrays.
You can view the full code for this post here: https://gist.github.com/Roysvork/8743663, or play around with it in jsFiddle!
Pete