Wednesday, January 03, 2024

UI5 Tips: Persisting JSONModel data using browser Storage

In this ui5tip, we'll take a look at integrating ui5's sap.ui.model.json.JSONModel with sap.ui.util.Storage utility. Our immediate use case for this was to allow easy and transparent persistence of UI State, which has its own dedicated tip. In this tip, we describe how the actual persistence is implemented.

Sample application: a Shopping List

To illustrate just the storage model, we developed a tiny Shopping List application. You can run it yourself by downloading the contents of the localstoragemodel folder and exposing them with your webserver. This is what the application looks like: Screenshot of the Shopping List sample application to illustrate the local storage model.

Sample Application Features

  • A Products list (left), and a Shopping list (right). Users can browse the Products list and see name and price. The Products list has a row action button with a shopping cart icon. If the product is already on the shopping list, the shopping cart appears full. Hitting the row action button will add the product to the Shopping List.
  • In the Shopping List, users see the product name, item price, quantity, and item total. The items in the shopping list also have a row action to remove the item from the shopping list.
The Shopping List also has a toolbar with some buttons that control the Shopping List Data:
  • The Save button will save the current contents of the shopping list to the local storage
  • The Undo button will restore the current contents of the shopping list with whatever data was stored in the local storage
  • The Submit button represents the action of actually placing an order for the shopping list. It will also clear the shopping list and save.
  • The Clear button will empty the current contents of the shopping list, but without saving the state to the local storage.

Sample Application Demo

To test the application, try the following sequence of actions:
  1. Open the application. Initially, the Shopping list should be empty.
  2. In the Products list, add a Product to the shopping list by hitting the shopping cart button. The item should be added to the Shopping list.
  3. Refresh the browser window. When the application reloads, you'll notice that the shopping list is empty - that's expected, since you didn't save the shopping list.
  4. Now, repeat step 2 and add some products to the shopping list. Hit the Save button.
  5. Refresh the Browser again. Now, when the application reloads, the products you added in step 4. should re-appear automatically in the list.
This demonstrates that the application is capable of persisting the saved shopping list data. Instead of refreshing the window, you can also try to completely close the browser, or even reboot your machine. But when you revisit the application - with the same browser - then you'll notice that the data will still appear. In addition to the persistence the application also provides a simple, one level undo action. Whenever you make a modification to the list, either by adding a new item, removing an item, or modifying the quantity of an item, both the Save and the Undo button will become enabled. We already demonstrated the Save button action. Hitting the Undo action button will restore the contents of the shopping list with whatever was available in the Storage, restoring the contents of the list to the previously saved state. You can use your browser to inspect the local storage. It might look something like this: Screenshot of the Shopping List sample application to illustrate the local storage model. In the remainder of this tip we will discuss how these features were built by combinding two classes in the ui5 framework - the sap.ui.util.Storage utility and the sap.ui.model.json.JSONModel.

sap.ui.util.Storage utility

The sap.ui.util.Storage utility offers a UI5 APIto access the Browser's standard HTML5 Web Storage API. It's a pretty basic, no-nonsense wrapper for managing modest amounts of data based on key/value access. Of course, you can use the sap.ui.util.Storage utility directly and code your own logic to control exactly when you want to retrieve and store some data. While there is nothing against that approach, we envisioned something that also works when using models and data binding. This may need a little bit of explanation.

UI5 models

Models are a way to achieve managed data access. A model manages a particular collection of data, and can be shared across multiple elements of the application, or even be accessible to all elements of the application. For example, both the Product List and the Shopping List are each managed by their own model, which are declared in the application's manifest.json:
  ...,
  "models": {
    "products": {
      "type": "sap.ui.model.json.JSONModel",
      "dataSource": "products"
    },
    "shoppingList": {
      "type": "ui5tips.utils.LocalStorageJSONModel",
      "dataSource": "shoppingListTemplate"
    }
  },
  ...
