Appearance
Lesson 4 — JSONModel, binding and local CRUD
Review the companion · Assignment
Build an Employee Directory that reads local JSON and lets you create, update and delete employees in browser memory. These are the techniques you will reuse in Project 1.
1. Start here
Environment: local. Data: webapp/model/employees.json. Persistence: changes reset when the page reloads.
Continue Lesson 3 or download its completed checkpoint. Run npm ci for a fresh download, then npm start. Confirm that the employee details check works before replacing the screen. Keep the existing sap.ui.layout library declaration.
2. Understand the idea
A JSONModel holds a small dataset in the browser. Binding connects model properties to controls. Changing data through model APIs updates affected bindings. The model is not a database and does not automatically write changes back to the JSON file.
| Path/concept | Meaning in this app |
|---|---|
/employees | The array of employee records. |
/draft/name | The current creation form's name input. |
/employees/0/status | The first array element's status, not employee ID 0. |
{name} in a row | A relative path resolved using that row's binding context. |
{/draft/name} | An absolute path from the default model's root. |
The separate /draft object stores unfinished form input. It is not an OData draft or a database transaction. The Add button copies that input into a new record only after validation.
A default model uses bindings such as {/employees}. A named model would use {employees>/employees} and getModel("employees")/getBindingContext("employees"). Keep the default model throughout this lesson; named models are recognition only for now.
Three JavaScript array methods appear below:
| Method | Purpose here |
|---|---|
reduce(callback, 0) | Accumulate the highest existing numeric ID, starting from 0. |
concat(newRecord) | Return a new array containing the old records and the new record. |
filter(callback) | Return a new array containing only records for which the callback returns true. |
These are JavaScript methods, not UI5 APIs. For this small local dataset, highest existing ID + 1 produces an unused ID. Deleted IDs may later be reused; a real backend normally owns permanent key generation.
3. Build it
A. Create the seed file
Create webapp/model/employees.json with the complete contents below. Preserve model/models.js, which belongs to the generated device-model setup.
webapp/model/employees.json — complete file
json
{
"employees": [
{
"id": 1,
"name": "Anna Meyer",
"department": "Sales",
"status": "Active"
},
{
"id": 2,
"name": "David Costa",
"department": "IT",
"status": "Active"
}
],
"draft": {
"name": "",
"department": "IT"
}
}B. Replace the entire Main View
Replace webapp/view/Main.view.xml. This removes Lesson 3's Check/Clear controls. Keep App.view.xml, the Component, manifest and Greeting module.
webapp/view/Main.view.xml — complete file
xml
<mvc:View
controllerName="training.fiori.day1.controller.Main"
displayBlock="true"
xmlns:mvc="sap.ui.core.mvc"
xmlns="sap.m"
xmlns:core="sap.ui.core"
xmlns:f="sap.ui.layout.form">
<Page id="mainPage" title="Employee Directory">
<content>
<MessageStrip id="loadErrorStrip" text="Employee data could not be loaded. Reload to try again." type="Error" showIcon="true" visible="false" class="sapUiSmallMargin" />
<f:SimpleForm id="newEmployeeForm" editable="true" layout="ResponsiveGridLayout" class="sapUiSmallMargin">
<Label id="newEmployeeNameLabel" labelFor="newEmployeeNameInput" text="Employee name" required="true" />
<Input id="newEmployeeNameInput" value="{/draft/name}" valueLiveUpdate="true" placeholder="Enter name" />
<Label id="newEmployeeDepartmentLabel" labelFor="newEmployeeDepartmentSelect" text="Department" />
<Select id="newEmployeeDepartmentSelect" selectedKey="{/draft/department}">
<items>
<core:Item id="newDepartmentItemIT" key="IT" text="IT" />
<core:Item id="newDepartmentItemSales" key="Sales" text="Sales" />
<core:Item id="newDepartmentItemFinance" key="Finance" text="Finance" />
</items>
</Select>
</f:SimpleForm>
<VBox id="addEmployeeBox" class="sapUiSmallMargin">
<Text id="draftNameText" text="Draft name: {/draft/name}" />
<Button id="addEmployeeButton" text="Add Employee" type="Emphasized" enabled="false" press=".onAddEmployee" />
</VBox>
<Table id="employeeTable" items="{path: '/employees', templateShareable: false}" autoPopinMode="true" noDataText="No employees to display">
<columns>
<Column id="employeeNameColumn" importance="High"><Text id="employeeNameHeader" text="Name" /></Column>
<Column id="employeeDepartmentColumn" importance="Low"><Text id="employeeDepartmentHeader" text="Department" /></Column>
<Column id="employeeStatusColumn" importance="Medium"><Text id="employeeStatusHeader" text="Status" /></Column>
<Column id="employeeActionsColumn" importance="High"><Text id="employeeActionsHeader" text="Actions" /></Column>
</columns>
<items>
<ColumnListItem id="employeeRow">
<cells>
<Text id="employeeNameText" text="{name}" />
<Text id="employeeDepartmentText" text="{department}" />
<Text id="employeeStatusText" text="{status}" />
<HBox id="employeeRowActions" wrap="Wrap">
<Button id="toggleEmployeeButton" text="Toggle status" press=".onToggleEmployee" class="sapUiTinyMarginEnd" />
<Button id="deleteEmployeeButton" text="Delete" press=".onDeleteEmployee" />
</HBox>
</cells>
</ColumnListItem>
</items>
</Table>
</content>
</Page>
</mvc:View>The table binds its items aggregation to /employees; UI5 creates a row from the template for each record. Its cells use relative bindings, so each row shows its own name, department and status. autoPopinMode lets lower-priority columns move into a row's pop-in area when space is limited.
Template IDs: employeeRow, its cells and its buttons all have semantic IDs. UI5 gives cloned controls distinct runtime IDs. templateShareable: false lets UI5 manage the template lifecycle. A control ID identifies a UI control; the JSON id identifies an employee. Do not use a control's generated ID to discover the employee.
The name Input uses valueLiveUpdate="true", so the Draft name text updates as you type. Without that setting, an Input normally updates its value on a committed change, such as leaving the field or pressing Enter. Two-way binding and per-keystroke updates are related but different concepts.
C. Replace the entire Main Controller
The complete file below contains model setup and every event handler used by the View. Replace Main.controller.js rather than appending a second sap.ui.define block. Save the View and Controller before reloading.
webapp/controller/Main.controller.js — complete file
javascript
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/model/json/JSONModel",
"sap/m/MessageToast"
], function (Controller, JSONModel, MessageToast) {
"use strict";
return Controller.extend("training.fiori.day1.controller.Main", {
onInit: function () {
this._oDataModel = new JSONModel({
employees: [],
draft: { name: "", department: "IT" }
});
this.getView().setModel(this._oDataModel);
this.getView().setBusyIndicatorDelay(0);
this.getView().setBusy(true);
this._oDataModel.attachRequestCompleted(this._onDataLoaded, this);
this._oDataModel.attachRequestFailed(this._onDataFailed, this);
this._oDataModel.attachParseError(this._onDataFailed, this);
this._oDataModel.loadData(sap.ui.require.toUrl(
"training/fiori/day1/model/employees.json"
));
},
_onDataLoaded: function (oEvent) {
if (!oEvent.getParameter("success")) {
this._onDataFailed();
return;
}
this.getView().setBusy(false);
this.byId("addEmployeeButton").setEnabled(true);
this.byId("loadErrorStrip").setVisible(false);
},
_onDataFailed: function () {
this.getView().setBusy(false);
this.byId("addEmployeeButton").setEnabled(false);
this.byId("loadErrorStrip").setVisible(true);
},
onAddEmployee: function () {
const oModel = this.getView().getModel();
const sName = (oModel.getProperty("/draft/name") || "").trim();
const oInput = this.byId("newEmployeeNameInput");
if (!sName) {
oInput.setValueState("Error");
oInput.setValueStateText("Enter an employee name.");
oInput.focus();
return;
}
oInput.setValueState("None");
const aEmployees = oModel.getProperty("/employees");
const iNextId = aEmployees.reduce(function (iMax, oEmployee) {
return Math.max(iMax, oEmployee.id);
}, 0) + 1;
const oEmployee = {
id: iNextId,
name: sName,
department: oModel.getProperty("/draft/department"),
status: "Active"
};
oModel.setProperty("/employees", aEmployees.concat(oEmployee));
oModel.setProperty("/draft", { name: "", department: "IT" });
MessageToast.show("Employee added for this session");
},
onToggleEmployee: function (oEvent) {
const oContext = oEvent.getSource().getBindingContext();
const sStatus = oContext.getProperty("status");
oContext.getModel().setProperty(
oContext.getPath() + "/status",
sStatus === "Active" ? "Inactive" : "Active"
);
MessageToast.show("Employee status updated");
},
onDeleteEmployee: function (oEvent) {
const oContext = oEvent.getSource().getBindingContext();
const iId = oContext.getProperty("id");
const oModel = oContext.getModel();
const aEmployees = oModel.getProperty("/employees");
const aRemaining = aEmployees.filter(function (oEmployee) {
return oEmployee.id !== iId;
});
oModel.setProperty("/employees", aRemaining);
MessageToast.show("Employee deleted for this session");
},
onExit: function () {
this._oDataModel.destroy();
}
});
});D. Trace loading and creation
Read onInit in order. It creates a safe empty model shape, attaches it to the View, marks the View busy, registers request/error handlers, and requests the seed JSON. loadData is asynchronous: statements after it do not mean the data has arrived.
The Add button starts disabled. A successful request clears busy state and enables Add. A failed request or malformed JSON clears busy state, keeps Add disabled and displays the MessageStrip. onExit releases the model this controller created. These lifecycle/error handlers are supplied patterns; understand their purpose before adapting them.
Read onAddEmployee in order. It trims the draft name, rejects empty/whitespace-only input, calculates an ID, builds an employee object, replaces the array with setProperty, and clears the draft. The table updates through its binding. No manual DOM change or forced model refresh is needed.
E. Trace a row action
Select Toggle status on David's row and follow this sequence in the handler:
| Expression | Result |
|---|---|
oEvent.getSource() | The Toggle status button that was selected. |
.getBindingContext() | The employee context inherited from that button's row. |
oContext.getPath() | A path such as /employees/1. |
oContext.getProperty("status") | That employee's current status. |
oContext.getModel().setProperty(...) | The update to that employee's status. |
For deletion, the handler reads the business id from the same context. filter keeps every employee with a different ID. This avoids assuming that a visible row number is a permanent employee identity. After deleting a record, array positions and row contexts can change; read the context again for each action.
Changed files: webapp/model/employees.json, webapp/view/Main.view.xml, webapp/controller/Main.controller.js.
4. Verify your result
| Action | Expected result |
|---|---|
| Open the app | Anna Meyer and David Costa appear after loading. |
| Type Maria Silva | Draft name text changes as you type. |
| Add Maria in Finance | A third row appears; the form resets to empty name/IT. |
| Add an empty or spaces-only name | Error shown; row count does not change. |
| Toggle David | Only David becomes Inactive; toggle again restores Active. |
| Delete Anna, then toggle David | David still changes correctly despite moving to the first array position. |
| Delete all records, then add one | A new row appears without an exception. |
| Reload | The two seed employees return and session changes disappear. |
| Test near 390 px wide | Pop-ins keep the table readable and row actions remain reachable. |
To test loading failures, stop the server, temporarily rename employees.json to employees-backup.json, restart, and reload. The error strip should appear and Add should remain disabled. Restore the exact filename and reload to recover. To test slow loading, use DevTools → Network throttling, reload, and observe that Add is unavailable until the request succeeds. Turn throttling off afterward.
Expected screen: Employee Directory, the creation form, draft-name preview and Add Employee button, followed by a two-row employee table with Toggle status/Delete actions.
Completed Lesson 4 checkpoint. Commit your completed work, including the seed JSON.
5. Troubleshoot
| Symptom | Likely cause | What to do |
|---|---|---|
| Error strip appears | Missing/invalid JSON or resource path | Check the Network request, filename case and JSON syntax. |
| Table stays empty after success | Wrong aggregation/model path | Check items points to /employees on the default model. |
| One cell is blank | Property spelling/case mismatch | Compare name, department, status with the JSON keys. |
| Row handler has no context | Button is outside the bound row or wrong model name | Keep row actions inside the row template; use the same model in binding and context lookup. |
| Duplicate IDs after editing | Repeated controls created manually or copied outside the template | Compare the full View with the checkpoint; let aggregation binding clone the template. |
| Add leaves the list unchanged | Raw array mutation without notifying bindings | Use the demonstrated setProperty replacement. |
| New records disappear after refresh | Expected behavior | The seed file is reloaded; browser-memory changes were not persisted. |
6. Check your understanding
- Why keep
/draftseparate from/employees? - Does
loadDatacomplete before the next JavaScript statement? - How does a row button identify its employee?
- Why delete using the record ID rather than a screen row number?
- Why do the original employees return after reload?
Suggested answers
- Form input can change before a valid record is added; creation is explicit.
- No. Loading is asynchronous, so dependent actions must wait for successful completion.
- The event source inherits its row's binding context; that context exposes the record's path, properties and model.
- Row positions change after deletion and can differ under sorting/filtering. The record ID identifies the intended object.
- The app loads the original JSON again; no code wrote browser-memory changes back to disk.
7. Continue to Lesson 5
You now have the patterns needed for local create, read, update and delete (CRUD). Project 1 applies them to tasks for one employee. Full list/detail binding is explored in Lesson 6. Open Lesson 5.
8. Official references
Use the SAPUI5 version selector to read the API for 1.148.12, the version used by these checkpoints. Documentation checked on 15 November 2026.