Technology Blogs by Members
Explore a vibrant mix of technical expertise, industry insights, and tech buzz in member blogs covering SAP products, technology, and events. Get in the mix!
cancel
Showing results for 
Search instead for 
Did you mean: 
mike_zaschka
Active Participant
7,824
This is the third and final post of a small series of blog posts in which I'll delve into the conceptual and technical details of building a ChatGPT-like chat app using the SAP Cloud Application Programming Model, SAPUI5 and the OpenAI API. In the first blog post I introduced the required concepts of the GPT model and the usage of its API. In the second post we looked into the backend part covering the SAP Cloud Application Model in combination with TypeScript.

In this post I will finally look at the SAPUI5 based frontend. By doing so, I will be showing the overall structure of the application, and also deep dive into certain aspects of the implementation, which I think are worth exploring. I will heavily focus on the usage of TypeScript and also show some patterns to modernize SAPUI5, and give our application a clean structure right from the start.

In case you want to look at the real code, you can check it out, is's Open Source. The repository is hosted in the the public p36 GitHub account and also includes detailed instructions on how to set things up for local development and to deploy the app to SAP BTP Cloud Foundry.

=> Public GitHub Repository
A word of warning before we start:
While all blog posts of the series are quite long, this one will be over 25 minutes to read. That's a lot of time to invest, but if you are into SAPUI5, I would be really grateful, if you would take the time to read through the discussed hows and whys. And I am also eager to hear, if your thoughts on modern SAPUI5 are similar to my vision presented in the app and in this blog post.

The ChatGPT-like SAPUI5 app


If you are familiar with the original ChatGPT application, you won't have any problems working with our cover version. I tried to mimic most of the functionality, so you are able to create (and delete) chats and engage in a conversation with the AI in a typical chat-like manner. There are also some fancy features included, like code formatting and highlighting.


Screenshot of our ChatGPT-like SAPUI5 app


Some of the original functionality is missing in our app (e.g. giving feedback on a reply, deleting all chats at once), but we also got some additions: You can select a personality when creating a chat and the AI will be acting with the instructions of this chosen personality (Ayyy 🏴☠️, a pirate one too!).


Create chat dialog



Layout and controls


SAPUI5 features a very rich set of controls that can be used when building an application based on the SAP Fiori Guideline. While SAP Fiori does not contain a floorplan for having a chat-like interface, with a little creativity we can come up with our own.
This final application is using the following controls as its building blocks:


Mockup and used controls


The overall layout is provided by a FlexibleColumnLayout.
In the left part (aggregation beginColumnPages) we have our Chats-View including its ChatsController and in the right part (aggregation midColumnPages) we do have the Chat-View with its corresponding ChatController (we should have used List and Detail for better readability 🙃).
Both views contain a sap.m.List control and while the left one shows a list of Chats-Entities from the OData-Service (the list is bound to: /Chats), the right one contains the Messages from the selected and bound Chat (the binding simplified looks like this/Chats('ID')?$expand=messsages). The chat messages are rendered by a custom control, because there is no list item type available in the control library, that is capable of applying code formatting.
And finally there is the FeedInput control at the bottom of the page, which let's the user enter his requests. It is placed inside of the footer aggregation and styled with a little CSS.

Custom CSS


Custom CSS in SAPUI5 apps should be used with caution. While I agree with this, sometimes it's just necessary to overcome some creative-limitations by the Fiori guideline. And in other scenarios it is also mandatory to break out of Fiori almost completely and provide a fully customized UI. While this all is possible with SAPUI5, you should be aware, that changing the default styles has an impact on the maintenance effort when upgrading to newer SAPUI5 versions.

In our GPT app, we need to make the FeedInput in the footer a little bigger and due to the fixed height of the footer, we have to apply some custom CSS. To not alter every footer on every page, we apply a custom CSS (chagPage) class to the DynamicPage and therefore are scoping the styles to this one.
The following CSS is extracted from webapp/css/styles.css:
.sapUiSizeCompact .sapFDynamicPage.chatPage .sapFDynamicPageFooter .sapFDynamicPageActualFooterControl {
height: 6rem;
}

.chatPage .sapMInputBaseContentWrapper,
.chatPage .sapMFeedIn:not(.sapMFeedInDisabled) .sapMFeedInContainer,
.chatPage .sapMFeedIn {
background: transparent !important;
}

The folder structure


