23
votes

I have a viewModel with an observableArray of objects with observable variables.

My template shows the data with an edit button that hides the display elements and shows input elements with the values bound. You can start editing the data and then you have the option to cancel. I would like this cancel to revert to the unchanged version of the object.

I have tried clone the object by doing something like this:

viewModel.tempContact = jQuery.extend({}, contact);

or

viewModel.tempContact = jQuery.extend(true, {}, contact);

but viewModel.tempContact gets modified as soon as contact does.

Is there anything built into KnockoutJS to handle this kind of situation or am I best off to just create a new contact with exactly the same details and replace the modified contact with the new contact on cancel?

Any advice is greatly appreciated. Thanks!

5

5 Answers

16
votes

There are a few ways to handle something like this. You can construct a new object with the same values as your current one and throw it away on a cancel. You could add additional observables to bind to the edit fields and persist them on the accept or take a look at this post for an idea on encapsulating this functionality into a reusable type (this is my preferred method).

3
votes

I ran across this post while looking to solve a similar problem and figured I would post my approach and solution for the next guy.

I went with your line of thinking - clone the object and repopulate with old data on "undo":

1) Copy the data object into a new page variable ("_initData") 2) Create Observable from original server object 3) on "undo" reload observable with unaltered data ("_initData")

Simplified JS: var _viewModel; var _initData = {};

$(function () {
    //on initial load
    $.post("/loadMeUp", {}, function (data) {
        $.extend(_initData , data);
        _viewModel = ko.mapping.fromJS(data);
    });

    //to rollback changes
    $("#undo").live("click", function (){
        var data = {};
        $.extend(data, _initData );
        ko.mapping.fromJS(data, {}, _viewModel);
    });

    //when updating whole object from server
    $("#updateFromServer).live("click", function(){
        $.post("/loadMeUp", {}, function (data) {
            $.extend(_initData , data);
            ko.mapping.fromJS(data, {}, _viewModel);
        });
    });

    //to just load a single item within the observable (for instance, nested objects)
    $("#updateSpecificItemFromServer).live("click", function(){
        $.post("/loadMeUpSpecificItem", {}, function (data) {
            $.extend(_initData.SpecificItem, data);
            ko.mapping.fromJS(data, {}, _viewModel.SpecificItem);
        });
    });

    //updating subItems from both lists
    $(".removeSpecificItem").live("click", function(){
        //object id = "element_" + id
        var id = this.id.split("_")[1];
        $.post("/deleteSpecificItem", { itemID: id }, function(data){
            //Table of items with the row elements id = "tr_" + id
            $("#tr_" + id).remove();
            $.each(_viewModel.SpecificItem.Members, function(index, value){
                if(value.ID == id)
                    _viewModel.SpecificItem.Members.splice(index, 1);
            });
            $.each(_initData.SpecificItem.Members, function(index, value){
                if(value.ID == id)
                    _initData.SpecificItem.Members.splice(index, 1);
            });
        });
    });
});

I had an object that was complicated enough that I didn't want to add handlers for each individual property.

Some changes are made to my object in real time, those changes edit both the observable and the "_initData".

When I get data back from the server I update my "_initData" object to attempt to keep it in sync with the server.

2
votes

Very old question, but I just did something very similar and found a very simple, quick, and effective way to do this using the mapping plugin.

Background; I am editing a list of KO objects bound using a foreach. Each object is set to be in edit mode using a simple observable, which tells the view to display labels or inputs.

The functions are designed to be used in the click binding for each foreach item.

Then, the edit / save / cancel is simply:

this.edit = function(model, e)
{
    model.__undo = ko.mapping.toJS(model);
    model._IsEditing(true);
};

this.cancel = function(model, e)
{
    // Assumes you have variable _mapping in scope that contains any 
    // advanced mapping rules (this is optional)
    ko.mapping.fromJS(model.__undo, _mapping, model);
    model._IsEditing(false);
};

this.save = function(model, e)
{
    $.ajax({
        url: YOUR_SAVE_URL,
        dataType: 'json',
        type: 'POST',
        data: ko.mapping.toJSON(model),
        success: 
            function(data, status, jqxhr)
            {
                model._IsEditing(false);
            }
    }); 
};

This is very useful when editing lists of simple objects, although in most cases I find myself having a list containing lightweight objects, then loading a full detail model for the actual editing, so this problem does not arise.

