modernize-ui5-app — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited modernize-ui5-app (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Convert a legacy UI5 freestyle JavaScript app (typical 2018–2021 era — sync bootstrap, JS controllers, jQuery.sap.*, global formatter, ES5 patterns, no types, sap_belize) into a modern UI5 TypeScript app on a recent 1.x release with async loading, manifest-driven configuration, a proper BaseController, sap_horizon theme, ES modules, typed event handlers, and clean ui5-linter + tsc --noEmit output. Runs side-by-side: the legacy app stays untouched at <source_app>/; the modern app lands in <modern_app>/.
This skill is one of two parallel UI paths after the RAP backend lands. Pick this one if the target architecture is a freestyle TypeScript app (custom controllers, manual binding, explicit i18n). Pick convert-ui5-to-fiori-elements.md instead if the target is a Fiori Elements V4 app (annotation-driven; minimal custom code). Both start from the same legacy JS app + the same V4 RAP service produced by migrate-segw-to-rap.
migrate-segw-to-rap.md (backend: SEGW V2 → RAP V4)
│
┌─────────────┴─────────────┐
▼ ▼
modernize-ui5-app.md convert-ui5-to-fiori-elements.md
(freestyle TS) (Fiori Elements V4)Path/namespace placeholders.<source_app>/,<modern_app>/,<source_namespace>,<modern_namespace>are user-provided. Defaults: source islegacy-*-app/(sibling of the target); modern app namespace is derived from source by appending.modern.
| MCP | Used for | When |
|---|---|---|
UI5 MCP (mcp__SAPUI5_MCP_Server__*) | Authoritative TS conversion guidelines, general UI5 guidelines, app scaffolding, API reference lookups, linter, manifest validator, version info | Throughout — this is the primary MCP for this skill |
sap-docs MCP (mcp__sap-docs__*) | OData V4 binding patterns, draft handling, control documentation | When V2→V4 binding behaviour is non-obvious (e.g. composite key on draft, $expand=_Tasks, action invocation) |
| arc-1 MCP | OPTIONAL — service binding URL lookup, status-code semantics | Only if the V4 URL isn't readily available (e.g. you can't read the FE app's manifest); skip otherwise |
| fiori-mcp | NOT USED | This is a freestyle TS app, not Fiori Elements — no annotations to generate |
There is no "convert to TS" tool in the UI5 MCP. Conversion is mechanical and driven by mcp__SAPUI5_MCP_Server__get_typescript_conversion_guidelines, which returns the authoritative playbook. You call it once at the start and follow it verbatim.
Also use `mcp__sap-docs__search` during the run whenever a UI5 best-practice is non-obvious or contested (FCL routing, V4 binding semantics, draft handling, accessibility, theming). The SAP help portal indexed there is authoritative and version-specific — quote it rather than guessing.
Past runs of this skill tripped on three issues that took multiple iterations to diagnose. They are easy to avoid if you know about them up front; brutal if you don't. Even a strong LLM lost ~15-20 minutes debugging "blank page, no console error" because of Traps 1 + 2.
Symptom: Both views are routed correctly, console is clean, accessibility tree shows content for both columns — but visually only the left column renders.
Root cause: sap.f.routing.Router only changes the FCL layout property when the matched route explicitly declares one. A route with no layout leaves FCL at its initial value (OneColumn), so the mid / end columns stay hidden even though the router placed targets in them.
Fix: Every FCL route MUST have a layout property. Not just detail/drill-down — also the home / main / not-found route, every route that targets anything other than the beginColumnPages aggregation alone.
"routes": [
{
"pattern": "",
"name": "main",
"target": ["main", "welcome"],
"layout": "TwoColumnsMidExpanded" // ← REQUIRED — Welcome lives in mid column
},
{
"pattern": "Project/{projectId}",
"name": "detail",
"target": ["main", "detail"],
"layout": "TwoColumnsMidExpanded" // ← REQUIRED
}
]Symptom: Console is clean, accessibility tree shows all content, but visually the page is blank. DevTools confirms every nested element has height: 0 cascading from the body's component div.
Root cause: UI5's ComponentSupport module strips the `data-sap-ui-component` attribute from the body's container div during processing. CSS selectors that match against that attribute (body > div[data-sap-ui-component] { height: 100% }) stop matching after ComponentSupport runs, so the wrapper div has no height and data-height="100%" resolves to 100% of 0.
Fix: Put style="height: 100%" inline on the component div in index.html. CSS selectors keyed on data-sap-ui-component are not reliable here.
<body class="sapUiBody" id="content">
<div
data-sap-ui-component
data-name="<source_namespace>.modern"
data-id="container"
data-height="100%"
style="height: 100%"></div> <!-- ← REQUIRED inline, not in <style> -->
</body>import Event from "sap/ui/base/Event" is a code smellSymptom: Strange casts like (event.getParameters() as { listItem?: ListItemBase }).listItem appear in the controller. TypeScript can't see event parameters; you compensate with as casts on getParameter or getParameters return values.
Root cause: Importing the generic Event type from sap/ui/base/Event forfeits the strong typing UI5 ≥ 1.115 provides. UI5 generates <Control>$<Event>Event and <Control>$<Event>EventParameters types for every control event — those are what to import.
Fix: If you find yourself writing import Event from "sap/ui/base/Event", stop and find the right specific type. Common ones for this skill's surface:
| Event source | Specific type | Module |
|---|---|---|
sap.m.List / sap.m.Table selectionChange | `ListBase$SelectionChangeEvent` | `sap/m/ListBase` |
sap.m.List / sap.m.Table itemPress | ListBase$ItemPressEvent | sap/m/ListBase |
sap.m.List / sap.m.Table updateFinished | ListBase$UpdateFinishedEvent | sap/m/ListBase |
sap.m.List / sap.m.Table delete | ListBase$DeleteEvent | sap/m/ListBase |
sap.m.ListItemBase press | ListItemBase$PressEvent | sap/m/ListItemBase |
sap.m.SearchField liveChange | SearchField$LiveChangeEvent | sap/m/SearchField |
sap.m.Button press | Button$PressEvent | sap/m/Button |
sap.ui.core.routing.Route patternMatched | Route$PatternMatchedEvent | sap/ui/core/routing/Route |
Important — ListBase inheritance:sap.m.Listandsap.m.Tableinherit their selection / item-press / update / delete / swipe events fromsap.m.ListBase. The TS event types live onsap/m/ListBase, not onsap/m/Listorsap/m/Table. Confirmed in the SAP Help portal: "Both sap.m.List and sap.m.Table offer the same events, inheriting them from sap.m.ListBase." Don't go looking forTable$SelectionChangeEvent— it doesn't exist; useListBase$SelectionChangeEventfromsap/m/ListBaseand TypeScript accepts it for both controls'selectionChangeevents without casts.
If you can't find a specific event type for a UI5 ≥ 1.115 control, call mcp__SAPUI5_MCP_Server__get_api_reference(query="sap.m.<Control>#<eventName>") to confirm the typed name exists. Do not fall back to the generic Event.
onApprove(event: Button$PressEvent) — declare with no parameterSymptom: ESLint flags _event (or event) as no-unused-vars. You drop the parameter, then ESLint flags the now-orphaned import { Button$PressEvent } as no-unused-imports. Two edits, two re-lint cycles, one minute lost.
Root cause: UI5 button-press handlers typically don't use the event payload — oCtx, projectId, etc. all come from this.getView() or from class fields populated by route matching. Declaring event: Button$PressEvent is a knee-jerk reflex from the typed-events rule that doesn't apply here.
Fix: declare press handlers with no parameters when you don't read the event:
// CORRECT:
public async onApprove(): Promise<void> {
const projectId = this.projectId;
// ...
}
// WRONG — generates two lint errors that need two edits to fix:
public async onApprove(_event: Button$PressEvent): Promise<void> {
void _event; // dead code
const projectId = this.projectId;
}The same applies to onNavBack, onItemPress (when you derive the source from this.byId not the event), and any other handler where the event payload is unused.
"type": "View" — routes match but nothing rendersSymptom: Page is blank. FCL columns exist with the right widths but each NavContainer is empty. Console is otherwise clean except for one easily-missed warning a millisecond after each route match:
page stack is empty but should have been initialized -
application failed to provide a page to displayRouting logs show the route matches correctly ("The route named 'main' did match"), but no view ever gets placed in any column.
Root cause: With "_version": "2.0.0" or higher (introduced in UI5 1.136 — the same version we're targeting in this skill), the older routing keys viewName, viewPath, viewLevel are removed and replaced by name, path, level — and a routing target no longer has an implicit `"type": "View"` default. Without an explicit type, target resolution silently produces nothing.
Source: SAP Help, "Migration Information for Upgrading the Manifest File", the 2.0.0 (1.136) "Deprecated Manifest Entries" row: "The routing properties ViewId, viewName, viewPath and viewLevel can no longer be used. Please use the documented alternatives by replacing them with the properties id, name, path and level, respectively along with adding the `type: "view"`."
Fix: in the routing block of manifest.json:
"type": "View" to routing.config (so it's the default for all targets in theblock — saves repeating it).
path (NOT viewPath) for the view-folder namespace.name (NOT viewName) on each target — and add "type": "View" per target too(belt + braces; some UI5 versions don't propagate the config default reliably).
level (NOT viewLevel) on each target."routing": {
"config": {
"routerClass": "sap.f.routing.Router",
"type": "View", // ← REQUIRED in manifest v2
"viewType": "XML",
"path": "<ns>.view", // ← NOT "viewPath"
"async": true,
"controlId": "flexibleColumnLayout",
"controlAggregation": "beginColumnPages",
"bypassed": { "target": "notFound" }
},
"routes": [ /* ... layout property per Trap 1 ... */ ],
"targets": {
"main": {
"type": "View", // ← REQUIRED per target
"id": "main",
"name": "Main", // ← NOT "viewName"
"level": 1, // ← NOT "viewLevel"
"controlAggregation": "beginColumnPages"
},
// ... other targets
}
}This shape works in both manifest v1.x and v2.x — adding type: "View" is backwards-compatible (it became available in 1.14.0 / UI5 1.62). The new key names (name/path/level) are also backwards-compatible. So always emit the v2 shape, even if you're not sure whether the project ends up on v1 or v2 — there's no downside.
Quick diagnostic when you see this: open the browser console, search for "page stack is empty". If you find it: you have Trap 5. If you don't, you're probably looking at Trap 1 (missing layout) or Trap 2 (height cascade).
Why the validators DON'T catch this (verified empirically against @ui5/[email protected] and @ui5/[email protected], the same versions wrapped by the UI5 MCP):
| Tool | Catches Trap 5? | Why |
|---|---|---|
run_ui5_linter (@ui5/linter) | No | The no-removed-manifest-property rule exists but only checks resources/js, rootView/async, routing/config/async. Does NOT check routing targets for viewName/viewPath/viewLevel. The linter source reads target.name ?? target.viewName — it gracefully accepts either, without warning. |
run_manifest_validation (@ui5/manifest schema + Ajv) | No | The schema marks viewName/viewPath/viewLevel as "deprecated": true with a description pointing to the v2 replacement, but Ajv with the MCP's strict: false config ignores the deprecated flag. The schema also has both legacyTargetAddition AND actualTargetAdditionStandard as alternatives side-by-side (no _version-conditional if/then branch), so a manifest with viewName validates as isValid: true. |
npm run ts-typecheck | No | Manifest is JSON — TypeScript doesn't see it. |
eslint webapp | No | ESLint doesn't model UI5 manifest semantics. |
| Browser runtime | Indirectly | The "page stack is empty but should have been initialized" console warning is the ONLY automated signal, and it only fires at runtime, not at lint time. |
Implication for the skill: the Phase 7 acceptance gates (linter / manifest validation / ts-typecheck) WILL all report green for a v2 manifest with the deprecated routing keys. The ONLY honest gate for this trap is the Phase 8d browser-render verification — that's why Phase 8d is mandatory, not optional. Don't believe a "clean lint + clean validation" report means the routing config is correct.
If you have the cycles to file an upstream issue, this is a clear gap in @ui5/linter's no-removed-manifest-property rule — adding routing.targets[].viewName/viewPath/viewLevel to the checked-property list would close it.
The skill captures common patterns, but every project has its own quirks. When you hit something the skill doesn't cover, investigate before guessing. UI5 has a fragmented middleware ecosystem and a deep type system; an educated guess often gets the wrong key name or the wrong inheritance branch. The investigations below cost 30 seconds and save iterations.
Trigger: you're setting a config key on a UI5 middleware (ui5-middleware-simpleproxy, ui5-middleware-livereload, fiori-tools-proxy, ui5-tooling-transpile, anything in ui5.yaml's customMiddleware or customTasks).
Why investigate: UI5 middlewares silently ignore unknown configuration keys — no error, no warning, but they do the wrong thing. The naming conventions differ across packages: TLS-skip is strictSSL: false in ui5-middleware-simpleproxy, ignoreCertErrors: true in fiori-tools-proxy, and other names elsewhere. Don't extrapolate from one to another.
Recipe:
Bash: cat <target>/node_modules/<package-name>/README.md
# or:
WebFetch: https://www.npmjs.com/package/<package-name>
WebFetch: https://github.com/<owner>/<package-name>If the README isn't local yet (pre-npm install), use WebFetch or gh api against the GitHub mirror.
Trigger: you're typing an event handler parameter and you're not sure what the specific <Control>$<Event>Event type is called or where it lives.
Why investigate: events are inherited — they're defined on a parent class and reused by subclasses. Looking for Table$SelectionChangeEvent returns nothing because selectionChange is defined on ListBase, not Table. The Trap 3 table covers the events this skill commonly hits; for anything else, ask the UI5 MCP.
Recipe:
mcp__SAPUI5_MCP_Server__get_api_reference(
projectDir="<absolute target>",
query="sap.m.<Control>#<eventName>"
)Read the result for: (a) which class actually defines the event (= which module to import from), (b) the canonical event type name. If query returns nothing, search broader (query="sap.m.<Control>") and inspect the event list; the type name is <DefiningClass>$<EventName>Event.
Trigger: you're writing V4-specific code (composite keys, $expand=_X navigation, action invocation, draft handling, batched updates) and you're not sure of the canonical pattern.
Why investigate: V4 differs from V2 in non-obvious ways, and the difference is version-specific. Guessing usually costs a tsc cycle or a runtime "no metadata" error.
Recipe:
mcp__sap-docs__search(
query="<feature> OData V4 model UI5",
sources=["sapui5","sap-help"],
includeOnline=true
)
mcp__sap-docs__fetch(id="<best result id>")Useful starting topics (search these terms verbatim):
$expand / $select"Trigger: FCL renders the wrong column count, columns flicker, deep-link doesn't restore layout, "Close" button absent.
Why investigate: sap.f.routing.Router is layout-driven. Almost every FCL surprise is a missing or wrong layout value on the route — not a view-XML bug.
Recipe: open manifest.json, audit every entry under routing.routes[] for a layout property. The home/main route needs one too (Trap 1). If you're not sure which LayoutType enum value to use, search:
mcp__sap-docs__search(query="sap.f.LayoutType FCL three-column")Trigger: accessibility tree shows content, console is clean, viewport is empty.
Why investigate: height cascading. Some ancestor element resolves to height: 0 and collapses every descendant. The trap is usually a data- attribute selector that doesn't match because ComponentSupport stripped it (Trap 2), but other height-cascade variants exist (e.g. <body> without height: 100%, a Page with implicit container width but no height).
Recipe: in browser DevTools, click the body, walk the descendant tree in the Elements panel, watch the computed height. The first element with 0 is where the chain breaks. Apply style="height: 100%" inline (preferred) or a CSS selector keyed on a non-stripped attribute (id, class — never data-sap-ui-*).
Trigger: npm run ts-typecheck is clean, but ui5lint flags issues — and tsc won't help diagnose them.
Why investigate: ts-typecheck checks types; ui5-linter checks UI5-runtime concerns (deprecated APIs, framework conventions, manifest cross-references, XML view binding correctness). They're orthogonal. The right order is eslint --fix first (cleans mechanical TS-level noise), then ui5-linter (catches the UI5-specific issues), then tsc (any remaining type errors).
If ui5-linter complains about a finding you don't understand, request context:
mcp__SAPUI5_MCP_Server__run_ui5_linter(
projectDir="<absolute target>",
filePatterns=["<the file>"],
provideContextInformation=true
)The provideContextInformation: true flag returns API-reference excerpts and documentation links explaining each finding.
Trigger: "should I use Form or SimpleForm?", "should this be a JSONModel or a path?", "is core:require the right way to load this formatter?", "should the manifest declare a specific theme?"
Why investigate: SAPUI5 has version-specific guidelines. The MCP returns the authoritative list for the project's version.
Recipe — start with the two pinned guideline tools:
mcp__SAPUI5_MCP_Server__get_guidelines # general UI5 dev rules
mcp__SAPUI5_MCP_Server__get_typescript_conversion_guidelines # TS-specific rulesIf the answer isn't in those, escalate to sap-docs:
mcp__sap-docs__search(query="<your question>")Trigger: you've written code, hit an error, fixed it, and the same shape of error recurred on a different file or step. That's a signal the skill is missing a generally-applicable pattern.
Action: capture it as a Run-Notes entry with: symptom, root cause, fix, and a one-line "generic rule" extracted. After the run, propose adding it to the Critical Traps section if it's likely to recur in future projects.
These two conventions override the older SAPUI5 JS Coding Guidelines. Apply silently — do not preserve the legacy style during conversion.
| Convention | Older SAPUI5 JS Guidelines | This skill's rule |
|---|---|---|
| Variable prefixes | Hungarian recommended (oModel, sQuery, iTotal, aFilters, mArgs, fHours, bFlag) | No Hungarian notation. Use plain names: model, query, total, filters, args, hours, flag. TypeScript types make the prefix redundant. |
| "Main" entity name | "master" (e.g. Master.view.xml, i18n key masterTitle) | "main" everywhere — controller / view / route / target / file / i18n keys. Aligns with SAP's own Inclusive Language guide ("master branch → main branch"). |
Rename map for the typical SEGW-to-RAP demo surface:
| Legacy | Modern |
|---|---|
Master.controller.js | Main.controller.ts |
Master.view.xml | Main.view.xml |
controllerName="...controller.Master" | controllerName="...modern.controller.Main" |
route "name": "master" | route "name": "main" |
target "master" | target "main" |
i18n key masterTitle | mainTitle |
i18n key masterSearchPlaceholder | mainSearchPlaceholder |
i18n key masterCount | mainCount |
var oModel = ... | const model = ... |
var sQuery = ... | const query = ... |
var iTotal = ... | const total = ... |
var aFilters = ... | const filters = ... |
var oCtx = ... | const ctx = ... (or const context = ... if clearer) |
var oList = ... | const list = ... |
var oRouter = ... | const router = ... |
var oEvent = ... (parameter) | event |
var that = this; | drop entirely; use arrow function |
Exception: keep a prefix only when dropping it would collide with a reserved word, a same-named import, or a UI5 control name (e.g. local const event = ... collides with no common UI5 import, so it's fine; but const Date = ... would shadow the global, so call it oDate or rename it workDate).
Include a "Naming overrides:" line in the Phase 2 plan output so the user can confirm them for this run.
Atomic-rename tip: the master → main rename touches at minimum 7 locations: file name, view controllerName attribute, manifest route name, manifest target name + key + viewName, i18n keys × 2 locale files, BaseController.onNavBack fallback, every navTo("master", ...) call in any controller. To avoid re-edits, grep up-front to enumerate all hits, then edit in one batch:
Bash: grep -rn -E "master|Master" <target>/webappReview the output, decide which hits are renames (not, e.g., the word "master" inside a sentence in the legacy German comments — those should be dropped anyway). Edit all in one pass, then verify with a second grep that returns empty.
| Setting | Default | Rationale |
|---|---|---|
| Source app | <source_app>/ | The freestyle JS app under the workspace |
| Target app | <modern_app>/ | Empty folder reserved for this skill's output |
| Target UI5 version | 1.147.2 | Latest 1.x at writing; matches the FE app in the demo; aligned with 2.0-API |
| Framework | SAPUI5 | Matches the legacy app's ui5.yaml and the FE app's runtime |
| Language | TypeScript | Per get_typescript_conversion_guidelines |
| Types package | @sapui5/[email protected] | Required by UI5 MCP TS guidelines (NOT the older sap-ui5-types typo) |
| App namespace | Source namespace + .modern (e.g. <source_namespace>.modern) | Distinguishes from legacy in routing; keeps grep continuity |
| Theme | sap_horizon | UI5 1.108+ default; legacy sap_belize is deprecated |
| Layout | Translate sap.m.SplitApp ➜ sap.f.FlexibleColumnLayout (FCL) | Modern responsive default for main + detail |
| Naming | No Hungarian prefixes; "main" not "master" — see "Naming overrides" section above | Aligned with TS-idiomatic names + SAP Inclusive Language |
| Bootstrap | data-sap-ui-async="true" + data-sap-ui-on-init="module:sap/ui/core/ComponentSupport" | Per UI5 guidelines; sync is deprecated and breaks 2.x |
| Manifest version | _version: 1.60.0 or later | Required for sap.app.dataSources + declarative models |
| OData model | The V4 service produced by migrate-segw-to-rap (or any V4 service the user names), via dev-server proxy. Pattern: /sap/opu/odata4/sap/<service_binding>/srvd/sap/<service_binding>/0001/ for SRVD-direct, or check $metadata of a sibling reference app if one exists | V4 demonstrates the modern pattern; same backend as any FE app you've already built |
| Routing | sap.m.routing.Router ➜ sap.f.routing.Router with per-FCL-column targets | FCL needs the f-router |
| BaseController | Required | Single source of truth for getRouter, getModel, getResourceBundle, getOwnerComponent |
| Event types | Use <Control>$<Event>Event (e.g. Button$PressEvent) | UI5 ≥ 1.115 supports them; UI5 guideline says MUST use |
| Formatters | OData types (sap.ui.model.odata.type.*) first; custom only for unique business logic | Per UI5 guideline §1 |
| Forms | sap.ui.layout.form.Form + ColumnLayout if any | Never SimpleForm (UI5 guideline §4) |
| Casts | Real control types (as Button), never as any / as unknown as ... | Per TS conversion §General Rules |
| Tests | OPA5 + QUnit skipped from first cut | Promoted to follow-up if Run 1 is green |
| Linter | mcp__SAPUI5_MCP_Server__run_ui5_linter → 0 findings | Hard acceptance criterion |
| Manifest validation | mcp__SAPUI5_MCP_Server__run_manifest_validation → 0 errors | Hard acceptance criterion |
| Type check | npm run ts-typecheck (script added to package.json) → 0 errors | Hard acceptance criterion |
The user provides one of:
<source_app>/<modern_app>/ (skill infers source as the sibling legacy-*)<source_app>/ ➜ <modern_app>/)If both folders exist and the target is non-empty, ask: "`<target>/` already has content. Wipe it and start over, or migrate into the existing structure?" Default to wipe-and-rewrite for the demo.
Bash: cat <legacy>/webapp/manifest.json
Bash: ls <legacy>/webapp/{controller,view,model,fragment,i18n}Assert: manifest.json parses; webapp/controller/ and webapp/view/ both exist; at least one *.controller.js and one *.view.xml are present. If any of these fail, stop with "`<legacy>` does not look like a UI5 app — check the path."
mcp__SAPUI5_MCP_Server__get_version_info(frameworkName="SAPUI5")Assert: returns at least 1.147.x in the version map. If the tool errors, stop with "UI5 MCP server is not configured; configure it in `.cursor/mcp.json` before running this skill."
mcp__SAPUI5_MCP_Server__get_typescript_conversion_guidelines
mcp__SAPUI5_MCP_Server__get_guidelinesRead both responses fully. They are the source of truth for:
@ui5/cli, typescript, ui5-tooling-transpile, ui5-middleware-livereload, typescript-eslint)tsconfig.json shape (target: es2023, module: es2022, types: ["@sapui5/types"], paths map)ui5.yaml shape (ui5-tooling-transpile-task + ui5-tooling-transpile-middleware)any, never unknown as ... — use real control typesButton$PressEvent from sap/m/Button, not Event from sap/ui/base/EventThese tool responses are large and version-specific — do not paraphrase from memory; quote them when you need to.
Bash: node --version && npm --versionAssert: Node 22+ (CLAUDE.md requirement) and npm 10+.
Read every relevant file in <legacy>/webapp/. Classify findings as blocker (must fix for modern UI5), cleanup (worst-practice but works), or cosmetic (style).
Pull _version, sap.ui5.dependencies.minUI5Version, sap.ui5.rootView, sap.ui5.dependencies.libs, sap.ui5.routing, sap.ui5.models, sap.ui5.contentDensities, sap.ui5.resources, theme references.
Common legacy patterns:
_version: 1.40.0 ➜ blocker (target 1.60+)minUI5Version < 1.108 ➜ blocker (no async guarantees)routerClass: "sap.m.routing.Router" with FCL target ➜ blockersap.ui5.models ➜ cleanup (move to sap.app.dataSources)contentDensities: { compact: true, cozy: true } ➜ cleanup (still supported, just move to manifest)supportedThemes: ["sap_belize"] ➜ cleanup (drop; sap_horizon is default)Read <legacy>/webapp/Component.js. Look for:
jQuery.sap.require("...") ➜ blocker (delete; use ES imports)new sap.ui.model.odata.v2.ODataModel(...) in init ➜ blocker (move model to manifest sap.ui5.models[""])device model creation ➜ keep (it's normal, but typed in TS)sap.ui.model.BindingMode.TwoWay for OData V4 ➜ blocker (V4 prefers OneWay reads + explicit edits)useBatch: false ➜ N/A for V4 (V4 always batches differently)jQuery.sap.require in Component) ➜ blockerFor each <legacy>/webapp/controller/<X>.controller.js:
Read: <legacy>/webapp/controller/<X>.controller.jsFlag:
var that = this; followed by closures ➜ cleanup (arrow functions)oCtx.getPath() regex parsing (.replace(/^\/Set\('/, "")) ➜ blocker (use oCtx.getProperty("Key"))sap.ui.getCore().byId(...) ➜ cleanup (this.byId(...))sap.ui.getCore().getModel(...) ➜ blocker (this.getOwnerComponent()!.getModel(...) with cast)sap.ui.core.UIComponent.getRouterFor(this) ➜ cleanup (move to BaseController)sap.m.MessageBox accessed as global ➜ blocker (import MessageBox from "sap/m/MessageBox")jQuery.sap.require(...) ➜ blocker (ES import)window.com.demo.formatter.X) ➜ blocker (consolidate into formatter module, import in views via core:require)oModel.callFunction("/X", {method:"POST", urlParameters: {...}}) (OData V2 function-import) ➜ blocker (translate to V4 action: model.bindContext("/Action(...)").execute())setTimeout(..., 500) for "refresh after data arrives" ➜ blocker (use binding events, e.g. dataReceived)For each <legacy>/webapp/view/<X>.view.xml:
Read: <legacy>/webapp/view/<X>.view.xmlFlag:
press="onSomething") referencing methods that don't exist ➜ blocker (will throw)<core:Fragment fragmentName="..."> without async ➜ cleanupsap.ui.commons.*) ➜ blockersap.m.SplitApp root ➜ blocker (translate to FCL per smart-defaults)formatter: 'window.com.demo.X.statusText') ➜ blocker (convert to core:require of formatter module + formatter: '.formatter.statusText')enabled="{= ${Status} === 'D' }" (expression binding) ➜ keep but adapt to V4 key (no Status ➜ OverallStatus etc., depending on the V4 model)For <legacy>/webapp/model/formatter.js:
jQuery.sap.declare(...) + window.com.demo... global namespace ➜ blocker (rewrite as ES module with export function)sap.ui.core.format.DateFormat.getDateInstance(...) instantiated per call ➜ cleanup (memoize once at module top-level)string | undefined ➜ stringOutput a structured summary to the user:
Discovery — legacy app: <legacy>
Manifest version: 1.40.0 (target 1.60+)
UI5 version: 1.84.x (target 1.147.2)
Controllers: 3 (App, Master, Detail)
Views: 5 (App, Master, Detail, Welcome, NotFound)
Layout: sap.m.SplitApp (target sap.f.FlexibleColumnLayout)
Theme: sap_belize (target sap_horizon)
OData: V2 hardcoded in Component.init (target V4 via manifest dataSources)
Blockers (<n>):
- Component.js: hardcoded service URL + manual ODataModel construction
- Component.js: jQuery.sap.require(...)
- formatter.js: window.com.* global namespace
- Master.controller.js: getPath() regex parsing of V2 entity key
- Detail.controller.js: oModel.callFunction("/ApproveProject", ...) for V2 function-import
- Detail.controller.js: setTimeout(..., 500) for counter refresh
- *.view.xml: window.com.* formatter paths
- App.view.xml: SplitApp root
- ...
Cleanups (<n>):
- Master.controller.js: `var that = this;` pattern (4×)
- All controllers: `sap.ui.core.UIComponent.getRouterFor(this)` direct calls
- ...
Cosmetic (<n>):
- Missing JSDoc across controllers
- ...Print the migration plan in this exact format and STOP for ok / edit / question:
Plan — modernize <legacy> ➜ <target>:
UI5 version: 1.147.2 SAPUI5 (TypeScript)
Types: @sapui5/[email protected]
Namespace: <source_namespace>.modern
Theme: sap_horizon
Layout: sap.f.FlexibleColumnLayout (translated from SplitApp)
OData: V4 — <v4_service_url>
Proxy: ui5-middleware-simpleproxy → <sap_baseuri>
(auth via .env: UI5_MIDDLEWARE_SIMPLE_PROXY_{USERNAME,PASSWORD})
Naming overrides: - No Hungarian prefixes (TS types replace the hint)
- "main" instead of "master" for controller / view / route / i18n keys
(legacy Master.controller.js ➜ Main.controller.ts, etc.)
Files to generate in <target>/webapp:
Component.ts
manifest.json (v1.60.0)
index.html (ComponentSupport bootstrap, async, inline height fix)
controller/BaseController.ts
controller/App.controller.ts
controller/Main.controller.ts ← renamed from Master per naming overrides
controller/Detail.controller.ts
view/App.view.xml (FCL root)
view/Main.view.xml ← renamed from Master
view/Detail.view.xml
view/Welcome.view.xml
view/NotFound.view.xml
i18n/i18n.properties (translated keys: masterX → mainX) + i18n_en.properties
css/style.css (copied from legacy if non-empty)
model/models.ts (device-model helper only)
model/formatter.ts (consolidated; ES module export)
Files at <target>/ root:
package.json (with @ui5/cli, typescript, ui5-tooling-transpile, ui5-middleware-livereload,
ui5-middleware-simpleproxy, @sapui5/types)
ui5.yaml (specVersion 4.0; transpile + livereload + simpleproxy middleware)
tsconfig.json (target es2023, module es2022, strict, allowJs)
.env.example
.gitignore
V2 ➜ V4 binding migrations (generic — adapt to your service's entity names):
/<EntitySet> ➜ /<Entity> (drop "Set" suffix)
/<EntitySet>('K') ➜ /<Entity>(<Key>='K') (named-key style)
expand: '<Nav>' ➜ $expand=_<Nav> (V4 RAP prefixes assocs with _)
oModel.callFunction(...) ➜ model.bindContext("/<Action>(...)").execute()
fieldName remapping ➜ inspect $metadata; RAP often renames fields between V2/V4
Blockers being fixed: <count>
Cleanups applied: <count>
Cosmetic skipped: <count> (separate prettier pass if desired)
Tests in this skill: none (OPA5/QUnit follow-up)
Acceptance: ui5-linter clean + manifest validation clean + tsc clean + npm start
renders Master list
Type `ok` to proceed, `edit` to revise, or ask any question.Wait for ok before mutating anything in <target>/.
If the user confirmed wipe-and-rewrite:
Bash: rm -rf <target>/* <target>/.[!.]* # safely empty <target>/ while keeping the folderCall create_ui5_app directly into <target>/ (NOT into a sub-folder):
mcp__SAPUI5_MCP_Server__create_ui5_app(
appNamespace = "<source_namespace>.modern",
basePath = "<absolute path to target>",
createAppDirectory = false,
framework = "SAPUI5",
frameworkVersion = "1.147.2",
typescript = true,
initializeGitRepository = false,
runNpmInstall = true
)oDataV4Url is intentionally omitted here — the V4 service is behind a proxy with credentials, so URL validation will fail. The data source is added manually in Phase 4.Bash: ls -la <target>/ && ls <target>/webapp/Expected at root: package.json, ui5.yaml, tsconfig.json, webapp/ with at minimum Component.ts, manifest.json, view/App.view.xml, controller/App.controller.ts, index.html, i18n/i18n.properties.
If create_ui5_app produces JS files instead of TS, fail loud — do NOT silently fall back to manual scaffolding.
Read: <target>/package.jsonCross-check against get_typescript_conversion_guidelines output. The expected dev-deps include at minimum:
@ui5/clitypescriptui5-tooling-transpileui5-middleware-livereload@sapui5/types matching framework versionAdd anything missing (e.g. add ui5-middleware-simpleproxy for OData proxying). Update versions only if they're below the floor the guidelines specify; never downgrade.
Then run npm install again if the dep list changed.
Also confirm "ts-typecheck": "tsc --noEmit" exists in scripts (UI5 MCP guideline). Add if missing.
The UI5 MCP scaffold templates (Component.ts, BaseController.ts, the default *.controller.ts, models.ts) ship with Hungarian-prefixed parameter and local-variable names (sName, oModel, oParameters, etc.). The Naming overrides section above applies to the scaffold too — rename them as you read each scaffolded file, not just when porting from the legacy controllers.
The fastest workflow: scaffold first, then do one batch grep+rename pass over the scaffolded webapp/ for the half-dozen common prefixes (oModel, sName, oEvent, oParameters, etc.) BEFORE writing any new per-view controllers. This way the BaseController you're about to extend is already in modern style.
Read <target>/webapp/manifest.json, then merge in the legacy specifics:
sap.app.id = <source_namespace>.modern.sap.app.title / sap.app.description from i18n (already templated as {{appTitle}}).sap.app.icons from legacy if non-empty.sap.app.dataSources with the V4 service URL the user names (or, if uncertain, runmcp__sap-docs__search for the V4 binding pattern your backend uses; SRVD-direct on ABAP on-prem is typically /sap/opu/odata4/sap/<service_binding>/srvd/sap/<service_binding>/0001/):
"dataSources": {
"mainService": {
"uri": "<v4_service_url>",
"type": "OData",
"settings": {
"odataVersion": "4.0",
"localUri": "localService/metadata.xml"
}
}
}sap.ui5.dependencies.minUI5Version = "1.147.0"; libs: sap.ui.core, sap.m, sap.f,sap.ui.layout.
sap.ui5.models[""]: "": {
"type": "sap.ui.model.odata.v4.ODataModel",
"dataSource": "mainService",
"settings": {
"operationMode": "Server",
"autoExpandSelect": true,
"earlyRequests": true
}
}sap.ui5.rootView to { "viewName": "<ns>.view.App", "type": "XML", "id": "app" }.have type: "View" + use name/path/level (NOT viewName/viewPath/viewLevel)** (Trap 5):
"routing": {
"config": {
"routerClass": "sap.f.routing.Router",
"type": "View", // ← REQUIRED in manifest v2 (Trap 5)
"viewType": "XML",
"path": "<ns>.view", // ← NOT "viewPath"
"async": true,
"controlId": "flexibleColumnLayout",
"controlAggregation": "beginColumnPages",
"bypassed": { "target": "notFound" }
},
"routes": [
{
"pattern": "",
"name": "main",
"target": ["main", "welcome"],
"layout": "TwoColumnsMidExpanded" // ← REQUIRED (Trap 1)
},
{
"pattern": "<EntityRoute>/{<keyParam>}",
"name": "detail",
"target": ["main", "detail"],
"layout": "TwoColumnsMidExpanded" // ← REQUIRED (Trap 1)
}
],
"targets": {
"main": { "type": "View", "id": "main", "name": "Main", "level": 1, "controlAggregation": "beginColumnPages" },
"welcome": { "type": "View", "id": "welcome", "name": "Welcome", "level": 2, "controlAggregation": "midColumnPages" },
"detail": { "type": "View", "id": "detail", "name": "Detail", "level": 2, "controlAggregation": "midColumnPages" },
"notFound": { "type": "View", "id": "notFound", "name": "NotFound", "level": 3, "controlAggregation": "midColumnPages" }
}
}Note: type: "View" appears both on config AND on each target. The config-level default should propagate, but per-target redundancy is belt+braces against UI5-version differences in how the default is resolved.
sap.ui5.contentDensities = { "compact": true, "cozy": true } (declarative — fine).supportedThemes (sap_horizon is the default; no need to pin).Write the merged manifest:
Write: <target>/webapp/manifest.jsonValidate:
mcp__SAPUI5_MCP_Server__run_manifest_validation(manifestPath="<absolute path>/webapp/manifest.json")Fix anything it flags before moving on.
Open <target>/ui5.yaml. It already has ui5-tooling-transpile-task and ui5-tooling-transpile-middleware after Phase 3 (verify). Add the simpleproxy and livereload:
specVersion: "4.0"
metadata:
name: <source_namespace>.modern
type: application
framework:
name: SAPUI5
version: "1.147.2"
libraries:
- name: sap.m
- name: sap.ui.core
- name: sap.f
- name: sap.ui.layout
- name: themelib_sap_horizon
builder:
customTasks:
- name: ui5-tooling-transpile-task
afterTask: replaceVersion
server:
customMiddleware:
- name: ui5-tooling-transpile-middleware
afterMiddleware: compression
- name: ui5-middleware-livereload
afterMiddleware: compression
- name: ui5-middleware-simpleproxy
afterMiddleware: compression
mountPath: /sap
configuration:
baseUri: "<sap_baseuri>/sap" # e.g. https://abap-host:50001/sap or https://my-cf-host
strictSSL: false # only if upstream uses a self-signed cert (dev trials)
query:
sap-client: "<client>" # e.g. "001"; omit if your service doesn't need itConfig-key trap: the key isstrictSSL: false— notskipCertificateCheckorignoreCertErrors. Those names exist for other proxy middlewares (fiori-tools-proxyusesignoreCertErrors), butui5-middleware-simpleproxysilently ignores unknown keys, so a wrong key fails the HTTPS handshake without any error message. Verified against the middleware's README and confirmed working in the demo workspace.
Add ui5-middleware-simpleproxy to package.json's ui5.dependencies so ui5 tooling auto-loads it:
"ui5": { "dependencies": ["ui5-middleware-simpleproxy"] }Write: <target>/.env.exampleUI5_MIDDLEWARE_SIMPLE_PROXY_USERNAME=
UI5_MIDDLEWARE_SIMPLE_PROXY_PASSWORD=Ask the user once (interactive) to copy values into <target>/.env. Don't commit .env.
The scaffolded index.html should already use ComponentSupport. Fix the height handling per Trap 2 above — the component <div> MUST have style="height: 100%" inline:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{appTitle}}</title>
<style>html,body,#content { height: 100%; margin: 0; }</style>
<script
id="sap-ui-bootstrap"
src="resources/sap-ui-core.js"
data-sap-ui-theme="sap_horizon"
data-sap-ui-resource-roots='{ "<source_namespace>.modern": "./" }'
data-sap-ui-on-init="module:sap/ui/core/ComponentSupport"
data-sap-ui-compat-version="edge"
data-sap-ui-async="true"
data-sap-ui-frame-options="trusted">
</script>
</head>
<body class="sapUiBody" id="content">
<div
data-sap-ui-component
data-name="<source_namespace>.modern"
data-id="container"
data-height="100%"
style="height: 100%"></div>
</body>
</html>Notes:
style="height: 100%" is mandatory — a CSS selector against [data-sap-ui-component]would seem cleaner but does NOT work because ComponentSupport strips that attribute during processing (Trap 2).
data-height="100%" alone is not enough — it's a ComponentContainer setting that resolvesagainst the parent's height, which is why we also need the inline style on the wrapping div.
<script> other than the bootstrap (CSP rule, UI5 guideline §1).Write: <target>/webapp/controller/BaseController.tsimport Controller from "sap/ui/core/mvc/Controller";
import UIComponent from "sap/ui/core/UIComponent";
import Router from "sap/f/routing/Router";
import Model from "sap/ui/model/Model";
import ResourceModel from "sap/ui/model/resource/ResourceModel";
import ResourceBundle from "sap/base/i18n/ResourceBundle";
import History from "sap/ui/core/routing/History";
/**
* @namespace <source_namespace>.modern.controller
*/
export default class BaseController extends Controller {
public getRouter(): Router {
return UIComponent.getRouterFor(this) as Router;
}
public getModel<T extends Model = Model>(name?: string): T {
return this.getView()!.getModel(name) as T;
}
public setModel(model: Model, name?: string): void {
this.getView()!.setModel(model, name);
}
public async getResourceBundle(): Promise<ResourceBundle> {
const i18n = this.getOwnerComponent()!.getModel("i18n") as ResourceModel;
return (await i18n.getResourceBundle()) as ResourceBundle;
}
public onNavBack(): void {
const previousHash = History.getInstance().getPreviousHash();
if (previousHash !== undefined) {
window.history.go(-1);
} else {
this.getRouter().navTo("master", {}, true);
}
}
}The template gives you something like this; ensure:
import UIComponent from "sap/ui/core/UIComponent";
import Device from "sap/ui/Device";
import JSONModel from "sap/ui/model/json/JSONModel";
/**
* @namespace <source_namespace>.modern
*/
export default class Component extends UIComponent {
public static metadata = {
manifest: "json",
interfaces: ["sap.ui.core.IAsyncContentCreation"]
};
public init(): void {
super.init();
const deviceModel = new JSONModel(Device);
deviceModel.setDefaultBindingMode("OneWay");
this.setModel(deviceModel, "device");
this.getRouter().initialize();
}
public getContentDensityClass(): string {
return Device.support.touch ? "sapUiSizeCozy" : "sapUiSizeCompact";
}
}(Carry the getContentDensityClass over from the legacy component since App.controller uses it.)
<mvc:View
xmlns:mvc="sap.ui.core.mvc"
xmlns="sap.f"
controllerName="<source_namespace>.modern.controller.App"
displayBlock="true"
height="100%">
<FlexibleColumnLayout id="flexibleColumnLayout"
backgroundDesign="Solid"
layout="OneColumn"/>
</mvc:View>import BaseController from "./BaseController";
import Component from "../Component";
/**
* @namespace <source_namespace>.modern.controller
*/
export default class App extends BaseController {
public onInit(): void {
const oOwner = this.getOwnerComponent() as Component;
this.getView()!.addStyleClass(oOwner.getContentDensityClass());
}
}For each legacy <View>.view.xml + <View>.controller.js, generate the modern equivalent. Process in order: Main (was Master) ➜ Detail ➜ Welcome ➜ NotFound.
Reminder before writing any controller: apply the naming overrides from the top of this skill — no Hungarian prefixes (const list = ..., notconst oList = ...); renameMaster.controller.js➜Main.controller.ts; use specific event types (Trap 3), neverEventfromsap/ui/base/Event.
For every controller, follow the 5-step TS conversion from get_typescript_conversion_guidelines:
Class.extend() ➜ class extends ... with @namespace JSDoc immediately preceding it.sap.ui.define([...], function(...)) ➜ ES import + export default class.Button$PressEvent, never bare Event from sap/ui/base/Event.
this.byId("x") as Table, this.getView()!.getModel() as ODataModel, event.getSource() as ColumnListItem.any / unknown as ....<SplitApp> root ➜ replaced by App.view.xml (FCL) — already done in 5c.beginColumnPages via routing; Detail goes into midColumnPages.<Page> wrappers inside each view — they're fine.controllerName="<source_namespace>.<X>" ➜ controllerName="<source_namespace>.modern.controller.<X>".formatter: 'window.com.demo.../statusText' (global path).Modern uses core:require + relative formatter:
<List
core:require="{ formatter: '<source_namespace>/modern/model/formatter' }"
...>
<ObjectListItem
title="{Title}"
number="{ path: 'OverallStatus', formatter: 'formatter.statusText' }"
numberState="{ path: 'OverallStatus', formatter: 'formatter.statusState' }"
.../>
</List>Note the namespace path uses / (slash notation) inside core:require. The legacy "dot" property name still works in the formatter binding because formatter.statusText is resolved relative to the core:require map.
sap.ui.commons.* references.numberUnit, firstStatus, responsive, condensed, backgroundDesign ifthe linter flags them (they may or may not be deprecated depending on the control).
For each <X>.controller.js:
#### Step 1: Class syntax
Before:
sap.ui.define(["sap/ui/core/mvc/Controller"], function (Controller) {
"use strict";
return Controller.extend("com.demo.X.controller.Master", {
onInit: function() { /* ... */ }
});
});After (NB the @namespace annotation — it is required):
import Controller from "sap/ui/core/mvc/Controller";
/**
* @namespace com.demo.X.modern.controller
*/
export default class Master extends Controller {
public onInit(): void { /* ... */ }
}(In practice you extend BaseController, not Controller directly — see 6d.)
#### Step 2: ES imports
Replace every sap.ui.define/sap.ui.require with ES import. Replace dynamic sap.ui.require(["sap/m/MessageBox"], cb) with import("sap/m/MessageBox").then(...) — never jQuery.sap.require.
#### Step 3: Typed event handlers (Trap 3 again — please read)
Before:
onPress: function (oEvent) {
var oItem = oEvent.getParameter("listItem");
var oCtx = oItem.getBindingContext();
}After (note: no Hungarian; specific event type):
import { List$SelectionChangeEvent } from "sap/m/List";
import ColumnListItem from "sap/m/ColumnListItem";
public onPress(event: List$SelectionChangeEvent): void {
const item = event.getParameter("listItem") as ColumnListItem;
const ctx = item.getBindingContext();
}Do NOT fall back to import Event from "sap/ui/base/Event" because the generic type forces as casts on every getParameter / getParameters call. If a specific event type doesn't seem to exist, search for it first:
mcp__SAPUI5_MCP_Server__get_api_reference(
projectDir="<target>",
query="sap.m.SearchField#liveChange"
)For OData V4 context-aware events, use sap.ui.model.odata.v4.Context:
import V4Context from "sap/ui/model/odata/v4/Context";
const ctx = item.getBindingContext() as V4Context;#### Step 4: Casts for generic getters
import ODataModel from "sap/ui/model/odata/v4/ODataModel";
const model = this.getOwnerComponent()!.getModel() as ODataModel;import Table from "sap/m/Table";
const tasksTable = this.byId("tasksTable") as Table;(Note: variable names are model / tasksTable, NOT oModel / oTable.)
#### Step 5: V2 ➜ V4 binding migrations
This is the largest non-mechanical chunk. Three patterns to know:
##### (i) Entity key in path
V2 path: /<EntitySet>('K') — single-quoted string key, Set suffix. V4 path: /<Entity>(<KeyField>='K') — no Set suffix, named-key style.
If the V4 service is draft-enabled (most RAP-managed scenarios are), the key is composite: /<Entity>(<KeyField>='K',IsActiveEntity=true). Reading active rows is the default for a read-only freestyle TS app, so IsActiveEntity=true is usually right.
Reverse-derivation from a binding context — read the property, don't parse the path:
// V2: var sKey = oCtx.getPath().replace(/^\/Set\('/, "").replace(/'\)$/, "");
// V4: just read the property
const key = ctx.getProperty("<KeyField>") as string;If you don't know whether the service is draft-enabled, search:
mcp__sap-docs__search(query="Draft Handling with the OData V4 Model")##### (ii) Expand
V2: parameters: { expand: "<Nav>" } (in bindElement). V4: pass via parameters: { $expand: "_<Nav>" } (note the underscore — RAP V4 services typically prefix association names with _) OR rely on autoExpandSelect: true in the manifest model config and let UI5 figure it out from the view bindings.
Recommended: rely on autoExpandSelect: true + path-based bindings (items="{_<Nav>}"). Don't hand-author $expand unless you have a specific reason. To confirm the navigation name for your service, fetch its $metadata and look for <NavigationProperty Name="_X">.
##### (iii) Function-imports ➜ actions
V2:
oModel.callFunction("/<Function>", {
method: "POST",
urlParameters: { <Key>: sKey },
success: function(oData) { /* ... */ },
error: function(oErr) { /* ... */ }
});V4 (bound action — the typical RAP pattern):
import V4Context from "sap/ui/model/odata/v4/Context";
import ODataModel from "sap/ui/model/odata/v4/ODataModel";
const ctx = this.getView()!.getBindingContext() as V4Context;
const model = ctx.getModel() as ODataModel;
// Bound action: invoke relative to the entity context
const operation = model.bindContext("<Action.FQN>(...)", ctx);
try {
await operation.invoke("$auto"); // newer V4 API; older: .execute()
MessageToast.show("Action succeeded.");
ctx.refresh(); // pick up state changes
} catch (err) {
MessageBox.error((err as Error).message);
}The fully-qualified action name (<Action.FQN>) is service-specific. For RAP V4 it looks like com.sap.gateway.srvd.<service_name>.v0001.<action_name>. To find it for your service, inspect $metadata and look for <Action Name="X" IsBound="true"> — the parent <Schema Namespace> attribute plus .X is the FQN. Use mcp__sap-docs__search(query="OData V4 Operations action invocation UI5") if uncertain about the API shape.
#### Step 6: Move private fields to class properties
Before (legacy):
onInit: function () {
this._sProjectId = undefined; // implicit, later
}After:
export default class Detail extends BaseController {
private projectId?: string; // no Hungarian; plain field name
public onInit(): void { /* ... */ }
}When a parent-row selection should refresh a child-table binding (e.g. "select a task → time entries reload"), the V4 idiomatic pattern is:
navigation association (_<Nav>):
<Table id="<childTable>"
items="{
path: '_<Nav>',
parameters: { $filter: 'IsActiveEntity eq true' }
}">
<columns>
<Column><Text text="{i18n>colA}" /></Column>
<!-- ... -->
</columns>
<items>
<ColumnListItem>
<cells>
<Text text="{<FieldA>}" />
<!-- ... -->
</cells>
</ColumnListItem>
</items>
</Table>up the _<Nav> association relative to the new context:
const childTable = this.byId("<childTable>") as Table;
childTable.setBindingContext(parentContext);Do NOT construct ColumnListItem template rows in the controller via new ColumnListItem({ cells: [...] }) followed by bindItems({ path, template, templateShareable }). That works but it's the imperative V2 pattern; in V4 the XML+context approach is shorter, declarative, and what UI5 expects. Every controller method allocating template rows is a smell.
Pull the legacy globals (window.com.demo...formatter.statusText etc.) into a single ES module:
Write: <target>/webapp/model/formatter.tsimport DateFormat from "sap/ui/core/format/DateFormat";
// instantiate once — not per call
const oDateMedium = DateFormat.getDateInstance({ style: "medium" });
/**
* @namespace <source_namespace>.modern.model
*/
const formatter = {
statusText(sStatus?: string): string {
switch (sStatus) {
case "A": return "Approved";
case "D": return "Draft";
case "X": return "Cancelled";
default: return sStatus ?? "";
}
},
statusState(sStatus?: string): "Success" | "Warning" | "Error" | "None" {
if (sStatus === "A") return "Success";
if (sStatus === "X") return "Error";
if (sStatus === "D") return "Warning";
return "None";
},
// ...taskStatusText, taskStatusState, priorityText, priorityState, dateShort, hoursDecimal
dateShort(oDate?: Date): string {
return oDate ? oDateMedium.format(oDate) : "";
},
};
export default formatter;Views reference via core:require="{ formatter: '<source_namespace>/modern/model/formatter' }" then use formatter: 'formatter.statusText' in the binding (or formatter: '.formatter.statusText' when controller-relative — both work; the core:require form is cleaner and doesn't need a controller field).
Write: <target>/webapp/view/<X>.view.xml
Write: <target>/webapp/controller/<X>.controller.tsAfter each pair, run the linter against just the new file:
mcp__SAPUI5_MCP_Server__run_ui5_linter(
projectDir="<absolute target>",
filePatterns=["webapp/controller/<X>.controller.ts", "webapp/view/<X>.view.xml"],
provideContextInformation=true
)Fix findings before advancing. Do not accumulate lint debt across views.
Copy <legacy>/webapp/i18n/i18n.properties to <target>/webapp/i18n/i18n.properties, but apply the naming-override rename:
| Legacy key | Modern key |
|---|---|
masterTitle | mainTitle |
masterSearchPlaceholder | mainSearchPlaceholder |
masterCount | mainCount |
Update the corresponding {i18n>X} references in the modern XML views.
Also write <target>/webapp/i18n/i18n_en.properties with the same content (UI5 guideline: when adding keys, propagate to all locale files). If the legacy app shipped German or other locales, copy those too — with the same rename applied.
These are simple — just MessagePages. Translate XML 1:1, drop xmlns:core if not used, ensure i18n keys are reused from legacy.
Run all four gates in this order. All four must pass before declaring the conversion done.
Important — what these gates don't catch: clean reports here do NOT mean the app renders. In particular, Trap 5 (manifest v2 + deprecated routing keys / missing type: "View") passes ALL of the gates below but produces a blank page at runtime. The Phase 8d browser-render check is the only honest acceptance gate. See the table in Trap 5 for the empirical tool-coverage breakdown.Bash: cd <target> && npx eslint webapp --fixThis pass resolves the bulk of @typescript-eslint/no-unnecessary-type-assertion, no-unused-vars, and no-unused-imports findings automatically. Doing it BEFORE manual review skips one full iteration cycle (otherwise: tsc passes → manual lint review → fix mechanical issues → re-lint).
After auto-fix, re-read any file with remaining manual findings (usually 1-2).
mcp__SAPUI5_MCP_Server__run_ui5_linter(
projectDir="<absolute target>",
provideContextInformation=false # avoid bloating output on the full-project run
)Expected: zero findings. Common false-positives are rare; most findings have a real fix suggested in the tool output. Use the fix=true argument only after the user confirms the suggested fixes look correct.
mcp__SAPUI5_MCP_Server__run_manifest_validation(
manifestPath="<absolute target>/webapp/manifest.json"
)Expected: zero errors. Warnings about unused i18n keys are OK.
Bash: cd <target> && npm run ts-typec~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.