Before we we deep dive into the more technical stuff, let's quickly look at the folder structure.
On the root folder of the ui package (please read the last post on the overall project structure), we have the typical configuration files for the ui5-tooling and TypeScript. I won't go into the configuration details, but I would encourage you to also use both in your projects (I will provide many reasons for TypeScript later).

package.json
ui5.yaml
tsconfig.json
.babelrc.json


The standard folders you find in every application as part of the MVC pattern (/controller, /model, /view) are filled with the controllers and views of our app. When looking at the model folder, you may find some additional files (and a missing formatter.ts), which we will look at later.

webapp/controller/App.controller.ts
webapp/controller/BaseController.ts
webapp/controller/Chats.controller.ts
webapp/controller/Chat.controller.ts

webapp/view/App.view.xml
webapp/view/Chats.view.xml
webapp/view/Chat.view.xml
webapp/model/modles.ts
webapp/model/UserModel.ts
webapp/model/LayoutModel.ts
webapp/css/styles.css

But that's not it. There are even more files available in different subfolders, not being attached to the traditional SAPUI5 MVC:

webapp/util/Helper.ts
webapp/util/LayoutManager.ts
webapp/formatter/ChatFormatter.ts
webapp/control/ChatMessageListItem.ts
webapp/control/ChatMessageListItemRenderer.ts
webapp/control/ChatMessageListItem.gen.d.ts
webapp/types/ChatService.ts
webapp/fragment/NewChatDialog.fragment.xml
webapp/service/ChatService.ts
webapp/service/NewEntityDialog.ts


I won't go into detail on all of those files, but in the upcoming sections of this blog post, I will look at various ones and share its purpose and also some implementation aspects.
But before doing so, I want to emphasize on two major points, that are at the center of everything to come:

  • Why did I choose TypeScript for SAPUI5 and think you should too?

  • Why is it so important to break out of the SAPUI5 MVC and I think you should too?


Let's start with the first one:

Modern (!) SAPUI5 with TypeScript


Why did I choose TypeScript, even if it is (almost) nowhere to be found in the official documentation on https://ui5.sap.com and, according to the official documentation of the project, is still in an experimental state?
The answer consists of two parts:

  1. Because TypeScript is a game changer in SAPUI5 and you will get so many benefits over classic/old-school SAPUI5 (JavaScript), that you should prefer it in almost every scenario!

  2. Because it's here, it's mature and we at p36 and other companies are using it successfully in production!


To support my claim, let's look at our ChatGPT-like app and the following two code snippets as an example. They basically show the same functionality (first one is classic SAPUI5 JavasScript and the second one is SAPUI5 TypeScript) extracted from the ChatController. The code is not complete, but it shows the onPostMessage event handler which is being called when a user submits a new message, and the definition of a private function to scroll the browser down to the last message.

  • SAPUI5 JavaScript:


sap.ui.define([
"./BaseController", "sap/m/List"], function (BaseController, List) {
"use strict";

var ChatController = BaseController.extend("com.p36.capui5gptchat.controller.ChatController", {

/**
* @public
*/
onPostMessage: async function (oEvent) {
const sMessage = oEvent.getParameter("value");
const oChat = this.getView().getBindingContext().getObject();
const oBinding = this.getView().byId("messageList").getBinding("items");

await this.getChatService().createEntity(
{
text: sMessage.trim(),
model: oChat.model,
sender: this.getModel("user").getUser().displayName,
chat_ID: oChat.Id,
},
oBinding,
false,
true
);

//...
},

/**
* @private
*/
_scrollToBottom: function(timeout) {
//...
}
});

return ChatController;
});


  • TypeScript:


import BaseController from "./BaseController";
import List from "sap/m/List";
import UI5Event from "sap/ui/base/Event";
import ODataListBinding from "sap/ui/model/odata/v4/ODataListBinding";
import { IMessages, IChats } from "../types/ChatService";

/**
* @namespace com.p36.capui5gptchat.controller
*/
export default class Chat extends BaseController {

public async onPostMessage(event: UI5Event): Promise<void> {
const message = event.getParameter("value");
const chat = <IChats>this.getView().getBindingContext().getObject();
const binding = <ODataListBinding>this.getView().byId("messageList").getBinding("items");

await this.getChatService().createEntity<IMessages>(
<IMessages>{
text: message.trim(),
model: chat.model,
sender: (<UserModel>this.getModel("user")).getUser().displayName,
chat_ID: chat.ID,
},
binding,
false,
true
);

// ...
}

private scrollToBottom(timeout: number) {
// ...
}
}