You could add saveUndo / restoreUndo methods to the model if you don't like tacking the __undo property on like that, but personally I think this way is clearer as well as being a lot less code and usable on any model, even one without an explicit declaration.

0
votes

You might consider using KO-UndoManager for this. Here's a sample code to register your viewmodel:

viewModel.undoMgr = ko.undoManager(viewModel, {
  levels: 12,
  undoLabel: "Undo (#COUNT#)",
  redoLabel: "Redo"
});

You can then add undo/redo buttons in your html as follows:

 <div class="row center-block">
    <button class="btn btn-primary" data-bind="
      click: undoMgr.undoCommand.execute, 
      text: undoMgr.undoCommand.name, 
      css: { disabled: !undoMgr.undoCommand.enabled() }">UNDO</button>
    <button class="btn btn-primary" data-bind="
      click: undoMgr.redoCommand.execute, 
      text: undoMgr.redoCommand.name, 
      css: { disabled: !undoMgr.redoCommand.enabled() }">REDO</button>
  </div> 

And here's a Plunkr showing it in action. To undo all changes you'll need to loop call undoMgr.undoCommand.execute in javascript until all the changes are undone.

0
votes

I needed something similar, and I couldn't use the protected observables, as I needed the computed to update on the temporary values. So I wrote this knockout extension:

This extension creates an underscore version of each observable ie self.Comments() -> self._Comments()

ko.Underscore = function (data) {
    var obj = data;
    var result = {};
    // Underscore Property Check
    var _isOwnProperty = function (isUnderscore, prop) {
        return (isUnderscore == null || prop.startsWith('_') == isUnderscore) && typeof obj[prop] == 'function' && obj.hasOwnProperty(prop) && ko.isObservable(obj[prop]) && !ko.isComputed(obj[prop])
    }
    // Creation of Underscore Properties
    result.init = function () {
        for (var prop in obj) {
            if (_isOwnProperty(null, prop)) {
                var val = obj[prop]();
                var temp = '_' + prop;
                if (obj[prop].isObservableArray)
                    obj[temp] = ko.observableArray(val);
                else
                    obj[temp] = ko.observable(val);
            }
        }
    };
    // Cancel
    result.Cancel = function () {
        for (var prop in obj) {
            if (_isOwnProperty(false, prop)) {
                var val = obj[prop]();
                var p = '_' + prop;
                obj[p](val);
            }
        }
    }
    // Confirm
    result.Confirm = function () {
        for (var prop in obj) {
            if (_isOwnProperty(true, prop)) {
                var val = obj[prop]();
                var p = prop.replace('_', '');
                obj[p](val);
            }
        }
    }
    // Observables
    result.Properties = function () {
        var obs = [];
        for (var prop in obj) {
            if (typeof obj[prop] == 'function' && obj.hasOwnProperty(prop) && ko.isObservable(obj[prop]) && !ko.isComputed(obj[prop])) {
                var val = obj[prop]();
                obs.push({ 'Name': prop, 'Value': val });
            }
        }
        return obs;
    }

    if (obj != null)
        result.init();

    return result;
}

This extension will save you writing duplicates of each of your observables and ignores your computed. It works like this:

var BF_BCS = function (data) {
    var self = this;

    self.Score = ko.observable(null);
    self.Comments = ko.observable('');

    self.Underscore = ko.Underscore(self);

    self.new = function () {
        self._Score(null);
        self._Comments('');
        self.Confirm();
    }

    self.Cancel = function () {
        self.Pause();
        self.Underscore.Cancel();
        self.Resume();
    }

    self.Confirm = function () {
        self.Pause();
        self.Underscore.Confirm();
        self.Resume();
    }

    self.Pause = function () {

    }

    self.Resume = function () {

    }

    self.setData = function (data) {
        self.Pause();

        self._Score(data.Score);
        self._Comments(data.Comments);
        self.Confirm();
        self.Resume();
    }

    if (data != null)
        self.setData(data);
    else
        self.new();
};

So as you can see if you have buttons on html:

<div class="panel-footer bf-panel-footer">
    <div class="bf-panel-footer-50" data-bind="click: Cancel.bind($data)">
        Cancel
    </div>
    <div class="bf-panel-footer-50" data-bind="click: Confirm.bind($data)">
        Save
    </div>
</div>

Cancel will undo and revert your observables back to what they were, as were save will update the real values with the temp values in one line