| name | port-a-sample |
| description | The complete recipe for rebuilding a UI5 demo kit sample as an abap2UI5 port class - class skeleton, dispatcher, model_init, view building with z2ui5_cl_ui5_view_builder, formatting rules, data binding and events, booleans, the 1.71 rule in practice, deviation types. Use when writing, changing or reviewing any port class under src/. |
Porting recipe — how a port is built
Part of the ai-demokit rulebook: AGENTS.md (always read first) defines
mission, scope, layout and the sidecar contract; this guide is the
authoritative long form of the generation recipe. When it changes in
substance, update scripts/generation-prompt.txt in the same change
(AGENTS.md "Generation prompt"). For the recurring hard idioms and the worked
reference ports see the idiom-lookup guide next to this one.
App skeleton — how a port is built
This is the complete recipe for turning one UI5 demo kit sample into a port.
Follow it exactly so every port looks the same and stays maintainable.
Inputs — the sample's original files from the OpenUI5 checkout: the
*.view.xml (the UI), the controller (*.controller.js — event handlers),
Component.js / manifest.json (which model data is loaded), plus any local
*.json mock data. All of these are also copied verbatim into the sample's
ui5/<library>/<SampleName>/ folder (AGENTS.md §4).
Output — one class z2ui5_cl_smpc_app_<n> implementing z2ui5_if_app, whose
view is a 1:1 rebuild of the sample's XML.
Class layout
CLASS z2ui5_cl_smpc_app_<n> DEFINITION PUBLIC. " lowercase, not FINAL
PUBLIC SECTION.
INTERFACES z2ui5_if_app.
" local types for the model data (ty_s_ / ty_t_) + the DATA that back the
" bindings live here, so the framework can serialise them across round-trips
TYPES: BEGIN OF ty_s_item, ... END OF ty_s_item.
DATA t_items TYPE STANDARD TABLE OF ty_s_item WITH EMPTY KEY.
" ONLY bound DATA belongs in PUBLIC: the round-trip model scan walks the
" public instance attributes, so every non-bound helper/backup kept here
" just slows the binding search. Put such state in PROTECTED (see below).
PROTECTED SECTION.
DATA client TYPE REF TO z2ui5_if_client.
METHODS view_display.
METHODS on_event. " only if the app reacts to events
METHODS model_init. " only if the app has model data - declared LAST
PRIVATE SECTION. " always present, kept empty
ENDCLASS.
z2ui5_if_app~main — the dispatcher
METHOD z2ui5_if_app~main.
me->client = client.
IF client->check_on_init( ).
model_init( ).
view_display( ).
ELSEIF client->check_on_navigated( ).
view_display( ).
ELSEIF client->check_on_event( ).
on_event( ).
ENDIF.
ENDMETHOD.
-
Method order in the implementation: z2ui5_if_app~main is always the
first method; the remaining methods follow in the order they are
called from main, depth-first (view_display → on_event → helpers
right after their caller) — except model_init, which always goes LAST,
after every other method (and is declared last in the DEFINITION too). It
usually holds a large VALUE #( ) block of mock data; keeping it at the
bottom stops that data from interrupting the reading flow of the dispatcher,
view and event methods. pattern-lint checks that main comes first and that
model_init comes last.
-
A sample with nothing to seed drops the check_on_init( ) branch
altogether, and a fully static one (no data, no events — app 051's class)
drops on_event with it, down to:
me->client = client.
IF client->check_on_navigated( ).
view_display( ).
ENDIF.
-
check_on_init( ) fires once when the app instance starts — seed the data
there, and nothing else. It is not a display branch: it being true implies
check_on_navigated( ) is true (every path to a first main( ) sets that
flag — factory_first_start for a fresh start and for a draft restore,
factory_system_startup, prepare_app_stack for call and leave), so an init
branch whose only statement is view_display( ) decides nothing, and
IF check_on_init( ) OR check_on_navigated( ). is the same redundancy in
another spelling. pattern-lint's redundant-init-display fails both.
-
check_on_event( ) fires on every user interaction — dispatch in on_event( ).
-
check_on_navigated( ) fires on the first start AND whenever the app regains
the screen — it is where view_display( ) lives, in every port.
-
Add model_init( ) / on_event( ) only when the app actually has data /
events — never a pass-through method with a single statement. A static app
(like app 051) has just view_display( ) in each of its two display
branches. A
app (its only "model" is one or two control-state
flags a button toggles, e.g. ) seeds those flags
(or ), no — the single-statement-method rule wins
(app 128 precedent).
model_init — the model
The sample's JSON model becomes ABAP: one ty_s_/ty_t_ type per JSON array,
filled with VALUE #( ( … ) ( … ) ). Field names are the JSON keys, upper-cased
by ABAP; bindings reference them in braces ({TITLE}, {PRODUCTID}). A
camelCase key mirrors verbatim — do not insert underscores: SupplierName →
field suppliername, binding {SUPPLIERNAME} (never SUPPLIER_NAME) — a corpus
convention. (structural-diff would tolerate either — its normBind lower-cases
and strips underscores — so this is for consistency, not to satisfy the
gate. The worked references 022/040 predate this convention and still use
SUPPLIER_NAME/PRODUCT_ID — do not copy their underscored field names; the
spec wins.)
Keep the
data verbatim from the sample — the full row set, no subsetting: inline every
row of the referenced mock array (e.g. all 123 /ProductCollection rows of
ui5/mock/products.json), byte-identical to the mock (SUBSET_DATA is no longer
an accepted deviation — user decision). Rows, not columns: per row, inline
only the fields the view actually binds (the 040/022 practice — unbound mock
keys stay out of the row type); "full row set" never means all 20 JSON keys of
every row. Where the original itself binds a single record
({/ProductCollection/0}) or a precomputed stats array
(/ProductCollectionStats/Filters), reproduce exactly that — that is the 1:1
data, not a shortening. A packed field must carry enough DECIMALS for the mock
(e.g. Price has 2-decimal values, so TYPE p … DECIMALS 2).
Line the columns up. A mock table of three or more rows with the same field
list is written as a table: every cell padded to the width of its column, the
LAST cell of a row left unpadded so no spaces pile up before the closing ).
node scripts/json-to-abap.mjs emits exactly that, so a generated block needs
no touching; pattern-lint's ragged-value-table catches a hand-written one
that drifted.
t_fixed_navigation = VALUE #(
( title = `Fixed Item 1` icon = `sap-icon://employee` enabled = abap_true )
( title = `Fixed Item 2` icon = `sap-icon://building` enabled = abap_true )
( title = `Fixed Item 3` icon = `sap-icon://card` enabled = abap_true ) ).
Two exceptions, both of them real:
- A padded row that would break the 255-character limit is wrapped instead —
at the SAME field boundaries in every row, so the columns still read down the
page. App 571 is the reference: 123 rows, every one of them
3+4+4 (identity /
dimensions / weight+price). Padding is then applied inside each group.
- Rows whose field list differs are left alone. Where one row carries a
key and the next does not, or some rows nest a child table
(app 585's t_navigation), there is no column to align — that is different
data, not sloppiness.
abap2UI5 serves a single default model — there are no named models. A sample
that binds against a named model (img>/products/pic1, a separate JSONModel,
sap/ui/demo/mock/*.json) must be flattened into the one default model:
merge the extra model's fields into the row type, or — for pure display assets
like image URLs that are the same for every row — inline them as literals /
build them from a shared base (a non-bound base_url kept in PROTECTED, not
PUBLIC, so the round-trip model scan stays small).
Deviation type for the flattening: a pure prefix-drop that renders
identically — same data, same leaf name, structural-diff 0 diffs
({ui>/rowMode}→{/ROWMODE}, {img>/products/pic1}→{/PIC1} with the real
value) — is faithful → NOTE. Use IMPROVISED only when the fold
actually loses or changes something: drops bound columns, resolves a live
model statically, or substitutes values with ones the original never shows.
A URL that is merely re-HOSTED is not a substitution: app 006 folds
img>/products/pic1..3 to the mock's own values on sdk.openui5.org and
therefore renders identically, which is why its sidecar types that fold as a
NOTE — this guide cited it as the IMPROVISED example until 2026-08-21,
contradicting the rule in the sentence above it and guaranteeing that every
review sweep would re-find the disagreement. When
binding a single record the original bindElements (/SupplierCollection/0),
seed those fields at the default-model root — and then bind them
absolutely (client->_bind( suppliername )), not with the original's
relative {SupplierName}: without the element binding there is no context for
a relative path to resolve against (see the flattened-element-binding trap
below; the linter rule is relative-binding-without-context). Seed the
actual mock row-0 values,
verified against the mock, not a neighbour port (app 162/142 had copied wrong
values). Worked examples: app 006 (sap.m.Carousel, img> → the mock's own
values, re-hosted — a NOTE); app 175 (SimpleForm, supplier row-0 flatten).
Absent JSON properties must not become empty strings. A flat ABAP row
serializes every field on every row; where the original JSON simply omits a
property, the port sends "" — and UI5 rejects "" on enum-typed
properties (validateProperty throws where the original's undefined
picked the default) and overrides non-empty property defaults (e.g.
Link.target _blank). Fill the UI5 default value explicitly in the ABAP
data, or split the aggregation into per-shape templates (the QuickView port's
QuickViewGroupElementType/AvatarShape crashed every page this way).
It takes the whole VIEW down, not the row, and the miss is usually a single
row: validateProperty throws inside a binding update, which
ManagedObjectBindingSupport re-throws. The corpus sweep measured it —
"3,285 row-build sites across 128 (port, table) pairs examined, 10 defects in 6
ports" — and the shape to expect is 536's and 538's: one model row, out of
46 and out of 37, without the field. A sibling row, or a sibling port, that does
seed it is no evidence the row beside it does — and fixing one enum on a row does
not fix the one next to it: 549's type was seeded by a linter rule that could
not see the aria omission on the same INSERT, and 547 had the aria half of
the identical pair fixed while the type half was not.
Read the default out of the UI5 source, and do not take it from the property
next to it. CalendarAppointment.ariaHasPopup defaults to None
(CalendarAppointment.js:120), but type defaults to Type01, inherited
from DateTypeRange.js:43 — CalendarAppointment does not override it. App 546
was once seeded type = None beside a comment saying the original "falls back
to the property default": None is secondaryType's default, so the port
rendered a different colour from the original and nothing failed.
The boolean case is the quiet one: abap_bool has no absent state either,
so an unset field serializes as a real JSON false and OVERRIDES a control
default of true. Nothing crashes — the control simply renders the opposite of
the sample. App 291's notification items lost their close button that way, and
with it the port's only backend wire, because there was nothing left to press.
Check every boolean the mock does not carry against the control's
defaultValue, remembering it is usually declared on a BASE class
(showCloseButton lives on NotificationListBase, not on the
NotificationListItem the view names).
scripts/probes/absent-boolean-probe.mjs scans the corpus for it.
view_display — the view via z2ui5_cl_ui5_view_builder
Build the view with the generic builder z2ui5_cl_ui5_view_builder. The class lives
in abap2UI5 core (src/02/, migrated from this repo) and resolves through the
abap2UI5 abaplint dependency. It translates a
UI5 XML view 1:1 by method chaining — every control, property and namespace maps
directly, nothing is approximated. The navigation verbs are short so the )->
arrows line up:
| Verb | XML meaning | Tree action | Returns |
|---|
ele( n ns ) | open a container tag <X> | add child and descend into it | the new child |
tag( n ns ) | a self-closing tag <X/> | add child, stay on current node | the same node |
end( ) | the closing </X> | ascend to the parent | the parent |
a( n v ) | one name="value" | add an attribute to the control just added | the same node |
Arguments: n = tag name, ns = namespace prefix (literal f, l, core,
mvc — omitted for the default sap.m namespace).
Attributes go through a( n = keyv =value ), chained right after the
control's ele/tag. a always targets that control (the last-added child,
or the node itself if none yet), so it works after both ele and tag. v is
any string expression — a literal, a client->_bind( … ) / _event( … ) result,
or a |…| template. For an ABAP boolean pass b instead of v (see Booleans
below); ele( )/tag( ) take n and ns only — there is no up-front
attribute table, every attribute gets its own a( ).
Both named XML aggregations (<headerToolbar>, <layoutData>) and controls are
just ele/tag calls — an aggregation is a nameless-namespace ele with no
attributes, e.g. )->ele( \headerToolbar` )(positional — a single namedn =would trip abaplint'somit_parameter_name`).
An aggregation carries the same ns= as the tag has in the XML — which is
its parent control's namespace, not the default one. <m:content> under an
sap.m.Page is )->ele( n = \content` ns = `m` ); but a default-namespace aggregation like /