Modernize UI5 freestyle JS app ➜ UI5 TypeScript app
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.
Which MCPs this skill uses (and which it doesn't)
| 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.
Critical traps — read these BEFORE writing any code
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.
Trap 1: FCL stays at "OneColumn" — only one column visible
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
}
]
Trap 2: Blank page — DOM populated but every element has height: 0
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>
Trap 3: import Event from "sap/ui/base/Event" is a code smell
Symptom: 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.
Trap 4: onApprove(event: Button$PressEvent) — declare with no parameter
Symptom: 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.
Trap 5: Manifest v2 + missing "type": "View" — routes match but nothing renders
Symptom: 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 display
Routing 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:
- Add
"type": "View"torouting.config(so it's the default for all targets in the block — saves repeating it). - Use
path(NOTviewPath) for the view-folder namespace. - Use
name(NOTviewName) on each target — and add"type": "View"per target too (belt + braces; some UI5 versions don't propagate the config default reliably). - Use
level(NOTviewLevel) 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/linter@1.19.0
and @ui5/manifest@1.86.0, 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.
Self-help: when the skill doesn't have the answer
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.
Pattern A — Middleware / proxy / build-tool config option
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.
Pattern B — UI5 control event TypeScript type
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.
Pattern C — OData V4 binding / draft / action semantic
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):
- "Draft Handling with the OData V4 Model"
- "Operations" (V4 actions/functions)
- "Auto-
$expand/$select" - "Reducing the Number of Requests Required to Get the Properties of a Single Entity"
- "Reducing Roundtrips"
- "Filtering" + "Sorting"
Pattern D — FCL routing behaviour surprise
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")
Pattern E — Page renders blank but DOM is populated
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-*).
Pattern F — TypeScript compiles but ui5-linter fails
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.
Pattern G — UI5 best-practice you're not sure about
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 rules
If the answer isn't in those, escalate to sap-docs:
mcp__sap-docs__search(query="<your question>")
Pattern H — You've debugged the same problem twice this run
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.
Naming overrides for this project (deviation from older SAPUI5 JS conventions)
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>/webapp
Review 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.
Smart defaults (apply silently — do NOT ask before research)
| 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/types@1.147.2 |
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 |
Input
The user provides one of:
- A relative path to the legacy app, e.g.
<source_app>/ - A target folder name, e.g.
<modern_app>/(skill infers source as the siblinglegacy-*) - Nothing — assume defaults (
<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.
Phase 0 — Preflight
0a. Legacy app readable
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."
0b. UI5 MCP server reachable
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."
0c. Pull the authoritative guidelines (do this BEFORE writing any code)
mcp__SAPUI5_MCP_Server__get_typescript_conversion_guidelines
mcp__SAPUI5_MCP_Server__get_guidelines
Read both responses fully. They are the source of truth for:
- dev-dependency versions (
@ui5/cli,typescript,ui5-tooling-transpile,ui5-middleware-livereload,typescript-eslint) tsconfig.jsonshape (target: es2023,module: es2022,types: ["@sapui5/types"], paths map)ui5.yamlshape (ui5-tooling-transpile-task+ui5-tooling-transpile-middleware)- The 5-step code conversion sequence: (1) class syntax, (2) ES modules, (3) type annotations, (4) casts for generic getters, (5) remaining issues
- The
@namespaceJSDoc rule: required immediately before each exported class for the back-transformation to re-add the UI5 class name - The casts to avoid: never
any, neverunknown as ...— use real control types - The control-event-type rule: import
Button$PressEventfromsap/m/Button, notEventfromsap/ui/base/Event
These tool responses are large and version-specific — do not paraphrase from memory; quote them when you need to.
0d. npm + node available
Bash: node --version && npm --version
Assert: Node 22+ (CLAUDE.md requirement) and npm 10+.
Phase 1 — Discover the legacy app
Read every relevant file in <legacy>/webapp/. Classify findings as blocker (must fix for
modern UI5), cleanup (worst-practice but works), or cosmetic (style).
1a. Manifest scan
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 ➜ blocker- Hard-coded service URLs in
sap.ui5.models➜ cleanup (move tosap.app.dataSources) contentDensities: { compact: true, cozy: true }➜ cleanup (still supported, just move to manifest)supportedThemes: ["sap_belize"]➜ cleanup (drop; sap_horizon is default)
1b. Component.js scan
Read <legacy>/webapp/Component.js. Look for:
jQuery.sap.require("...")➜ blocker (delete; use ES imports)- Hardcoded service URL constant +
new sap.ui.model.odata.v2.ODataModel(...)ininit➜ blocker (move model to manifestsap.ui5.models[""]) - Manual
devicemodel creation ➜ keep (it's normal, but typed in TS) sap.ui.model.BindingMode.TwoWayfor OData V4 ➜ blocker (V4 prefersOneWayreads + explicit edits)useBatch: false➜ N/A for V4 (V4 always batches differently)- Mixed concerns (formatter loaded via
jQuery.sap.requirein Component) ➜ blocker
1c. Controller scan (per controller)
For each <legacy>/webapp/controller/<X>.controller.js:
Read: <legacy>/webapp/controller/<X>.controller.js
Flag:
var that = this;followed by closures ➜ cleanup (arrow functions)oCtx.getPath()regex parsing (.replace(/^\/Set\('/, "")) ➜ blocker (useoCtx.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.MessageBoxaccessed as global ➜ blocker (import MessageBox from "sap/m/MessageBox")jQuery.sap.require(...)➜ blocker (ES import)- Global formatter calls (
window.com.demo.formatter.X) ➜ blocker (consolidate into formatter module, import in views viacore: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)- No JSDoc on parameters ➜ cosmetic (TS rewrite handles it)
1d. View scan (per view)
For each <legacy>/webapp/view/<X>.view.xml:
Read: <legacy>/webapp/view/<X>.view.xml
Flag:
- Inline event handlers (
press="onSomething") referencing methods that don't exist ➜ blocker (will throw) <core:Fragment fragmentName="...">without async ➜ cleanup- Deprecated controls (
sap.ui.commons.*) ➜ blocker sap.m.SplitApproot ➜ blocker (translate to FCL per smart-defaults)- Hardcoded strings instead of i18n keys ➜ cleanup
- Path-based formatters (
formatter: 'window.com.demo.X.statusText') ➜ blocker (convert tocore:requireof formatter module +formatter: '.formatter.statusText') enabled="{= ${Status} === 'D' }"(expression binding) ➜ keep but adapt to V4 key (noStatus➜OverallStatusetc., depending on the V4 model)
1e. Formatter scan
For <legacy>/webapp/model/formatter.js:
jQuery.sap.declare(...)+window.com.demo...global namespace ➜ blocker (rewrite as ES module withexport function)sap.ui.core.format.DateFormat.getDateInstance(...)instantiated per call ➜ cleanup (memoize once at module top-level)- Per-call format object instantiation ➜ cleanup (instantiate once)
- Status / priority mapping switches ➜ keep but type as
string | undefined➜string
1f. Build the findings report
Output 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
- ...
Phase 2 — Design plan + user approval
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/types@1.147.2
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>/.
Phase 3 — Scaffold the modern TS app
3a. Wipe target if non-empty
If the user confirmed wipe-and-rewrite:
Bash: rm -rf <target>/* <target>/.[!.]* # safely empty <target>/ while keeping the folder
3b. Scaffold via UI5 MCP
Call 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
)
oDataV4Urlis 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.
3c. Verify the structure
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.
3d. Confirm dependencies match UI5 MCP guidelines
Read: <target>/package.json
Cross-check against get_typescript_conversion_guidelines output. The expected dev-deps include
at minimum:
@ui5/clitypescriptui5-tooling-transpileui5-middleware-livereload@sapui5/typesmatching framework version
Add 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.
3e. Warning — the scaffold itself uses Hungarian notation
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 prefix
…(truncated)