Wednesday, January 03, 2024

UI5 Tips: Persistent UI State

This tip provides a way to centrally manage UI state, and to persist it - automatically and without requiring intrusive custom code sprinkled through your apps.

The UI State

Many ui5 controls and widgets allow some aspect of their appearance or behavior to be changed by the user. For example, a panel may be collapsed or expanded, a tab may be selected, columns width in a data grid may be adjused, and so on. We call all this the ui state. When the user restarts the app, normally, the ui state is reset: properties that were explicitly set are reinitialized to that value, and properties that were not explicitly assigned will get assigned some default, which may either be a constant or some calculated value, depending on how the component is coded. A reset of the ui state may not always be desirable. For example, if the user has to go through multiple clicks and selections before they arrive at a certain item inside the application that interests them, then it will be frustrating if they have to repeat the sequence the next time they open the application. Fortunately, for these use cases, UI5 offers routing and navigation, which lets the user find content inside the application by navigation to a particular url. However, not all ui state is about navigation. For example, the user may collapse a panel to get a bit more screen real estate, or resize the width of a column in a data grid, or toggle the state of a checkbox that controls some application-wide setting. These cases are clearly not navigational in nature, but have to do with layout and presentation. It would be confusing for the user to control this by visiting a particular url. Rather, we'd like the application to be able to retain the ui state exactly as the user left it. In this tip we will explain a way to achieve this, and in a way that does not require any specific application code. It can all

Sample Application

The sample application for this tip is in the uistate directory. Simply expose the contents of the directory with your webserver and use your browser to navigate to index.html. A screenshot is shown below: Screenshot of the UI State App

Sample Application Features

The application has the following features: Users can click on a company in the sidebar to select it, and then the company will be shown in more detail in the Detail Page. The Detail Page has some features of its own:
  • In the top, there's a sap.m.Panel which shows the Company Name as title. The Panel is expandable and expanded by default. Inside the panel we can see the company's phone number. Below the Panel, there's an sap.m.IconTabBar with 2 tabs:
  • Details, which shows the address of the currently selected company. This tab is also selected by default.
  • Departments, which shows the departments of the selected company.

Sample Application Demo

To test the application, try the following sequence of actions:
  1. Use the browser to navigate to the index.html page. The sidebar should show the list op Companies, but no company will be selected yet. You can click any row in the sidebar to select a company, and if you do its details will be shown in the detailpage. For the demonstration it doesn't matter if you select one or not.
  2. In the sidebar, the Name column is not wide enough to show the full company name Euismod Ac Fermentum Corp.. Adjust the width of the column by dragging the right end of its header to the right until the full company name is visible.
  3. Also in the sidebar, the Country column is not wide enough to show the full name of the country Congo, the Democratic Republic of the. Adjust of that column too so the full name is visible.
  4. After adjusting the column width in step 2. and 3., the sidebar will now have a horizontal scrollbar at the bottom, as the data grid is now wider than the position of the splitter grip. Drag the Splitter grip to the right so both columns of the sidebar are visible and the sidebar's horizontal scrollbar disappears again.
  5. The Panel is expaned and the company's phone number is visible inside the panel. Click the button to left of the panel header title to collapse it.
  6. The Details tab is selected by default. Click the Departments tab instead.
After all these actions, the application should now look something more like this: Application after changing the UI State If you now refresh the browser window (or even close the browser alltogether) and then revisit the application, you will notice that the selection is lost. However, the column widths, the position of the splitter grip, the collapsed state of the panel and the selected tab have all been preserved. (You can restore the UI to the original state by pressing the Undo button in the top of the main page.)

Using the [LocalStorageJSONModel] to manage UI State

The UI State behavior is the result of binding all the relevant properties of the UI to a LocalStorageJSONModel. The model is declared in sample application's manifest.json, so it's instantiated automatically as the application starts, and becomes available throughout the application as uistate.
  "uistate": {
    "type": "ui5tips.utils.LocalStorageJSONModel",
    "dataSource": "uistateTemplate"
  }
The model is initialized with the uistateTemplate data source:
  "uistateTemplate": {
    "uri": "data/uistateTemplate.json",
    "type": "JSON"
  }
The datasource grabs its data from data/uistateTemplate.json:
{
  "autoSaveTimeout": 1000,
  "storagePrefix": "uistate",
  "template": {
    "appSettings": {
      "sidebar": {
        "splitterSize": "431px",
        "columns": {
          "name": {
            "width": "190px"
          },
          "country": {
            "width": "190px"
          }
        }
      },
      "detailpage": {
        "panel": {
          "expanded": true
        },
        "tabContainer": {
          "selectedTab": "details" 
        }
      }
    }
  }
}
The options for the LocalStorageJSONModel are described in the LocalStorageJSONModel wiki page. For the sample app, the autoSaveTimeout is relevant - for this example, the value is assigned 1000 and this means that if the state of the model is changed, there will be a 1000 ms (1 second) waiting period after which the data from the model is persisted in the Browser's local storage. The template represents the default initial state of the UI. Please refer to the the LocalStorageJSONModel wiki page for a detailed discussion of the template and model initialization.

Managing Binding

The LocalStorageJSONModel wiki page has some general remarks and clarifications about UI5 data binding. But it may not be entirely clear how to practically organize it to manage UI state. After all, even a simple example like the sample application we discuss here already has 5 distinct UI properties that the user can change.

Template Structure