Before we are looking at the code, a short history lesson:

SAPUI5 is locked out of modern JavaScript

Back in the old days when JavaScript did not have the features to provide real object-oriented patterns and a concept for referencing modules in the browser was absent, SAP introduced its own concepts and naming conventions, that were SAPUI5-specific, but state of the art at that time (probably years before its release in 2013).
But since then, JavaScript as a language really evolved and the JavaScript of today is a really powerful language with standardized solutions for the former issues. And while we can already use some awesome new JavaScript features in SAPUI5 (e.g. Promises, arrow-functions, let/const), others will be forever locked away, because their usage would interfere with some essential parts of the SAPUI5 framework. Those locked out features include basically everything around the real JavaScript class and module system (and some more).

Why does it matter?

Because I personally think that even with its old-school syntax, SAPUI5 is a very powerful enterprise-grade framework that can compete with many, if not all shiny other frameworks out there. But many of them provide support for modern JavaScript and the developer experience is simply better (it's easier to get into because of the well-known JavaScript-standards and using those is much more fun in certain areas). You can find discussions on this also in SAP Community. And it's not only the developers, but the general sense, that SAPUI5 is old-school and not a modern JavaScript framework.

TypeScript to the rescue

In case you don't know: TypeScript is a design-time only language designed specifically for easing the development. It's not a replacement for JavaScript, but something that sits on top of it only when you are developing. Before the code will be executed in the browser (or on the server via Node.js), the TypeScript code will be transpiled into real Javascript.

And when we talk about SAPUI5 with TypeScript, then it's just this: We write TypeScript (the code we see in the second listing above) and when the code is transpiled, we will actually get something out of the transpiler, that looks kind of similar to the code in the first listing. And this is working, because the SAPUI5 team provides a specific UI5-tooling (Babel transpiler) for SAPUI5, that does all the magic. And our experience is, that it is, even when still marked as experimental, working without any flaws.

Let's compare

By using TypeScript, we do get all the benefits of modern JavaScript (and even more!) right at our fingertips for developing SAPUI5 applications. Some examples from the above code:

  • Module system:


With TypeScript you can write imports in the well-established standard way that many other JavaScript frameworks use. And you cannot only import SAPUI5 related classes/modules, but also other external libraries (in combination with ui5-tooling-modules) in the exact same way.
// JavaScript: Proprietary SAPUI5 module loader
sap.ui.define([
"./BaseController", "sap/m/List"], function (BaseController, List) {...}

// TypeScript: Well known import concept introduced with ES6
import BaseController from "./BaseController";
import List from "sap/m/List";


  • Class definitions and inheritance:


While SAPUI5 has its own way of defining class-like objects, with TypeScript you can actually define real classes and use advanced things like inheritance (via extends) or even things completely absent in SAPUI5, like Interface definitions, abstract classes and many more.
// JavaScript: Proprietary SAPUI5 class definition with inheritance
return BaseController.extend("com.p36.capui5gptchat.controller.ChatController", {...}

// TypeScript: standard class definition, inheritance and export
export default class Chat extends BaseController {...}


  • Private functions and member access:


The class system in SAPUI5 does not provide any functionality to define public, protected or private functions. There is a naming convention to name private members of a class with a beginning underscore, but it is, well, a naming convention. TypeScript supports real member access for functions and also variables out of the box.
// JavaScript: public function simply named by convention  
_scrollToBottom: function () {...}

// TypeScript: real private function not accessiable from the outside
private scrollToBottom () {...}

So that's just three examples taken from the above code listing and while those already show some of the benefits of using TypeScript, let's look at the real game changer:

In the above JavaScript code, there is a tiny bug:
In the onPostMessage function, we want to create a new message and have to fill in the chat_ID parameter, to assign the message to the current chat. But sadly, there is no Id field present on the oChat object (it's ID).
      await this.getChatService().createEntity(
{
text: sMessage.trim(),
model: oChat.model,
sender: this.getModel("user").getUser().displayName,
// Whoopsie, this should be ID
chat_ID: oChat.Id,
},
oBinding,
false,
true
);

This small typo will cause the message to be created without a chat reference and the message simply will not show up in the UI. And it will then take me some time to analyze the issue and potentially find the bug, frustrated, that I lost another valuable hour of my life on this. (Arrr, me matey! Spendin' time on unimportant things be a waste of good rum and lead to a mutiny among the crew.🏴☠️)

When using TypeScript, things like this simply will not happen!

The benefits of having a real type system with TypeScript


JavaScript does not have a type system at all and you have the full freedom to use variables in any way you want. Even if this will break things at run-time. SAPUI5 therefore introduced a naming scheme (Hungarian notation) to prefix all variables based on their type (e.g. sValue, oBindingContext).
I was never a fan of this, because I think this really hurts the readability of the code (and others share my opinion), but it is still around and part of the best practices. But again, those are not real types, just naming conventions, and even if I have an object called oChat, I don't know anything about it, other than it is (probably) an object.

TypeScript on the other hand does have real types and that's basically the reason it exists. In TypeScript I can (and should) assign a type to every variable that I work with to exactly know, how it is defined. For TypeScript beginners, this may seem as a initial burden, because it can slow you down to always define the types first. But in the long run, types are an absolut game changer!
To proof this, let's look at that bug:

In the above TypeScript variant of the code, we basically cast the bound object to a defined interface (basically another form of type) IChats
import { IChats } from "../types/ChatService";

const chat = <IChats>this.getView().getBindingContext().getObject();

IChats is defined like this:
export interface IChats {
ID: string;
createdAt?: Date;
createdBy?: string;
modifiedAt?: Date;
modifiedBy?: string;
topic: string;
model: string;
personality?: IPersonalities;
personality_ID?: string;
messages: IMessages[];
}

So we explicitly know, that the variable chat contains an ID field (not  Id). And by we, I am referring to the development environment (in my case Visual Studio Code) as well, since this is also able to read the type definitions. When I then try to use the wrong Id property, then I will get the following precise error message and I am immediately able to fix this and spend the saved hour of lifetime on something more meaningful than finding bugs (Shiver me timbers! I'll be spendin' me time on seekin' out treasure like any self-respectin' pirate would do! 🏴☠️).


Type check on design-time finding my error


And it's not ending on simple object types. How many times have you scrolled the SAPUI5 API reference to look for methods and/or properties on classes you are using in your code (e.g. ODataListBinding)? You don't have to any more, because the type definitions available in your IDE contain all the information you need, for your own classes and the whole SAPUI5 library.


Type definitions of the SAPUI5 framework



Usage of the types from our OData ChatService in SAPUI5


But that's still not it, there is one additional selling point for TypeScript to be used in our ChatGPT SAPUI5 app:

We also used TypeScript on the backend part of the application in combination with CAP. And by using cds2types, we were able generate the type definitions for our whole data model including the database and OData service layer automatically. What if we just could re-use those typings and have the complete OData service definition including all entities, functions, etc. available in SAPUI5?
Well, we already do. The referenced file webapp/types/ChatService.ts contains the complete definitions of the ChatService and the file is being copied over from the server package when we change something in the cds files. So, whenever we have to deal with objects/structures from the backend, we can fully rely on type-safety in SAPUI5.


Example of the usage of the ChatService typings for the getCompletion response



TLDR;


That was longer than initially planned, but I really wanted to provide proof for my claim on TypeScript. By using TypeScript in our SAPUI5 project...:

  • we are able use modern, state-of-the-art JavaScript for coding in SAPUI5

  • we get all the benefits of type definitions (also for the whole SAPUI5 framework)

  • we can re-use the type definitions from the CAP backend and have an exact definition of the OData API at hand while coding


A closer look at some aspects of the ChatGPT-like SAPUI5 app


After talking a lot about TypeScript, let's look at the real application. And while it is actually not that complex, I think there are some things worth talking about. Especially when we look at the structure of the project and some code examples.

But before that, I have to give another short history lesson, this time regarding the still unanswered second question from above:

Why is it so important to break out of the MVC in SAPUI5?


I have worked with SAPUI5 since 2016 and especially in my early years, I dealt great damage to me and others, because I strictly followed the still existing best practice for the file and folder structure provided by the official documentation. And while building complex applications, I simply missed on the opportunity to break out of this MVC pattern early and ended up with a nearly un-manageable mess.
I started with simple event handlers in my controllers, but then I needed to fetch additional data, transform the data into complex tree structures and many other things. And because there was no other place in this MVC pattern for this, I placed the code in my controller. Later I proudly introduced a BaseController to include the shared stuff and I thought that MVC and (multi-)inheritance in combination must be good software design.
Long story short: The final product had controllers with over 2.000 lines of code and the BaseController another staggering 1.500. And I still feel sorry for the guys that went on to maintain this mess (May they still be sailin' the seven seas  🏴☠️).
But it was not just me and over time, I stumbled across many different applications sharing the same issue.

When we now look back at the folder structure of the ChatGPT-like UI5 app above, then we may notice, that I  seem to have learned from those mistakes. There are not only models, views and controllers, but also service and util classes and even in the models section, there are other files then just the generic models.ts. While our ChatGPT-like app is nowhere near as complex as those applications from the past, I explicitly built it in a way to showcase best practices, that I learned over the years and that we at p36 also use as good-practice patterns in our SAPUI5 applications.

Let's start with the controllers:

Controllers


In our ChatGPT app, we do have several controllers, but none has more then 100 lines of code. The reason for this is, that we do not want to end up with an un-manageable mess. And therefore, we follow strict rules. Our controllers should only include logic for:

  • event handlers directly related and specific to the controllers view and controls

  • other event handlers directly related to the controller (e.g. routing)


We explicitly don't want this to be part of our controllers:

  • formatter functions

  • factory functions

  • other dependent functionality (e.g. reading/writing data directly to/from services, data transformations, etc.)

  • any other stuff, that is not meant to live in a controller


As an example, this is our most complex controller, the ChatController. And it simply contains event handlers for UI events and the router, a private function to scroll down after the list of messages is re-rendered and another private function to enable keyboard shortcuts to submit a message.
Of course, there is other stuff around in our ChatGPT-like app, that the former me would have put in the controller, but this it is now located somewhere else.

And it may just be a personal thing, but I really really like how our controller class looks with its standard JavaScript class system, strong types, no Hungarian Notation anywhere and even other JavaScript people without any knowledge of SAPUI5 would probably agree, that this is looking like modern code.
import BaseController from "./BaseController";
import UI5Event from "sap/ui/base/Event";
import Helper from "../util/Helper";
import Context from "sap/ui/model/odata/v4/Context";
import { IMessages, IChats, Sender } from "../types/ChatService";
import FeedInput from "sap/m/FeedInput";
import ODataListBinding from "sap/ui/model/odata/v4/ODataListBinding";
import UserModel from "../model/UserModel";
import List from "sap/m/List";
import ChatService from "../service/ChatService";

/**
* @namespace com.p36.capui5gptchat.controller
*/
export default class Chat extends BaseController {
public onInit(): void {
this.getRouter().getRoute("chat").attachPatternMatched(this.onRouteMatched, this);
}

public onAfterRendering(): void {
this.addKeyboardEventsToInput();
(<List>this.getView().byId("messageList")).addEventDelegate({
onAfterRendering: () => {
this.scrollToBottom(100, "auto");
},
});
}

private onRouteMatched(event: UI5Event): void {
const { chat } = event.getParameter("arguments");
this.getView().bindElement({
path: `/Chats(${chat})`,
});
}

public onDeleteChat(event: UI5Event): void {
Helper.withConfirmation("Delete Chat", "Are you sure you want to delete this chat?", async () => {
await ChatService.getInstance().deleteEntity(<Context>this.getView().getBindingContext());
this.getRouter().navTo("home");
});
}

public async onPostMessage(event: UI5Event): Promise<void> {
const message = event.getParameter("value");
const chat = <IChats>this.getView().getBindingContext().getObject();
const chatService = ChatService.getInstance();
const binding = <ODataListBinding>this.getView().byId("messageList").getBinding("items");

await chatService.createEntity<IMessages>(
<IMessages>{
text: message.trim(),
model: chat.model,
sender: (<UserModel>this.getModel("user")).getUser().displayName,
chat_ID: chat.ID,
}, binding, false, true
);

const completion = await chatService.getCompletion({
chat: chat.ID,
model: chat.model,
personality: chat.personality_ID,
});

await chatService.createEntity<IMessages>(
<IMessages>{
text: completion.message,
model: chat.model,
sender: Sender.AI,
chat_ID: chat.ID,
}, binding, false, true
);
}

private scrollToBottom(timeout: number, behavior: ScrollBehavior = "smooth"): void {
setTimeout(() => {
const items = (<List>this.getView().byId("messageList")).getItems();
items[items.length - 1].getDomRef().scrollIntoView({ behavior: behavior });
}, timeout);
}

private addKeyboardEventsToInput(): void {
const input = <FeedInput>this.getView().byId("newMessageInput");
input.attachBrowserEvent("keydown", (event: KeyboardEvent) => {
if (event.key == "Enter" && (event.ctrlKey || event.metaKey) && input.getValue().trim() != "") {
input.fireEvent("post", { value: input.getValue() });
input.setValue("");
event.preventDefault();
}
});
}
}

You could argue, that the onPostMessage is a little large and the creation of those messages could be extracted into another Domain class. And in a more complex application I potentially would have agreed. But let's make it not too complex for now, since I want to focus on some other essentials.

BaseController


Our ChatController is extending our BaseController and this one contains the basic stuff that you would expect it to have: shortcut functions to the owner component and the view (getModel(), etc.). But that's about it. In more complex applications our BaseController might include some additionally shared functionality between a set or all controllers, that cannot be implemented with the patterns, I am going to explain next.

Formatters


Formatters are used in views to apply a specific format to a bound property value. In our app, we for example use a formatter to display a different icon in a message dependent on the sender. You can include the formatter function inside of a controller, but as stated above, we should not do that. Instead it is a common practice, to specify a generic formatter class/module and reference this as the XML view.
In our scenario, we don't go the generic route, but we created a specific class for all chat related stuff in formatters/ChatFormatter.ts and we would likely add other formatter classes in a more complex application.
Please follow this approach and do not put all formatters into model/formatter.ts. A formatter mess is not as bad as a controller one, but you should still avoid it.
Our simplified formatter class looks like this (and it is also using the CAP types by the way 😁😞
import { Sender } from "../types/ChatService";

export default class ChatFormatter {

public static senderIcon(sender: string): string {
return sender === Sender.AI ? "sap-icon://tnt/robot" : "sap-icon://tnt/user";
}

}

It's a known pattern, to require the formatter inside of your controller and make it available, as a formatter variable, that will be referenced in the views. But this is only necessary, when you also want to use a formatter function inside of your controller.
If not (probably in almost all cases), you can get rid of the reference, by using require modules in the views:
<mvc:View controllerName="com.p36.capui5gptchat.controller.Chat" 
xmlns:core="sap.ui.core"
xmlns="sap.m"
core:require="{
ChatFormatter: 'com/p36/capui5gptchat/formatter/ChatFormatter'
}"
xmlns:layout="sap.ui.layout" height="100%">

<!-- .... -->
<Avatar src="{ path: 'sender', formatter: 'ChatFormatter.senderIcon' }" />
<!-- .... -->

</mvc:View>

By requiring the formatter directly in the view, we don't need to hold any reference in our controllers and simply include the formatter in the place, where it's being used. This is especially useful in more complex applications, where you have many different formatters and don't need to spoil your controller with many formatter-variables.

Utility classes in XML views


But the benefits of using the require modules pattern in XML views are not stopping there. Often times you have a functionality, that is not really specific to the view/controller and can be designed in a non-specific way.  An example from our app:
Since we are using the FlexibleColumnLayout, we can switch a column to full screen mode. This behavior needs to be triggered via event and is often bound to a Button control and handled in the press-event callback. Many times, this event will be handled in a controller and it does nothing more, than updating a global JSONModel.
In our ChatGPT app, we extracted all the layout management into its own utility class, the LayoutManager. I will not go into all the little details of the class here, but only mention, that our LayoutManager follows the Singleton pattern. So there is only one LayoutManager instance existing in our application. And because I don't need any reference for this in my controller, I can use the LayoutManager instance directly in my view:
<mvc:View controllerName="com.p36.capui5gptchat.controller.Chat" 
xmlns:core="sap.ui.core"
xmlns="sap.m"
core:require="{
LayoutManager: 'com/p36/capui5gptchat/util/LayoutManager',
}"
xmlns:layout="sap.ui.layout" height="100%">

<!-- Usage with Singleton to not call static methods -->
<Button icon="sap-icon://full-screen" press="LayoutManager.getInstance().setMidColumnFullScreen()" />
<!-- .... -->

</mvc:View>

The event handling in XML views allows for calling static functions on required classes. And since we are using a singleton, we can also call instance methods. That's very convenient and so my controller is not involved at all. And while the LayoutManager is just an example in our chat app, there may be other potential uses cases like a generic Routing, Popover.... Handler.

The whole event handling in XML views can be especially useful, because you can not only call methods, but also pass in different kind of parameters, like static values, the actual $event, the views' $controller or even the value of a binding (${ID} refers to the ID attribute of the bound object).
    <!-- Potentially example usage with parameters -->
<Button press="RoutingHandler.getInstance().navTo('chat', ${ID}, $event, $controller)" />
<!-- .... -->

It is a very powerful feature to directly call included classes/modules, without routing everything through a controller function. But it should also be used with caution, to not grow your views into a mess containing too complex logic.

Utility classes in controllers


Another way to get code out of the controllers is via helper functions, for tasks like generic confirmations or even dialogs.

Callback helpers


In our app it is possible to delete an existing chat. But when the user clicks on the delete button, we want to play this safe and ask for a confirmation. Instead of handling everything in the controller, our controller function just looks like this:
  public onDeleteChat(event: UI5Event): void {
Helper.withConfirmation("Delete Chat", "Are you sure you want to delete this chat?", async () => {
await ChatService.getInstance().deleteEntity(<Context>this.getView().getBindingContext());
this.getRouter().navTo("home");
});
}

And the corresponding helper class (util/Helper.ts) contains a simple generic way to open a confirmation dialog, asking the user to confirm. And only if the user clicks on Okay, the passed in callback function will be triggered for the controller.
export default class Helper {

public static withConfirmation(title: string, text: string, callback: () => void): void {
MessageBox.confirm(text, {
title,
onClose: (action: Action) => {
if (action === Action.OK) {
callback();
}
},
});
}

}

Using callback helpers to extract code out of controllers, while still having the possibility to get the scope back is just one option. With the power of Promises, there are even more advanced things possible.

Promise-based async helpers


Fragments in SAPUI5 are a really powerful feature to not load complete views, but only a specific control tree (in many cases a Popover or a Dialog with its content). And while you can assign a different class as the controller of the Fragment to handle its events, most of the time this will be the controller which loaded the fragment. But we want our controllers clean, so here is another approach:

In our ChatGPT-like app we open a dialog, when the user wants to create a new chat. The user can then select the topic, model and personality and either confirm to create the chat or cancel and the dialog will close itself. The whole logic including everything related to the creation of a new chat is encapsulated in its own class NewEntityDialog. A high level summary of the following code:

  • We simply return a Promise when the dialog should be opened (open-function), so the controller will wait for the Promise to either be resolved or rejected.

  • We capture the corresponding resolve and reject functions as instance variables inside of our class and do not execute neither, until we have created a chat or the user canceled.

  • We will then load the Fragment, open the dialog and all related events will be handled by our class.

  • If the user confirms the creation and the chat has finally been created in the backend, only then will we resolve the Promise and the control flow will be returned to the controller.

  • In case the user closes the dialog, we simply reject the Promise and the flow will also be returned to the controller, but with an error.


export default class NewEntityDialog {
private resolve: (args: any) => void;
private reject: (error: any) => void;
private dialog: Dialog;
private model: ODataModel;

constructor(private context: Context, private fragment: string, private view: View) {
this.model = <ODataModel>this.context.getModel();
}

public async open(): Promise<Context> {
return new Promise(async (resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
this.dialog = <Dialog>await Fragment.load({
id: "newEntityDialog",
name: `com.p36.capui5gptchat.fragment.${this.fragment}`,
controller: this,
});
this.view.addDependent(this.dialog);
this.dialog.setBindingContext(this.context);
this.context.created().then(() => {
this.dialog.close();
this.resolve(this.context);
}, reject);

this.dialog.open();
});
}

public async onCreate(): Promise<void> {
await this.model.submitBatch(this.model.getUpdateGroupId());
}

public onCancel(): void {
this.dialog.close();
this.reject({ error: "User cancelled" });
}
}

With all the logic being handled inside of the NewEntityDialog instance, the controller code (extracted from controller/Chats.controller.ts) looks as simple as this:
  public async onAddChat(event: UI5Event): Promise<void> {

// ...
const dialog = new NewEntityDialog(context, "NewChatDialog", this.getView());
context = await dialog.open().catch(() => {
// User canceled
});
// Chat created, continue
}

It's almost not getting cleaner than that.

Service classes


The above file is placed under /service, because it contains functionality to send and read data from the OData service. In our ChatGPT-like app, we are using CAP as the OData-service backend and are therefore fully relying on sap.ui.model.v4.ODataModel. Because the ODataV4-support in SAPUI5 is super convenient, we can in almost all cases fully rely on the data binding and the framework will handle the rest. The NewEntityDialog is one exception and the other use cases are captured inside of the ChatService class, that is meant to contain all the functionality required to directly talk to the OData API from our controllers. So again, we keep our controllers clean by providing a simple interface through our ChatService class.

Again, I will not cover every line of code you will find in this class, but highlight one use case showcasing the super power of using modern Javascript (Promise), TypeScript (Generics) in combination the ODataModel, to create a super convenient, type-safe API, to be used in a controller:
The createEntity method will receive an entity of a to-be-defined type and an ODataListBinding as parameters. The function will return a Promise, which will be resolved with the returned data once the entity has been created by the ODataModel or rejected, in case of an error.
export default class ChatService {

public createEntity<T extends Object>(
entity: T,
binding: ODataListBinding,
😞 Promise<T> {
return new Promise((resolve, reject) => {
const context = binding.create(entity);
context.created().then(() => {
resolve(context.getObject());
}, reject);
this.model.submitBatch(this.model.getUpdateGroupId());
});
}

}

The full code to use this function in a controller is like looking like this:
import ODataListBinding from "sap/ui/model/odata/v4/ODataListBinding";
import { IMessages } from "../types/ChatService";

const chat = <IChats>this.getView().getBindingContext().getObject();
const chatService = ChatService.getInstance();
const binding = <ODataListBinding>this.getView().byId("messageList").getBinding("items");

const createdMessage = await chatService.createEntity<IMessages>(
<IMessages>{
text: "Example message",
model: chat.model,
sender: (<UserModel>this.getModel("user")).getUser().displayName,
chat_ID: chat.ID,
},
binding
).catch((error) => { /* something went wrong */ });

We grab the ChatService instance and the binding from the list of messages, and then call the createEntity function with a simple object typed as IMessages (a type we received from the CAP backend). Because of the dynamic nature of the createEntity function, we can simply pass in any valid type in combination with a correct binding, and the rest will be fully handled by the ChatService class and the OData model.
When the message finally has been created in the OData service, it will be returned to the controller including all of its properties. And because we specified the incoming type, we will receive the same type back.


Type-safety even when using the generic createEntity function


This might not look spectacular at first sight, but is actually is! With only a few lines of code, we do get a generic-typed create method for ALL types of entities to be used in our controllers.
And there are even more exciting things to discuss on this, but this would break the boundaries of this blog post.

And there is even more


I could continue to share my excitement, but since I somehow seem to got lost in all the joy of using SAPUI5 with TypeScript and the blog post is already becoming as bloated as my controllers in 2016, I probably should stop here.
When you look at the source code on GitHub, you will find some more interesting patterns, that I think are worth looking at:

  • Custom JSONModel classes that extend the standard JSONModel to partially introduce type-safety and move code directly into the model instead of the controller

  • Custom controls with the usage of external Node.js modules, that can easily be integrated into SAPUI5


Please feel free, to look at the sources and use GitHub to ask questions/give comments.

Closing comment


Throughout the journey of this small blog series, we used our ChatGPT-like app as the container to take a look behind the disruptive GPT models and its API. In the second and this last post, I took a technical deep dive on the app with the motivation, to showcase the usage of SAP CAP and SAPUI5 as modern SAP technologies.

To put the emphasis on modern, I spent a good amount of time discussing patterns and good practices and also the motivation behind. This actually took way longer than initially planned, but I nevertheless wanted to bring up many of those topics, because:

  • I really think that SAP CAP and SAPUI5 are great technologies and even when they seem to  somehow look old-fashioned (at least SAPUI5) when being compared to other frameworks, they can be used in a very modern way.

  • I really want to showcase and discuss some patterns, architectures and best practices, that I think are worth looking at, if you are using and/or learning those tools.


But I am also very interested in your thoughts, on all of those topics. Do you already use TypeScript in SAPUI5 and CAP? What are your experiences? Are you using other patterns, then the ones I discussed? Or do you have valid points to criticize some of them? And even when looking at the app and ChatGPT? Are you in on this?

In any case, thank you for joining me on this journey and... fair winds and calm seas to ye, me hearty! I be wishin' ye all the best on yer journey, wherever it may lead ye! 🏴☠️
6 Comments
Labels in this area