Knock Me Out

Thoughts, ideas, and discussion about Knockout.js

A Simple Editor Pattern for Knockout.js

| Comments

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:

  1. The creation of observables and computeds needs to be separate from actually populating them with values.

  2. 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
    var Item = function(data) {
        this.name = ko.observable();
        this.price = ko.observable();

        this.update(data);
    };

    Item.prototype.update = function(data) {
        this.name(data.name || "new item");
        this.price(data.price || 0);
    };

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
    var Item = function(data) {
        this.name = ko.observable();
        this.price = ko.observable();
        this.price.formatted = ko.computed(this.getFormattedPrice, this);

        this.update(data);
    };

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
    var Item = function(data) {
        this.name = ko.observable();
        this.price = ko.observable();
        this.cache = function() {};

        this.update(data);
    };

    ko.utils.extend(Item.prototype, {
      update: function(data) {
        this.name(data.name || "new item");
        this.price(data.price || 0);

        //save off the latest data for later use
        this.cache.latestData = data;
      },
      revert: function() {
        this.update(this.cache.latestData);
      }
    });

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
    commit: function() {
        this.cache.latestData = ko.toJS(this);
    }

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
    var ViewModel = function(items) {
        //turn the raw items into Item objects
        this.items = ko.observableArray(ko.utils.arrayMap(items, function(data) {
            return new Item(data);
        });

        //hold the real currently selected item
        this.selectedItem = ko.observable();

        //make edits to a copy
        this.itemForEditing = ko.observable();
    };

    ko.utils.extend(ViewModel.prototype, {
        //select and item and make a copy of it for editing
        selectItem: function(item) {
            this.selectedItem(item);
            this.itemForEditing(new Item(ko.toJS(item)));
        },

        acceptItem: function(item) {
            var selected = this.selectedItem(),
                edited = ko.toJS(this.itemForEditing()); //clean copy of edited

            //apply updates from the edited item to the selected item
            selected.update(edited);

            //clear selected item
            this.selectedItem(null);
            this.itemForEditing(null);
        },

        //just throw away the edited item and clear the selected observables
        revertItem: function() {
            this.selectedItem(null);
            this.itemForEditing(null);
        }
    });

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.

Comments