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.
FlexibleColumnLayout
.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 🙃).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.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.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.webapp/css/styles.css
:.sapUiSizeCompact .sapFDynamicPage.chatPage .sapFDynamicPageFooter .sapFDynamicPageActualFooterControl {
height: 6rem;
}
.chatPage .sapMInputBaseContentWrapper,
.chatPage .sapMFeedIn:not(.sapMFeedInDisabled) .sapMFeedInContainer,
.chatPage .sapMFeedIn {
background: transparent !important;
}
package.json
ui5.yaml
tsconfig.json
.babelrc.json
/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
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
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.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;
});
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) {
// ...
}
}
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).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";
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 {...}
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 () {...}
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
);
sValue
, oBindingContext
).oChat
, I don't know anything about it, other than it is (probably) an object.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[];
}
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! 🏴☠️).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.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?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.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.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.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();
}
});
}
}
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.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.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.formatters/ChatFormatter.ts
and we would likely add other formatter classes in a more complex application.model/formatter.ts
. A formatter mess is not as bad as a controller one, but you should still avoid it.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";
}
}
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.<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>
formatter
-variables.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
.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>
LayoutManager
is just an example in our chat app, there may be other potential uses cases like a generic Routing, Popover.... Handler.$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)" />
<!-- .... -->
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");
});
}
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();
}
},
});
}
}
Promises
, there are even more advanced things possible.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:NewEntityDialog
. A high level summary of the following code:Promise
when the dialog should be opened (open
-function), so the controller will wait for the Promise
to either be resolved or rejected.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.Fragment
, open the dialog and all related events will be handled by our class.Promise
and the control flow will be returned to the controller.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" });
}
}
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
}
/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.Promise
), TypeScript (Generics) in combination the ODataModel, to create a super convenient, type-safe API, to be used in a controller: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());
});
}
}
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 */ });
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.JSONModel
classes that extend the standard JSONModel
to partially introduce type-safety and move code directly into the model instead of the controllerYou must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
User | Count |
---|---|
13 | |
10 | |
7 | |
7 | |
7 | |
6 | |
6 | |
5 | |
5 | |
5 |