The model can be observed by listening to its events, and this allows different parts of the application to react whenever something interesting happens to the state of the model - i.e. when its data is manipulated. For example, in MainPage.controller.js, an event handler is attached to listen to the Shopping list's dirtyStateChange and propertyChange events, which in turn control certain aspects of the screen logic, such as enabling and disabling the Save and Undo buttons:
  ...,
  initShoppingListModelHandlers: function(){
    var shoppingListModel = this.getShoppingListModel();
    shoppingListModel.attachDirtyStateChange(function(event){
      this.dirtyStateChanged(event.getParameters());
    }, this);
    shoppingListModel.attachPropertyChange(function(event){
      var path = event.getParameter('path');
      var context = event.getParameter('context');
      var itemsPath = '/items';

      if (path === itemsPath || context && context.getPath() === itemsPath) {
        this.itemsChanged(shoppingListModel.getProperty(itemsPath));
      }          
    }, this);
  },
  ...

Data Binding

In Ui5, databinding is a mechanism that lets you declaratively construct objects and change their properties based on the state of a model. The declarative aspect means that no explicit coding is involved. For example, rather than setting up an event handler that contains explicit code to respond to changes to the state of the model, you can use a special syntax in designtime property assignments that ensures the runtime property value will be assigned directly from some part of the data in the model. Some examples include:
  • The actual data in both the Product List and Shopping List. Both these are implemented using a sap.ui.table.Table control, which only support adding rows through data binding. For example, take a look at ShoppingList.fragment.xml to see how it gets its rows from the Shopping List model:
  ...
  <table:Table
    id="shoppingList"      
    title="Shopping List"
    editable="true"
    selectionMode="None"
    enableBusyIndicator="true"
    visibleRowCountMode="Auto"
    rowActionCount="1"
    rows="{
      path: 'shoppingList>/items'
    }"
  >
  ...
(This example basically says: create a row in the shopping list for each item in the shopping list model)
  • The Product List's row action shows a full or empty shopping cart, depending upon whether the product is already in the shopping list. This is achieved in Products.fragment.xml with databinding, which passes the current product and the items from the shopping list to the controller's getShoppingCartRowActionIconSource formatter function:
  ...
  <table:RowActionItem
    binding="{shoppingList>/items}"
    icon="{
      parts: [
        {path: 'products>'},
        {path: 'shoppingList>'}
      ],
      formatter: '.getShoppingCartRowActionIconSource'
    }"
    text="Add to Cart"
    press="onCartButtonPressed"
  />
  ...
(This example says: call the getShoppingCartRowActionIconSource method to obtain an icon, depending on the current product from the product model and all the items in the shopping list model.)
  • In ShoppingList.fragment.xml, the state of the Submit and Clear buttons is enabled depending upon whether the shopping list has any items:
  ...
  <m:contentMiddle>
    <m:Button 
      id="approvalButton"
      icon="sap-icon://cart-approval"
      tooltip="Send Order"
      enabled="{= ${shoppingList>/items}.length > 0 && ${shoppingList>/items/0} !== undefined }"
      press="onApproveButtonPressed"
    />
  </m:contentMiddle>
  <m:contentRight>
    <m:Button 
      id="clearAllButton"
      icon="sap-icon://clear-all"
      tooltip="Clear Shoppinglist"
      enabled="{= ${shoppingList>/items}.length > 0 && ${shoppingList>/items/0} !== undefined}"
      press="onClearButtonPressed"
    />
  </m:contentRight>
  ...
