SAPUI5 JSONModel undo redo
Background
We all are used to having undo redo functionality in applications which we use everyday. Undo redo can be found in text editors, email programs, graphics editors, most of the online tools etc. Wouldn’t it be nice to have it also in our shiny new business applications? Wouldn’t that be one essential part of the user experience?
Here it is. Undo redo functionality for UI5 JSONModels:
Implementation
Before coming up with the idea of this undo redo implementation, I was thinking what could be a good use case for a reusable UI5 Faceless component (class: sap.ui.core.Component)
Faceless components do not have a user interface and are used, for example, for a service that delivers data from a back-end system.
That’s when I understood a nice use case. Attach it with a Model so that changes made to the Model can be kept track of.
How should we continue with the implementation, then? There were basically two options I could think of:
- Bind the Faceless component directly into the Model and listen when it dispatches change events.
- Wrap the Model with another construct so that every time we change any value in the model it goes through our wrapper.
I first pondered with the first option as it would’ve been more comprehensive. No matter how the model would have been changed, we could have tracked the changes. Unfortunately, this was not an option, as we cannot bind a Faceless component to the Model directly (see http://scn.sap.com/thread/3492009 for possible solution).
Luckily the second option proved to be possible. One downside, of course, is that we have to make every change to the Model using our wrapper. But once you take it, it’s now nice as we have single point of entry for methods modifying our model.
Below you can find the code for undo redo component.
jQuery.sap.declare("util.undoRedo");
util.undoRedo = (function(jsonModel){
var _jsonModel = jsonModel;
var _undoStack = [];
var _redoStack = [];
var updateModelValue = function(sPath, oValue, oContext) {
_jsonModel.setProperty(sPath, oValue, oContext);
};
return {
setProperty: function(sPath, oValue, oContext) {
var oldValue = _jsonModel.getProperty(sPath);
updateModelValue(sPath, oValue, oContext)
window.console&&console.log("Updating stack with newvalue " + oValue + " old value " + oldValue);
// TODO also keep track of oldContext and newContext
// TODO should propably make a deep copy of oValue so that tracking works for objects also
// var newValue = JSON.parse(JSON.stringify(oValue));
// add new entry into the undoStack
_undoStack.unshift({"path": sPath, "oldValue": oldValue, "newValue": oValue});
window.console&&console.log("Undo stack is now: " + JSON.stringify(_undoStack));
// clear redo stack if needed
if (_redoStack.length > 0) {
_redoStack = [];
}
},
undo: function() {
var undoItem = (_undoStack.length > 0) ? _undoStack.shift() : null;
if (undoItem !== null) {
window.console&&console.log("Undo item is " + JSON.stringify(undoItem));
updateModelValue(undoItem.path, undoItem.oldValue);
// add item to redoStack
_redoStack.unshift(undoItem);
}
},
redo: function() {
// TODO get item from redoStack
var redoItem = (_redoStack.length > 0) ? _redoStack.shift() : null;
if (redoItem !== null) {
window.console&&console.log("Redo item is " + JSON.stringify(redoItem));
updateModelValue(redoItem.path, redoItem.newValue);
// add item to undoStack
_undoStack.unshift(redoItem);
}
},
};
});
It is using module pattern to hide implementation details. I won’t go through all the code verbally as I think that code is best explained by itself. Just a few notes. Component keeps changes in objects that are saved in two stacks, one for undo and another for redo. Change objects contain info about path, oldValue and newValue. In undo value in the path is replaced with oldValue and in redo value is replaced with newValue. Changes are saved as objects so saving possibly only works for simple types. Saving objects or arrays could be possible if change data was kept in json format (see TODO in code).
Here’s how to use the code with two different examples:
jQuery(function() {
// create model
var modelJson = {"textVal" : "initial value"};
var model = new sap.ui.model.json.JSONModel(modelJson);
sap.ui.getCore().setModel(model);
// initialize undo redo component and wrap JSONModel with it
var undoRedoComponent = util.undoRedo(model);
var textInput = new sap.m.Input({
//value : "{/textVal}",
change : function(evt) {
var newVal = evt.mParameters.newValue;
// change value on Model through undoRedo so that it can keep track of changes
undoRedoComponent.setProperty("/textVal", newVal);
}
});
// BindingMode should be Oneway because undoRedo does not listen to changes in the model directly
textInput.bindValue("/textVal", null, sap.ui.model.BindingMode.OneWay);
var hbox = new sap.m.HBox();
var undoButton = new sap.m.Button({text: 'Undo',
press : function() {
undoRedoComponent.undo();
}});
var redoButton = new sap.m.Button({text: 'Redo',
press : function() {
undoRedoComponent.redo();
}});
hbox.addItem(undoButton);
hbox.addItem(redoButton);
textInput.placeAt('content');
hbox.placeAt('content');
});
jQuery(function() {
// create model
var beers = [
{"style": "Imperial Stout", "name": "Plevna Siperia", "rating": 3},
{"style": "Weissbier", "name": "Paulaner Naturtrüb", "rating": 3},
{"style": "India Pale ale", "name": "Founders Centennial IPA", "rating": 3},
{"style": "Trappist", "name": "Orval", "rating": 3},
];
var modelJson = {"listItems": beers};
var model = new sap.ui.model.json.JSONModel(modelJson);
sap.ui.getCore().setModel(model);
// initialize undo redo component and wrap JSONModel with it
var undoRedoComponent = util.undoRedo(model);
// TABLE STARTS HERE
//Create an instance of the table control
var oTable = new sap.ui.table.Table({
title: "Beers table",
visibleRowCount: 7,
firstVisibleRow: 3,
columnHeaderHeight: 30,
selectionMode: sap.ui.table.SelectionMode.Single,
toolbar: new sap.ui.commons.Toolbar({items: [
new sap.ui.commons.Button({text: "Undo", press: function() {
undoRedoComponent.undo();
// we refresh the model to show changes
model.refresh(true);
}})
]}),
extension: [
new sap.ui.commons.Button({text: "Redo", press: function() {
undoRedoComponent.redo();
// we refresh the model to show changes
model.refresh(true);
}})
]
});
//Define the columns and the control templates to be used
var oColumn = new sap.ui.table.Column({
label: new sap.ui.commons.Label({text: "Name"}),
template: new sap.ui.commons.TextView().bindProperty("text", "name"),
sortProperty: "name",
filterProperty: "name",
width: "200px",
editable: false
});
var oColumn2 = new sap.ui.table.Column({
label: new sap.ui.commons.Label({text: "Style"}),
template: new sap.ui.commons.TextView().bindProperty("text", "style"),
sortProperty: "style",
filterProperty: "style",
width: "200px",
editable: false
});
// BindingMode should be Oneway because undoRedo does not listen to changes in the model directly
var oColumn3 = new sap.ui.table.Column({
label: new sap.ui.commons.Label({text: "Rating"}),
template: new sap.ui.commons.RatingIndicator({
maxValue: 5,
change: function(evt) {
var value = evt.mParameters.value;
var bindingContext = evt.getSource().getBindingContext();
window.console&&console.log("binding context is " + bindingContext);
undoRedoComponent.setProperty(bindingContext.sPath + "/rating", value);
}
}).bindValue("rating", null, sap.ui.model.BindingMode.OneWay),
sortProperty: "rating",
filterProperty: "rating",
width: "200px"
});
var oCustomMenu = new sap.ui.commons.Menu();
oCustomMenu.addItem(new sap.ui.commons.MenuItem({
text:"Custom Menu",
select:function() {
alert("Custom Menu");
}
}));
oColumn.setMenu(oCustomMenu);
oTable.addColumn(oColumn);
oTable.addColumn(oColumn2);
oTable.addColumn(oColumn3);
oTable.bindRows("/listItems");
oTable.placeAt('content');
});
Most important thing to note here is that undoRedo component does not work with two-way binding. That is because it cannot keep track of changes made to the Model using View to Model change. This is similar to how formatters work so I don’t see this as a big problem.
Todo
- Make undoRedo work with Objects. I’ve only tested it with simple types
- Allow adding items into and removing them from arrays in a Model. Example use case: add a row into a table
- Set maximum size of undo stack to limit memory usage in production applications
- Make undoredo component listen to all changes made to the Model even if they were made directly
Live examples
Example 1: http://jsbin.com/weboqi/1/edit
Example 2: http://jsbin.com/norani/1/edit
Thanks for reading!
Hello Kimmo,
thanks for this interesting blog.
I've used a very similar design pattern in a recent Wd4A application.
Would it also be possible to group several changes together?
An undo/redo would then make several changes that always go together.
Regards,
Thomas
Hi Thomas,
Thanks. Grouping changes surely is possible. There's many ways how to achieve. You could, for example, keep track of the groups out of undoRedo.
I would try to tackle that requirement by implementing the grouping in the undoRedo. Here's how I would do it with a code example (not tested):
Hope this is helpful.
Regards,
Kimmo
Hi Kimmo,
thanks for your ideas and code. I will try to use your code and let you know.
Thanks,
Thomas
Hi Thomas,
Just wondering did you get it working?
Regards,
Kimmo
Hi Kimmo,
yes I got a first version working.
You can check it out at http://jsbin.com/tukadu/4/edit.
In my example the salutation property changes when the *** property changes. The two properties are always changed together.
Changes that have to go together are inside an array. When such a grouped change is undone all changes in the array are undone.
You can also still make single (non grouped) changes.
Regards,
Thomas
The salutation property changes when the gender property changes. The forum system doesn't allow to write S e x 🙂
Hi Thomas,
Thanks for the info and example. Your solution of grouping the changes always in an array is a nice one.
Regards,
Kimmo