diff --git a/README.md b/README.md index 450c3e5..98af57d 100644 --- a/README.md +++ b/README.md @@ -156,9 +156,8 @@ The sample-app smoke gate diffs actual startup results against adaptations always win. Added fill-ins are **load-gated**: each must actually `require()` (self-referencing `abap2UI5/*` via the assembled core's own `exports`, third-party via the cap adapter's `node_modules`); ones that fail - are dropped and reported. This is what keeps e.g. `z2ui5_cl_app_preload` — - whose deps resolve to a path that does not exist — out of the published - package. + are dropped and reported — a transpiled class whose dependencies resolve to + a path that does not exist never reaches the published package. - **samples** (`run/output/samples` → `core/srv/app/samples`): flattened (every class lands directly under `samples/`, keyed by bare class name) and overwritten; the hand-written `samples/README.md` from `src/` stays. @@ -216,7 +215,7 @@ lives in [builder-cap2UI5-web](https://github.com/cap2UI5/builder-cap2UI5-web) — it mirrors the published cap2UI5 app repo and only relies on the two framework hooks `z2ui5_cl_util.register_app_class()` and -`z2ui5_cl_core_srv_draft.set_store()`. +`z2ui5_cl_ui5_srv_draft.set_store()`. ## External wiring diff --git a/adapters/cap/README.md b/adapters/cap/README.md index b26cb40..eface35 100644 --- a/adapters/cap/README.md +++ b/adapters/cap/README.md @@ -23,7 +23,7 @@ npm start # → http://localhost:4404/z2ui5/webapp/index.html ``` Start any bundled app via URL parameter, e.g. -`?app_start=z2ui5_cl_app_hello_world` or `?app_start=z2ui5_cl_demo_app_001`. +`?app_start=z2ui5_cl_ui5_app_hi_world` or `?app_start=z2ui5_cl_smp_app_004`. Drafts live in an in-memory `Map` — swap the `set_store` implementation for anything durable. Samples that consume the CAP OData services (e.g. diff --git a/adapters/express/README.md b/adapters/express/README.md index 4d892f6..a404342 100644 --- a/adapters/express/README.md +++ b/adapters/express/README.md @@ -13,7 +13,7 @@ npm start # → http://localhost:4204/z2ui5/webapp/index.html ``` Start any bundled app via URL parameter, e.g. -`?app_start=z2ui5_cl_app_hello_world` or `?app_start=z2ui5_cl_demo_app_001`. +`?app_start=z2ui5_cl_ui5_app_hi_world` or `?app_start=z2ui5_cl_smp_app_004`. Drafts live in an in-memory `Map` — swap the `set_store` implementation for anything durable. Samples that consume the CAP OData services (e.g. diff --git a/adapters/node/README.md b/adapters/node/README.md index b538ec4..6a2124f 100644 --- a/adapters/node/README.md +++ b/adapters/node/README.md @@ -28,8 +28,8 @@ npm start # → http://localhost:4104/z2ui5/webapp/index.html Start any bundled app via URL parameter, e.g. ``` -http://localhost:4104/z2ui5/webapp/index.html?app_start=z2ui5_cl_app_hello_world -http://localhost:4104/z2ui5/webapp/index.html?app_start=z2ui5_cl_demo_app_001 +http://localhost:4104/z2ui5/webapp/index.html?app_start=z2ui5_cl_ui5_app_hi_world +http://localhost:4104/z2ui5/webapp/index.html?app_start=z2ui5_cl_smp_app_004 ``` ## What is (deliberately) different from the CAP project diff --git a/adapters/node/server.js b/adapters/node/server.js index d312658..c2eaaf8 100644 --- a/adapters/node/server.js +++ b/adapters/node/server.js @@ -12,7 +12,7 @@ * GET /resources/* local UI5 runtime (openui5-dist) * * Start: npm install && npm start → http://localhost:4104/z2ui5/webapp/index.html - * Apps: ?app_start=z2ui5_cl_app_hello_world (all bundled samples work) + * Apps: ?app_start=z2ui5_cl_ui5_app_hi_world (all bundled samples work) */ "use strict"; diff --git a/adapters/web/README.md b/adapters/web/README.md index c5808c6..4b259bd 100644 --- a/adapters/web/README.md +++ b/adapters/web/README.md @@ -21,7 +21,7 @@ are skipped at registry load with a console warning — everything else runs. ```bash npm install # links ../../core + esbuild npm run build # → dist/ -npm run serve # → http://localhost:4304/index.html?app_start=z2ui5_cl_app_hello_world +npm run serve # → http://localhost:4304/index.html?app_start=z2ui5_cl_ui5_app_hi_world ``` `dist/` is a plain static site — GitHub Pages, S3 or nginx serve it as-is. diff --git a/adapters/web/build.js b/adapters/web/build.js index 3c7d738..88c1d71 100644 --- a/adapters/web/build.js +++ b/adapters/web/build.js @@ -28,7 +28,7 @@ const dist = path.join(__dirname, "dist"); // ---- 1. registry --------------------------------------------------------- // subpath specifier per app-class location (must match the package exports map) const SOURCES = [ - { dir: "srv/z2ui5/02", spec: (n) => `abap2UI5/${n}`, filter: (n) => /^z2ui5_cl_app_/.test(n) }, + { dir: "srv/z2ui5/01/04", spec: (n) => `abap2UI5/${n}`, filter: (n) => /^z2ui5_cl_ui5_app_/.test(n) }, { dir: "srv/z2ui5/99/02", spec: (n) => `abap2UI5/${n}`, filter: (n) => /^z2ui5_cl_pop_/.test(n) }, { dir: "srv/app", spec: (n) => `abap2UI5/app/${n}`, filter: (n) => /^z2ui5_c[lx]_/.test(n) }, { dir: "srv/app/samples", spec: (n) => `abap2UI5/app/samples/${n}`, filter: (n) => /^z2ui5_c[lx]_/.test(n) }, @@ -78,6 +78,8 @@ require("esbuild").buildSync({ "node:path": path.join(__dirname, "shims/path.js"), crypto: path.join(__dirname, "shims/crypto.js"), "node:crypto": path.join(__dirname, "shims/crypto.js"), + async_hooks: path.join(__dirname, "shims/async_hooks.js"), + "node:async_hooks": path.join(__dirname, "shims/async_hooks.js"), }, external: ["@sap/cds", "openui5-dist"], // CJS node-isms: __dirname feeds only the (shimmed, no-op) fs discovery diff --git a/adapters/web/shims/async_hooks.js b/adapters/web/shims/async_hooks.js new file mode 100644 index 0000000..3e43d81 --- /dev/null +++ b/adapters/web/shims/async_hooks.js @@ -0,0 +1,50 @@ +/** + * async_hooks shim for the browser bundle — the framework only needs + * AsyncLocalStorage, and only to isolate the user-exit context per request + * (z2ui5_cl_ui5_user_exit._als). + * + * A page is single-threaded and answers one roundtrip at a time, so a single + * current-store slot is an exact stand-in: run( ) sets it for the duration of + * the callback (including its awaits) and restores the previous one after, + * which is the only nesting the framework produces. + */ +"use strict"; + +class AsyncLocalStorage { + constructor() { + this._store = undefined; + } + + run(store, fn, ...args) { + const previous = this._store; + this._store = store; + try { + const out = fn(...args); + // async callback: keep the store alive until it settles + if (out && typeof out.then === "function") { + return out.finally(() => { + this._store = previous; + }); + } + this._store = previous; + return out; + } catch (e) { + this._store = previous; + throw e; + } + } + + getStore() { + return this._store; + } + + enterWith(store) { + this._store = store; + } + + exit(fn, ...args) { + return this.run(undefined, fn, ...args); + } +} + +module.exports = { AsyncLocalStorage }; diff --git a/core/app/z2ui5/webapp/Component.js b/core/app/z2ui5/webapp/Component.js index d388beb..5d823a9 100644 --- a/core/app/z2ui5/webapp/Component.js +++ b/core/app/z2ui5/webapp/Component.js @@ -4,24 +4,26 @@ sap.ui.define( "z2ui5/model/models", "z2ui5/core/Server", "sap/ui/VersionInfo", - "z2ui5/core/DeveloperTools", + "z2ui5/devtools/DevTools", "z2ui5/core/Lib", "z2ui5/core/AppState", "z2ui5/Util", "z2ui5/model/formatter", - "sap/ui/core/routing/HashChanger", + "z2ui5/core/Router", + "z2ui5/core/ScrollFocus", ], ( UIComponent, Models, Server, VersionInfo, - DeveloperTools, + DevTools, Lib, AppState, DateUtil, Formatter, - HashChanger, + Router, + ScrollFocus, ) => { "use strict"; @@ -39,6 +41,36 @@ sap.ui.define( // a fully initialized global from here on. AppState.initGlobal(); + // Two sibling BSPs carry frontend artefacts the framework itself does + // not ship: z2ui5_cci (abap2UI5-addons/custom-controls) and z2ui5_ccc + // (abap2UI5/customer-frontend-extension, the customer's own library). + // The two roots differ in one letter and are NOT the same BSP - each + // matches the ABAP prefix of its repository (z2ui5_cl_cci for the + // community controls, z2ui5_cl_ccc for the customer extension). + // Both are normally found through their reserved resourceRoot in + // manifest.json ("z2ui5_cci": "../z2ui5_cci/", "z2ui5_ccc": + // "../z2ui5_ccc/"), a sibling of THIS BSP. In the standalone HTTP + // service there is no BSP for them to be a sibling of, so the backend + // hands the absolute paths over on the global instead + // (z2ui5_cl_http_handler=>_http_get). + // + // They have to be applied HERE and not in the page: the manifest + // registers its own value while the component is being created, which + // is after everything the shell can run, so a registration made there + // is overwritten again. init() runs after manifest processing. + // Absent in BSP and Launchpad mode, where the manifest entries are + // right. Neither BSP is loaded from here - nothing is requested until + // a view actually names the namespace - so a system that has only one + // of them installed (or neither) never pays for the other. + const ccResourceRoot = AppState.getGlobal("ccResourceRoot"); + if (ccResourceRoot) { + sap.ui.loader.config({ paths: { z2ui5_cci: ccResourceRoot } }); + } + const cccResourceRoot = AppState.getGlobal("cccResourceRoot"); + if (cccResourceRoot) { + sap.ui.loader.config({ paths: { z2ui5_ccc: cccResourceRoot } }); + } + UIComponent.prototype.init.call(this); AppState.getGlobal("oConfig").ComponentData = this.getComponentData(); @@ -82,20 +114,14 @@ sap.ui.define( this._initVersionInfo(); this._installUnloadListener(); - this._installDeveloperToolsShortcut(); + // The developer tools own everything of their own: the Ctrl+F12 + // shortcut, the dialog instance, the roundtrip recorder and the + // "?z2ui5-devtools=" auto open. This call and the exit() below are + // the framework's ENTIRE coupling to devtools/ - keep it that + // way (see the module header there). + DevTools.install(); this._installScrollListener(); this._installRouterListener(); - - // The stopped router removed with the manifest routing section used - // to initialize the HashChanger (and its underlying hasher - // singleton) as a side effect. Without that init hasher never - // learns the URL's current hash, so the app-state cleanup after - // every roundtrip (View1._updateBrowserHistory calling - // replaceHash("")) is treated as a change and rewrites the URL to - // "...#" - every app start ended with a dangling "#". Initialize it - // explicitly; inside the FLP the shell has already done this and - // init() is a guarded no-op. - HashChanger.getInstance().init(); }, // ------------------------------------------------------------------ @@ -104,36 +130,24 @@ sap.ui.define( _installUnloadListener() { this._boundUnload = this._onUnload.bind(this); - // Safari on iOS does not fire "beforeunload" reliably, so we use - // "pagehide" there. iPads on iPadOS 13+ report a Mac user agent - // ("desktop site" default) - the touch-point probe catches those, - // while real Macs report 0 touch points. - const isIos = - /iPad|iPhone/.test(navigator.userAgent) || - (navigator.userAgent.includes("Mac") && navigator.maxTouchPoints > 1); - this._unloadEvent = isIos ? "pagehide" : "beforeunload"; + // "pagehide", not "beforeunload": pagehide fires only after the + // navigation is committed (any "leave page?" prompt was answered), + // so tearing the app down here can neither swallow the cc/Dirty + // unsaved-changes prompt (destroying the app mid-beforeunload + // removed its window.onbeforeunload handler before the browser + // invoked it) nor kill the live session when the user chooses to + // stay. It is also the reliable event on iOS Safari, which never + // fired beforeunload dependably. + this._unloadEvent = "pagehide"; window.addEventListener(this._unloadEvent, this._boundUnload); }, - _installDeveloperToolsShortcut() { - // Ctrl + F12 opens / closes the in-app developer tools. - this._boundKeydown = (event) => { - if (event.ctrlKey && event.key === "F12") { - const state = AppState.state; - if (!state.developerTools) - state.developerTools = new DeveloperTools(); - state.developerTools.toggle(); - } - }; - document.addEventListener("keydown", this._boundKeydown); - }, - _installScrollListener() { // Scroll events do not bubble, but they do trigger capture-phase // listeners on ancestors - a single document-level listener observes - // every scrollable container. Server.onScrollCapture records the + // every scrollable container. ScrollFocus.onScrollCapture records the // last scrolled element per view slot for the S_SCROLL request info. - this._boundScroll = (event) => Server.onScrollCapture(event); + this._boundScroll = (event) => ScrollFocus.onScrollCapture(event); document.addEventListener("scroll", this._boundScroll, { capture: true, passive: true, @@ -141,20 +155,15 @@ sap.ui.define( }, _installRouterListener() { - // Hash-based app routing (UI5 Router style). The HashChanger is the - // same engine the sap.ui.core.routing.Router sits on; listening to its - // hashChanged event makes the native browser Back/Forward buttons (and - // manual URL edits / bookmarks) drive navigation - a hash of the form - // "#/app/" starts that app. Only sessions that opted in via - // client->set_nav_routing( ) act on it (Server.onHashChange guards on - // AppState.navRouting), so apps that manage their own hash are - // unaffected. - this._boundHashChanged = (oEvent) => - Server.onHashChange(oEvent.getParameter("newHash")); - HashChanger.getInstance().attachEvent( - "hashChanged", - this._boundHashChanged, - ); + // Hash-based app routing (UI5 Router style), owned by core/Router.js. + // It sits on the HashChanger - the same engine + // sap.ui.core.routing.Router uses, and inside the FLP the shell's own + // one - so the native browser Back/Forward buttons and the launchpad + // back button drive navigation. Only apps that opted in via + // follow_up_action( cs_event-set_nav_routing ) act on it, so apps that manage their own + // hash are unaffected. Server does the actual restore roundtrip; it is + // injected here so the router stays free of a Server dependency. + Router.init(() => Server.restoreFromRoute()); }, // ------------------------------------------------------------------ @@ -246,7 +255,10 @@ sap.ui.define( return ""; }, - _onUnload() { + _onUnload(event) { + // pagehide with persisted = true means the page enters the browser's + // back/forward cache and may be shown again - keep the app alive. + if (event?.persisted) return; // destroy() runs exit(), which removes the unload listener (and every // other one) - no need to remove it here too. this.destroy(); @@ -258,25 +270,32 @@ sap.ui.define( exit() { window.removeEventListener(this._unloadEvent, this._boundUnload); - document.removeEventListener("keydown", this._boundKeydown); document.removeEventListener("scroll", this._boundScroll, { capture: true, }); - HashChanger.getInstance().detachEvent( - "hashChanged", - this._boundHashChanged, - ); + Router.exit(); - // The developer tools control is created lazily by the Ctrl+F12 - // shortcut - destroy it (which also closes its dialog) so a re-launch - // (FLP) does not leak the control instance. - if (AppState.state.developerTools) { - AppState.state.developerTools.destroy(); - AppState.state.developerTools = null; - } + // Drops the shortcut, the dialog instance and the recorded history - + // all of which are module-scoped and would otherwise outlive the + // component on an FLP re-launch. + DevTools.exit(); Server.endSession(); + // Global state that would outlive the component (FLP keeps the page + // alive): cancel any pending backend timer, empty the shortcut + // registry so the module-scoped keydown listener becomes a no-op, + // and detach the device model's handlers from the Device singleton. + for (const key of Object.keys(AppState.state.timers)) { + clearTimeout(AppState.state.timers[key]); + delete AppState.state.timers[key]; + } + AppState.state.shortcuts = {}; + if (AppState.state.oDeviceModel) { + AppState.state.oDeviceModel.destroy(); + AppState.state.oDeviceModel = null; + } + // Robust launchpad teardown: // 1. Clear the FLP dirty flag so it does not carry over into the // next app the user opens. diff --git a/core/app/z2ui5/webapp/cc/CameraPicture.js b/core/app/z2ui5/webapp/cc/CameraPicture.js index 77fb5f5..6bc87b5 100644 --- a/core/app/z2ui5/webapp/cc/CameraPicture.js +++ b/core/app/z2ui5/webapp/cc/CameraPicture.js @@ -19,7 +19,6 @@ sap.ui.define( return Control.extend("z2ui5.cc.CameraPicture", { metadata: { properties: { - id: { type: "string" }, value: { type: "string" }, thumbnail: { type: "string" }, // Empty default leaves the trigger button auto-sized; a bare diff --git a/core/app/z2ui5/webapp/cc/Dirty.js b/core/app/z2ui5/webapp/cc/Dirty.js index f08bc99..7956ae6 100644 --- a/core/app/z2ui5/webapp/cc/Dirty.js +++ b/core/app/z2ui5/webapp/cc/Dirty.js @@ -32,8 +32,8 @@ sap.ui.define( }, }, setIsDirty(val) { - // Empty renderer -> suppress the no-op invalidation; the dirty state - // is applied explicitly below. + // Empty renderer -> suppress the no-op invalidation; the effect below + // (applying the dirty state) is what actually matters. this.setProperty("isDirty", val, true); if (val) { dirtyControls.add(this); @@ -58,7 +58,7 @@ sap.ui.define( syncUnloadPrompt(anyDirty); } } catch (e) { - Lib.logError("Dirty.setIsDirty: setDirtyFlag failed", e); + Lib.logError("Dirty._applyDirtyState: setDirtyFlag failed", e); syncUnloadPrompt(anyDirty); } }, diff --git a/core/app/z2ui5/webapp/cc/Favicon.js b/core/app/z2ui5/webapp/cc/Favicon.js index cd2c802..c9b97d1 100644 --- a/core/app/z2ui5/webapp/cc/Favicon.js +++ b/core/app/z2ui5/webapp/cc/Favicon.js @@ -2,6 +2,7 @@ // `favicon` URL (updates the existing tag or creates one). sap.ui.define(["sap/ui/core/Control", "z2ui5/core/Lib"], (Control, Lib) => { "use strict"; + // OBSOLETE: replaced by the frontend event cs_event-set_favicon - kept for backward compatibility. return Control.extend("z2ui5.cc.Favicon", { metadata: { properties: { @@ -15,7 +16,12 @@ sap.ui.define(["sap/ui/core/Control", "z2ui5/core/Lib"], (Control, Lib) => { // (updating the tag) is what actually matters. this.setProperty("favicon", val, true); const href = Lib.toText(val); - const existing = document.head.querySelector('link[rel="shortcut icon"]'); + // Match ANY icon link, not just rel="shortcut icon": a page that + // declares the modern rel="icon" (or "icon shortcut") would otherwise + // keep its own link and get a second, competing one appended on every + // app start - which icon the browser then shows is up to it. + // ~= matches one entry of the whitespace-separated rel list. + const existing = document.head.querySelector('link[rel~="icon"]'); if (existing) { existing.href = href; return; diff --git a/core/app/z2ui5/webapp/cc/Focus.js b/core/app/z2ui5/webapp/cc/Focus.js index 98f7d9b..340664d 100644 --- a/core/app/z2ui5/webapp/cc/Focus.js +++ b/core/app/z2ui5/webapp/cc/Focus.js @@ -28,7 +28,8 @@ sap.ui.define( }, setFocusId(val) { try { - this.setProperty("focusId", val); + // Empty renderer -> suppress the no-op invalidation + this.setProperty("focusId", val, true); const oElement = ViewSlots.byIdOfOwner(this, val); if (oElement) oElement.applyFocusInfo(oElement.getFocusInfo()); } catch (e) { @@ -45,22 +46,7 @@ sap.ui.define( // the user had typed past the (stale) captured position. Reading it // here - while the old, still-focused element is in the DOM - keeps the // guard working across a full view rebuild, not only an in-place patch. - this._liveCaret = null; - try { - const active = document.activeElement; - if ( - active && - (active.tagName === "INPUT" || active.tagName === "TEXTAREA") - ) { - const s = active.selectionStart; - const e = active.selectionEnd; - if (s != null && e != null) { - this._liveCaret = { start: s, end: e }; - } - } - } catch { - this._liveCaret = null; - } + this._liveCaret = Lib.readCaret(document.activeElement); }, onAfterRendering() { const liveCaret = this._liveCaret; diff --git a/core/app/z2ui5/webapp/cc/History.js b/core/app/z2ui5/webapp/cc/History.js index 2cd49f3..ab522cb 100644 --- a/core/app/z2ui5/webapp/cc/History.js +++ b/core/app/z2ui5/webapp/cc/History.js @@ -13,8 +13,8 @@ sap.ui.define(["sap/ui/core/Control", "z2ui5/core/Lib"], (Control, Lib) => { }, }, setSearch(val) { - // Empty renderer -> suppress the no-op invalidation; the URL rewrite - // below is the actual effect. + // Empty renderer -> suppress the no-op invalidation; the effect below + // (rewriting the URL) is what actually matters. this.setProperty("search", val, true); try { const search = Lib.toText(val); diff --git a/core/app/z2ui5/webapp/cc/Info.js b/core/app/z2ui5/webapp/cc/Info.js index 287825b..af72092 100644 --- a/core/app/z2ui5/webapp/cc/Info.js +++ b/core/app/z2ui5/webapp/cc/Info.js @@ -65,13 +65,17 @@ sap.ui.define( // fires the event. onAfterRendering() { if (!this._pendingInfo) return; - this._pendingInfo = false; try { // The device model is created by Component.init(); it exposes - // system / resize / os / browser info. + // system / resize / os / browser info. It reaches this control + // through model propagation, so on the very first rendering of a + // freshly built view it may not be attached yet - keep the pending + // flag in that case so the next rendering retries, instead of + // consuming it and never firing `finished` at all. const deviceModel = ViewSlots.getView("MAIN")?.getModel("device"); const deviceData = deviceModel?.getData(); if (!deviceData) return; + this._pendingInfo = false; const { system, resize, os, browser } = deviceData; // Filled by Component._initVersionInfo (async, may not have @@ -79,7 +83,7 @@ sap.ui.define( const ui5Info = AppState.getGlobal("oConfig")?.S_UI5; const ui5Version = ui5Info?.VERSION || ""; - // Single system-type label, same derivation as Server._getDeviceInfo. + // Single system-type label, same derivation as core/Session.js. const systemType = Lib.deriveSystemType(system); const props = [ diff --git a/core/app/z2ui5/webapp/cc/LPTitle.js b/core/app/z2ui5/webapp/cc/LPTitle.js index 559de78..dc341b7 100644 --- a/core/app/z2ui5/webapp/cc/LPTitle.js +++ b/core/app/z2ui5/webapp/cc/LPTitle.js @@ -18,8 +18,8 @@ sap.ui.define( }, }, setTitle(val) { - // Empty renderer -> suppress the no-op invalidation; the shell title - // is set explicitly below. + // Empty renderer -> suppress the no-op invalidation; the effect below + // (setting the shell title) is what actually matters. this.setProperty("title", val, true); try { const shell = AppState.state.oLaunchpad?.ShellUIService; diff --git a/core/app/z2ui5/webapp/cc/MessageManager.js b/core/app/z2ui5/webapp/cc/MessageManager.js index ec7c755..932a1c8 100644 --- a/core/app/z2ui5/webapp/cc/MessageManager.js +++ b/core/app/z2ui5/webapp/cc/MessageManager.js @@ -22,7 +22,7 @@ sap.ui.define( ); // Invisible companion control that bridges the UI5 message manager to a - // two-way bound ABAP table (`items`). The table is the app's OWN messages: + // bound ABAP table (`items`). The table is the app's OWN messages: // on every backend update the control reconciles the message manager to // match it - adding new rows as sap.ui.core.message.Message objects (with // a target + the view's model as processor, so they set the bound field's @@ -51,6 +51,12 @@ sap.ui.define( }, exit() { Lib.unregisterCallback("onAfterRendering", this._setupBound); + // remove this control's own rows from the message model, otherwise a + // full view rebuild leaves them behind and re-adds a duplicate set + if (this._added.size && this._messaging) { + this._messaging.removeMessages([...this._added.values()]); + } + this._added.clear(); }, renderer: { apiVersion: 2, render() {} }, @@ -58,7 +64,7 @@ sap.ui.define( if (this.getProperty("checkInit")) return; const messaging = Lib.getMessaging?.(); if (!messaging) return; - this.setProperty("checkInit", true); + this.setProperty("checkInit", true, true); this._messaging = messaging; const view = ViewSlots.getView( ViewSlots.containingSlotKey(this) ?? "MAIN", @@ -69,7 +75,7 @@ sap.ui.define( this.reconcile(); }, - // property setter override: the two-way binding calls this when the + // property setter override: the binding calls this when the // backend ships a new message table (base setProperty is used for the // internal store, so it never re-enters here) setItems(aItems) { @@ -83,17 +89,26 @@ sap.ui.define( reconcile() { const rows = this.getProperty("items") || []; const wanted = new Map(rows.map((r) => [keyOf(r), r])); + // `change` reports an actual message update, so it only fires when + // this pass added or removed something. Firing unconditionally made + // every model update (the table is bound, so it arrives on + // each roundtrip) look like a change - and an app that binds the + // event to a backend roundtrip would answer with the next model + // update, i.e. loop. + let changed = false; // remove app rows no longer wanted for (const [key, oMessage] of this._added) { if (!wanted.has(key)) { this._messaging.removeMessages(oMessage); this._added.delete(key); + changed = true; } } // add newly wanted rows for (const [key, r] of wanted) { if (this._added.has(key)) continue; + changed = true; const oMessage = new Message({ message: r.MESSAGE ?? "", description: r.DESCRIPTION ?? "", @@ -110,7 +125,7 @@ sap.ui.define( this._messaging.addMessages(oMessage); this._added.set(key, oMessage); } - this.fireChange(); + if (changed) this.fireChange(); }, }); }, diff --git a/core/app/z2ui5/webapp/cc/MultiInputExt.js b/core/app/z2ui5/webapp/cc/MultiInputExt.js index deb3b37..2dde776 100644 --- a/core/app/z2ui5/webapp/cc/MultiInputExt.js +++ b/core/app/z2ui5/webapp/cc/MultiInputExt.js @@ -59,8 +59,7 @@ sap.ui.define( this, this.getProperty("MultiInputId"), ); - if (!input || this.getProperty("checkInit")) return; - this.setProperty("checkInit", true); + if (!Lib.claimOnce(this, input)) return; try { input.attachTokenUpdate(this.onTokenUpdate.bind(this)); // Custom validator: turn any free-text entry into a Token where diff --git a/core/app/z2ui5/webapp/cc/Scrolling.js b/core/app/z2ui5/webapp/cc/Scrolling.js index 807a910..af627b6 100644 --- a/core/app/z2ui5/webapp/cc/Scrolling.js +++ b/core/app/z2ui5/webapp/cc/Scrolling.js @@ -32,7 +32,7 @@ sap.ui.define( // Some controls expose a scroll delegate; prefer it when available. const delegate = control?.getScrollDelegate?.(); if (delegate) return delegate.getScrollTop(); - const element = this._getDomInnerElement(item.ID); + const element = this._getDomInnerElement(item.N); return element ? element.scrollTop : 0; } catch (e) { Lib.logError("Scrolling._getScrollTop: failed", e); @@ -81,7 +81,7 @@ sap.ui.define( control.scrollTo(item.V); return; } - const element = this._getDomInnerElement(item.ID); + const element = this._getDomInnerElement(item.N); if (element) element.scrollTop = item.V; } catch (e) { Lib.logError("Scrolling._restoreScrollPosition: failed", e); diff --git a/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js b/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js index ec878a9..593fe97 100644 --- a/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js +++ b/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js @@ -20,8 +20,9 @@ sap.ui.define( type: "object", }, rangeData: { + // no defaultValue: an object default in the metadata is shared + // by reference across every instance of the control type: "object", - defaultValue: [], }, checkInit: { type: "boolean", @@ -115,8 +116,7 @@ sap.ui.define( this, this.getProperty("multiInputId"), ); - if (!input || this.getProperty("checkInit")) return; - this.setProperty("checkInit", true); + if (!Lib.claimOnce(this, input)) return; try { input.attachTokenUpdate(this.onTokenUpdate.bind(this)); input.attachInnerControlsCreated( diff --git a/core/app/z2ui5/webapp/cc/Storage.js b/core/app/z2ui5/webapp/cc/Storage.js index 26f572a..2332dbf 100644 --- a/core/app/z2ui5/webapp/cc/Storage.js +++ b/core/app/z2ui5/webapp/cc/Storage.js @@ -1,12 +1,39 @@ +// Invisible control that reads a value from browser storage +// (session/local, see sap.ui.util.Storage) into its `value` property +// and fires `finished` when the stored value differs from the current +// one. The write side is handled by the STORE_DATA frontend action. sap.ui.define( ["sap/ui/core/Control", "sap/ui/util/Storage", "z2ui5/core/Lib"], (Control, Storage, Lib) => { "use strict"; - // Invisible control that reads a value from browser storage - // (session/local, see sap.ui.util.Storage) into its `value` property - // and fires `finished` when the stored value differs from the current - // one. The write side is handled by the STORE_DATA frontend action. + // Value equality for the stored payload. `value` is typed `any`: a plain + // string for the common case, a structure or a table once an app binds + // one. A reference comparison reports EVERY object as different, because + // sap/ui/util/Storage JSON-round-trips what it stores and therefore hands + // back a fresh object on every read - the control would fire `finished` + // on each render, the backend would answer with a re-render, and that + // fires again: an endless round-trip loop that made a structured value + // unusable. Compare by value instead. Key order is not significant (the + // stored copy went through JSON, the bound one comes from the model), and + // the payload is JSON by construction, so this covers every shape that + // can reach the property. + function isSameValue(a, b) { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + return a.length === b.length && a.every((v, i) => isSameValue(v, b[i])); + } + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + return keys.every( + (k) => + Object.prototype.hasOwnProperty.call(b, k) && isSameValue(a[k], b[k]), + ); + } + return Control.extend("z2ui5.cc.Storage", { metadata: { properties: { @@ -63,15 +90,24 @@ sap.ui.define( try { const storageType = Storage.Type[type] || Storage.Type.session; const storage = new Storage(storageType, prefix); - stored = storage.get(key) ?? ""; + stored = storage.get(key); } catch (e) { Lib.logError(`Storage: read failed for key '${key}'`, e); return; } + // A key that holds nothing must leave the bound field ALONE. Reporting + // the empty string for it used to overwrite whatever the app had - + // and, worse, its TYPE: an app binding a structure got a plain "" on + // the very first render, which the next roundtrip could not parse back + // ("JSON_PARSING_ERROR ... Unsupported target for value [v]", because + // a string cannot land on a deep ABAP target). "Nothing is stored" is + // not a value, so there is nothing to report. + if (stored === null || stored === undefined) return; + // Only fire "finished" when the stored value differs from the // current property to avoid feedback loops. - if (stored !== value) { + if (!isSameValue(stored, value)) { this.setProperty("value", stored, true); this.fireFinished({ type, prefix, key, value: stored }); } diff --git a/core/app/z2ui5/webapp/cc/Title.js b/core/app/z2ui5/webapp/cc/Title.js index 5fa7352..3469762 100644 --- a/core/app/z2ui5/webapp/cc/Title.js +++ b/core/app/z2ui5/webapp/cc/Title.js @@ -12,8 +12,8 @@ sap.ui.define(["sap/ui/core/Control", "z2ui5/core/Lib"], (Control, Lib) => { }, }, setTitle(val) { - // Suppress invalidation: the renderer is empty, so a re-render would be - // a no-op; the effect happens explicitly below. + // Empty renderer -> suppress the no-op invalidation; the effect below + // (setting the tab title) is what actually matters. this.setProperty("title", val, true); document.title = Lib.toText(val); }, diff --git a/core/app/z2ui5/webapp/cc/UploadSetExt.js b/core/app/z2ui5/webapp/cc/UploadSetExt.js index 004e632..5faa423 100644 --- a/core/app/z2ui5/webapp/cc/UploadSetExt.js +++ b/core/app/z2ui5/webapp/cc/UploadSetExt.js @@ -90,11 +90,20 @@ sap.ui.define( this, this.getProperty("uploadSetId"), ); - if (!uploadSet || this.getProperty("checkInit")) return; - this.setProperty("checkInit", true); + if (!Lib.claimOnce(this, uploadSet)) return; try { uploadSet.attachAfterItemAdded(this.onItemAdded.bind(this)); - uploadSet.attachAfterItemRemoved(this.onItemRemoved.bind(this)); + // afterItemRemoved is @since 1.83; below that, adds keep working + // and the gap is reported instead of failing the whole setup + // (beforeItemRemoved is no substitute - it fires before the + // confirm dialog and would report cancelled removals) + if (uploadSet.attachAfterItemRemoved) { + uploadSet.attachAfterItemRemoved(this.onItemRemoved.bind(this)); + } else { + Lib.logError( + "UploadSetExt: afterItemRemoved needs UI5 >= 1.83, removals will not be reported", + ); + } } catch (e) { Lib.logError("UploadSetExt.setControl: setup failed", e); } diff --git a/core/app/z2ui5/webapp/cc/Websocket.js b/core/app/z2ui5/webapp/cc/Websocket.js new file mode 100644 index 0000000..aa46d10 --- /dev/null +++ b/core/app/z2ui5/webapp/cc/Websocket.js @@ -0,0 +1,201 @@ +// Invisible control that keeps a WebSocket connection to an ABAP Push +// Channel (APC) open and hands every inbound message to the backend: the +// message text lands in the bound `value` property and the +// `received` event triggers the roundtrip that lets the app process it. +// Sending is deliberately NOT part of the control - an app publishes to the +// AMC channel from ABAP, so consuming a push channel needs no app JavaScript. +sap.ui.define( + ["sap/ui/core/Control", "z2ui5/core/Lib", "z2ui5/core/AppState"], + (Control, Lib, AppState) => { + "use strict"; + + // A roundtrip already in flight makes View1.eB DROP the event (its + // isBusy guard), so everything the control reports is queued and + // delivered one item per roundtrip instead of being lost in a burst. + // This is the retry interval of the drain loop - it only runs while + // items are actually waiting. + const DRAIN_RETRY_MS = 50; + + return Control.extend("z2ui5.cc.Websocket", { + metadata: { + properties: { + // APC path ("/sap/bc/apc/sap/z2ui5_apc_smp_2"), resolved against + // the current origin; a full ws:// or wss:// URL is taken as is. + path: { + type: "string", + defaultValue: "", + }, + // Text of the message currently reported to the backend. + value: { + type: "string", + defaultValue: "", + }, + checkActive: { + type: "boolean", + defaultValue: true, + }, + // false -> close the connection after the first message. + checkRepeat: { + type: "boolean", + defaultValue: true, + }, + }, + events: { + received: { + allowPreventDefault: true, + parameters: {}, + }, + // Fired when the connection could not be opened or ended without + // the app asking for it, so a backend can react. `code` is the + // WebSocket close code ("1006" for a handshake that never + // completed - inactive ICF node, rejected authentication, unknown + // APC application) or "CONSTRUCT" when the constructor itself + // threw. The control never surfaces any UI on its own - handling + // is delegated entirely to whoever binds this event. + error: { + parameters: { + code: { type: "string" }, + message: { type: "string" }, + }, + }, + }, + }, + init() { + this._queue = []; + }, + // Every state change (checkActive toggled, path rebound) invalidates + // the control, so this single hook is where the connection is brought + // in line with the properties - no setter override needed. + onAfterRendering() { + const url = this._resolveUrl(); + if (url !== this._url) this._disconnect(); + this._url = url; + if (this.getProperty("checkActive")) { + this._connect(); + } else { + this._disconnect(); + } + }, + exit() { + clearTimeout(this._drainId); + this._disconnect(); + }, + _resolveUrl() { + const path = this.getProperty("path"); + if (!path) return ""; + if (/^wss?:\/\//i.test(path)) return path; + // https -> wss, http -> ws + const origin = window.location.origin.replace(/^http/i, "ws"); + return path.charAt(0) === "/" ? origin + path : origin + "/" + path; + }, + _connect() { + if (this._ws || !this._url) return; + const url = this._url; + let ws; + try { + ws = new WebSocket(url); + } catch (err) { + const message = "Cannot open " + url + ": " + (err.message || err); + Lib.logError("Websocket: " + message, err); + this._report({ kind: "error", code: "CONSTRUCT", message }); + return; + } + this._ws = ws; + this._opened = false; + ws.onopen = () => { + if (this._ws === ws) this._opened = true; + }; + ws.onmessage = (event) => { + // The control may have been torn down, or replaced by a newer + // connection, while this socket was still open. + if (Lib.isDestroyed(this) || this._ws !== ws) return; + if (typeof event.data !== "string") { + Lib.logError("Websocket: ignored a non-text message"); + return; + } + if (!this.getProperty("checkRepeat")) this._disconnect(); + this._report({ kind: "message", value: event.data }); + }; + ws.onerror = () => { + // The WebSocket error event carries no detail by specification - + // it is always followed by onclose, which is where the actual + // reason (the close code) becomes available and is reported. + Lib.logError("Websocket: connection error on " + url); + }; + // A close the app asked for never gets here: _disconnect() drops the + // handlers first. So every close reaching this point is one the + // server or the network caused, and the backend should hear about it. + ws.onclose = (event) => { + if (this._ws !== ws) return; + this._ws = null; + if (Lib.isDestroyed(this)) return; + const cause = this._opened + ? "Connection to " + url + " was closed" + : "Connection to " + url + " could not be established"; + const message = event.reason ? cause + ": " + event.reason : cause; + Lib.logError("Websocket (" + event.code + "): " + message); + this._report({ + kind: "error", + code: String(event.code), + message: message, + }); + }; + }, + _disconnect() { + const ws = this._ws; + if (!ws) return; + this._ws = null; + ws.onopen = null; + ws.onmessage = null; + ws.onerror = null; + ws.onclose = null; + try { + ws.close(); + } catch (err) { + Lib.logError("Websocket: close failed", err); + } + }, + // Queue one item for the backend and start draining. Messages and + // errors share the queue so they reach the app in the order they + // happened - an error after three messages is reported after them. + _report(item) { + this._queue.push(item); + this._drain(); + }, + // Hand the oldest queued item to the backend and round-trip once. + // While the backend is busy nothing is consumed - the queue is retried + // until the event can actually get through, so nothing is dropped. + _drain() { + if (!this._queue.length) return; + if (AppState.state.isBusy) { + this._scheduleDrain(); + return; + } + const item = this._queue.shift(); + if (item.kind === "error") { + this.fireError({ + code: item.code, + message: item.message, + }); + } else { + this.setProperty("value", item.value, true); + this.fireReceived(); + } + if (this._queue.length) this._scheduleDrain(); + }, + _scheduleDrain() { + clearTimeout(this._drainId); + this._drainId = setTimeout(() => { + if (Lib.isDestroyed(this)) return; + this._drain(); + }, DRAIN_RETRY_MS); + }, + renderer: { + apiVersion: 2, + render(oRm, oControl) { + Lib.renderInvisibleSpan(oRm, oControl); + }, + }, + }); + }, +); diff --git a/core/app/z2ui5/webapp/controller/App.controller.js b/core/app/z2ui5/webapp/controller/App.controller.js index 2564f12..7d99c68 100644 --- a/core/app/z2ui5/webapp/controller/App.controller.js +++ b/core/app/z2ui5/webapp/controller/App.controller.js @@ -7,8 +7,9 @@ sap.ui.define( "z2ui5/controller/View1.controller", "z2ui5/core/Server", "z2ui5/core/AppState", + "z2ui5/core/ViewSlots", ], - (BaseController, Controller, Server, AppState) => { + (BaseController, Controller, Server, AppState, ViewSlots) => { "use strict"; return BaseController.extend("z2ui5.controller.App", { onInit() { @@ -26,16 +27,17 @@ sap.ui.define( AppState.getGlobal("checkLocal") ? window.location.href : uri, ); - // Wire up the controller instances and the app container. All other + // Wire up the controller instances and the app container. One + // controller per view slot, driven by the slot table in + // core/ViewSlots - the single place that knows which slots exist, so + // adding one there does not need a matching line here. All other // shared state (callback arrays, error log, roundtrip flags, ...) // starts from the defaults that core/AppState set during // Component.init. - state.oController = new Controller(); + for (const slot of ViewSlots.slots) { + state[slot.controllerProp] = new Controller(); + } state.oApp = this.getView().byId("app"); - state.oControllerNest = new Controller(); - state.oControllerNest2 = new Controller(); - state.oControllerPopup = new Controller(); - state.oControllerPopover = new Controller(); // Kick off the initial roundtrip. Historically a stopped router's // initial routeMatched event triggered this; the manifest carries no diff --git a/core/app/z2ui5/webapp/controller/View1.controller.js b/core/app/z2ui5/webapp/controller/View1.controller.js index cb1e1f6..d53e068 100644 --- a/core/app/z2ui5/webapp/controller/View1.controller.js +++ b/core/app/z2ui5/webapp/controller/View1.controller.js @@ -1,113 +1,124 @@ // The central view controller. One instance serves each of the five view // slots (main view, two nested views, popup, popover - see -// core/ViewSlots.js). It builds the request for backend events (eB), -// dispatches frontend-only events (eF), renders the views and fragments a -// response asks for, and runs the post-render follow-ups. +// core/ViewSlots.js). It carries the protocol entry points the backend binds +// events to (eB, eBP, eF), builds the request for backend events and runs +// the response's two action phases. The display machinery behind those +// actions lives in core/actions/Slots.js. sap.ui.define( [ "sap/ui/core/mvc/Controller", - "sap/ui/core/mvc/XMLView", - "sap/ui/model/json/JSONModel", "sap/ui/core/BusyIndicator", "sap/m/MessageBox", - "sap/ui/core/Fragment", "z2ui5/core/Server", - "sap/ui/model/odata/v2/ODataModel", - "sap/ui/core/routing/HashChanger", "z2ui5/core/Lib", "z2ui5/core/FrontendAction", + "z2ui5/core/actions/Slots", "z2ui5/core/ViewSlots", + "z2ui5/core/Router", "z2ui5/core/AppState", ], ( Controller, - XMLView, - JSONModel, BusyIndicator, MessageBox, - Fragment, Server, - ODataModel, - HashChanger, Lib, FrontendAction, + Slots, ViewSlots, + Router, AppState, ) => { "use strict"; - // Helpers reused across calls; kept as module-level singletons. - const _hashChanger = HashChanger.getInstance(); - - function applyStoredSizeLimit(viewKey, oModel) { - if (!oModel) return; - // For the root slots (MAIN/NEST/NEST2) this is the max limit across them, - // since they share this one model; popup/popover get their own limit. - const limit = Lib.effectiveSizeLimit( - AppState.state.viewSizeLimits, - viewKey, - ); - if (limit !== undefined) oModel.setSizeLimit(limit); - } - return Controller.extend("z2ui5.controller.View1", { - // ------------------------------------------------------------------ - // Model change tracking - remembers which model paths the user edited - // so the next roundtrip only ships the delta. - // ------------------------------------------------------------------ - _trackChanges(oModel) { - // Mark the model as framework-owned: updateModelIfRequired may only - // reuse models that carry this change tracker. - oModel._z2ui5Tracked = true; - // Edited paths are tracked PER MODEL, not in one shared set: the main - // view and a popup/popover each have their own JSON model, and a - // roundtrip ships only the picked model's own edits. A single shared - // set would build the delta of one model against another's data (a - // path missing there serializes as `undefined` and clears the field - // on the backend) and would drop the other model's still-unsent edits. - oModel._z2ui5ChangedPaths = new Set(); - oModel.attachPropertyChange((e) => { - const params = e.getParameters(); - const raw = params.path; - const ctx = params.context; - if (!raw) return; - // Resolve relative paths against the binding context. - const changedPath = - ctx && !raw.startsWith("/") ? `${ctx.getPath()}/${raw}` : raw; - if (changedPath.startsWith("/")) { - oModel._z2ui5ChangedPaths.add(changedPath); - } - }); - return oModel; - }, - onAfterRendering() { - if (AppState.state.oResponse && !AppState.state.oResponse._processed) { - this._processAfterRendering(); - } + // _processAfterRendering re-checks _processed itself - only the + // null check is load-bearing here + if (AppState.state.oResponse) this._processAfterRendering(); }, // Runs once after each roundtrip's view has been rendered, in two // named phases: display pending fragments/views, then update the - // browser history/hash. - async _processAfterRendering() { + // browser history/hash. `reqSeq` is the stamp of the request the + // response being processed belongs to (Server.responseSuccess); the + // onAfterRendering entry above has none and falls back to the newest. + async _processAfterRendering(reqSeq) { + // The claim happens BEFORE the try: the MAIN rebuild is a system + // action now, so slots render (and re-enter here via their own + // onAfterRendering - possibly with a NESTED controller as `this`) + // while phase 1 is still awaiting. A losing entry must return here + // and never reach the finally, which would hide the busy state and + // consume the pending custom JS mid-phase, on the wrong controller. + // The record is also pinned for the finally: the shared + // AppState.state.oResponse may point at a newer response by then. + const oResponse = AppState.state.oResponse; + if (!oResponse || oResponse._processed) return; + oResponse._processed = true; try { - const oResponse = AppState.state.oResponse; - if (oResponse._processed) return; - oResponse._processed = true; - - const PARAMS = oResponse.PARAMS; - if (!PARAMS) return; - - await this._displayPendingViews(PARAMS); + // An APP SWITCH kills the two standalone slots implicitly: they + // live outside the MAIN control tree, so they do not fall with + // the page the new app renders - and the switch is visible right + // here (the response names its app), so no destroy action travels + // for it. BEFORE the system actions, so the new app's own + // popup_display still opens afterwards. (A hop to another + // instance of the SAME class is invisible here - the backend + // queues the teardown for exactly that case.) + const state = AppState.state; + if (oResponse.APP && state.renderedApp !== oResponse.APP) { + if (state.renderedApp) { + ViewSlots.destroy("POPUP"); + ViewSlots.destroy("POPOVER"); + } + // the leaving app's keyboard shortcuts die with it - the new app + // registers its own (actions/Shortcuts documents this reset) - + // and so do its tree-expansion snapshots, which are keyed by the + // LOCAL tree_id and would otherwise leak into a same-named tree + // of the next app + state.shortcuts = {}; + state.treeStates = {}; + state.renderedApp = oResponse.APP; + } + // No early return on an empty action list: a response without any + // action still gets its model push, its hash sync and the + // after-render hooks below - with the ROUTER and updateModel + // actions derived/gated away, an action-free response is the + // COMMON case now, not the exception. + if (oResponse.S_ACTION) { + // Stamp of the request this response belongs to: every await in + // the display phase re-checks it, so a response superseded by a + // parallel request (check_allow_multi_req, Back/Forward restore) + // never attaches popups/nested views the backend no longer knows. + const seq = reqSeq ?? Server._requestSeq; + await this._runSystemActions(oResponse, seq); + } // The app may have been torn down (reset / FLP re-launch) while the // pending views loaded; don't mutate history or fire onAfterRendering // hooks against a dead app (the custom-JS phase below guards the same - // way via isDestroyed). - if (Lib.isDestroyed(this)) return; - this._updateBrowserHistory(PARAMS, oResponse.ID); - if (PARAMS.SET_NAV_BACK) history.back(); - + // way via isDestroyed). And a response a PARALLEL request replaced + // mid-phase must not push its model or write its ids into the URL - + // the push would read the NEWER response's data into this stale + // render, and the sync would mix this draft id with the newer app. + // The newer response runs its own push and sync. + if (Lib.isDestroyed(this) || oResponse !== AppState.state.oResponse) { + return; + } + // A MODEL key in the response IS the model push - run it after the + // displays, so a slot built in this same roundtrip is filled before + // it is pushed to. This reaches what a fresh build alone does not: + // a nested view re-displayed without its MAIN view (it inherits the + // MAIN model by UI5 propagation) and a popup left open across a + // roundtrip that rebuilt no view (one that DOES rebuild MAIN takes + // the standalone slots down with it - see actions/Slots). + if (oResponse.MODELPRESENT) Slots.action("updateModel"); + // Phase 2: ONE history/hash sync per response. A ROUTER action only + // travels when the roundtrip carries nav intent - its options were + // stashed by the ControlCall hook. The plain response still syncs, + // so hash routing and app-state tracking follow every new draft id. + Router.sync({ + ...(oResponse._routerOptions || {}), + id: oResponse.ID, + }); Lib.runCallbacks(AppState.state.onAfterRendering); } catch (e) { Lib.logError("_processAfterRendering: unexpected error", e); @@ -119,307 +130,46 @@ sap.ui.define( // run the follow-up JS snippets the backend asked for. Doing it here // - rather than as an early microtask - guarantees render-dependent // actions like SET_FOCUS find their target control in the DOM. - this._runPendingCustomJs(); + this._runPendingCustomJs(oResponse); } }, - // Phase 1: open/destroy the popup, nested views and popover the - // response asked for. - async _displayPendingViews(PARAMS) { - const S_POPUP = PARAMS.S_POPUP; - const S_VIEW_NEST = PARAMS.S_VIEW_NEST; - const S_VIEW_NEST2 = PARAMS.S_VIEW_NEST2; - const S_POPOVER = PARAMS.S_POPOVER; - - if (S_POPUP?.CHECK_DESTROY) this.destroyPopup(); - if (S_POPOVER?.CHECK_DESTROY) this.destroyPopover(); - if (S_VIEW_NEST?.CHECK_DESTROY) this.destroyNestView(); - if (S_VIEW_NEST2?.CHECK_DESTROY) this.destroyNestView2(); - - if (S_POPUP?.XML) { - this.destroyPopup(); - await this.displayFragment(S_POPUP.XML); - } - - if (!AppState.state.checkNestAfter && S_VIEW_NEST?.XML) { - this.destroyNestView(); - await this.displayNestedView(S_VIEW_NEST.XML, "NEST"); - AppState.state.checkNestAfter = true; - } - - if (!AppState.state.checkNestAfter2 && S_VIEW_NEST2?.XML) { - this.destroyNestView2(); - await this.displayNestedView(S_VIEW_NEST2.XML, "NEST2"); - AppState.state.checkNestAfter2 = true; - } - - if (S_POPOVER?.XML) { - this.destroyPopover(); - await this.displayPopover(S_POPOVER.XML, S_POPOVER.OPEN_BY_ID); - } - }, - - // Phase 2: push the backend-requested URL and update the app-state - // hash. - _updateBrowserHistory(PARAMS, ID) { - try { - // Hash-based app routing (UI5 Router style), opt-in per session. The - // flag carries the MODE (z2ui5_if_client=>cs_nav_mode): "KEEP" routes - // by class + draft id (exact state restored on Back/Forward), "FRESH" - // routes by class only (Back/Forward start the app fresh); any other - // non-empty value ("DEFAULT") turns routing back OFF (framework - // default). An EMPTY value is "no change" so a later roundtrip that - // does not re-send the flag keeps routing on with the mode already - // chosen (an app that enabled it once in check_on_init stays routed). - if (PARAMS.SET_NAV_ROUTING) { - const mode = String(PARAMS.SET_NAV_ROUTING).toUpperCase(); - const on = mode === "KEEP" || mode === "FRESH"; - AppState.state.navRouting = on; - AppState.state.navMode = on ? mode : null; - } - const state = AppState.state; - if (state.navRouting) { - const app = state.oResponse?.APP; - if (app) { - // In FRESH mode the route carries the class only, so every history - // entry (Back/Forward/reload/bookmark) starts the app fresh; in - // KEEP mode it carries the draft id too, so they restore the exact - // preserved state. draftForRoute is what the route (and the echo - // guard below) uses - null in FRESH, the app-state ID in KEEP. - const draftForRoute = state.navMode === "FRESH" ? null : ID; - // Set current app/draft BEFORE touching the hash: the setHash/ - // replaceHash below re-fires hashChanged, and Server.onHashChange - // compares the incoming route's draft id against currentDraftId to - // ignore our own echo (no navigation loop). In FRESH mode there is - // no draft, so the guard matches on the class instead. - state.currentApp = app; - state.currentDraftId = draftForRoute; - if (state.navFromHash) { - // This render is the result of a browser Back/Forward (or manual - // hash edit) routed through Server.onHashChange. The hash already - // matches this history entry and the browser sits at a non-top - // position - rewriting the hash here would drop the forward - // entries and break the Forward button. Just adopt the state. - state.navFromHash = false; - } else if (!PARAMS.SET_PUSH_STATE) { - // Reflect the running app in the URL as a bookmarkable route - // "/app/" (FRESH) or "/app//" (KEEP). In - // KEEP the draft id makes Back/Forward restore the EXACT - // preserved state, not a fresh app. A forward navigation done in - // the backend (client->nav_app_call, CHECK_NAV_APP_CALL) pushes a - // NEW history entry so Back returns to the calling app - the - // routing equivalent of a UI5 navTo. A plain roundtrip only - // replaces the current (top) entry, advancing it to the app's - // latest draft so a later Forward restores the newest state. - const route = Lib.routeForApp(app, draftForRoute); - if (PARAMS.CHECK_NAV_APP_CALL) { - // repoint the caller's entry first - it borrows the echo - // guard, so restore it to this app before pushing the route - this._repointCallerEntry(PARAMS, draftForRoute); - state.currentApp = app; - state.currentDraftId = draftForRoute; - _hashChanger.setHash(route); - } else if (_hashChanger.getHash() !== route) { - _hashChanger.replaceHash(route); - } - } - } - // Routing owns the app-state hash; skip the legacy handling below. - if (!PARAMS.SET_PUSH_STATE) return; - } - - if (PARAMS.SET_PUSH_STATE) { - const hash = _hashChanger.getHash(); - const newUrl = `${window.location.pathname}${window.location.search}#${hash}${PARAMS.SET_PUSH_STATE}`; - history.pushState(null, "", newUrl); - } - // Keep the leading "/" so the live URL matches the format the copy - // link (FrontendAction.evClipboardAppState) writes and the backend - // restore path expects: request_app_start_draft reads the state id - // via iv_hash+2, i.e. it skips exactly the "#/" prefix. Without the - // slash the live hash is "#z2ui5-xapp-state=..." and iv_hash+2 eats - // the leading "z", so bookmarking/reloading the live URL never - // restores the app state (only the explicitly copied link did). - const newHash = PARAMS.SET_APP_STATE_ACTIVE - ? `/z2ui5-xapp-state=${ID || ""}` - : ""; - _hashChanger.replaceHash(newHash); - } catch (e) { - Lib.logError("_updateBrowserHistory: history update failed", e); + // Phase 1: run the SYSTEM actions - the framework's own view-lifecycle + // calls (destroy a slot, display one, push the model into it), in the + // order the backend queued them. They run BEFORE anything an app + // queued, and one at a time: a display is async, and the next action + // may well be about the slot it is still building. The action context + // carries the request stamp (so the slot displays can discard a build + // a newer parallel request superseded) and the response record (so the + // ROUTER action stashes its options on the response they belong to). + async _runSystemActions(oResponse, seq) { + const systemJs = oResponse?.S_ACTION?.T_SYSTEM; + if (!systemJs) return; + for (const item of systemJs) { + // Stop the whole phase once a newer request superseded this + // response - the remaining actions would tear down or overwrite + // what the newer response builds (the per-display guards check + // the same stamp, but the synchronous teardowns do not). + if (Lib.isDestroyed(this) || seq !== Server._requestSeq) return; + await FrontendAction.runSystem(item, this, { + seq, + response: oResponse, + }); } }, - // Point the CALLING app's history entry at the draft the backend saved - // for it during this very nav_app_call (PARAMS.NAV_APP_CALL_PREV_*). - // That draft carries every client-side change the user made since the - // caller last rendered - two-way bound switches, checkboxes, input - all - // of which travelled to the backend with the event that triggered the - // navigation. The entry itself still carries the older draft of that - // last render, so without this Back restores the caller as it was - // RENDERED and silently drops those changes. The entry is still the top - // one here (the called app's route is pushed right after), so a - // replaceHash updates it in place and leaves the history depth alone. - // KEEP mode only - a FRESH route carries no draft and always restarts - // the app anyway. - _repointCallerEntry(PARAMS, draftForRoute) { - const state = AppState.state; - const prevApp = PARAMS.NAV_APP_CALL_PREV_APP; - const prevDraft = PARAMS.NAV_APP_CALL_PREV_ID; - if (!draftForRoute || !prevApp || !prevDraft) return; - const prevRoute = Lib.routeForApp(prevApp, prevDraft); - if (_hashChanger.getHash() === prevRoute) return; - // Server.onHashChange ignores the echo of our own hash writes by - // comparing the route's draft id against currentDraftId - adopt the - // caller's fresh draft BEFORE replacing, or the write reads as a user - // navigation and fires a restore roundtrip. The caller of this method - // sets the state back to the called app right afterwards. - state.currentDraftId = prevDraft; - _hashChanger.replaceHash(prevRoute); - }, - // Execute the follow-up JS snippets stashed by Server.responseSuccess. // Runs once per roundtrip, after the view has rendered. - _runPendingCustomJs() { - const customJs = AppState.state.pendingCustomJs; - AppState.state.pendingCustomJs = null; + _runPendingCustomJs(oResponse) { + const customJs = oResponse?._pendingCustomJs; + if (oResponse) oResponse._pendingCustomJs = null; if (!customJs) return; if (Lib.isDestroyed(this)) return; for (const item of customJs) { - Server._runCustomJs(item, this); + FrontendAction.runCustom(item, this); } }, - _createViewModel() { - const data = AppState.state.oResponse?.OVIEWMODEL; - return this._trackChanges(new JSONModel(data)); - }, - - // ------------------------------------------------------------------ - // Display: popups, popovers, nested views, main view - // ------------------------------------------------------------------ - - async displayFragment(xml) { - const oModel = this._createViewModel(); - applyStoredSizeLimit("POPUP", oModel); - const oFragment = await Fragment.load({ - definition: xml, - controller: ViewSlots.getController("POPUP"), - id: "popupId", - }); - // The app might have been torn down while the fragment loaded. - if (!Lib.isAlive(AppState.state.oApp)) { - oFragment.destroy(); - return; - } - oFragment.setModel(oModel); - // The shared device + message models are attached inside - // ViewSlots.setView (the single funnel), so error paths that - // destroy a view without reaching setView never register it. - ViewSlots.setView("POPUP", oFragment); - oFragment.open(); - }, - - async displayPopover(xml, openById) { - // No catch-all here on purpose: a malformed-XML load or render - // failure must propagate to _processAfterRendering and surface the - // fatal "App Terminated" overlay, exactly like displayFragment and - // displayNestedView. The explicit returns below stay graceful - they - // handle expected, non-error conditions (app torn down mid-load, or - // the openBy anchor not being present), matching the parent-not-found - // guard in displayNestedView. - const oModel = this._createViewModel(); - applyStoredSizeLimit("POPOVER", oModel); - const oFragment = await Fragment.load({ - definition: xml, - controller: ViewSlots.getController("POPOVER"), - id: "popoverId", - }); - if (!Lib.isAlive(AppState.state.oApp)) { - oFragment.destroy(); - return; - } - oFragment.setModel(oModel); - - // Find the control to attach the popover to: any open slot first, - // then the global UI5 control registry as a last resort. - const oControl = ViewSlots.resolveById(openById); - - if (!oControl) { - Lib.logError( - `displayPopover: openBy control '${openById}' not found`, - ); - oFragment.destroy(); - return; - } - ViewSlots.setView("POPOVER", oFragment); - oFragment.openBy(oControl); - }, - - async displayNestedView(xml, slotKey) { - const paramKey = ViewSlots.paramByKey(slotKey); - // Nested views do NOT create their own model. They are inserted into - // the MAIN control tree below and inherit its default JSON model via - // UI5 model propagation, so every view binds against the same data with - // one change tracker and one refresh per roundtrip - no duplicate - // models pointing at the same data. The model passed to the XML - // preprocessor here only feeds {template>...} bindings at build time; - // it is the MAIN view's JSON model (the named "http" model when - // SWITCH_DEFAULT_MODEL_PATH moved OData into the default slot, otherwise - // the default model), mirroring displayView's template model. - const oMainView = ViewSlots.getView("MAIN"); - const oTemplateModel = - oMainView?.getModel("http") ?? oMainView?.getModel(); - const oView = await XMLView.create({ - definition: xml, - controller: ViewSlots.getController(slotKey), - preprocessors: { xml: { models: { template: oTemplateModel } } }, - }); - - if (!Lib.isAlive(AppState.state.oApp)) { - oView.destroy(); - return; - } - - const nestParams = AppState.state.oResponse?.PARAMS?.[paramKey]; - if (!nestParams) { - Lib.logError(`displayNestedView: missing PARAMS.${paramKey}`); - oView.destroy(); - return; - } - const { ID, METHOD_DESTROY, METHOD_INSERT } = nestParams; - - const oParent = ViewSlots.byId("MAIN", ID); - if (!oParent) { - Lib.logError( - `displayNestedView: parent control '${ID}' not found, nested view discarded`, - ); - oView.destroy(); - return; - } - - // METHOD_DESTROY is optional: only call it when the app asked for a - // parent teardown method. An empty value used to reach oParent[""]() - // and throw on every render (e.g. app 065 passes only method_insert). - if (METHOD_DESTROY) { - try { - oParent[METHOD_DESTROY](); - } catch (e) { - Lib.logError( - `displayNestedView: parent destroy method '${METHOD_DESTROY}' failed`, - e, - ); - } - } - try { - oParent[METHOD_INSERT](oView); - } catch (e) { - Lib.logError("displayNestedView: parent insert method failed", e); - oView.destroy(); - return; - } - ViewSlots.setView(slotKey, oView); - }, - // Thin wrappers around the shared slot teardown in ViewSlots, kept // because existing apps may call them via custom JS. destroyPopup() { @@ -442,8 +192,8 @@ sap.ui.define( // eF = "event frontend": handles frontend-only events triggered by // the backend response, without a roundtrip. The name is part of the // protocol - backend-generated view XML binds events to eB/eF - and - // must not be renamed. The individual handlers live in - // core/FrontendAction.js. + // must not be renamed. The individual handlers live in the domain + // modules under core/actions/ (merged in core/FrontendAction.js). // ------------------------------------------------------------------ eF(...args) { FrontendAction.execute(this, args); @@ -459,10 +209,16 @@ sap.ui.define( // Example: sap.tnt NavigationListItem.press, where cancelling the // default suppresses the item selection and leaves the decision to // the backend. The name is part of the protocol - do not rename it. + // + // The second argument is the veto CONDITION, so the decision can be + // made per firing instead of per wire: s_ctrl-check_prevent_default + // sends the constant true, s_ctrl-prevent_default_expr sends an + // expression UI5 resolves on each firing (e.g. "is this the one column + // that must not be resized?"). Everything after it is the eB payload. // ------------------------------------------------------------------ - eBP(oEvent, ...args) { + eBP(oEvent, bVeto, ...args) { // guard the call: a malformed wire (no $event) must still round-trip - if (typeof oEvent?.preventDefault === "function") { + if (bVeto && typeof oEvent?.preventDefault === "function") { oEvent.preventDefault(); } this.eB(...args); @@ -482,12 +238,14 @@ sap.ui.define( // The name is part of the protocol - backend-generated view XML binds // events to eB/eF - and must not be renamed. // - // args[0] is the event array built by the backend: + // args[0] is the event array built by the backend (get_event): // [0] event name + // [1] reserved placeholder, always false // [2] "ignore busy" flag - background events (e.g. timers) skip the // busy guard below // [3] "use main view model" flag - events fired from a popup or - // popover controller that still target the main app's model + // popover controller that still target the main app's model; + // not emitted by the framework today, only by custom JS // ------------------------------------------------------------------ eB(...args) { const [, , ignoreBusy, useMainModel] = args[0]; @@ -537,8 +295,8 @@ sap.ui.define( // If the user edited model paths, send only the delta to keep the // payload small. The edited paths live on the picked model itself - // (set in _trackChanges), so onBeforeRoundtrip hooks that mark paths - // dirty (e.g. the Scrolling control) must have run above first. + // (set in Slots.trackChanges), so onBeforeRoundtrip hooks that mark + // paths dirty (e.g. the Scrolling control) must have run above first. const changedPaths = oModel?._z2ui5ChangedPaths; if (oModel && changedPaths?.size > 0) { const data = oModel.getData(); @@ -557,24 +315,19 @@ sap.ui.define( // turned into JSON strings by the backend when it fills // T_EVENT_ARG, so apps keep receiving them as strings; stringifying // them here as well would encode (and escape) the payload twice. - // `args` is this call's own rest-parameter array (Server.roundtrip - // mutates ARGUMENTS via shift), so it can be handed over directly - - // no defensive copy needed. - oBody.ARGUMENTS = args; + // Control-valued arguments are marshalled into plain data first (see + // Lib.normalizeEventArgs): a UI5 event parameter is often a control or + // an array of controls, and JSON.stringify throws on the circular + // parent/aggregation graph of a ManagedObject. Everything else passes + // through untouched. normalizeEventArgs returns a fresh array, which + // is what Server.roundtrip needs - it mutates ARGUMENTS via shift and + // must not reach this call's own rest-parameter array. + oBody.ARGUMENTS = Lib.normalizeEventArgs(args); Server.roundtrip(oBody); Lib.runCallbacks(AppState.state.onAfterRoundtrip); }, - // The framework-owned JSON model on a slot's view: the DEFAULT model - // normally, but the NAMED "http" model when SWITCH_DEFAULT_MODEL_PATH put - // an OData model in the default slot. Returns undefined when neither model - // is ours (marked by _z2ui5Tracked). - _resolveTrackedModel(oView) { - const isOurs = (m) => (m?._z2ui5Tracked ? m : undefined); - return isOurs(oView.getModel()) ?? isOurs(oView.getModel("http")); - }, - _pickModelForRoundtrip(useMainModel) { // useMainModel forces use of the main view's model even when called // from a popup/popover controller. @@ -590,101 +343,12 @@ sap.ui.define( // edit is silently dropped. The data and changedPaths delta are shared // across the root slots, so any of them yields the same model. if (Lib.isRootModelSlot(slotKey)) { - return this._resolveTrackedModel(oView); + return Slots.resolveTrackedModel(oView); } // Popup/popover are standalone and return their own (default) model. return oView.getModel(); }, - - // Refresh a slot's model when the response signals an update for it - // (CHECK_UPDATE_MODEL - the data-only roundtrip every app triggers - // via client->view_model_update( )). - updateModelIfRequired(slotKey) { - const params = AppState.state.oResponse?.PARAMS; - const slotParams = params?.[ViewSlots.paramByKey(slotKey)]; - if (!slotParams?.CHECK_UPDATE_MODEL) return; - - const oView = ViewSlots.getView(slotKey); - if (!oView) return; - - // Reuse the existing model whenever it is ours: setData() keeps the - // view's bindings alive and only refreshes what changed, while a new - // model + setModel() destroys and recreates every binding - measured - // ~3x slower with all values changed and ~150x slower when little - // changed (see node/tests-examples/modelUpdate.bench.spec.js). - // Never overwrite an OData default (switch mode) with a fresh JSON model. - const tracked = this._resolveTrackedModel(oView); - if (tracked) { - applyStoredSizeLimit(slotKey, tracked); - // MAIN and its nested views resolve to the SAME root model here, and - // the update loop calls this once per slot. setData replaces the - // model's data reference with OVIEWMODEL, so once the first root slot - // has swapped it in, the others already hold it - skip the redundant - // setData (and its full binding refresh) instead of running it once - // per shared slot. - const data = AppState.state.oResponse?.OVIEWMODEL; - if (tracked.getData() !== data) tracked.setData(data); - return; - } - - // No framework-owned model on this slot at all: bind a fresh default - // JSON model (keeps the previous behavior for that edge case). - const oModel = this._createViewModel(); - applyStoredSizeLimit(slotKey, oModel); - oView.setModel(oModel); - }, - - // Replace the main app view with the XML coming from the backend. - async displayView(xml, viewModel, reqSeq) { - const oViewModel = this._trackChanges(new JSONModel(viewModel)); - - const sView = AppState.state.oResponse?.PARAMS?.S_VIEW; - const switchPath = sView?.SWITCH_DEFAULT_MODEL_PATH; - - // When the app wants OData as the default model, build it here and - // keep the JSON model as the named "http" model. - let oModel; - if (switchPath) { - oModel = new ODataModel({ - serviceUrl: switchPath, - annotationURI: sView.SWITCHDEFAULTMODELANNOURI || "", - }); - } else { - oModel = oViewModel; - } - applyStoredSizeLimit("MAIN", oModel); - - const oView = await XMLView.create({ - definition: xml, - models: oModel, - controller: ViewSlots.getController("MAIN"), - id: "mainView", - preprocessors: { xml: { models: { template: oViewModel } } }, - }); - - // Guard against the app being destroyed during the await above. - if (!Lib.isAlive(AppState.state.oApp)) { - oView.destroy(); - if (switchPath) oModel.destroy(); - return; - } - - // A newer parallel request (check_allow_multi_req) superseded this one - // while XMLView.create was awaiting - discard this rebuild instead of - // letting an out-of-order resolve overwrite the newer view. Last-write - // wins by request order, not by which create() happened to resolve last. - if (reqSeq !== undefined && reqSeq !== Server._requestSeq) { - oView.destroy(); - if (switchPath) oModel.destroy(); - return; - } - - ViewSlots.setView("MAIN", oView); - if (switchPath) oView.setModel(oViewModel, "http"); - AppState.state.oApp.removeAllPages(); - AppState.state.oApp.insertPage(oView); - }, }); }, ); diff --git a/core/app/z2ui5/webapp/core/AppState.js b/core/app/z2ui5/webapp/core/AppState.js index ef620fe..38270bd 100644 --- a/core/app/z2ui5/webapp/core/AppState.js +++ b/core/app/z2ui5/webapp/core/AppState.js @@ -31,6 +31,11 @@ // core:require; the global covers releases without // core:require); owns the date helpers Util // re-exports - grows via framework PRs only (Component) +// ccResourceRoot absolute path of the custom-control BSP, set by the +// backend GET page when there is no sibling BSP to +// resolve "../z2ui5_cci/" against (backend HTML) +// cccResourceRoot same for the customer frontend-extension BSP +// ("../z2ui5_ccc/") (backend HTML) // requestTimeoutMs optional override for the roundtrip timeout (apps) // apps can register functions via the js_loader popup // and call them through the Z2UI5 frontend event @@ -42,7 +47,15 @@ // oApp sap.m.App hosting the main view (App.controller) // oOwnerComponent, oDeviceModel (Component / App.controller) // oView, oViewNest, oViewNest2, oViewPopup, oViewPopover -// the five view slots, see core/ViewSlots.js (View1) +// the five view slots, written by ViewSlots.setView +// slotXml the view XML each slot was filled with, per slot key - +// recorded by ViewSlots.setView and dropped by +// ViewSlots.destroy, so it tracks the slot itself no +// matter who tore it down (backend action or a +// roundtrip-free frontend close). The developer tools +// read a slot's source from here: a fragment or a view +// built from a `definition` keeps no viewContent of its +// own // oController, oControllerNest, oControllerNest2, oControllerPopup, // oControllerPopover controller instance per slot (App.controller) // oLaunchpad FLP services when running inside the launchpad, else @@ -54,11 +67,14 @@ // Server.roundtrip/readHttp; this record exists for // onBeforeRoundtrip hooks and the developer tools // (View1.eB / Server) -// oResponse last processed response { ID, PARAMS, OVIEWMODEL } +// oResponse last processed response { ID, S_ACTION, OVIEWMODEL, +// APP, MODELPRESENT } +// renderedApp class name of the last rendered app - an APP switch in +// a response tears the standalone slots down implicitly +// (View1._processAfterRendering) // responseData raw parsed response JSON (Server.readHttp); kept -// besides oResponse because it carries fields the -// cooked record does not (e.g. S_FRONT.APP, used by -// the developer tools) +// besides oResponse because the developer tools render +// the raw payload // contextId stateful session id, header transport (Server) // isBusy roundtrip in flight (View1.eB / Server) // oSentModel the JSON model whose edited-path set the in-flight @@ -66,25 +82,30 @@ // once that request wins (Server), so a stale response // never clears newer edits and edits made in a DIFFERENT // model (e.g. a popover) are never shipped against this one -// checkNestAfter, checkNestAfter2 nested views rebuilt this roundtrip // search overrides location.search in S_FRONT; never written // by the framework itself, set externally (custom JS) -// pendingCustomJs follow-up JS to run after rendering (Server) // // Control / helper state // errors capped error log, see Lib.logError -// timers single pending backend timer (FrontendAction) +// timers single pending backend timer (actions/ViewOps) // shortcuts registered keyboard shortcuts, normalized combo -> -// { event, controller } (FrontendAction.KEYBOARD_SHORTCUT); +// scope -> { event, controller }, the scope being a view +// slot key or "" for unscoped (actions/Shortcuts). Dispatch takes the innermost OPEN +// scope, so a popover-local shortcut shadows the page one +// the way a UI5 CommandExecution in dependents does; // an app switch resets it, the document listener stays -// lastScrolled last scrolled element per slot (Server.onScrollCapture) -// viewSizeLimits per-slot model size limits (FrontendAction) +// lastScrolled last scrolled element per slot (ScrollFocus.onScrollCapture) +// viewSizeLimits per-slot model size limits (actions/ViewOps) // treeStates tree binding state per tree_id across rebuilds (Tree control) -// developerTools DeveloperTools instance (Component, Ctrl+F12) // lastError the last fatal error shown by ErrorView (title/text/ -// onRetry), so the DeveloperTools Error tab can re-show it +// onRetry), so a details view can re-show it // onBeforeRoundtrip, onAfterRoundtrip, onAfterRendering, -// onBeforeEventFrontend callback arrays, see Lib.registerCallback +// onBeforeEventFrontend, onErrorDetails callback arrays, see +// Lib.registerCallback. onErrorDetails is the extension +// point behind the fatal-error overlay's Details action: +// ErrorView runs whatever registered and hides the button +// when nothing did (devtools/DevTools.js registers +// the in-app developer tools there) sap.ui.define([], () => { "use strict"; @@ -106,21 +127,22 @@ sap.ui.define([], () => { oControllerNest2: null, oControllerPopup: null, oControllerPopover: null, + slotXml: {}, oLaunchpad: null, // Roundtrip state oBody: null, oResponse: null, + renderedApp: null, responseData: null, contextId: null, isBusy: false, oSentModel: null, - checkNestAfter: false, - checkNestAfter2: false, search: null, - pendingCustomJs: null, // Hash-based app routing (UI5 Router style, opt-in via set_nav_routing). + // Owned by core/Router.js - see there for the route format and how the + // hash is split between the FLP shell and the app. // navRouting once the running app enabled routing, the URL hash mirrors // the current app as a bookmarkable route and browser // Back/Forward navigate between apps via the hash. @@ -135,7 +157,7 @@ sap.ui.define([], () => { // our own hash writes do not re-trigger a navigation, and // (KEEP) browser Back/Forward restore the exact draft. // navFromHash the pending roundtrip was triggered by a browser - // Back/Forward (or manual hash edit) via onHashChange, so + // Back/Forward (or manual hash edit) via the router, so // the resulting render must NOT rewrite the hash: the // browser is at a non-top history position and rewriting // there drops the forward entries (Forward would break). @@ -152,7 +174,6 @@ sap.ui.define([], () => { lastScrolled: {}, viewSizeLimits: {}, treeStates: {}, - developerTools: null, lastError: null, // Callback arrays (see Lib.registerCallback / Lib.runCallbacks) @@ -160,6 +181,7 @@ sap.ui.define([], () => { onAfterRoundtrip: [], onAfterRendering: [], onBeforeEventFrontend: [], + onErrorDetails: [], }; } diff --git a/core/app/z2ui5/webapp/core/DeveloperTools.fragment.xml b/core/app/z2ui5/webapp/core/DeveloperTools.fragment.xml deleted file mode 100644 index 982bb5c..0000000 --- a/core/app/z2ui5/webapp/core/DeveloperTools.fragment.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/app/z2ui5/webapp/core/DeveloperTools.js b/core/app/z2ui5/webapp/core/DeveloperTools.js deleted file mode 100644 index c404c9c..0000000 --- a/core/app/z2ui5/webapp/core/DeveloperTools.js +++ /dev/null @@ -1,761 +0,0 @@ -sap.ui.define( - [ - "sap/ui/core/Control", - "sap/ui/core/Fragment", - "sap/ui/model/json/JSONModel", - "z2ui5/core/Lib", - "z2ui5/core/ViewSlots", - "z2ui5/core/AppState", - "z2ui5/core/ErrorView", - ], - (Control, Fragment, JSONModel, Lib, ViewSlots, AppState, ErrorView) => { - "use strict"; - - // Fragment id under which the developer tools dialog's controls are registered; - // used to resolve controls by their id instead of by content position. - const FRAGMENT_ID = "z2ui5DeveloperTools"; - - // toJson() pretty-prints with this many spaces per nesting level, so a - // line's leading-space count divided by it gives that line's JSON depth. - const INDENT_UNIT = 3; - - // The System tab shows the whole (deeply nested) z2ui5 global; open only - // the first two levels so it is readable - the rest can be unfolded by - // hand in the editor. - const SYSTEM_OPEN_LEVELS = 2; - - // Leading-space matcher, hoisted so the per-line fold loop below does not - // recompile it on every row of a large JSON dump. - const LEADING_SPACES = /^ */; - - // JSON nesting depth of a pretty-printed line, read from its indentation. - function indentLevel(line, unit) { - return Math.floor(LEADING_SPACES.exec(line)[0].length / unit); - } - - // Fold every foldable block in the ACE edit session that sits at or below - // `keepLevels` nesting levels, leaving the outer levels open. Uses only the - // public EditSession folding API (unfold / getFoldWidget / - // getFoldWidgetRange / addFold), so it works with any CodeEditor build; a - // block's depth is read from its line indentation. Skipping to the folded - // block's end row keeps us from descending into (already hidden) children. - function foldSessionToLevel(session, keepLevels, unit) { - session.unfold(); - const rowCount = session.getLength(); - for (let row = 0; row < rowCount; row++) { - if (session.getFoldWidget(row) !== "start") continue; - if (indentLevel(session.getLine(row) || "", unit) < keepLevels) - continue; - const range = session.getFoldWidgetRange(row); - if (range && range.isMultiLine()) { - session.addFold("...", range); - row = range.end.row; - } - } - } - - // Pretty-print any value (object, array, primitive) as indented JSON. - // `null` is used as a fallback so undefined values still produce output. - // A replacer drops circular references (the z2ui5 global can hold them, - // e.g. via ComponentData) so the output stays useful JSON instead of - // throwing and degrading to a bare "[object Object]". - function toJson(val) { - const safe = val === undefined ? null : val; - // Track the ANCESTOR chain, not every object ever visited: a plain - // WeakSet of all seen objects would mislabel a value referenced twice in - // sibling branches (common in the live z2ui5 global) as "[Circular]". - // `this` inside the replacer is the object the key belongs to, so we can - // unwind the stack back to it before testing containment. - const ancestors = []; - try { - return JSON.stringify( - safe, - function (key, value) { - if (typeof value === "object" && value !== null) { - while ( - ancestors.length > 0 && - ancestors[ancestors.length - 1] !== this - ) { - ancestors.pop(); - } - if (ancestors.includes(value)) return "[Circular]"; - ancestors.push(value); - } - return value; - }, - INDENT_UNIT, - ); - } catch { - // The developer tools must never crash the host app, so degrade to the - // plain string form if serialization still fails. - return String(safe); - } - } - - // XSL stylesheet used by prettifyXml to reindent any XML string. - const PRETTIFY_XSL = ` - - - - - - - - - - - `; - - // The XSLT processor and (de)serializers are expensive to construct, so - // we keep them as module-level singletons. - const _xmlSerializer = new XMLSerializer(); - const _domParser = new DOMParser(); - let _xsltProcessor = null; - - function getXsltProcessor() { - if (_xsltProcessor) return _xsltProcessor; - const xsltDoc = _domParser.parseFromString( - PRETTIFY_XSL, - "application/xml", - ); - _xsltProcessor = new XSLTProcessor(); - _xsltProcessor.importStylesheet(xsltDoc); - return _xsltProcessor; - } - - // Helpers to pull the various pieces of state shown in the dialog. - function getModelJson(view) { - const model = view?.getModel(); - return model?.getData(); - } - - function getViewContent(view) { - // Private member access (developer tools only): XMLView keeps the raw XML - // string as a pseudo property in mProperties, but does not declare it - // in its metadata - getProperty("viewContent") therefore throws and - // would abort the whole tab selection. Read the plain object instead. - return view?.mProperties?.viewContent; - } - - function getRenderedContent(view) { - // Private member access (developer tools only): _xContent holds the view - // XML after XML templating ran; there is no public equivalent. - return view?._xContent?.outerHTML; - } - - // Minimal text dump of the shared error log (the entries Lib.logError - // pushes to AppState.state.errors): one " " line per entry, - // oldest first. Empty log yields a short placeholder. - function formatErrorLog() { - const errors = AppState.state.errors || []; - if (!errors.length) return "(log is empty)"; - return errors.map((e) => `${e.ts} ${e.message}`).join("\n"); - } - - // The last fatal error the ErrorView overlay showed (title + full text), - // so the Error tab reproduces the overlay's content. Empty when the app - // has not hit a fatal error this session. - function formatLastError() { - const err = AppState.state.lastError; - if (!err) return "(no fatal error captured this session)"; - return err.title ? `${err.title}\n\n${err.text}` : err.text; - } - - function getResponseXml(key) { - const params = AppState.state.oResponse?.PARAMS; - const slot = params?.[key]; - return slot?.XML; - } - - // Preload the sap.ui.codeeditor modules used by the fragment. On older - // UI5 releases (e.g. 1.71) Fragment.load still processes the XML with - // the sync strategy, so an unloaded CodeEditor would be fetched via - // synchronous XHR and executed with eval - which a Content-Security- - // Policy without 'unsafe-eval' blocks. Requiring the modules - // asynchronously up front makes the sync lookup a cache hit. - function preloadCodeEditor() { - return new Promise((resolve) => { - sap.ui.require( - ["sap/ui/codeeditor/library", "sap/ui/codeeditor/CodeEditor"], - () => resolve(), - // On failure continue anyway and let Fragment.load surface the - // real error. - () => resolve(), - ); - }); - } - - // What each dropdown entry shows: either a JSON source or an XML source - // (the latter optionally with the rendered DOM for the templating - // toggle). The "SOURCE" entry is handled separately in onItemSelect. - const jsonSources = { - // The whole public z2ui5 global facade (oConfig, url, checkLocal, - // Util, app-registered members, ...). Read directly here on purpose: - // this is the developer tools inspector, whose job is to surface the live global - // as-is - functions drop out under JSON.stringify, which is fine. - // ui5lint-disable-next-line no-project-globals -- see reason above - SYSTEM: () => window.z2ui5, - MODEL: () => getModelJson(ViewSlots.getView("MAIN")), - PLAIN: () => AppState.state.responseData, - REQUEST: () => AppState.state.oBody, - POPUP_MODEL: () => getModelJson(ViewSlots.getView("POPUP")), - POPOVER_MODEL: () => getModelJson(ViewSlots.getView("POPOVER")), - NEST1_MODEL: () => getModelJson(ViewSlots.getView("NEST")), - NEST2_MODEL: () => getModelJson(ViewSlots.getView("NEST2")), - }; - - const xmlSources = { - // Prefer the actual viewContent string; fall back to the XML that - // arrived in the last server response. - VIEW: () => ({ - xml: - getViewContent(ViewSlots.getView("MAIN")) || getResponseXml("S_VIEW"), - rendered: getRenderedContent(ViewSlots.getView("MAIN")), - }), - POPUP: () => ({ xml: getResponseXml("S_POPUP") }), - POPOVER: () => ({ xml: getResponseXml("S_POPOVER") }), - NEST1: () => ({ - xml: getViewContent(ViewSlots.getView("NEST")), - rendered: getRenderedContent(ViewSlots.getView("NEST")), - }), - NEST2: () => ({ - xml: getViewContent(ViewSlots.getView("NEST2")), - rendered: getRenderedContent(ViewSlots.getView("NEST2")), - }), - }; - - return Control.extend("z2ui5.core.DeveloperTools", { - // Reformat an XML string with indentation. If anything goes wrong the - // original input is returned unchanged - the developer tools must never - // crash the host app. - prettifyXml(sourceXml) { - if (!sourceXml) return ""; - try { - const xmlDoc = _domParser.parseFromString( - sourceXml, - "application/xml", - ); - const resultDoc = getXsltProcessor().transformToDocument(xmlDoc); - if (!resultDoc) return sourceXml; - const resultXml = _xmlSerializer.serializeToString(resultDoc); - // The serializer escapes < and > inside text nodes; undo this so - // the output is browseable XML again. - return resultXml.replace(/>|</g, (match) => - match === ">" ? ">" : "<", - ); - } catch { - return sourceXml; - } - }, - - // Called when the user picks an entry in the dropdown of the developer - // tools dialog - resolve the model + key and render that tab. - onItemSelect(oEvent) { - this.renderTab( - oEvent.getSource().getSelectedKey(), - oEvent.getSource().getModel(), - ); - }, - - // Render one tab's content into the dialog model. Shared by the user's - // tab selection (onItemSelect) and show(initialTab), which opens the - // dialog directly on a given tab (e.g. the error popup's Details jumps - // to "ERROR"). The content per entry is defined declaratively in - // jsonSources / xmlSources above. - renderTab(selItem, oModel) { - if (jsonSources[selItem]) { - this.displayEditor(oModel, toJson(jsonSources[selItem]()), "json"); - if (selItem === "SYSTEM") this.foldSystemTab(); - return; - } - - if (xmlSources[selItem]) { - const { xml, rendered } = xmlSources[selItem](); - this.displayEditor( - oModel, - this.prettifyXml(xml), - "xml", - this.prettifyXml(rendered), - ); - return; - } - - if (selItem === "LOG") { - this.displayEditor(oModel, formatErrorLog(), "text"); - return; - } - - if (selItem === "ERROR") { - this.showError(oModel); - return; - } - - if (selItem === "SOURCE") this.showAbapSource(oModel); - }, - - // Show the last fatal error (the ErrorView overlay's content). The - // Retry/Restart/Logout actions live in the dialog footer (always - // present); refresh hasRetry so the footer's Retry button shows only - // when this error carried a retry action. - showError(oModel) { - this.displayEditor(oModel, formatLastError(), "text"); - const modelData = oModel.getData(); - modelData.hasRetry = - typeof AppState.state.lastError?.onRetry === "function"; - oModel.refresh(); - }, - - // The Error tab's buttons mirror the ErrorView overlay: re-run the - // captured request, hard-reload, or log out (reusing ErrorView's own - // logout so the launchpad/fallback logic stays in one place). - onErrorRetry() { - const onRetry = AppState.state.lastError?.onRetry; - // Retrying re-runs the request, so don't bounce back to the error popup. - this.reopenErrorOnClose = false; - this.close(); - if (typeof onRetry === "function") onRetry(); - }, - onErrorRestart() { - window.location.reload(); - }, - onErrorLogout() { - ErrorView.handleLogout(); - }, - - // Collect the content of every developer-tools tab into one plain-text - // blob so it can be copied elsewhere in one go. XML tabs are - // pretty-printed, JSON tabs serialized; empty / inactive sections are - // skipped. Every source is guarded (a throwing one can never blank the - // whole export) and each section is capped, because the SYSTEM global can - // serialize to several MB - a value that large blanks a sap.m.TextArea. - // `abapSource` is the running app's ABAP class source, fetched - // asynchronously by onExport (empty when it could not be retrieved). - buildExport(abapSource) { - // Max characters per section; long ones (mainly SYSTEM) are truncated - // so the popup's TextArea still renders. - const MAX_SECTION = 100000; - const sections = []; - const push = (title, content) => { - if (!content) return; - let body = String(content); - if (body.length > MAX_SECTION) { - body = `${body.slice(0, MAX_SECTION)}\n\n... [truncated ${body.length - MAX_SECTION} more characters - open the ${title} tab for the full content]`; - } - sections.push(`===== ${title} =====\n${body}`); - }; - const json = (fn) => { - try { - const v = fn(); - return v === undefined || v === null ? "" : toJson(v); - } catch { - return ""; - } - }; - const xml = (fn) => { - try { - return this.prettifyXml(fn()); - } catch { - return ""; - } - }; - const text = (fn) => { - try { - return fn() || ""; - } catch { - return ""; - } - }; - - if (AppState.state.lastError) push("ERROR", text(formatLastError)); - push("LOG", text(formatErrorLog)); - // The running app's ABAP class source (fetched by onExport). Placed - // high up because it is usually the most useful context when sharing - // an error - a reader can see the class that produced it. - push("ABAP SOURCE", abapSource); - push( - "RESPONSE", - json(() => jsonSources.PLAIN()), - ); - push( - "PREVIOUS REQUEST", - json(() => jsonSources.REQUEST()), - ); - push( - "VIEW", - xml(() => xmlSources.VIEW().xml), - ); - push( - "VIEW MODEL", - json(() => jsonSources.MODEL()), - ); - if (getResponseXml("S_POPUP")) { - push( - "POPUP", - xml(() => xmlSources.POPUP().xml), - ); - push( - "POPUP MODEL", - json(() => jsonSources.POPUP_MODEL()), - ); - } - if (getResponseXml("S_POPOVER")) { - push( - "POPOVER", - xml(() => xmlSources.POPOVER().xml), - ); - push( - "POPOVER MODEL", - json(() => jsonSources.POPOVER_MODEL()), - ); - } - if (getViewContent(ViewSlots.getView("NEST"))) { - push( - "NEST1", - xml(() => xmlSources.NEST1().xml), - ); - push( - "NEST1 MODEL", - json(() => jsonSources.NEST1_MODEL()), - ); - } - if (getViewContent(ViewSlots.getView("NEST2"))) { - push( - "NEST2", - xml(() => xmlSources.NEST2().xml), - ); - push( - "NEST2 MODEL", - json(() => jsonSources.NEST2_MODEL()), - ); - } - // SYSTEM (the whole z2ui5 global) is the largest by far - keep it last - // so the useful sections come first even after truncation. - push( - "SYSTEM", - json(() => jsonSources.SYSTEM()), - ); - - return sections.join("\n\n") || "(nothing to export)"; - }, - - // Fetch the running app's ABAP class source via the ADT REST endpoint, - // so the export can include the class that produced the current state. - // Returns the raw source text, or "" when the class name is unknown or - // the request fails (the endpoint needs an authenticated, ADT-enabled - // session, which is not always available - the export must still work - // without it). Never throws: the export must succeed regardless. - async fetchAbapSource() { - const url = this.getAbapSourceUrl(); - if (!url) return ""; - try { - const response = await fetch(url, { - headers: { Accept: "text/plain" }, - credentials: "same-origin", - }); - if (!response.ok) return ""; - return await response.text(); - } catch { - return ""; - } - }, - - // Show the whole export in a stretched popup with a read-through TextArea - // (selectable for manual copy) and a one-click "Copy to Clipboard". The - // ABAP class source is fetched first (asynchronously) so it can be part - // of the exported / copied blob. - async onExport() { - let text; - try { - const abapSource = await this.fetchAbapSource(); - text = this.buildExport(abapSource); - } catch (e) { - text = `(export failed: ${e?.message || e})`; - } - sap.ui.require( - ["sap/m/Dialog", "sap/m/TextArea", "sap/m/Button"], - (Dialog, TextArea, Button) => { - const area = new TextArea({ - editable: true, - width: "100%", - rows: 25, - growing: false, - }); - // Set the value explicitly (not only via the constructor) so a - // large payload is applied reliably after the control exists. - area.setValue(text); - const dialog = new Dialog({ - title: "abap2UI5 - Developer Tools Export", - stretch: true, - content: [area], - beginButton: new Button({ - text: "Copy to Clipboard", - type: "Emphasized", - press: () => { - // navigator.clipboard needs a secure (HTTPS) context, which - // an on-premise ABAP system often is not. Select the - // TextArea and use the classic execCommand("copy") first - // (works over plain HTTP), then fall back to the async API. - const ta = area.getFocusDomRef(); - let copied = false; - if (ta) { - ta.focus(); - ta.select(); - ta.setSelectionRange(0, (ta.value || "").length); - try { - copied = document.execCommand("copy"); - } catch { - copied = false; - } - } - if (!copied && navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text).catch(() => {}); - } - }, - }), - endButton: new Button({ - text: "Close", - press: () => dialog.close(), - }), - afterClose: () => dialog.destroy(), - }); - dialog.addStyleClass("dbg-ltr"); - dialog.open(); - }, - ); - }, - - // The CodeEditor's underlying ACE editor, or null if it does not exist - // yet (created on the CodeEditor's first render) or the build exposes no - // internal instance. - getEditorInstance() { - const ce = Fragment.byId(FRAGMENT_ID, "developerToolsEditor"); - return ce && typeof ce.getInternalEditorInstance === "function" - ? ce.getInternalEditorInstance() - : null; - }, - - // Fold the System tab's JSON down to the first SYSTEM_OPEN_LEVELS levels. - // The ACE editor is created lazily on the CodeEditor's first render, so - // on the very first open we retry briefly until it exists. Best-effort: - // any failure leaves the tab fully expanded rather than breaking the - // developer tools. - foldSystemTab(triesLeft = 10) { - let editor; - try { - editor = this.getEditorInstance(); - } catch (e) { - Lib.logError("DeveloperTools System fold failed", e); - return; - } - if (editor) { - try { - const session = editor.getSession && editor.getSession(); - if (session && typeof session.getFoldWidget === "function") { - foldSessionToLevel(session, SYSTEM_OPEN_LEVELS, INDENT_UNIT); - } - } catch (e) { - Lib.logError("DeveloperTools System fold failed", e); - } - return; - } - if (triesLeft > 0) { - setTimeout(() => this.foldSystemTab(triesLeft - 1), 30); - } - }, - - // The ADT REST endpoint that renders the running app's ABAP class - // source. Empty when the app class name is unknown (no response yet). - getAbapSourceUrl() { - const appName = AppState.state.responseData?.S_FRONT?.APP || ""; - if (!appName) return ""; - const appId = encodeURIComponent(appName); - return `${window.location.origin}/sap/bc/adt/oo/classes/${appId}/source/main`; - }, - - // Open the ABAP class source as a top-level document in a new browser - // tab. The ADT REST endpoint renders it with syntax highlighting and its - // own "Open in ABAP Development Tools" link; opening it top-level is what - // lets that link's adt:// navigation reach the desktop ADT. From inside - // the inline iframe below the jump never worked - browsers suppress a - // custom-scheme navigation started in a subframe, and some systems block - // framing the ADT endpoint entirely (X-Frame-Options), so the preview is - // just blank there. noopener keeps the new tab from reaching back into - // window.opener. - onOpenAbapInAdt() { - const url = this.getAbapSourceUrl(); - if (!url) return; - window.open(url, "_blank", "noopener,noreferrer"); - }, - - // Show the ABAP source of the running app inside an iframe. - showAbapSource(oModel) { - const contentControl = Fragment.byId(FRAGMENT_ID, "sourceHtml"); - if (!contentControl) return; - - const url = this.getAbapSourceUrl(); - // setContent (not a bare setProperty) so an already rendered iframe - // is replaced in the live DOM; a plain property set never reached - // the DOM once the control had rendered, leaving a stale class - // on screen after navigating to another app. - contentControl.setContent( - url - ? `