(This example says uses a slightly different binding syntax called expression binding to enable the button if there is at least one item in the shopping list.) While all these features could also have been implemented by explicit coding, data binding allows a lot of this to be defined completely declaratively in the view, with much less code, and denoted in a way that transparently and unambiguously ties the data to the relevant item in the UI. Now - there is no shame (I think) in not immediately embracing ui5's data binding. There are a number of areas that can be somewhat complex and unintuitive at first. But by using it more and more often, you start experiencing the benefits, and - just as important -, learn about the limitations. Now, this post is not an in-depth article on ui5 databinding. It's just that, at some point, you learn to use it in such a way that it becomes one of the most important factors in how you design ui5 applications, as well as the way different parts of the application communicate with each other. So, we consider using ui5 models and data binding as a given. And if you find you have a need for the kind of client-side persistence capabilities offered by the Web Storage API, then you are probably not interested in that as an isolated way of storing some bits of data. Instead, you're going to want to have a normal, regular ui5 model that incorporates these persistence features.

A sap.ui.model.json.JSONModel backed by sap.ui.util.Storage

We decided to take the sap.ui.model.json.JSONModel as a base, and extend it to add a few methods that allow the model's data to be stored and retrieved from sap.ui.util.Storage. The reason for this approach is to allow our model exactly the same as the standard ui5 sap.ui.model.json.JSONModel. This means that in particular, all behavior with regards to databinding will be exactly as with the sap.ui.model.json.JSONModel. In theory, it would also be possible to extend the abstract sap.ui.model.ClientModel, but it turns out that implementing reliable databinding is not as easy as it seems. Or I should say, I took a naive shot at doing that, and failed. While it might be very instructive to try it in earnest, I decided that at this point I am more interested in having a working solution than to learn all the ui5 internals required to succesfully implement databinding. The result is the LocalStorageJSONModel.

Instantiating the LocalStorageJSONModel from the manifest.json

The sample application creates the LocalStorageJSONModel implicitly by declaring it in the manifest.json:
      "shoppingList": {
        "type": "ui5tips.utils.LocalStorageJSONModel",
        "dataSource": "shoppingListTemplate"
      }
It gets initialized with a datasource called shoppingListTemplate which is also declared in the manifest.json:
      "shoppingListTemplate": {
        "uri": "data/shoppingListTemplate.json",
        "type": "JSON"
      }
The datasource refers to some configuration data stored in data/shoppingListTemplate.json and its contents are:
{
  "autoSaveTimeout": -1,
  "storagePrefix": "shoppingList",
  "template": {
    "items": [
    ]
  }
}
This data is passed as first argument to the LocalStorageJSONModel constructor. Its properties are:
  • int autoSaveTimeout: (optional) an integer specifying the number of milliseconds to wait after the last change to the model before automatically saving the model's data to the persistent storage. If this is 0 or less, data is not automatically persisted.
  • string storagePrefix: (optional) a string that is used to prefix the key under which the models data will be stored in storage. The sap.ui.util.Storage constructor takes a storagePrefix, and the LocalStorageJSONModel takes its own class name for that. But if you have several of these models in one application, you can keep them apart by specifying a specific storagePrefix here.
  • object template: (optional) an object that will be used as template data for the model.

Instantiating the LocalStorageJSONModel directly

Of course, you can also import the class into your in your ui5 classes (for example, in a controller) and call its constructor to create an instance:
sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "ui5tips/utils/LocalStorageJSONModel"
], 
function(
  Controller,
  LocalStorageJSONModel
){
  "use strict";  
  var controller = Controller.extend("ui5tips.components.app.App", {
    onInit: function(){
      var localStorageModel = new LocalStorageJSONModel({
        "autoSaveTimeout": -1,
        "storagePrefix": "myApp",
        "template": {
          ...data...
        }
      });
      this.getView().setModel(localStorageModel, 'localStorageModel');
    }
  });
  return controller;
});

Key Methods

