Skip to Content
Author's profile photo Kimmo Jokinen

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:

  1. Bind the Faceless component directly into the Model and listen when it dispatches change events.
  2. 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!

Assigned Tags

      7 Comments
      You must be Logged on to comment or reply to a post.
      Author's profile photo Former Member
      Former Member

      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

      Author's profile photo Kimmo Jokinen
      Kimmo Jokinen
      Blog Post Author

      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):

      1. add method setProperties: function(propertyArray) which would take as input an array of objects that contain fields sPath, oValue and oContext
      2. setProperties calls setProperty (I extracted the logic into a "private" method _setPropertyImpl) as many times as there are objects in the input array
      3. add a counter (_getNextGroupId) into the undoRedo component that is incremented by one everytime after a setProperty or setProperties call. Current value of the counter would be saved with sPath, oValue and oContext in undo stack (and redo stack).
      4. Then in undo and redo objects are removed (_undoStack.shift()) from the stack as many times as the counter is the same as for the first removed item.

      util.undoRedo = (function(jsonModel){

            

              var _jsonModel = jsonModel;

              var _undoStack = [];

              var _redoStack = [];

              var _currentGroupId = 0;

              var updateModelValue = function(sPath, oValue, oContext) {

                _jsonModel.setProperty(sPath, oValue, oContext);

              };

            

              var _setPropertyImpl = function(sPath, oValue, oContext, groupId) {

                var oldValue = _jsonModel.getProperty(sPath);

                   updateModelValue(sPath, oValue, oContext);

                  // TODO also keep track of oldContext and newContext

                  // TODO should propably make a deep copy of oValue so that tracking would work for objects also

                  // var newValue = JSON.parse(JSON.stringify(oValue));

                  // add new entry into the undoStack

                  window.console&&console.log("Updating stack with newvalue " + oValue + " old value " + oldValue);

                  _undoStack.unshift({"path": sPath, "oldValue": oldValue, "newValue": oValue, "groupId": groupId});

                  window.console&&console.log("Undo stack is now: " + JSON.stringify(_undoStack));

                  // clear redo stack if needed

                  if (_redoStack.length > 0) {

                    _redoStack = [];

                  }

              };

            

              var _getNextGroupId = function() {

                _currentGroupId++;

                return _currentGroupId;

              };

            

              return {

                setProperty: function(sPath, oValue, oContext) {

                  var groupId = _getNextGroupId();

                  _setPropertyImpl(sPath, oValue, oContext, groupId);

                },

                /*

                * Array with multiple sPath, oValue, oContext

                */

                setProperties: function(propertyArray) {

                  var groupId = _getNextGroupId();

                  for (var i = 0; i < propertyArray.length; i++) {

                    _setPropertyImpl(propertyArray.sPath, propertyArray.oValue, propertyArray.oContext, groupId);

                  };

                },

                undo: function() {

                  var undoItem = (_undoStack.length > 0) ? _undoStack.shift() : null;

                  var groupId = (undoItem !== null && undoItem.groupId) ? undoItem.groupId : null;

                  while (undoItem !== null) {

                    window.console&&console.log("Undo item is " + JSON.stringify(undoItem));

                    updateModelValue(undoItem.path, undoItem.oldValue);

                    // add item to redoStack

                    _redoStack.unshift(undoItem);

                    if (groupId !== null && _undoStack.length > 0 && _undoStack[0].groupId === groupId) {

                      undoItem = _undoStack.shift();

                    }

                  }          

                },

      ...

      Hope this is helpful.

      Regards,

      Kimmo

      Author's profile photo Former Member
      Former Member

      Hi Kimmo,

      thanks for your ideas and code. I will try to use your code and let you know.

      Thanks,

      Thomas

      Author's profile photo Kimmo Jokinen
      Kimmo Jokinen
      Blog Post Author

      Hi Thomas,

      Just wondering did you get it working?

      Regards,

      Kimmo

      Author's profile photo Former Member
      Former Member

      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

      Author's profile photo Former Member
      Former Member

      The salutation property changes when the gender property changes. The forum system doesn't allow to write S e x 🙂

      Author's profile photo Kimmo Jokinen
      Kimmo Jokinen
      Blog Post Author

      Hi Thomas,

      Thanks for the info and example. Your solution of grouping the changes always in an array is a nice one.

      Regards,

      Kimmo