List & Detail
A table filled from your own CDS entity, and a row that can be written back. This is examples/bookshop/srv/apps/books.js in ES-module form (the bookshop itself is CommonJS and requires) — the app the coexistence test drives, so both directions below are measured rather than sketched.
// srv/apps/books.js
import cds from "@sap/cds";
import { defineApp, t } from "@cap2ui5/cds-plugin";
const { SELECT, INSERT } = cds.ql;
defineApp("ZCL_JS_BOOKS", class {
search = "";
hits = 0;
books = t.table({ ID: 0, title: "", author: "", price: t.packed(9, 2) });
async main(client) {
if (client.check_on_navigated()) {
client.view_display(
`<mvc:View xmlns:mvc="sap.ui.core.mvc" xmlns="sap.m" displayBlock="true" height="100%">` +
`<Shell><Page title="cap2UI5 - Books">` +
`<SearchField value="${client._bind("search")}" search="${client._event("SEARCH")}"/>` +
`<Table items="${client._bind("books")}">` +
`<columns><Column><Text text="Title"/></Column><Column><Text text="Author"/></Column>` +
`<Column><Text text="Price"/></Column></columns>` +
`<items><ColumnListItem><cells><Text text="{TITLE}"/><Text text="{AUTHOR}"/>` +
`<ObjectNumber number="{PRICE}"/></cells></ColumnListItem></items></Table>` +
`<Text text="${client._bind("hits")} hits"/>` +
`<Button text="Add" press="${client._event("ADD")}"/>` +
`</Page></Shell></mvc:View>`);
return;
}
if (client.check_on_event("ADD")) {
const { Books } = cds.entities("my.bookshop");
const max = await SELECT.one.from(Books).columns("max(ID) as m");
await INSERT.into(Books).entries({
ID: (max?.m ?? 0) + 1, title: this.search, author: "the app", stock: 1, price: 1.0,
});
client.message_toast_display(`added ${this.search}`);
return;
}
if (client.check_on_event("SEARCH")) {
const { Books } = cds.entities("my.bookshop");
this.books = await SELECT.from(Books).where`title like ${"%" + this.search + "%"}`;
this.hits = this.books.length;
client.message_toast_display(`${this.hits} found`);
}
}
});The three things worth copying
main is async here — because the app does I/O. The framework calls (client._bind(), client.message_toast_display(), …) still need no await; SELECT does.
The table is declared, not inferred. t.table({…}) names the row, and t.packed(9, 2) makes price a decimal. An empty array carries no type, so a bare books = [] would be left out of the model and named in a warning.
Assign the whole array. this.books = await SELECT… replaces the table and the plugin rebuilds the rows — and pushes them to the view without a re-render. Mutating the array you read back does not write through.
Row fields are uppercase
Inside the table's template the cells bind {TITLE}, {AUTHOR}, {PRICE} — uppercase, and relative to the row, so they take no client._bind(). Component names are stored lowercase, as the transpiler does, and appear uppercase in the model.
cds.ql is the whole data layer
There is no cap2UI5 data API. You use exactly what a CAP handler uses, with the same tagged templates and therefore the same parameterization:
await SELECT.from(Books).where`title like ${"%" + this.search + "%"}`The ${…} is a bound parameter, not string concatenation.
Both directions, measured
coexistence.test.mjs drives an ordinary OData service beside this app and asserts:
- a row the app writes through
INSERTis there for the OData client at once — same transaction, same database; - a row POSTed through OData is found by the app's next
SEARCH; cap2ui5.Draftsis not reachable through that service — 404, and absent from$metadata.
That last one is asserted rather than assumed: session state reachable through somebody's OData service would be the worst kind of surprise.
A detail screen
For a second screen with its own state, call another app rather than growing this one — see Navigation. When the callee leaves, the caller reads it through client.get_app_prev(), or the typed client.get().r_event_data if it left with nav_app_leave({ event, r_data }).
Next
- Selection Screen — a filter form
- Data Binding — tables, structures, nesting
- Navigation — the detail screen