The most important methods provided by LocalStorageJSONModel are:
  • loadFromStorage(template): populates the model with the data persisted in the storage. If the template argument is specified, then the data from the storage is patched with the data in the template. (For more details, see the next section about model initialization and the template). In the sample application, the Undo button action is implemented by calling loadFromStorage():
    onUndoButtonPressed: function(){
      var shoppingListModel = this.getShoppingListModel();
      shoppingListModel.loadFromStorage();
    }
  • saveToStorage(): stores the model data to the browser storage. In the sample application, the Save button action is implemented by calling the saveToStorage() method.
    onSaveButtonPressed: function(){
      var shoppingListModel = this.getShoppingListModel();
      shoppingListModel.saveToStorage();
    }
  • deleteFromStorage(): permanently removes the data from the local storage. Use this if you're sure the application will not need any of the currently stored data anymore.
  • isDirty(): returns a boolean that indicates whether the current model state is different from what is stored. If it returns true, it means the current state of the model is different from the stored state. Note that you can also use the dirtyStateChanged event to get notified of a change in the dirty state.

Template and Model initialization

As part of model initialization, whatever data the browser had associated with the storagePrefix is retrieved. If a template is specified, then the data retrieved from the storage is patched with the template and the resulting data structure is immediately saved to the storage. This provide a basic method to evolve the structure of the model and pre-populate it with any defaults. The patching of the data occurs non-destructively: only those paths in the template that do not exist already in the stored data structure will be added. If you need it, you can always apply more advanced patching schemes after instantiation, but in many cases, this built-in behavior will suffice to update and upgrade the model structure as your application grows and gets more features. You can use the following methods to work with the template:
  • getTemplateData(): retrieve the template passed to the constructor.
  • resetToTemplate(): repopulates the model with the template. Any data stored in the model will be lost.
  • updateDataFromTemplate(data, template): utility method that is used to patch the data argument with the template argument. It returns a object that represents the merge of the data argument and the template argument.

Events

The LocalStorageJSONModel provides these events:
  • dirtyStateChange: this event has two parameters, isDirty to indicate whether the model is now dirty and wasDirty, indicating whether the model was dirty prior to the latest change. The sample application uses this event to determine whether to enable or disable the Save and Undo buttons:
    shoppingListModel.attachDirtyStateChange(function(event){
      this.dirtyStateChanged(event.getParameters());
    }, this);
and
    dirtyStateChanged: function(parameters){
      var isDirty = parameters.isDirty;
      this.byId('saveButton').setEnabled(isDirty);
      this.byId('undoButton').setEnabled(isDirty);
    },
The following events can be used to keep track of the model state:
  • attachDirtyStateChange(data, handler, listener): attach a handler function to get notifications of a change in the dirty state. If a some change is made that causes a difference between the stored data and the model data, this event is fired and the handler is called in the scope of the listener, and gets passed the application specific payload data.

Autosave

The sample application controls when the model data will be persisted to local storage by calling saveToStorage() explicitly. But there are also use cases where you simply want the storage to always reflect the state of the model, or at least, track it as closely as possible. The persistence of UI state is such a case, and for these scenarios the LocalStorageJSONModel supports an automatic save feature. Autosave works by monitoring the state of the model, and then saving to the storage whenever a change is detected. While saving to storage should generally be pretty fast, it is a blocking operation. So rather than always explicitly persisting after a change occurs, we simply buffer the change events with the bufferedEventHandler and persist the data to storage some time after the occurrence of the last change event. To use the autosave feature, simply pass a positive value for the autoSaveTimeout property when you instantiate the model. Alternatively, can also get or set the value of the autoSaveTimeout property after model construction by calling the getAutoSaveTimeout() and setAutoSaveTimeout() methods respectively. To disable autosave, simply set the property to a zero or negative value.

Finally

Did you like this tip? Do you have a better tip? Feel free to post a comment and share your approach to the same or similar problem. Want more tips? Find other posts with the ui5tips tag!

No comments:

DuckDB bag of tricks: Processing PGN chess games with DuckDB - Rolling up each game's lines into a single game row (6/6)

DuckDB bag of tricks is the banner I use on this blog to post my tips and tricks about DuckDB . This post is the sixth installment of a s...