One could, in principle, make one big property bag to to manage each and every UI property, and this may be the right choice if the application remains really simple. But as an application gets more features, more views and more functionality one may prefer to design a data structure that mimics the structure of the application, and that's the approach we have taken in this example. If you look at data/uistateTemplate.json, you'll notice that the template contains a applicationConfig key which itself has two keys: one to maintain all settings for the sidebar and one for all settings for the detailpage:
  "template": {
    "appSettings": {
      "sidebar": {
        ...
      },
      "detailpage": {
        ...
      }
    }
  }
To these keys, an object is assigned which may have a further hierarchical structure, depending on the UI control tree.

Template Structure and UI Tree Structure

Once we decide to structure the template hierarchically, it may be tempting to attempt to faithfully mimic the actual container/component structure of the UI in the model structure. However at this point it is my opinion that this is not necessary and not productive. The reason to warn against a too tight mapping of the UI tree to the model structure is that the UI tree is only to some extent a reflection of the functional organization of an application's parts. A simple example from the sample application may illustrate this. For example, let's take look at the structure of the template that manages the settings for the detail page:
  "detailpage": {
    "panel": {
      "expanded": true
    },
    "tabContainer": {
      "selectedTab": "details" 
    }
  }
Let's compare this to the ui tree of the detail page, which is defined in DetailPage.view.xml
  <layout:FixFlex binding="{uistate>detailpage}">
    <layout:fixContent>
      <m:Panel
        expandable="true"
        expandAnimation="false"
        binding="{uistate>panel}"
        expanded="{uistate>expanded}"        
        headerText="{companies>CompanyName}"
      >
        <m:content>
          <m:Text text="Phone: {companies>Phone}"/>
        </m:content>
      </m:Panel>
    </layout:fixContent>
    <layout:flexContent>
      <m:IconTabBar
        stretchContentHeight="true"
        applyContentPadding="false"
        expandable="false"
        binding="{uistate>tabContainer}"
        selectedKey="{uistate>selectedTab}"
      >
        <m:items>                                                                     
          <core:Fragment fragmentName="ui5tips.components.detailpage.DetailsIconTabFilter" type="XML" />
          <core:Fragment fragmentName="ui5tips.components.detailpage.DepartmentsIconTabFilter" type="XML" />
        </m:items>
      </m:IconTabBar>
    </layout:flexContent>
  </layout:FixFlex>
While we refer to it as detail page, the actual UI component that is used to implement it is a sap.ui.layout.FixFlex. But it might just as well have been another type of container, like, say, a sap.m.Page. Another example might be the tabs. In this sample application we chose the sap.m.IconTabBar and in the future we might change that to the sap.m.TabContainer. While these are functionally similar components, certain details, like property names and aggregation names might be different. During normal application development, changing and rewriting the UI, swapping out particular containers for other types of containers is quite common. Often, much of the functional aspects are retained and expanded, even though the details of the implementation and choice and structuring of UI components may be quite different. If our template would mimic the UI structure too closely, we would have to modify our template also, and often without any real benefit. So the recommendation here is to make sure the template is organized according to functionality, not to the exact details of the UI tree.

Managing Binding Paths with Element Binding

Once you settle for a hierarchical template structure, the problem arises of how to deal with the paths. For example, consider the selected tab in the the detail page:
  "template": {
    "appSettings": {
      "detailpage": {
        "tabContainer": {
          "selectedTab": "details" 
        }
      }
    }
  }
If we would have to write a path for this in a databinding, we would get uistate>/appSettings/detailpage/tabContainer/selectedTab. Obviously, in a realistic application we would have many properties and the UI code would soon become littered with these very long paths. Element binding is a UI5 feature that lets you bind a particular path from the model to a UI container or control, thus establishing a scope. Witin that scope, you can use relative paths, which UI5 will resolve against the path bound at the higher level. To understand this feature fully it's best to first look at the component that sits at the top of the UI tree - App.view.xml:
  <m:App 
    id="app"
    binding="{uistate>/appSettings}"
  >
    <m:pages>
      <mvc:XMLView viewName="ui5tips.components.mainpage.MainPage"/>
    </m:pages>
  </m:App>
Note how the binding property on the sap.m.App is bound to uistate>/appSettings. What this means is that relative bindings for the uistate model that occur on that component itself, but also to any components that are nested within it, will be resolved against uistate>/appSettings. MainPage.view.xml is nested inside the sap.m.App. If we look at that:
  <m:Page title="UIState App">
    ...
    <layout:Splitter>
      <mvc:XMLView id="sideBar" viewName="ui5tips.components.sidebar.SideBar">
        <mvc:layoutData>
          <layout:SplitterLayoutData size="{uistate>sidebar/splitterSize}"/>
        </mvc:layoutData> 
      </mvc:XMLView>
      <mvc:XMLView id="detailPage" viewName="ui5tips.components.detailpage.DetailPage"/>
    </layout:Splitter>
  </m:Page>
You might notice one bound property to the uistate model here - it is the size property of the sap.ui.layout.SplitterLayoutData object, which is bound to uistate>sidebar/splitterSize. As you can see, that binding refers also to the uistate model, but as it does not start with a /, it is a relative path. So, UI5 will try to resolve it by going up the UI tree, and it will then find the scope from the uistate model that is established by the element binding in App.view.xml. In App.view.xml, the binding was to uistate>/appSettings. If we resolve uistate>sidebar/splitterSize against that, the effective path will become uistate>/appSettings/sidebar/splitterSize. If you look back at the earlier example of DetailPage.view.xml, we notice that its top sap.layout.FixFlex component was bound to uistate>detailpage, effectively letting all relative bindings to the uistate model inside it to be resolved to uistate>/appSettings/detailpage. Of course, element binding does not only let you use shorter paths, using them consitently will also make it much easier to maintain the structure of the template. Whenever there is a radical change of structure, you should be able to rewire the element bindings without having to change each and every property individually.

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!

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!

