This repository is a step-by-step guide explaining how to build a UI5 freestyle bookshop application on top of the booskop CAP server from the Cloud CAP Samples project.
The recorded session with a live-walkthrough can be found on YouTube: 🟠 UI5 Freestyle: TypeScript E2E Development - from zero to hero in 1hr!.
Make sure to have a current supported version of Node.js installed, and a modern JavaScript IDE such as VSCode or use a hosted IDE such as SAP Business Application Studio.
To create projects, install Yeoman and Easy-UI5 globally to your Node.js environment:
npm install -g yo generator-easy-ui5
To run the CAP server locally, you should also install the CAP SDK globally:
npm install -g @sap/cds-dk
Interested in more details about CAP, just got here....
Download the ZIP of the Cloud CAP Samples repository:
- Direct link to zip: https://github.com/SAP-samples/cloud-cap-samples/archive/refs/heads/main.zip
- Unpack the archive and copy the
bookshop
directory into your projects' root directory
Your folder structure should look like that now:
<root>
└── bookshop
To make the bookshop server usable, you need to run npm install
in the bookshop
directory.
Now you can test the bookshop
server by running npm start
and open the welcome page http://localhost:4004.
It has already a bookshop application based on Vue.js built-in - but we prefer OpenUI5/SAPUI5 to build our application! 😉
Let's stop the server again and proceed...
To create the UI5 application, we are using Easy-UI5 with the available community templates. For application development in TypeScript, we are using the ts-app
template. Run the following command in your project root:
yo easy-ui5 ts-app
Answer the questions (just set the namespace to devtoberfest.app
, for the rest use the defaults) but skip the Git initializiation of the repository.
Your folder structure should look like that now:
<root>
├── bookshop
└── devtoberfest.app
The project is fully created and you can run the following commands in the project:
# running the devserver
npm start
npm run start-cdn # load UI5 from CDN
# building the project (create the dist)
npm run build
# start from dist
npm run start:dist
npm run start:dist-cdn # load UI5 from CDN
# testing the project:
# - executs lint
# - functional and integration tests + code coverage
npm test
npm run karma # test-driven development
# system tests (requires the app to run already)
npm run wdi5
# type checking
npm run ts-typecheck
The source code of the project is completely written in TypeScript. The package.json
includes the types, the tsconfig.json
contains the configuration for Typescript and the ui5.yaml
is using the ui5-tooling-transpile
to transpile the project back into JavaScript.
In your IDE you will notice code completion support, lint checks (ESLint and TypeScript), Quick Fixes support, and some specialities for UI5: ES modules and ES classes are used in TypeScript + JSDoc tags is needed as instructions for the transpiler regarding the namespace. The GitHub project SAP-samples/ui5-typescript-tutorial explains all that in depth.
Due to CORS, the bookshop server can't be used remotely. The most familar option to integrate the bookshop server is using a proxy. But for CAP an easier option is available: ui5-middleware-cap
.
Install the middleware in your app as a devDependency
:
npm i -D ui5-middleware-cap
Add the bookshop server as devDependency
:
npm i -D @capire/bookshop@../bookshop
Configure the middleware in your ui5.yaml
:
specVersion: "4.0"
metadata:
name: devtoberfest.app
type: application
[...]
server:
customMiddleware:
- name: ui5-middleware-cap
afterMiddleware: compression
If you run your devserver now with npm start
you will see that also the CAP server is starting up in the log. The services will be available under the umbrella of the UI5 devserver, e.g.:
- books service: http://localhost:8080/browse/$metadata
- user service: http://localhost:8080/user/$metadata
We are ready to consume the services in our UI5 application.
Open the manifest.json
of the application and add the following entries:
{
"sap.app": {
[...]
"dataSources": {
"bookshop": {
"uri": "/browse/",
"type": "OData",
"settings": {
"odataVersion": "4.0"
}
}
}
[...]
"sap.ui5": {
[...]
"models": {
"": {
"dataSource": "bookshop",
"preload": true,
"settings": {
"groupId": "$auto",
"synchronizationMode": "None",
"operationMode": "Server",
"autoExpandSelect": true
}
},
[...]
}
In the Main.view.xml
add the sap.m.Table
control:
<Table id="booksTable" items='{/Books}'>
<columns>
<Column>
<Text text="Book" />
</Column>
<Column>
<Text text="Author" />
</Column>
<Column>
<Text text="Genre" />
</Column>
<Column>
<Text text="Price" />
</Column>
<Column>
<Text text="Stock" />
</Column>
</columns>
<items>
<ColumnListItem vAlign="Middle" type="Active">
<cells>
<ObjectIdentifier title="{title}" />
<Text text="{author}" />
<Text text="{genre/name}" />
<ObjectNumber number="{price}" unit="{currency_code}" />
<ObjectStatus text="{stock}" state="{=
${stock} >= 20 ? 'Success' : ${stock} > 0 ? 'Warning' : 'Error'
}" />
</cells>
</ColumnListItem>
</items>
</Table>
In the Main.view.xml
add the following to the books cell:
<HBox>
<Text text="Book" />
<core:Icon src="sap-icon://sort-ascending" class="sapUiSmallMarginBegin" press=".onBooksSort" />
</HBox>
Hint: CSS Margin Classes to position controls
adopt the binding of the books to sort ascending by default:
<Table id="booksTable" items='{
path: "/Books",
sorter: {
"path": "title",
"ascending": "true"
}
}'/>
In the Main.controller.ts
, add the following code:
export default class Main extends BaseController {
private _booksTable: Table;
onInit(): void {
this._booksTable = this.byId("booksTable") as Table;
}
public onBooksSort(event: Icon$PressEvent): void {
const asc = event.getSource().getSrc() === "sap-icon://sort-ascending";
const itemsBinding = this._booksTable.getBinding("items") as ODataListBinding;
itemsBinding.sort(new Sorter("title", asc));
event.getSource().setSrc(asc ? "sap-icon://sort-descending" : "sap-icon://sort-ascending");
}
}
In the Main.view.xml
add the following code below the table:
<Panel id="bookDetails" headerText="Details" visible="false" class="sapUiSmallMarginTop" >
<VBox>
<Label text="Title" />
<Text text="{title}" />
<Label text="Description" class="sapUiSmallMarginTop" />
<Text text="{descr}" />
</VBox>
</Panel>
and add a press handler to the ColumnListItem
:
<ColumnListItem vAlign="Middle" type="Active" press=".onBookSelect">
In the Main.controller.js
add the following event handler to display the book details:
public onBookSelect(event: ListItemBase$PressEvent): void {
const listItem = event.getSource();
this._bookDetails.setVisible(true);
this._bookDetails.bindElement(listItem.getBindingContext().getPath());
}
and extend the onInit
callback to also store the reference to the book details Panel
:
private _booksTable: Table;
private _bookDetails: Panel;
onInit(): void {
this._booksTable = this.byId("booksTable") as Table;
this._bookDetails = this.byId("bookDetails") as Panel;
}
Login Action
Add the user
model to the manifest.json
:
{
"sap.app": {
[...]
"dataSources": {
[...]
"user": {
"uri": "/user/",
"type": "OData",
"settings": {
"odataVersion": "4.0"
}
}
}
[...]
"sap.ui5": {
[...]
"models": {
[...]
"user": {
"dataSource": "user",
"preload": true,
"settings": {
"groupId": "$direct",
"synchronizationMode": "None",
"operationMode": "Server"
}
},
[...]
}
Hint: we use
$direct
to disable the batch request for the login operation.
In the Component.ts
init lifecycle method we add the login handling using Deferred Action Binding.
public init(): void {
// call the base component's init function
super.init();
[...]
// login via Deferred Action Binding
const oLoginAction = this.getModel("user").bindContext("/login(...)") as ODataContextBinding;
oLoginAction.invoke().then(() => {
this.getModel("user").refresh();
}).catch((err) => {
MessageBox.error("Failed to login.\nReason: " + err);
});
}
In the Main.view.xml
we want to display the user using expression binding syntax:
<Page title="{i18n>appTitle} (User: {= ${user>/me/id} || 'anonymous'})" ... />
Important is that you must not use async function for lifecycle methods as described in the ECMAScript Support in the Demo Kit.
Verify the basic auth login in a new incognito window.
Order Books
In the Main.view.xml
add an order button to the book details panel in the VBox
after the description:
<Button text="Order" press=".onBookOrder" class="sapUiSmallMarginTop" />
In the Main.controller.ts
we add the event handler to trigger the order:
public async onBookOrder(event: Button$PressEvent): Promise<void> {
const oAction = this.getModel().bindContext("/submitOrder(...)") as ODataContextBinding;
oAction.setParameter("book", event.getSource().getBindingContext().getProperty("ID"));
oAction.setParameter("quantity", 1);
try {
await oAction.invoke();
this.getModel().refresh();
} catch (err) {
MessageBox.error("Failed to order the book.\nReason: " + (err as Error).message);
}
}
To create the UI5 library, we are using Easy-UI5 with the available community templates. For application development in TypeScript, we are using the ts-app
template. Run the following command in your project root:
yo easy-ui5 ts-library
Answer the questions (just set the namespace to devtoberfest.lib
, for the rest use the defaults) but skip the Git initializiation of the repository.
Your folder structure should look like that now:
<root>
├── bookshop
├── devtoberfest.app
└── devtoberfest.lib
Similar like the UI5 application in TypeScript, the library contains many scripts.
To integrate thirdparty libraries and benefit from TypeScript, just install them as dependencies. The tooling extension ui5-tooling-modules
enhances the UI5 tooling to easily consume NPM packages in UI5.
Hint: the NPM packages must be dependent on Node.js!
Install the ui5-tooling-modules
tooling extension as devDependency
:
npm i -D ui5-tooling-modules
In the ui5.yaml
we configure the middleware and the task:
specVersion: "4.0"
metadata:
name: devtoberfest.lib
type: library
[...]
builder:
customTasks:
- name: ui5-tooling-modules-task
afterTask: replaceVersion
[...]
server:
customMiddleware:
- name: ui5-tooling-modules-middleware
afterMiddleware: compression
[...]
Install the dependencies to Chart.js:
# Install chart.js as dependency (so that it can be found by ui5-tooling-modules)
npm i chart.js
# Install the types as devDependency
npm i -D @types/chart.js
Now we can start the app and consume the chart.js as UI5 AMD-like module!
You can see the result by opening the chart.js/auto module via: http://localhost:8080/resources/chart.js/auto.js
Add Chart.ts
into the src
folder:
/*!
* ${copyright}
*/
import "./library";
import Control from "sap/ui/core/Control";
import { ChartData, Chart as ChartJS } from "chart.js/auto";
import { MetadataOptions } from "sap/ui/base/ManagedObject";
import ChartRenderer from "./ChartRenderer";
/**
* Constructor for a new <code>devtoberfest.lib.Chart</code> control.
*
* Some class description goes here.
* @extends Control
*
* @author You
* @version ${version}
*
* @constructor
* @public
* @namespace devtoberfest.lib
*/
export default class Chart extends Control {
private chart: ChartJS;
static readonly metadata: MetadataOptions = {
library: "devtoberfest.lib",
properties: {
title: {
type: "string",
group: "Data",
defaultValue: null
}
},
defaultAggregation: "items",
aggregations: {
items: {
type: "devtoberfest.lib.ChartItem",
multiple: true
}
}
};
onAfterRendering() {
const data = {
labels: this.getItems().map((item) => item.getText()),
datasets: [{
data: this.getItems().map((item) => item.getValue()),
borderWidth: 1
}]
} as unknown as ChartData;
if (!this.chart) {
this.chart = new ChartJS(this.getDomRef("canvas") as HTMLCanvasElement, {
type: 'bar',
data: data,
options: {
responsive: true,
plugins: {
legend: {
display: false
},
title: {
display: true,
text: this.getTitle()
}
}
},
});
} else {
this.chart.data = data;
this.chart.update();
}
}
static renderer: typeof ChartRenderer = ChartRenderer;
}
Add the ChartRenderer.ts
to the src
folder:
/*!
* ${copyright}
*/
import RenderManager from "sap/ui/core/RenderManager";
import Chart from "./Chart";
/**
* Chart renderer.
*/
export default {
apiVersion: 2, // usage of DOM Patcher
/**
* Renders the HTML for the given control, using the provided {@link RenderManager}.
*
* @param rm The reference to the <code>sap.ui.core.RenderManager</code>
* @param control The control instance to be rendered
*/
render: function (rm: RenderManager, control: Chart) {
rm.openStart("div", control);
rm.style("padding", "2em");
rm.openEnd();
rm.openStart("canvas", control.getId() + "-canvas");
rm.openEnd();
rm.close("canvas");
rm.close("div");
}
};
Add the ChartItem.ts
to the src
folder:
/*!
* ${copyright}
*/
import "./library";
import { MetadataOptions } from "sap/ui/base/ManagedObject";
import UI5Element from "sap/ui/core/Element";
/**
* Constructor for a new <code>devtoberfest.lib.ChartItem</code> control.
*
* Some class description goes here.
* @extends UI5Element
*
* @author Peter Muessig
* @version ${version}
*
* @constructor
* @public
* @namespace devtoberfest.lib
*/
export default class ChartItem extends UI5Element {
static readonly metadata: MetadataOptions = {
library: "devtoberfest.lib",
properties: {
text: {
type: "string",
group: "Data",
defaultValue: null
},
value: {
type: "int",
group: "Data",
defaultValue: 0
}
}
};
}
Remove the Example*.ts
controls and modify the library.ts
:
/*!
* ${copyright}
*/
import Lib from "sap/ui/core/Lib";
// library dependencies must also be imported here
import "sap/ui/core/library";
/**
* Initialization Code and shared classes of library devtoberfest.lib.
*/
// delegate further initialization of this library to the Core
const thisLib: { [key: string]: unknown } = Lib.init({
name: "devtoberfest.lib",
version: "${version}",
dependencies: [
// keep in sync with the ui5.yaml and .library files
"sap.ui.core"
],
types: [],
interfaces: [],
controls: ["devtoberfest.lib.Chart"],
elements: ["devtoberfest.lib.ChartItem"],
noLibraryCSS: true // if no CSS is provided, you can disable the library.css load here
}) as { [key: string]: unknown };
// export the library namespace
export default thisLib;
We remove all references to Example
and add the Chart*
classes and enable the option noLibraryCSS
plus deleting the themes folder as we do not need theming support for the chart.
Finally, we adopt the test page - therefore rename the Example.html
to Chart.html
and put the following content inside:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Test Page for devtoberfest.lib.Chart</title>
<base href="../../../" />
<script
src="resources/sap-ui-core.js"
id="sap-ui-bootstrap"
data-sap-ui-async="true"
data-sap-ui-oninit="module:local/Chart"
data-sap-ui-libs="sap.ui.core,devtoberfest.lib"
data-sap-ui-theme="sap_horizon"
data-sap-ui-resourceroots='{"local" : "./test-resources/devtoberfest/lib/"}'
></script>
</head>
<body class="sapUiBody">
<h1 id="header">Test Page for <code>devtoberfest.lib.Chart</code></h1>
<div id="content"></div>
</body>
</html>
The same for the Example.ts
next to the Chart.html
, rename it to Chart.ts
and put the following content inside:
import Chart from "devtoberfest/lib/Chart";
import ChartItem from "devtoberfest/lib/ChartItem";
// create a new instance of the Example control and
// place it into the DOM element with the id "content"
new Chart({
title: "Example Chart",
items: [
new ChartItem({ text: "A", value: 10 }),
new ChartItem({ text: "B", value: 20 }),
new ChartItem({ text: "C", value: 30 }),
new ChartItem({ text: "D", value: 40 }),
new ChartItem({ text: "E", value: 50 }),
]
}).placeAt("content");
Last thing is to adopt the package.json to open the Chart.html
instead of the Example.html
, modify the start script like that:
"start": "ui5 serve -o test-resources/devtoberfest/lib/Chart.html",
Now you can run the custom Chart control in your library project. The interfaces for the controls will be generated and the constructor code as suggested in the console must be added!
Hint: Excluding the library in the preload build (add to the `ui5.yaml the following):
builder:
libraryPreload:
excludes:
- "devtoberfest/lib/thirdparty/"
Before you connect the app and the library, make sure that you have built the library project once so that the types exist. Run in the library project the following command:
npm run build
Let's connect the app with the library. In the app project install the lib as dependency:
npm i devtoberfest.lib@../devtoberfest.lib
Important next step is to add the library in the tsconfig.json
as a type:
"types": ["@openui5/types", "@types/qunit", "devtoberfest.lib"],
This tells the ui5-tooling-transpile
that this dependency must be transpiled during development.
Now we can integrate the chart, in the Main.view.xml
, we add the following code to the View XML tag to define the namespace mapping for the library:
xmlns:dtlib="devtoberfest.lib"
And then we can add the chart control:
<dtlib:Chart title="Stock" items="{/Books}">
<dtlib:ChartItem text="{title}" value="{stock}"></dtlib:ChartItem>
</dtlib:Chart>
Oh, doesn't work, we need to add the ui5-tooling-modules
as a middleware to the project:
npm i -D ui5-tooling-modules
and register it in the ui5.yaml
:
specVersion: "4.0"
metadata:
name: devtoberfest.app
type: application
[...]
server:
customMiddleware:
- name: ui5-tooling-modules-middleware
afterMiddleware: compression
[...]
Now the chart.js is loaded there!
specVersion: "4.0"
metadata:
name: devtoberfest.app
type: application
[...]
builder:
settings:
includeDependency:
- devtoberfest.lib
[...]
This adds the library to the dist folder of the application and it can be deployed together which is useful for some standalone scenarios.
To be continued...
This project is open to feature requests/suggestions, bug reports etc. via GitHub issues. Contribution and feedback are encouraged and always welcome.
This work is licensed under Apache 2.0.