SAP UI5 / Fiori Development Skill
Table of Contents
- Project Naming & Application ID
- Folder Structure
- Fiori Launchpad Integration (App Alias)
- manifest.json Essentials
- Component.js
- MVC Conventions
- BaseController Pattern
- Formatters
- Routing & Navigation
- i18n & Resource Model
- Models & Data Binding
- OData Integration
- Fragments
- JS Variable & Function Naming
- XML View Conventions
- Performance Best Practices
- UI5 CLI Tooling Reference
Project Naming & Application ID
- Project name: lowercase alphanumeric, max 15 characters (ABAP Repository limit), no hyphens.
Example:zpurchorders - Namespace (reverse-DNS): 3 segments, all lowercase, descriptive.
Pattern:<company>.<module>.<appname>
Example:com.relacon.purchorders - ABAP BSP application name: same as project name → makes SICF lookups consistent.
- Git repository name: lowercase alphanumeric + hyphens, max 30 chars.
- Keep namespace segments short but readable. Avoid abbreviations that are not universally understood.
Folder Structure
Standard webapp/ directory layout for a Fiori freestyle app:
webapp/
├── controller/
│ ├── BaseController.js ← NOT BaseController.controller.js
│ ├── App.controller.js
│ ├── Main.controller.js
│ └── Detail.controller.js
├── view/
│ ├── App.view.xml
│ ├── Main.view.xml
│ └── Detail.view.xml
├── fragment/
│ └── FilterDialog.fragment.xml
├── model/
│ ├── formatter.js ← all formatter functions
│ └── models.js ← model factory helpers (optional)
├── i18n/
│ ├── i18n.properties ← default (English)
│ └── i18n_de.properties ← German translation
├── css/
│ └── style.css
├── localService/ ← mock data for local testing
│ ├── mockdata/
│ └── metadata.xml
├── Component.js
└── manifest.json
Rules:
- One view per controller, same base name:
Main.view.xml↔Main.controller.js BaseController.jshas no.controller.infix — it is never directly referenced by a view.- Fragments go in
fragment/(not insideview/). - Formatters always go in
model/formatter.js— never inline in controllers. - Models factory logic (createDeviceModel, createODataModel) goes in
model/models.js.
Fiori Launchpad Integration (App Alias)
The semantic object + action pair is the Fiori Launchpad identity of the app.
| Field | Convention | Example |
|---|---|---|
| Semantic Object | PascalCase business entity | PurchaseOrder |
| Action | camelCase verb | display, manage, create |
| App Alias (tile) | <SemanticObject>-<action> |
PurchaseOrder-display |
| Technical ID (manifest) | reverse-DNS namespace | com.relacon.purchorders |
- Semantic objects map to real business entities — reuse SAP standard semantic objects where possible.
- Actions must be lowercase.
- App alias must be unique across all apps in the Fiori Launchpad catalog.
- App title and subtitle always from
i18n— never hard-coded in manifest.
manifest.json Essentials
{
"_version": "1.59.0",
"sap.app": {
"id": "com.relacon.purchorders",
"type": "application",
"title": "{{appTitle}}",
"description": "{{appDescription}}",
"applicationVersion": { "version": "1.0.0" },
"dataSources": {
"mainService": {
"uri": "/sap/opu/odata/sap/ZPURCHORDER_SRV/",
"type": "OData",
"settings": { "odataVersion": "2.0" }
}
}
},
"sap.ui": {
"technology": "UI5",
"deviceTypes": { "desktop": true, "tablet": true, "phone": true }
},
"sap.ui5": {
"rootView": {
"viewName": "com.relacon.purchorders.view.App",
"type": "XML",
"async": true,
"id": "app"
},
"dependencies": {
"minUI5Version": "1.120.0",
"libs": {
"sap.ui.core": {},
"sap.m": {},
"sap.ui.layout": {}
}
},
"models": {
"": {
"dataSource": "mainService",
"preload": true,
"settings": { "defaultBindingMode": "TwoWay" }
},
"i18n": {
"type": "sap.ui.model.resource.ResourceModel",
"settings": { "bundleName": "com.relacon.purchorders.i18n.i18n" }
}
},
"routing": { }
}
}
Rules:
appTitleandappDescriptionuse{{key}}placeholders — resolved fromi18n.properties.- Always declare all libraries used in
dependencies.libs— do not rely on implicit loading. - Pin
minUI5Versionto the minimum version your app requires. - The default (unnamed) model
""is the OData model. - The i18n model must always be named
"i18n".
Component.js
sap.ui.define([
"sap/ui/core/UIComponent",
"sap/ui/Device",
"com/relacon/purchorders/model/models"
], function (UIComponent, Device, models) {
"use strict";
return UIComponent.extend("com.relacon.purchorders.Component", {
metadata: {
manifest: "json"
},
init: function () {
// call the base component's init function — ALWAYS first
UIComponent.prototype.init.apply(this, arguments);
// set device model
this.setModel(models.createDeviceModel(), "device");
// initialize the router — ALWAYS last in init
this.getRouter().initialize();
},
destroy: function () {
UIComponent.prototype.destroy.apply(this, arguments);
}
});
});
Rules:
UIComponent.prototype.init.apply(this, arguments)must be the first line ininit.this.getRouter().initialize()must be the last line ininit— never call it before models are set.- Never put business logic in
Component.js— it is bootstrapping only. - Do not use
createContent()— routing-based apps userootViewin manifest instead.
MVC Conventions
- Always use XML Views — JS views are obsolete (< 2% usage in modern apps).
- One view ↔ one controller, same base name.
- No business logic in views — views are declaration only.
- No DOM manipulation — always use UI5 APIs (
byId,bindElement, etc.), neverdocument.getElementById. - Lifecycle hooks to use:
| Hook | Purpose |
|---|---|
onInit |
One-time initialization, model setup, router subscription |
onBeforeRendering |
Pre-render logic (e.g., disable fields based on settings) |
onAfterRendering |
Post-render DOM-dependent logic |
onExit |
Clean up: detach event handlers, destroy models |
BaseController Pattern
Every controller must extend BaseController, never sap/ui/core/mvc/Controller directly.
webapp/controller/BaseController.js:
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/core/routing/History",
"sap/ui/core/UIComponent"
], function (Controller, History, UIComponent) {
"use strict";
return Controller.extend("com.relacon.purchorders.controller.BaseController", {
/**
* Convenience method - get the component router.
* Usage: this.getRouter()
*/
getRouter: function () {
return UIComponent.getRouterFor(this);
},
/**
* Convenience method - get a named (or default) model from the view.
* Usage: this.getModel() / this.getModel("i18n")
*/
getModel: function (sName) {
return this.getView().getModel(sName);
},
/**
* Convenience method - set a model on the view.
* Usage: this.setModel(oModel, "view")
*/
setModel: function (oModel, sName) {
return this.getView().setModel(oModel, sName);
},
/**
* Convenience method - get i18n resource bundle.
* Usage: this.getResourceBundle().getText("myKey")
*/
getResourceBundle: function () {
return this.getOwnerComponent().getModel("i18n").getResourceBundle();
},
/**
* Back navigation with browser history support.
* Usage: set navButtonPress=".onNavBack" in XML view.
*/
onNavBack: function () {
var sPreviousHash = History.getInstance().getPreviousHash();
if (sPreviousHash !== undefined) {
window.history.go(-1);
} else {
this.getRouter().navTo("home", {}, true);
}
}
});
});
Feature controllers extending BaseController:
sap.ui.define([
"com/relacon/purchorders/controller/BaseController"
], function (BaseController) {
"use strict";
return BaseController.extend("com.relacon.purchorders.controller.Main", {
onInit: function () {
var oRouter = this.getRouter();
oRouter.getRoute("main").attachMatched(this._onRouteMatched, this);
},
_onRouteMatched: function (oEvent) {
// handle route match
}
});
});
Rules:
- All public event handlers use
onprefix:onPress,onSearch,onNavBack. - All private functions use
_prefix:_loadData,_onRouteMatched. - Never pass anonymous (naked) functions to event handlers — always use named object functions.
Formatters
All formatter functions live in webapp/model/formatter.js — a single module, globally available.
sap.ui.define([], function () {
"use strict";
return {
/**
* Formats a status code to a semantic ValueState.
* @param {string} sStatus - "A" | "B" | "C"
* @returns {string} sap.ui.core.ValueState
*/
statusToState: function (sStatus) {
var mStates = {
"A": "Success",
"B": "Warning",
"C": "Error"
};
return mStates[sStatus] || "None";
},
/**
* Formats a boolean to a visible/invisible state.
* @param {boolean} bValue
* @returns {boolean}
*/
boolToVisible: function (bValue) {
return !!bValue;
}
};
});
Using formatters in XML Views:
<mvc:View
controllerName="com.relacon.purchorders.controller.Main"
xmlns:mvc="sap.ui.core.mvc"
xmlns="sap.m">
<!-- Reference formatter at view level -->
<Page title="{i18n>pageTitle}">
<ObjectStatus
state="{
path: 'Status',
formatter: '.formatter.statusToState'
}"
/>
</Page>
</mvc:View>
And in the controller, import formatter as a dependency:
sap.ui.define([
"com/relacon/purchorders/controller/BaseController",
"com/relacon/purchorders/model/formatter"
], function (BaseController, formatter) {
"use strict";
return BaseController.extend("com.relacon.purchorders.controller.Main", {
formatter: formatter, // expose to XML view via .formatter.*
...
});
});
Rules:
- Formatters are always one-way — they convert model → view only.
- Never modify model data inside a formatter.
- Never put complex business logic in a formatter — it is display/format conversion only.
- Always import formatter module as a controller dependency and expose it as
this.formatter.
Routing & Navigation
Always configure routing in manifest.json, never by calling new sap.m.routing.Router() manually.
manifest.json routing section:
"routing": {
"config": {
"routerClass": "sap.m.routing.Router",
"viewType": "XML",
"viewPath": "com.relacon.purchorders.view",
"controlId": "app",
"controlAggregation": "pages",
"async": true,
"bypassed": {
"target": "notFound"
}
},
"routes": [
{
"pattern": "",
"name": "home",
"target": "home"
},
{
"pattern": "orders/{orderId}",
"name": "orderDetail",
"target": "orderDetail"
}
],
"targets": {
"home": {
"viewName": "Main",
"viewLevel": 1,
"transition": "slide"
},
"orderDetail": {
"viewName": "Detail",
"viewLevel": 2,
"transition": "slide"
},
"notFound": {
"viewName": "NotFound",
"viewLevel": 3
}
}
}
Navigation in controller:
// Navigate to a route with parameters
this.getRouter().navTo("orderDetail", {
orderId: encodeURIComponent(sOrderId)
});
// Navigate and replace history (no back button)
this.getRouter().navTo("home", {}, true);
Attach route matched in controller:
onInit: function () {
// Preferred: attach to specific route only
this.getRouter()
.getRoute("orderDetail")
.attachMatched(this._onRouteMatched, this);
},
_onRouteMatched: function (oEvent) {
var sOrderId = decodeURIComponent(
oEvent.getParameter("arguments").orderId
);
this._loadOrder(sOrderId);
}
Rules:
- Route names are camelCase:
orderDetail,home,notFound. - Target names match route names exactly.
viewLevelmust increase as you drill deeper (controls the back-button animation).- Always
encodeURIComponent/decodeURIComponentfor route parameters. - Use
attachMatchedon a specific route (notattachRouteMatchedon the router) to avoid firing on every route change. - Always call
this.getRouter().initialize()at the end ofComponent.js init(). - The
onNavBackhelper inBaseControllerhandles back navigation with proper history fallback.
i18n & Resource Model
File location: webapp/i18n/i18n.properties (default, English).
Additional locales: i18n_de.properties, i18n_fr.properties, etc.
i18n.properties format:
# ── Application ──────────────────────────────────────────────
#XTIT: Browser tab title
appTitle=Purchase Orders
#YDES: Application description shown in Fiori Launchpad
appDescription=Manage purchase orders
# ── Main View ─────────────────────────────────────────────────
#XTIT: Page title on main view
mainPageTitle=Purchase Orders
#XBUT: Confirm button label
btnConfirm=Confirm
#XMSG: Success message after saving
msgSaveSuccess=Order {0} saved successfully.
#XMSG: Error message when order not found
msgOrderNotFound=Order {0} could not be found.
Annotation prefixes (ABAP-style, SAP convention):
| Prefix | Meaning |
|---|---|
#XTIT |
Title |
#XBUT |
Button label |
#XLBL |
Field label |
#XMSG |
Message |
#YDES |
Description (longer text) |
#XFLD |
Input field placeholder |
Usage in controllers (via BaseController):
// Simple text
var sTitle = this.getResourceBundle().getText("mainPageTitle");
// Text with placeholder
var sMsg = this.getResourceBundle().getText("msgSaveSuccess", [sOrderId]);
Usage in XML Views:
<Page title="{i18n>mainPageTitle}">
<Button text="{i18n>btnConfirm}" press=".onConfirm"/>
</Page>
Rules:
- All static UI texts must use i18n — never hard-code UI strings.
- App title and description in manifest.json use
{{appTitle}}double-brace syntax. - Use placeholder notation
{0},{1}for dynamic values — never string concatenation. - The i18n model must always be set as named model
"i18n"at component level (via manifest models section).
Models & Data Binding
Named model convention:
| Model Name | Type | Purpose |
|---|---|---|
"" (default) |
ODataModel v2/v4 | Main backend data |
"i18n" |
ResourceModel | Translations |
"view" |
JSONModel | Local view state (UI flags, loading indicators) |
"device" |
JSONModel | sap.ui.Device properties |
View model pattern (local state):
onInit: function () {
var oViewModel = new sap.ui.model.json.JSONModel({
busy: false,
editable: false,
itemCount: 0
});
this.setModel(oViewModel, "view");
}
In XML:
<Page busy="{view>/busy}">
<Toolbar>
<Button text="Edit" visible="{= !${view>/editable} }" press=".onEdit"/>
</Toolbar>
</Page>
Binding mode:
| Model | Default Binding Mode |
|---|---|
| JSONModel | TwoWay |
| ODataModel v2 | OneWay (must explicitly set TwoWay for editable fields) |
| ResourceModel | OneTime |
Rules:
- Never use
sap.ui.getCore().setModel()for app-level models — set them viaComponent.jsor manifest. - Always use named models — except the default OData model.
- Do not chain more than 3 dots in a single expression (readability rule): break into variables.
- Use
oModel.createKey()to generate OData key paths — never build key strings manually.
OData Integration
This section covers both OData v2 (classic Gateway / SEGW services) and OData v4 (RAP-based services). Choose the model version based on what the backend exposes — they are not interchangeable.
| OData v2 | OData v4 (RAP) | |
|---|---|---|
| Model class | sap.ui.model.odata.v2.ODataModel |
sap.ui.model.odata.v4.ODataModel |
| Service URL pattern | /sap/opu/odata/sap/ZSERVICE_SRV/ |
/sap/opu/odata4/sap/zservice/default/sap/zservice/0001/ |
| Data access | oModel.read(), oModel.createKey(), bindElement |
Context API: oContext.requestObject(), oContext.setProperty() |
| Batch | Deferred groups, oModel.submitChanges() |
$$groupId, oModel.submitBatch() |
| CRUD | Model-level methods | Context-level methods (oContext.delete(), oList.create()) |
| Draft | Manual implementation | Built-in with RAP draft actions (Edit/Activate/Discard) |
| Test tool | /n/IWFND/GW_CLIENT |
/n/IWFND/GW_CLIENT or Postman with CSRF token |
Manifest: Declaring Data Sources
OData v2:
"dataSources": {
"mainService": {
"uri": "/sap/opu/odata/sap/ZPURCHORDER_SRV/",
"type": "OData",
"settings": { "odataVersion": "2.0" }
}
}
OData v4 (RAP):
"dataSources": {
"mainService": {
"uri": "/sap/opu/odata4/sap/zpurchorder/default/sap/zpurchorder/0001/",
"type": "OData",
"settings": { "odataVersion": "4.0" }
}
}
Model declaration in sap.ui5.models (v4 specific settings):
"models": {
"": {
"dataSource": "mainService",
"preload": true,
"settings": {
"autoExpandSelect": true,
"operationMode": "Server",
"synchronizationMode": "None"
}
}
}
autoExpandSelect: true— model automatically generates$selectand$expandbased on bound controls. Recommended for RAP.synchronizationMode: "None"— required parameter for v4, no exceptions.operationMode: "Server"— all filtering/sorting done server-side.
Local Proxy (both v2 and v4, in ui5.yaml)
server:
customMiddleware:
- name: fiori-tools-proxy
afterMiddleware: compression
configuration:
backend:
- path: /sap
url: http://your-s4-host:8000
OData v2: Read & Bind Patterns
Object page — bind a single entity:
_loadOrder: function (sOrderId) {
var oModel = this.getModel();
// Always use createKey() - never build path strings manually
var sPath = oModel.createKey("/PurchaseOrderSet", { OrderId: sOrderId });
this.getView().bindElement({
path: sPath,
parameters: {
expand: "to_Items,to_Partner"
},
events: {
dataRequested: function () {
this.getModel("view").setProperty("/busy", true);
}.bind(this),
dataReceived: function (oEvent) {
this.getModel("view").setProperty("/busy", false);
if (oEvent.getParameter("error")) {
// handle error
}
}.bind(this)
}
});
}
List — filter and read:
_applyFilter: function (sStatus) {
var oList = this.byId("orderList");
var oBinding = oList.getBinding("items");
var aFilters = [new sap.ui.model.Filter("Status", sap.ui.model.FilterOperator.EQ, sStatus)];
oBinding.filter(aFilters);
}
Write — update and submit:
onSave: function () {
var oModel = this.getModel();
// v2 collects all pending changes and submits in one batch
oModel.submitChanges({
success: function () {
sap.m.MessageToast.show(this.getResourceBundle().getText("msgSaveSuccess"));
}.bind(this),
error: function (oError) {
// handle error
}
});
},
onCancel: function () {
this.getModel().resetChanges();
}
v2 Rules:
- Always use
oModel.createKey()to build entity paths — never concatenate strings manually. - Always handle both
dataRequestedanddataReceived(including error parameter) for busy state. - Use
oModel.submitChanges()/oModel.resetChanges()for transactional save/cancel. - Never manually set the CSRF token — the v2 model fetches and manages it automatically.
- Test all services in
/n/IWFND/GW_CLIENTbefore building the UI layer.
OData v4 / RAP: Read & Bind Patterns
In v4 the Context is the central object — not the model. All data access and modifications go through oContext.
Object page — bind a single entity:
_loadOrder: function (sOrderId) {
// v4: path with literal key value, no createKey() needed
var sPath = "/PurchaseOrderSet('" + sOrderId + "')";
this.getView().bindElement({
path: sPath,
parameters: {
$expand: "to_Items($select=ItemNo,Material,Quantity),to_Partner",
$select: "OrderId,Status,TotalAmount,Currency"
},
events: {
dataReceived: function (oEvent) {
this.getModel("view").setProperty("/busy", false);
if (oEvent.getParameter("error")) {
// handle error
}
}.bind(this)
}
});
}
List binding with $expand in XML view (recommended — let autoExpandSelect handle it):
<Table
id="orderTable"
items="{
path: '/PurchaseOrderSet',
parameters: {
$orderby: 'OrderId desc',
$$groupId: '$auto'
}
}">
<columns>...</columns>
<items>
<ColumnListItem press=".onItemPress" type="Navigation">
<cells>
<Text text="{OrderId}"/>
<Text text="{Status}"/>
</cells>
</ColumnListItem>
</items>
</Table>
Programmatic read with requestContexts:
_readOrders: function () {
var oModel = this.getModel();
var oListBinding = oModel.bindList("/PurchaseOrderSet", null, null, null, {
$select: "OrderId,Status,TotalAmount",
$$groupId: "$auto"
});
oListBinding.requestContexts(0, 50).then(function (aContexts) {
var aOrders = aContexts.map(function (oCtx) {
return oCtx.getObject(); // returns plain JS object
});
this.getModel("view").setProperty("/orders", aOrders);
}.bind(this));
}
v4 Rules:
- Use
oContext.requestObject()(async, returns Promise) oroContext.getObject()(sync, from cache) for data access — neveroModel.getProperty(). - Use
oContext.setProperty("FieldName", value)for direct property changes. $$groupId: "$auto"sends requests automatically;$$groupId: "myGroup"defers untiloModel.submitBatch("myGroup").- Do not chain more than 3
.expressions — break into variables for readability. autoExpandSelect: truein manifest is preferred over manually specifying$select/$expandon every binding — controls drive the selection automatically.
OData v4 / RAP: Draft Handling (Edit / Activate / Discard)
RAP draft-enabled entities have three standard actions: EditAction, ActivationAction, DiscardAction. These are bound actions on the entity context.
Edit — create a draft from active entity:
onEdit: function () {
var oObjectPage = this.byId("objectPage");
var oActiveContext = oObjectPage.getBindingContext();
var that = this;
// Remember active context for cancel scenario
this._oActiveContext = oActiveContext;
oActiveContext.getModel()
.bindContext("com.sap.namespace.EditAction(...)", oActiveContext, {
$$inheritExpandSelect: true // reuse same $expand/$select as list
})
.invoke("$auto", false, null, /*bReplaceWithRVC*/ true)
.then(function (oDraftContext) {
// Switch object page to draft context
oObjectPage.setBindingContext(oDraftContext);
that.getModel("view").setProperty("/editable", true);
})
.catch(function (oError) {
// handle error
});
},
Activate (Save) — turn draft into active entity:
onActivate: function () {
var oDraftContext = this.byId("objectPage").getBindingContext();
var that = this;
oDraftContext.getModel()
.bindContext("com.sap.namespace.ActivationAction(...)", oDraftContext, {
$$inheritExpandSelect: true
})
.invoke("$auto")
.then(function (oActiveContext) {
that.byId("objectPage").setBindingContext(oActiveContext);
that.getModel("view").setProperty("/editable", false);
that._oActiveContext = null;
})
.catch(function (oError) {
// handle activation errors (validation messages from RAP)
});
},
Discard — delete draft, restore active entity:
onDiscard: function () {
var oDraftContext = this.byId("objectPage").getBindingContext();
var oActiveContext = this._oActiveContext;
var that = this;
// Replace draft row in list with active entity in-situ, then delete draft
oDraftContext.replaceWith(oActiveContext);
oDraftContext.delete("$auto").then(function () {
that.byId("objectPage").setBindingContext(oActiveContext);
that.getModel("view").setProperty("/editable", false);
that._oActiveContext = null;
});
}
Draft Rules:
- Store the active context in
this._oActiveContextbefore callingEditAction— needed forDiscard. - Always use
$$inheritExpandSelect: trueon draft action bindings — avoids duplicate$expanddefinitions. - Use
bReplaceWithRVC: trueflag ininvoke()forEditActionso the list row is updated in-place. - Call
oDraftContext.replaceWith(oActiveContext)beforedelete()on discard — keeps list UI consistent without a full refresh. - RAP validation messages come back as OData error responses — always add a
.catch()onActivationAction.
OData v4 / RAP: Actions and Function Imports
RAP exposes both bound actions (on an entity context) and unbound actions (on the service root).
Bound action (e.g., Approve an order):
onApprove: function () {
var oContext = this.byId("objectPage").getBindingContext();
oContext.getModel()
.bindContext("com.sap.namespace.ApproveAction(...)", oContext)
.invoke("$auto")
.then(function () {
// Refresh side-affected fields after action
oContext.requestSideEffects(["Status", "ApprovalDate"]);
})
.catch(function (oError) {
// show error message
});
},
Unbound action (e.g., mass processing):
_callUnboundAction: function (aOrderIds) {
var oModel = this.getModel();
var oActionBinding = oModel.bindContext("/com.sap.namespace.MassApprove(...)");
// Set action parameters
oActionBinding.setParameter("OrderIds", aOrderIds);
oActionBinding.setParameter("Reason", "Approved in bulk");
return oActionBinding.invoke("$auto");
},
Action Rules:
- Bound actions: path format is
"namespace.ActionName(...)"— the(...)is required. - After an action that causes server-side changes, always call
oContext.requestSideEffects([...])with the list of affected property paths. - Unbound actions: bind on model directly with absolute path
/namespace.ActionName(...). - Use
$$patchWithoutSideEffects: trueon bindings when you manage side effects explicitly — avoids double-loading.
OData v4 / RAP: Deep Entity Navigation ($expand)
v4 $expand is more powerful than v2 — you can nest $select, $filter, $top inside expand.
In XML view binding:
<Table items="{
path: '/PurchaseOrderSet',
parameters: {
$expand: {
to_Items: {
$select: 'ItemNo,Material,Quantity,UoM',
$orderby: 'ItemNo'
},
to_Partner: {
$select: 'PartnerId,PartnerName'
}
},
$select: 'OrderId,Status,TotalAmount,Currency'
}
}">
Inline expand on bindElement (controller):
this.getView().bindElement({
path: "/PurchaseOrderSet('" + sOrderId + "')",
parameters: {
$expand: "to_Items($select=ItemNo,Material,Quantity;$orderby=ItemNo),to_Partner($select=PartnerId,PartnerName)",
$select: "OrderId,Status,TotalAmount,Currency"
}
});
Expand Rules:
- With
autoExpandSelect: truein manifest, you usually do NOT need to specify$select/$expandmanually — UI5 derives them from bound controls. Only override when you need data not shown in the UI (e.g., for logic in the controller). - Never expand entire entities without
$select— this over-fetches data and slows RAP performance. - Nested
$filterinside$expandis only supported on collection navigation properties (one-to-many). - Test expanded responses in
/n/IWFND/GW_CLIENTor Postman before binding in UI to confirm RAP behavior.
Fragments
Fragments are reusable UI parts (dialogs, popovers) — they do not have their own controller.
Fragment file (webapp/fragment/FilterDialog.fragment.xml):
<core:FragmentDefinition
xmlns="sap.m"
xmlns:core="sap.ui.core">
<Dialog
id="filterDialog"
title="{i18n>filterDialogTitle}"
afterClose=".onDialogClose">
<content>
<!-- content here -->
</content>
<beginButton>
<Button text="{i18n>btnApply}" press=".onApplyFilter" type="Emphasized"/>
</beginButton>
<endButton>
<Button text="{i18n>btnCancel}" press=".onCancelFilter"/>
</endButton>
</Dialog>
</core:FragmentDefinition>
Loading a fragment in controller (lazy, recommended):
onOpenFilter: function () {
if (!this._oFilterDialog) {
this._oFilterDialog = this.loadFragment({
name: "com.relacon.purchorders.fragment.FilterDialog"
});
}
this._oFilterDialog.then(function (oDialog) {
oDialog.open();
});
},
onCancelFilter: function () {
this.byId("filterDialog").close();
}
Rules:
- Fragments share the host controller — event handlers are defined in the host controller.
- Use
this.loadFragment()(async, UI5 1.93+) rather thansap.ui.xmlfragment(). - Lazy-load fragments — create them on first use, cache in a private property (
this._oFilterDialog). - Fragment IDs are scoped to the view using
this.byId(). - Never put business logic in fragments — they are UI layout only.
JS Variable & Function Naming
Follow Hungarian Notation prefix convention (standard in UI5 SAP code):
| Prefix | Type | Example |
|---|---|---|
s |
string | sOrderId, sTitle |
i |
integer | iCount, iIndex |
f |
float | fPrice, fAmount |
b |
boolean | bVisible, bEditable |
o |
object | oModel, oEvent, oView |
a |
array | aItems, aFilters |
fn |
function (var) | fnCallback, fnSuccess |
_ |
private | _loadData, _oDialog |
Additional naming rules:
- All event handler functions:
onprefix, camelCase →onPress,onSearch,onRouteMatched. - Private helper functions:
_prefix →_loadOrder,_formatStatus. - Do not use
varfor module-level declarations — always inside functions. - Always
"use strict"at the top of everysap.ui.definecallback. - No global variables — everything scoped inside the AMD module function.
XML View Conventions
<!-- One attribute per line when element has 3+ attributes -->
<Button
id="confirmButton"
text="{i18n>btnConfirm}"
type="Emphasized"
press=".onConfirm"/>
<!-- Single attribute - keep on same line -->
<Title text="{i18n>pageTitle}"/>
<!-- Self-closing tag when no aggregations -->
<Input value="{OrderId}"/>
<!-- Empty lines before/after aggregation blocks for readability -->
<Page title="{i18n>pageTitle}">
<headerContent>
<Button text="{i18n>btnEdit}" press=".onEdit"/>
</headerContent>
<content>
<List items="{/OrderSet}">
<items>
<StandardListItem title="{OrderId}" type="Navigation" press=".onItemPress"/>
</items>
</List>
</content>
</Page>
Rules:
- Always use XML views — never JS, JSON, or HTML views.
- Namespace prefixes:
xmlns="sap.m"as default, others prefixed:xmlns:core="sap.ui.core". - All text-facing properties must use
{i18n>key}binding. - Event handlers use
.onHandlerName(dot prefix means "in this controller"). - Self-close tags with no aggregations:
/>without space before slash. - IDs use camelCase:
id="confirmButton".
Performance Best Practices
- Async views: set
"async": truein manifestrootViewand routing targets. - Component preloading: ensure
Component-preload.jsis generated (ui5 build --all). - Lazy loading: load fragments and rarely-used modules on demand with
sap.ui.require(). - Growing list: use
growing="true" growingThreshold="20"on lists and tables. - $batch: ODataModel batches requests automatically — avoid disabling batch mode unless required.
- No
sap.ui.getCore()for model access in controllers — usethis.getModel()/this.getOwnerComponent(). - Avoid
setTimeoutin UI5 — use lifecycle hooks or model events instead. - ESLint: always run ESLint with the UI5 plugin before committing.
UI5 CLI Tooling Reference
Node.js: v20.11.0+ or v22.0.0+ required.
Project initialization
npm init --yes
ui5 init
ui5 use sapui5@1.120.0 # always pin version for production
ui5 add sap.ui.core sap.m sap.ui.layout themelib_sap_fiori_3
Development
ui5 serve # start dev server on http://localhost:8080
ui5 serve --open index.html # open browser automatically
Build
ui5 build --all --clean-dest # production build, clean output first
Minimal ui5.yaml
specVersion: "4.0"
type: application
metadata:
name: com.relacon.purchorders
framework:
name: SAPUI5
version: "1.120.0"
libraries:
- name: sap.ui.core
- name: sap.m
- name: sap.ui.layout
- name: themelib_sap_fiori_3
optional: true
Proxy middleware for local OData (add to ui5.yaml)
server:
customMiddleware:
- name: fiori-tools-proxy
afterMiddleware: compression
configuration:
backend:
- path: /sap
url: http://your-s4-host:8000
Rules:
- Always use local
@ui5/cliinstallation (--save-dev), not global. - Always commit
ui5.yamlandpackage.jsonto version control. - Use
ui5 serveduring development — neverui5 buildfor testing. - Use
ui5 build --clean-dest --allfor production deployments only. - All modules must use
sap.ui.defineformat — no ESimport/export.
Quick Reference: What Goes Where
| Concern | Location |
|---|---|
| App bootstrapping, routing init | Component.js |
| App descriptor, models, routing config | manifest.json |
| Shared controller helpers (router, model, i18n, navBack) | BaseController.js |
| Business / feature logic | <ViewName>.controller.js |
| Display formatting | model/formatter.js |
| Device / view state models | model/models.js |
| All UI texts | i18n/i18n.properties |
| Reusable UI parts (dialogs, popovers) | fragment/*.fragment.xml |
| Global styles | css/style.css |
| Mock data for local testing | localService/mockdata/ |
OData v2 vs v4 Cheat Sheet
| Task | OData v2 | OData v4 (RAP) |
|---|---|---|
| Build entity path | oModel.createKey("/Set", {Key: val}) |
"/Set('" + val + "')" |
| Bind object page | this.getView().bindElement({path, events}) |
this.getView().bindElement({path, parameters: {$expand, $select}}) |
| Read property | oModel.getProperty(path) |
oContext.getObject() / oContext.requestObject() |
| Set property | oModel.setProperty(path, val) |
oContext.setProperty("Field", val) |
| Save changes | oModel.submitChanges() |
oModel.submitBatch("groupId") |
| Cancel changes | oModel.resetChanges() |
oModel.resetChanges(["groupId"]) |
| Call action/function | oModel.callFunction("/ActionImport", {...}) |
oModel.bindContext("/ns.Action(...)").invoke() |
| Bound action | N/A (use function imports) | oModel.bindContext("ns.Action(...)", oContext).invoke() |
| Side effects after change | Manual re-read | oContext.requestSideEffects(["Field1","Field2"]) |
| Draft: start edit | Manual | oModel.bindContext("ns.EditAction(...)", oCtx).invoke() |
| Draft: activate/save | Manual | oModel.bindContext("ns.ActivationAction(...)", oCtx).invoke() |
| Draft: discard | Manual delete | oDraftCtx.replaceWith(oActiveCtx) + oDraftCtx.delete() |
References: SAP UI5 SDK (ui5.sap.com), DSAG UI5 Best Practice Guide, SAP Fiori Design Guidelines, UI5 Tooling v4 documentation.