UI5 Tips: Change expand/collapse icons for Tree, Panel and TreeTable using only CSS

UI5 offers a couple of widgets that can expand and collapse. To do that, these controls render a button with an icon that indicates the current state, and which the user can click to toggle the state. The standard icons that UI5 renders for the expand/collapse button are navigation arrows, which some of our users disliked. In this tip, you'll learn how you can replace them with more appropriate icons using only a few lines of CSS. No javascript code is involved. If you want to check out this tip yourself, download the app from the expandcollapse directory and expose it to your webserver. You can then navigate to index.html to see the sample app in effect.

UI5 exandable/collapsible Controls

First, lets take a look at the standard UI5 controls.

Panel

The sap.m.Panel has an expandable property. If true the Panel renders a button which the user can use to hide and show the contents of the panel. A screenshot is shown below: An expandable sap.m.Panel (This screenshot is taken from UI5's Panel - Expand / Collapse sample)

Tree

The sap.m.Tree is a classical way of presenting hierarchically organized items like a folder structure. A screenshot is shown below: A sap.m.Tree control (This screenshot is taken from UI5's Tree - Basic sample)

TreeTable

The sap.ui.table.TreeTable is just like a regular data grid table (sap.ui.table.Table), but with an added functionality to hierarchically organize the rows in the table, and with the ability to expand or collapse rows according to the hierarchy. A screenshot is shown below: A sap.ui.table.TreeTable (This screenshot is taken from UI5's sap.ui.table.TreeTable JSONTreeBinding sample)

A look at the icons

Let's take a look at the standard icons that UI5 renders for the expand/collapse button: UI5 Right arrow icon UI5 Down arrow icon

Proposed Icons

While I don't really have a problem with these icons, some of our users had a problem recognizing the collapse/expand functionality for Panels. We looked a bit around in the UI5 Icon explorer and decided we'd rather use these icons instead: UI5 Expand icon UI5 Expand icon Going by their name, it's a bit of a mystery to me why they weren't used by UI5 in the first place. But anway, now we have this tip to explain how you can change them.

CSS to change the icons

We prepared a separate CSS file for each of the aforementioned UI5 controls, and included them into the app via the manifest.json:
"resources": {
  "css": [
    { "uri": "css/ui5-customization-m.Panel.css" },
    { "uri": "css/ui5-customization-m.TabContainer.css" },
    { "uri": "css/ui5-customization-m.Tree.css" },
    { "uri": "css/ui5-customization-ui.tree.TreeTable.css" }
  ]
}

How UI5 renders icons

Before we discuss how to apply the CSS to change the icons, it's useful to understand how UI5 icon rendering works. In general, UI5 uses icon fonts. The UI5 framework loads a library.css stylesheet, which has a @font-face rule like this:
@font-face {
    font-family: "SAP-icons";
    src: url('../base/fonts/SAP-icons.woff2') format('woff2'),
         url('../base/fonts/SAP-icons.woff') format('woff'),
         url('../base/fonts/SAP-icons.ttf') format('truetype'),
         local('SAP-icons');
    font-weight: normal;
    font-style: normal
}
This binds the name SAP-icons to the font resource, and will ensure that whenever a HTML element is assigned the font-family: "SAP-icons" css property, it will render whatever text it contains with glyphs from that font. Now, when using the UI5 javascript API, you don't actually ever have to deal with these details at this level. Rather, if you ever need to assign an icon explicitly, for example, when using a sap.ui.core.Icon control, you can assign a custom icon uri using the sap-icon protocol, which maps more or less reasonable icon names to the glyph that depicts the desired icon. (You can read more about the sap-icon uri protocol in the Icon topic of the SAP UI5 walkthrough) Apart from these explicitly assigned icons, the renderer classes of various UI5 controls will write out the required HTML code for the icons that just happen to be fixed to it. Let's call these structural icons. For example, there is no property that allows you to change the icon that a sap.m.Panel uses for its exapand/collapse button - that's just part of how the Panel happens to be coded - it's part of its structure. As we will see in the following sections, the font-family is just the underlying medium that allows the UI5 framework to render icons. The details of how a particular control renderer renders its structural icons can still vary a bit, and we'll need to figure out how a particular control renders its icons before we can change them.

How sap.ui.table.TreeTable renders the collapse/expand icons

The sap.ui.table.TreeTable renderer takes a straightforward approach to rendering the collapse/expand icons. If you open one of the standard UI5 TreeTable samples, and right click the expand/collapse icon to inspect it (for example, with the Chrome developer tools), then you might see something like this: Inspecting the sap.ui.table.TreeTable expand/collapse icon with chrome developer tools The sap.ui.table.TreeTable renderer has written a <span> element with a sapUiTableTreeIcon class:
<span 
  class="
    sapUiTableTreeIcon 
    sapUiTableTreeIconNodeClosed
" 
  title="Expand Node" 
  role="button" 
  aria-expanded="false"
></span>
The span does not actually contain any text - rather a css ::before pseudo class is used for that. This is also used to bind it to the "SAP-icons" font, using the font-family property - this ensures that element will render glyphs from the icon font:
.sapUiTableTreeIcon::before {
    font-family: "SAP-icons";
    font-size: .75rem;
    color: #0854a0;
}
The actual text content that determines the icon is controlled through another rule, using another css class, which uses the css content property to write out the character that renders the appropriate icon from the font. When collapsed, its:
.sapUiTableTreeIcon.sapUiTableTreeIconNodeClosed::before {
    content: '\e066';
}
(You may recall that \e066 is the character that corresponds to the navigation-right-arrow icon.) When expanded, its:
.sapUiTableTreeIcon.sapUiTableTreeIconNodeOpen::before {
    content: '\e1e2';
}
(You may recall that \e1e2 is the character that corresponds to the navigation-down-arrow icon.) This way, the sap.ui.TreeTable only needs to change the style class from sapUiTableTreeIconNodeClosed to sapUiTableTreeIconNodeOpen on the <span>, depending on the expanded/collapsted state of the row: the css magic will take care of rendering the right icon.

Changing the expand/collapse icons for the sap.ui.table.TreeTable

As we have just witnessed, the sap.ui.table.TreeTable uses separate classes for the collapse and expand icons. This makes it really quite simple to change the icons. We only have to write our own rules for the sapUiTableTreeIconNodeOpen::before and sapUiTableTreeIconNodeClosed::before classes to mask the default ones, and assign the proper value for the content property:
/**
* sap.ui.table.TreeTable: better icons for expanded
*/
.sapUiTableTreeIcon.sapUiTableTreeIconNodeOpen::before {
  content: '\e1d9';
}
/**
* sap.ui.table.TreeTable: better icons for collapsed
*/
.sapUiTableTreeIcon.sapUiTableTreeIconNodeClosed::before {
  content: '\e1da';
}
(You will find similar rules in the ui5-customization-ui.tree.TreeTable.css provided by this ui5tip) The only thing we really need to think of when applying this stylesheet is that it is loaded after UI5 framework loads the CSS specific to the ui.tree.TreeTable control: if our CSS is loaded before the framework's CSS, then our rules will be masked by the framework's, and we want to do it exactly the other way around. To ensure that the framework's CSS for the ui.tree.TreeTable control is loaded before our custom CSS, simply include the sap.ui.table library in the data-sap-ui-libs property of the <script> element you use to load UI5. (see the index.html for this tip):
<script
  id="sap-ui-bootstrap"
  src="https://openui5.hana.ondemand.com/1.87.0/resources/sap-ui-core.js"
  data-sap-ui-theme="sap_belize"
  data-sap-ui-libs="sap.m, sap.ui.table"
  data-sap-ui-bindingSyntax="complex"
  data-sap-ui-compatVersion="edge"
  data-sap-ui-preload="async"
  data-sap-ui-resourceroots='{
    "ui5tips": "./"
  }'
></script>
That's it! The screenshot below shows what the TreeTable looks like in this tip's sample app: sap.ui.table.TreeTable with modified collapse/expand icons

How sap.m.Panel renders the collaps/expand icons

Let's take a look at how the sap.m.Panel renders its collapse/expand icon. We can again open UI5's own sap.m.Panel sample and use our browser's develpoment tools to inspect the page's HTML code: Inspecting sap.m.Panel's standard expand/collapse icon. Just like the sap.ui.table.TreeTable we discussed in the previous section, the sap.m.Panel renders a <span> element for the icon, which is assigned a CSS class to mark it as the icon, and wich is bound to the icon font face:
<span 
  data-sap-ui-icon-content=""
  class="
      sapUiIcon 
      sapUiIconMirrorInRTL 
      sapMBtnCustomIcon 
      sapMBtnIcon 
      sapMBtnIconLeft" 
  style="font-family: 'SAP\2dicons';"
></span>
And, just like for the sap.ui.table.TreeTable, there is a CSS rule to select the ::before pseudo-class, which has the content property to insert the appropriate character that corresponds to the glyph.
.sapUiIcon::before {
    content: attr(data-sap-ui-icon-content);
    speak: none;
    font-weight: normal;
    -webkit-font-smoothing: antialiased;
}
There are some remarkable differences too with respect to the sap.ui.tree.TreeTable example. In this case, there are no separate classes corresponding to the collapsed/expanded state of the Panel. Instead, the content property of the .sapUiIcon::before pseudo-class uses the value of the elements data-sap-ui-icon-content attribute. It will render whatever text is in the elements data-sap-ui-icon-content attribute. If you check the code for the <span>, you'll note the data-sap-ui-icon-content has been assigned some text, which is rendered as a so-called .notdef glyph, both in the developer tools and here on the page. (The .notdef glyph is the "boxed question mark"). You can copy the text from the data-sap-ui-icon-content attribute in the browser tools and paste it in a hex editor, or in a javascript string to figure out what its character code is, for example:
// decimal: 57839
"".charCodeAt(0)

// hex: 0xE1EF
("".charCodeAt(0)).toString(16)
Inspecting the value of the data-sap-ui-icon-content attribute It turns out that this corresponds to UI5's slim-arrow-down icon, which has a similar appearance to the down-arrow icon. If you collapse the panel and inspect it again, you'll notice that the value of the data-sap-ui-icon-content is now 0xE1ED, which corresponds to UI5's slim-arrow-right icon.

Changing the expand/collapse icons for the sap.m.Panel

Now, it's clear that we cannot simply mask the existing classes in the same way we did in the sap.ui.tree.TreeTable case. The reason is that in this case the icon is driven directly by an attribute value, not by change of style class. Since the icon is so clearly driven by the value of the attribute, your initial hunch might be to somehow change the value that is written out to the HTML. But this would involve rewriting or overriding the sap.m.Panel or its renderer, and we're not quite prepared to do that just to change the icon. But, there is a way. What we can do is write some rules that match the <span> depending on the value of the data-sap-ui-icon-content attribute. And if we can match a CSS selector based on the attribute value, we can simply write out a content property with the desired character instead. This works as long as we know what values the attribute will have, which is of course the case here, as there will only be 2 different values, corresponding to the collapsed or expanded state of the panel. This is what it looks like in ui5-customization-m.Panel.css:
/*
  sap.m.Panel better expanded button. 

  The value in the predicate for data-sap-ui-icon-content may not render correctly,
  but this is decimal 57839, or 0xE1EF, which corresponds to UI5's "slim-arrow-down" icon
  (https://sapui5.hana.ondemand.com/sdk/test-resources/sap/m/demokit/iconExplorer/webapp/index.html#/overview/SAP-icons/?tab=grid&icon=slim-arrow-down)
  
*/
div.sapMPanel.sapMPanelExpandable > div > span[data-sap-ui-icon-content=].sapUiIcon::before {
  content: '\e1d9';
}

/*
  sap.m.Panel better collapse button
  
  The value in the predicate for data-sap-ui-icon-content may not render correctly,
  but this is decimal 57837, or 0xE1ED, which corresponds to UI5's "slim-arrow-right" icon
  (https://sapui5.hana.ondemand.com/sdk/test-resources/sap/m/demokit/iconExplorer/webapp/index.html#/overview/SAP-icons/?tab=grid&icon=slim-arrow-down)
*/
div.sapMPanel.sapMPanelExpandable > div > span[data-sap-ui-icon-content=].sapUiIcon::before {
  content: '\e1da';
}
Note the span[data-sap-ui-icon-content=].sapUiIcon::before is the essential bit that allows us to react to a specific icon value. The selector part before is there to ensure the rule will only apply to the expand/collapse button of a Panel, and not to some random other control's icon. And, here's what it looks like in the sample app: sap.m.Panel with modified expand/collapse icons.

How sap.m.Tree renders the collapse/expand icons

The sap.m.Tree uses exactly the same mechanism to render the icons as the sap.m.Panel does - the character that corresponds to the appropriate icon glyph is written to a data-sap-ui-icon-content, and the value of the attribute is rendered against the icon font's font face. The only difference with the Panel is that the sap.m.Tree uses the navigation-right-arrow and navigation-down-arrow icons, just like the sap.ui.table.TreeTable did. Apart from that, we also need to ensure the first bit of the selectors are specific to the sap.m.Tree, which is similar to what we did for the sap.m.Panel. This is what the CSS looks like in ui5-customization-m.Tree.css:
/**
  sap.m.TreeItem : better icons for collapsed 

  The value in the predicate for data-sap-ui-icon-content may not render correctly,
  but this is decimal 57446, or 0xE066, which corresponds to UI5's "navigation-right-arrow" icon
  (https://sapui5.hana.ondemand.com/sdk/test-resources/sap/m/demokit/iconExplorer/webapp/index.html#/overview/SAP-icons/?tab=grid&icon=navigation-right-arrow)

*/

li.sapMTreeItemBase > span[data-sap-ui-icon-content=].sapMTreeItemBaseExpander.sapUiIcon::before {
  content: '\e1da';
}


/**
  sap.m.TreeItem : better icons for expanded

  The value in the predicate for data-sap-ui-icon-content may not render correctly,
  but this is decimal 57826, or 0xE1E2, which corresponds to UI5's "navigation-down-arrow" icon
  (https://sapui5.hana.ondemand.com/sdk/test-resources/sap/m/demokit/iconExplorer/webapp/index.html#/overview/SAP-icons/?tab=grid&icon=navigation-down-arrow)
*/

li.sapMTreeItemBase > span[data-sap-ui-icon-content=].sapMTreeItemBaseExpander.sapUiIcon::before {
  content: '\e1d9';
}
And this is what the Tree looks like in the sample app: A sap.m.Tree with modified expand/collapse icons.

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!

UI5 Tips: Buffering Events to avoid a request-storm

Standard UI5 event handling will usually go a long way. Yet sometimes, certain user actions can cause ui5 objects to generate a lot of similar events within a small period of time, and it is often not useful to handle each and all of them: only the last event needs handling. A very common scenario is doing a search in response to the liveChange event: if you'd attach a handler to handle the liveChange event, and do the backend query from there, then a backend request would be sent for each keystroke while the user is typing in the search field. This causes a storm of requests that the backend must somehow handle. But most of these requests will be for naught, as the user is only interested in the result of the query that matches the last complete search term they typed. So, rather than firing a query to the backend for each and every keystroke, it makes more sense to buffer these events, and react to only the last one. The bufferedEventHandler utility helps you to do just that in a generic and reusable way. This ui5tip describes the bufferedEventHandler utility. It is available on github under terms of the Apache 2.0 License. There's also a sample application so you can try it out yourself.

The BufferedEventHandler sample app

The bufferedEventHandler sample application illustrates the scenario from the introduction. It consists of a single page showing mockup company data in a sap.ui.table.Table. A screenshot is shown below: Screenshot of the BufferedEventHandler sample app. At the top left of the grid, there's a sap.m.SearchField labeled "Search in Name". The user can type some search term into the searchfield, and the grid will automatically refresh and show only the rows for which the CompanyName has a case-insensitive match with the entered search term. While the search happens automatically, it does not happen immediately as the search term changes at every keystroke. Rather, about 1 second after the user stops typing, the data grid is filtered. At the top right of the grid, there's a sap.m.ProgressIndicator labeled "Event buffer Timeout". The progress indicator reflects how much time has passed since the last keystroke. When the progress indicator reaches a 100%, the filter action is executed.

The bufferedEventHandler Utility

To buffer events we provide a bufferedEventHandler utility object with just one bufferEvents function. You can find this in the bufferedEventHandler file in the utils directory. To use it, we need to import it into the source file where we want to use it. This will usually be in a ui5 controller and in the sample app we do this in MainPage.controller.js:
sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/ui/table/Column",
  "sap/m/Text",
  "sap/ui/model/Filter",
  "sap/ui/model/FilterOperator",
  "sap/ui/model/FilterType",
  "ui5tips/utils/bufferedEventHandler"
], 
function(
  Controller,
  Column,
  Text,
  Filter,
  FilterOperator,
  FilterType,
  bufferedEventHandler
){
 "use strict";  
  var controller = Controller.extend("ui5tips.components.mainpage.MainPage", {
    ...
  });
  return controller;
}
We can now refer to the bufferedEventHandler utility through the local variable that is also called bufferedEventHandler. The controller uses the bufferedEventHandler utility in the initSearchField() method. This called from the controller's standard onInit() lifecycle method, which is called just once for the Controller instance:
  ...
  onInit: function() {
    this.initSearchField();
  },
  initSearchField: function(){
    var searchField = this.byId('searchField');
    bufferedEventHandler.bufferEvents(
      // event provider
      searchField,
      // timeInterval
      1000, 
      // eventId
      'liveChange', 
      // data
      null, 
      // handler
      this.doSearch, 
      // listener
      this,
      // progressHandler
      this.searchFieldProgress,
      // progressUpdateInterval
      50
    );
  },
  ...

The bufferEvents() Method

The meat of the initSearchField() method is the call to the bufferEvents method of the bufferedEventHandler utility. This method has the following arguments:
  • eventProvider: the 1st argument should be the object that emits the events - in our example this is the sap.m.SearchField. This object should be a subclass of sap.ui.base.EventProvider. (bufferEvents will throw an error if it's not!)
  • timeInterval: the 2nd argument is the timeout, in milliseconds. This is the amount of time that should pass between the occurrence of the last event and the call to the actual handler of the event. If a new event occurs during the wait period, the timeout is reset, and a new waiting period is started. In the example, we use a timeInterval of 1000 - that is, we will wait 1000 milliseconds (1 second) before handling the last event.
Choosing the timeInterval is a balancing act. In the case of the example, where the events are generated in response to user actions, the timeInterval should not be too short, as the user should be given enough time to type a meaningful searchterm before the actual query kicks in. But if the timeInterval is too long, the application may appear unresponsive to the user. If the application appears unresponsive, the user may try to retype their search term, which will only postpone the reaction even more. (There's more about this in the section about the ProgressIndicator). The next 4 arguments of bufferEvents correspond to sap.ui.base.EventProvider's attachEvent() method:
  • eventId: a string that identifies the event to listen to. In our example this is 'liveChange'. Some ui5 objects, (for example, sap.ui.base.ManagedObjects, which includes all sap.ui.core.Controls) expose the events they expose through their metadata. In these cases, bufferEvents will verify whether the passed eventId is in fact exposed by the object, and it will throw an error in case it doesn't. EventProviders that do not expose their events through metadata can still be used with the bufferedEventHandler, but you'll just need to make sure yourself the value for eventId is valid, as bufferEvent has no way of checking it.
  • data: an optional argument to pass any "extra" data that the event handler might need. In the example, we pass null as we have no need for any additional data.
  • handler: this should be the callback-function that will be called upon to actually handle the event. The callback function will receive an instance of an sap.ui.base.Event as single argument, which typically provides access to all relevant information pertaining to the event. In the example, we pass this.doSearch, which is a method of the controller that will perform the actual filtering of the data grid.
  • listener: this is an optional argument which you can use to specify the scope in which the handler will be called. Typically the handler will not be completely standalone, but it will refer to a this object, one way or another. If the handler function is not already bound (for example, by using the function's bind() method), then you should pass whatever object should act as this for the handler function via the listener argument. In the example, we simply use this which refers to the controller instance itself. This makes sense as the handler function is also a method of the controller. (Remember: we passed this.doSearch as handler.)
In the call to bufferEvents, these arguments will be used to create an actual handler for the event, and also automatically attach it to the eventProvider for the specified eventId. But rather then calling the passed handler, it will start a javascript timeout for a duration of the passed timeInterval. If the timeout was already initiated, it is cleared, thus canceling the previous event, and initiating a new waiting period.

Monitoring wait progress

The final 2 arguments to bufferEvents are optional, and may be used by the application to monitor the waiting period between the occurrence of the last event and the time when the handler will actually be called:
  • progressHandler: when passed, this should be a callback function which is to be called at the start and during the waiting period. If the progressHandler callback is called, it will be called using the listener as scope. The callback will be passed a floating point number between 0 and 1, indicating the fraction of the time that has passed between the last event and now. If a progressHandler is specified, it is always called at least once and passed 0 whenever a new waiting period is initiated. In this example we passed this.searchFieldProgress, which is a method of the controller that updates the sap.m.ProgressIndicator that sits in the right top of the data grid.
  • progressUpdateInterval: this should be in integer, indicating the number of milliseconds between the calls to the progressHandler. In our example it is 50, which means we will get 1000 / 50 = 20 updates during the waiting period, which ensures a smooth and regular update of the ProgressIndicator control.

The ProgressIndicator

The sample application provides a sap.m.ProgressIndicator to indicate when the entered search term will be used to filter the data. A progress indicator may not be necessary in case the timeInterval is so short that it will appear to the user as if the event is handled immediately. But when the timeInterval exceeds 200 or 250 milliseconds, most users will start to experience a noticeable lag. Now, there is this strange psychological phenomenon happening here - as the user is still typing their search term, they will be happy that the backend query is not already fired. It would make them feel rushed if the grid was constantly being updated while they were typing. But once the user is done typing their search term, they want to have the result as quickly as possible. Obviously, the software cannot read the user's mind (yet!), so once the user stops typing, the application needs to let the user know they acknowledged their action, and that it is 'working on it'. Hence the need for a progress indicator: by having a visual indicator that "something's happening", the user will be assured the application has acknowledged their input, and this will make the wait period before actually handling the event more acceptable. If the wait is sufficiently short, a simple busyIndicator might do the trick, but since the progressHandler gets passed an exacte estimate of how much longer the user will need to wait, our progress indicator can communicate this to the user. This will make the application's behavior more predictable and hopefully more satisfying to use. Of course, it is not absolutely necessary to use the sap.m.ProgressIndicator to give this kind of feedback to the user. It's just that for this sample, this was the easiest, most straighforward illustration of this principle. You can use the `progressHandler` callback to do anything you like to fit your need.

Detaching

The bufferEvents method will create and attach a handler to the eventProvider. bufferEvents will also return that generated handler so you can detach it explicitly from the eventProvider if you need to. As a convenience, the returned handler provides its own detach() method for this purpose:
var bufferedEventHandlerInstance = bufferedEventHandler.bufferEvents(...);
...
bufferedEventHandlerInstance.detach();
(Note that in a typical scenario, the eventHandler and the eventProvider will almost certainly be in the same scope and lifecycle, so there is rarely a need to explicitly do this.)

Other Use Cases

The liveSearch scenario may not always be a convincing use case. For example, if the query is done against a client model rather than a remote backend system then it might not actually be a problem to re-issue the query for every keystroke. But there are some other scenarios that benefit from event buffering. We will encounter one such case in the tip about Persisting UI State.

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!

UI5 Tips: Manipulating the sap.m.TabContainer close buttons with custom CSS

Here's a ui5tip to show how you can change the look and feel of the sap.m.TabContainer with a minimal amount of custom CSS. If you want to try this for yourself, be sure to check out the sample application from github.

The sap.m.TabContainer

The sap.m.TabContainer provides a simple, no-nonsense widget to build a tabbed user interface (check out the samples). Tabs can be added via the items aggregation, which should contain a collection of sap.m.TabContainerItem's. While this control generally suits my needs, it has one feature I find problematic: each tabs always has a close button, which appears as a little 'X' icon in the right side of the tab. If the user clicks it, it will actually 'close' the tab, that is: the respective sap.m.TabContainerItem will be removed from the TabContainer. See the screenshot below to see what the default looks like (close buttons highligthed in red):default sap.m.TabContainer with close buttons on each tab.

Suppressing the close action

The openui5 samples show how you can suppress that behavior: you can write an event handler for the itemClose event, and then call the preventDefault() method on the event: (In the view xml:)
<m:TabContainer itemClose="onTabContainerItemClose">
  <m:items>
    <m:TabContainerItem>
       ...
    </m:TabContainerItem>
    <m:TabContainerItem>
       ...
    </m:TabContainerItem>
  </m:items>
</m:TabContainer>
(In the controller javascript:)
  onTabContainerItemClose: function(event){
    event.preventDefault();
  }
Obviously, it would be strange if we'd always prevent the tab from being closed: Suppressing the default action of closing the tab only makes sense in a context where the user is supposed to be able to close the tab at all, and in such a case this could be used to pop up a dialog to let the user choose if they really meant to close the tab or want to keep it open. But the use case I frequently encounter is that the tab should not be closeable in the first place. While suppressing the close action would ensure the tab is never closed, it would confuse and anger the user, as the close button itself would still be there, inviting users to perform an action that can never be fulfilled.

Using the other Tab widget

One might suggest to use the sap.m.IconTabBar widget instead of the sap.m.TabContainer. The sap.m.IconTabBar takes sap.m.IconTabFilter's in its items collection, and these do not have a close button. Now, in some cases the sap.m.IconTabBar/sap.m.IconTabFilter may suit your needs and then you're fine. However I find that it has a number of other drawbacks (which I won't get into here). Besides, the sap.m.IconTabBar introduces a similar problem, but the other way around: whereas we cannot get rid of the close button in the sap.m.TabContainer, we cannot ever have a close button in the sap.m.IconTabBar. What we really want, is a property or something like that, which will let us control whether the tab will have a close button or not.

CSS to the rescue

In the previous section we argued that we'd really like to be able to control for each individual tab whether they have a close action at all, for example, by setting a property. To add a property one would normally have to extend a ui5 control, and attach some code so that the property setting can somehow influence the behavior of the control - in this case, control whether or not the close button will be displayed. While this is probably possible (I haven't tried it for this case), it does seem like an extraordinary measure for such a humble request. I found that a similar effect can be achieved by applying some custom CSS in combination with standard ui5 features. That's what this entire sample is about. With this tip you can:
  • hide all close buttons for an entire sap.m.TabContainer
  • hide the close button on an individual sap.m.TabContainerItem
  • show the close button on an individual sap.m.TabContainerItem in case the close buttons are hidden by default on the sap.m.TabContainer
All this functionality requires the inclusion of some css. In the sample this is all isolated in a single ui5-customization.css file, which is included into the application by declaring it the manifest.json.

Hiding all close buttons

To hide all the close buttons for all sap.m.TabContainerItem in the items collection of a particular sap.m.TabContainer, simply add the noCloseButtons style class:
<m:TabContainer 
  class="noCloseButtons" 
>
  <m:items>
    <!-- note: close button will be hidden by default for each m:TabContainerItem -->
    ...
  </m:items>
</m:TabContainer>
This works because the class property in the ui5 xml view is rendered to the html dom directly. So whatever html elements that ui5 creates to implement the TabControl widget will then be selectable with a class selector in css, and this is how we can relatively simply influence the look of our TabContainer through css. In our ui5-customization.css file, this is how we use noCloseButtons class to hide the buttons:
div.sapMTabContainer.noCloseButtons > .sapMTabStripContainer > .sapMTabStrip > .sapMTSTabsContainer > .sapMTSTabs > .sapMTabStripItem > .sapMTSItemCloseBtnCnt {
  visibility: hidden;
}
(Note the initial selector, div.sapMTabContainer.noCloseButtons and the chain of > child selectors which target the actual bit of html that is used to render the close button, which is simply hidden by setting the css visibility property to hidden.) In the sample application, you can see this behavior in action in the App.view.xml. This contains the code for the outermost tabcontainer. A screenshot is shown below and as you can see both tabs ("Hide individually" and "Show individually") do not have a close button: Hiding the close buttons on a sap.m.TabContainer by applying the noCloseButtons css class.

Hiding an individual close button

If we can add a custom css class to sap.m.TabContainer to hide all close buttons, then surely it should be possible to follow the same approach for an individual sap.m.TabContainerItem, right? Yes, it should, but sadly, we cannot. (The reason is that sap.m.TabContainer is a subclass of sap.m.Control, which provides a addStyleClass() method, whereas sap.m.TabContainerItem is a subclass of sap.ui.core.Element, which does not have such a method) Now, let's take a step back and think about how we used the css style class on the sap.m.TabContainer to hide all the close buttons. By setting the custom css style class on the sap.m.TabContainer, the html dom was changed to include the custom class, and we could then use that in a css selector. So even if we cannot apply a css style class to a sap.m.TabContainerItem, might there be another way that would allow us to influence how ui5 writes the html dom so we may write a css selector in our custom css? It turns out that such a feature exists in the shape of a feature called ui5 custom data. The custom data aggregation is provided by sap.ui.core.Element and thus available to its subclasses, including sap.m.TabContainerItem. A custom data item is an arbitrary key/value pair, and by setting its writeToDom property, ui5 will render it to the html dom as a html data attribute. To see what it looks like in our sample, take a look at TabContainerItemWithHiddenCloseButton.fragent.xml, which uses it to hide the close button in the second sap.m.TabContainerItem, in an otherwise normal sap.m.TabContainer:
<m:TabContainerItem
  id="item2"
  name="No Close Button"
>
  <m:customData>
    <core:CustomData writeToDom="true" key="noCloseButton" value="true"/>
  </m:customData>
  ...
</m:TabContainerItem>
Because the CusomtData's writeToDom property is set to true, ui5 will generate a html data attribute to the html dom that looks something like this:
data-noclosebutton='true'
And in our ui5-customization.css file, the following rule is intended to pick that up and hide the close button:
div.sapMTabContainer > .sapMTabStripContainer > .sapMTabStrip > .sapMTSTabsContainer > .sapMTSTabs > .sapMTabStripItem[data-noclosebutton='true'] > .sapMTSItemCloseBtnCnt {
  visibility: hidden;
}
As you can see it is very similar to the rule we used to hide all close buttons on any sap.m.TabContainer having the noCloseButtons class, except now the class is missing and instead we use a css predicate selector based on the data attribute:
.sapMTabStripItem[data-noclosebutton='true']
The screenshot below shows what it looks like in the app. Note that the tab named "Default" has the close button as usual, but the one named "No Close Button - the one with the custom data attribute - does not show a close button: Individual TabContainerItem with a hidden close button.

Showing an individual close button

The final hack in this sample combines the style class and the custom data attribute. CSS allows us to write a selector that takes both the presence of the css style class as well as the presence of a html data attribute into account. We can put this to good use if we want to have a sap.m.TabContainer that hides all close buttons by default, but undo the hiding of the close button of specific sap.m.TabContainerItem's, based on the value of a data attribute (which is in turn controlled by the ui5 Custom Data feature). You an see this in action in the TabContainerItemWithHiddenCloseButtons.fragment.xml file of the example:
<m:TabContainer 
  class="noCloseButtons"
>
  <m:items>

    <m:TabContainerItem name="Default">
     ...
    </m:TabContainerItem>

    <m:TabContainerItem name="Show Close Button">
      <m:customData>
        <core:CustomData key="noCloseButton" value="false" writeToDom="true" />
      </m:customData>
      ..
    </m:TabContainerItem>

  </m:items>
</m:TabContainer>
Again the second sap.m.TabContainerItem has a CustomData item with the key noCloseButton, but now the value is true so as to override the effect of the noCloseButtons style class applied to the sap.m.TabContainer. In our our ui5-customization.css file, the following rule is intended to pick that up and show the close button:
div.sapMTabContainer > .sapMTabStripContainer > .sapMTabStrip > .sapMTSTabsContainer > .sapMTSTabs > .sapMTabStripItem[data-noclosebutton='false'] > .sapMTSItemCloseBtnCnt {
  visibility: visible;
}
The screenshot below shows what it looks like when you run the sample application: Show the close button in an individual TabContainerItem, overriding the effect of the noCloseButtons style class of the sap.m.TabContainer.

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!

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...