📜 ⬆️ ⬇️

KnockoutJS: a tale about how to easily accept or reject changes

Quite often in the user interface there are buttons "Save" and "Cancel". Especially often these buttons are used in forms. Despite the fact that in the modern world everything is going to simplify the interface, there is still a demand for these buttons.

Today, I propose to understand how to use KnockoutJS to accept and roll back changes for individual observables as well as whole view models.

Those familiar with KnockoutJS can immediately give two links to the best blog about the subject

These methods have both advantages and quite significant shortcomings, from which it is necessary to get rid of. Disadvantages from a functional point of view

Well, besides, they are aimed at individual observables, and I would like to work with several fields at once.
')
Under the cut we will examine in detail the process of creating a simple mechanism that will make the work with the acceptance and cancellation of changes simple and transparent.



The first thing that comes to my mind when it comes to making or canceling changes is transactions and their implementation in a DBMS. Before the change, begin transaction, and then commit or rollback. Everything is simple and clear.

Nothing prevents us from making an analogue of these methods from observables. You can simply do and invent your ko.transactionableObservable by analogy with examples from the beginning of the article.

 ko.transactionableObservable = function(initialValue) { var result = ko.observable(initialValue); result.beginTransaction = function() { ... }; result.commit = function() { ... }; result.rollback = function() { ... }; return result; } var name = ko.transactionableObservable('habrauser'); 


But I absolutely do not like this approach. As you can see, the result will be the usual observable. And how should we be if we want to accept / cancel observableArray or writeable dependentObservable changes?

We are come to the aid of extenders , which appeared in the second version of the Knockout.

The extender allows you to change or add to the behavior of any kind of observables in a very elegant way. Our code should look like this:

 var name = ko.observable('habrauser').extend({editable: true}); 


Implementing extenders to disgrace is simple:
ko.extenders['myExtender'] = function(observavle, params){}

The idea seems worthwhile to me, so let's make a simple implementation of our extender.

 ko.extenders['editable'] = function (target) { var oldValue; var inTransaction = false; target.beginEdit = function () { var currentValue = target(); if (currentValue instanceof Array) { currentValue = currentValue.slice(); // make copy } oldValue = currentValue; inTransaction = true; }; target.commit = function () { inTransaction = false; }; target.rollback = function () { if (inTransaction) { target(oldValue); inTransaction = false; } }; return target; }; 


I think that there is no sense to translate the code into Russian. The only caveat is working with arrays.
If there is an array inside the observable, we cannot simply “remember” this value. Since this is a reference data type, we need to remember a copy of the array, not a reference to it. And yes, only observables containing non-reference data types make sense to expand. We will deal with this problem later.

Example of use:
 var name = ko.observable().extend({editable: true}); var nameLength = ko.dependentObservable(function() { return name() ? name().length : 0; }); name('user'); // name set to 'user' name.beginEdit(); // begin transaciton name('me'); // name set to 'me', nameLength was recalculated nameLength(); // gives us 2 name.commit(); // transaction commited; values are unchanged since last edit; we could start another one name.rollback(); // nothing happens since transation is commited name.beginEdit(); // begin another transaction name('someone'); // name set to 'someone', nameLength was recalculated name.rollback(); // rollback transaction; name set to initial value 'me', nameLength recalculated name(); // returns 'me' 


It would also be great to have a flag having a change. The principle is simple:
 target.hasChanges = ko.dependedObservable(function () { var hasChanges = inTransaction && oldValue != target(); return hasChanges; }); 


The output should receive true only when there is an open transaction and the current knowledge is different from the initial one at the time the transaction starts. We recall that knockout recalculates the value dependentObservable when changing any of the observables that it uses.

In the current implementation, the return will always be false. And the reason for this is very simple: during the first execution (that is, immediately after the declaration) inTransaction == false, which means the second check of the AND operator will not even be executed. This means that dependendObservable will never be recalculated. Fix it easy enough. Make the variable inTransaction "observable."

Thus, the value will be recalculated when entering / exiting the transaction and changing the original observable. This is what you need!

 ko.extenders['editable'] = function (target) { var oldValue; var inTransaction = ko.observable(false); target.beginEdit = function () { var currentValue = target(); if (currentValue instanceof Array) { currentValue = currentValue.slice(); // make copy } oldValue = currentValue; inTransaction(true); }; target.commit = function () { inTransaction(false); }; target.rollback = function () { if (inTransaction()) { target(oldValue); } }; target.hasChanges = deferredDependentObservable(function () { var hasChanges = inTransaction() && oldValue != target(); return hasChanges; }); return target; }; 


As for me, we received quite a good implementation for one field. But what about when we have a dozen fields. Before editing each field must be done beginEdit (), and then commit / rollback. You can hang yourself.

Due to the fact that we use extenders in our implementation, we can expand the required fields after they are declared and not worry that our dependentObservables from these fields will break. And this means that we can do it quite automatically.
I will express my thought more down to earth:

 var user = { firstName : ko.observable('habrauser'), lastName: ko.observable('sapiens') }; user.fullName = ko.dependentObservable(function() { return user.firstName() + ' ' + user.lastName(); }); ko.editable(user); user.beginEdit(); user.firstName('homo'); user.fullName(); // 'homo sapiens' user.hasChanges(); // true user.commit(); 


If at the beginning of the article, we made a specific observable transactional, then now we make an arbitrary object transactional. By and large, it is implemented quite simply:
 ko.editable = function (viewModel, autoInit) { var editables = ko.observableArray(); (function makeEditable(rootObject) { for (var propertyName in rootObject) { var property = rootObject[propertyName]; if (ko.isWriteableObservable(property)) { var observable = property; observable.extend({ editable: true }); editables.push(observable); } property = ko.utils.unwrapObservable(property); if (typeof (property) == 'object') { makeEditable(property); } } })(viewModel); viewModel.beginEdit = function () { ko.utils.arrayForEach(editables(), function (obj) { obj.beginEdit(); }); }; viewModel.commit = function () { ko.utils.arrayForEach(editables(), function (obj) { obj.commit(); }); }; viewModel.rollback = function () { ko.utils.arrayForEach(editables(), function (obj) { obj.rollback(); }); }; viewModel.addEditable = function (editable) { editables.push(editable.extend({ editable: true })); }; viewModel.hasChanges = deferredDependentObservable(function () { var editableWithChanges = ko.utils.arrayFirst(editables(), function (editable) { return editable.hasChanges(); }); return editableWithChanges != null; }); }; 


Only the beginning deserves attention. We bypass all the properties of the object, if the property is writeableObservable (and only such can be transactional, if you think about it), then we extend it with the help of our extender. Further along the code, if there is an object in the field (and the array is also an object), then we go through its fields too.

And a simple example of use:

 var user = { FirstName: ko.observable('Some'), LastName: ko.observable('Person'), Address: { Country: ko.observable('USA'), City: ko.observable('Washington') } }; ko.editable(user); user.beginEdit(); user.FirstName('MyName'); user.hasChanges(); // returns `true` user.commit(); user.hasChanges(); // returns `false` user.Address.Country('Ukraine'); user.hasChanges(); // returns `true` user.rollback(); user.Address.Country(); // returns 'USA' 


As a result of our research, we got quite ready for production use the code I posted on GitHub and called ko.editables: github.com/romanych/ko.editables

There are also examples of simple use and a little more complicated .

Thanks to everyone who read to the end. I hope the code is useful.

Source: https://habr.com/ru/post/136782/


All Articles