diff --git a/UPSTREAM_HEAD b/UPSTREAM_HEAD index 7ccba44..9a0297e 100644 --- a/UPSTREAM_HEAD +++ b/UPSTREAM_HEAD @@ -1 +1 @@ -8f1413b1873ed74df61610fa00159123fc8d1743 +0f49fdc4d60f5f4c116e8b4e7b1eb99b841f0631 diff --git a/run/input/UPSTREAM_COMMIT b/run/input/UPSTREAM_COMMIT index 357d0da..9a0297e 100644 --- a/run/input/UPSTREAM_COMMIT +++ b/run/input/UPSTREAM_COMMIT @@ -1 +1 @@ -23deacb95561364c3fe598ef9e7362acd9cdd803 +0f49fdc4d60f5f4c116e8b4e7b1eb99b841f0631 diff --git a/run/input/core/app/z2ui5/webapp/Component.js b/run/input/core/app/z2ui5/webapp/Component.js index 0704803..95ab9f7 100644 --- a/run/input/core/app/z2ui5/webapp/Component.js +++ b/run/input/core/app/z2ui5/webapp/Component.js @@ -4,7 +4,7 @@ sap.ui.define( "z2ui5/model/models", "z2ui5/core/Server", "sap/ui/VersionInfo", - "z2ui5/core/DebugTool", + "z2ui5/core/DeveloperTools", "z2ui5/core/Lib", "z2ui5/core/AppState", "z2ui5/Util", @@ -16,7 +16,7 @@ sap.ui.define( Models, Server, VersionInfo, - DebugTool, + DeveloperTools, Lib, AppState, DateUtil, @@ -60,11 +60,29 @@ sap.ui.define( AppState.state.oDeviceModel = Models.createDeviceModel(); this.setModel(AppState.state.oDeviceModel, "device"); + // Warm-load the messaging module so Lib.getMessaging's synchronous + // sap.ui.require resolves it before the first view is displayed. + // On UI5 2.x sap/ui/core/Messaging is the only messaging API (the + // sap.ui.getCore().getMessageManager() fallback is gone), and + // nothing else pulls the module into the graph - without this the + // message> model and validation collection would silently no-op. + // Only attempt it where the module exists (1.118+): on older releases + // (e.g. 1.71) the require would 404 and make the ui5loader retry + // loudly via synchronous XHR; there Lib.getMessaging falls back to + // sap.ui.getCore().getMessageManager() instead. + if (Lib.hasMessagingModule()) { + sap.ui.require( + ["sap/ui/core/Messaging"], + () => {}, + () => {}, + ); + } + this._initLaunchpad(); this._initVersionInfo(); this._installUnloadListener(); - this._installDebugToolShortcut(); + this._installDeveloperToolsShortcut(); this._installScrollListener(); // The stopped router removed with the manifest routing section used @@ -96,13 +114,14 @@ sap.ui.define( window.addEventListener(this._unloadEvent, this._boundUnload); }, - _installDebugToolShortcut() { - // Ctrl + F12 opens / closes the in-app debug tool. + _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.debugTool) state.debugTool = new DebugTool(); - state.debugTool.toggle(); + if (!state.developerTools) + state.developerTools = new DeveloperTools(); + state.developerTools.toggle(); } }; document.addEventListener("keydown", this._boundKeydown); @@ -225,12 +244,12 @@ sap.ui.define( capture: true, }); - // The debug tool 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.debugTool) { - AppState.state.debugTool.destroy(); - AppState.state.debugTool = null; + // 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; } Server.endSession(); diff --git a/run/input/core/app/z2ui5/webapp/cc/Dirty.js b/run/input/core/app/z2ui5/webapp/cc/Dirty.js index 4ec6567..c4d0538 100644 --- a/run/input/core/app/z2ui5/webapp/cc/Dirty.js +++ b/run/input/core/app/z2ui5/webapp/cc/Dirty.js @@ -5,6 +5,23 @@ sap.ui.define( ["sap/ui/core/Control", "z2ui5/core/Lib", "z2ui5/core/AppState"], (Control, Lib, AppState) => { "use strict"; + + // Every live Dirty instance that is currently dirty. The FLP dirty flag + // and the browser's onbeforeunload are single global slots, so the guard + // must reflect whether ANY instance is dirty - one instance clearing its + // own flag (or being destroyed) must not wipe another instance's unsaved + // guard (e.g. a main-view form plus a form in a dialog). + const dirtyControls = new Set(); + + function syncUnloadPrompt(anyDirty) { + window.onbeforeunload = anyDirty + ? (e) => { + e.preventDefault(); + e.returnValue = ""; + } + : null; + } + return Control.extend("z2ui5.cc.Dirty", { metadata: { properties: { @@ -16,38 +33,36 @@ sap.ui.define( }, setIsDirty(val) { this.setProperty("isDirty", val); + if (val) { + dirtyControls.add(this); + } else { + dirtyControls.delete(this); + } + this._applyDirtyState(); + }, - // Fallback for non-launchpad scenarios: ask the browser to confirm - // before leaving the page when something is unsaved. - const fallback = () => { - if (val) { - window.onbeforeunload = (e) => { - e.preventDefault(); - e.returnValue = ""; - }; - } else { - window.onbeforeunload = null; - } - }; - - // Use the FLP dirty flag when running inside the Launchpad (SAPUI5 - // only); otherwise fall back to the browser unload prompt. + // Apply the AGGREGATE dirty state (any instance dirty) to whichever + // mechanism is active: the FLP dirty flag inside the Launchpad (SAPUI5 + // only), else the browser unload prompt. + _applyDirtyState() { + const anyDirty = dirtyControls.size > 0; try { const launchpad = AppState.state.oLaunchpad; const hasFlpDirtyFlag = launchpad?.Container?.setDirtyFlag && launchpad.ShellUIService; if (hasFlpDirtyFlag) { - launchpad.Container.setDirtyFlag(val); + launchpad.Container.setDirtyFlag(anyDirty); } else { - fallback(); + syncUnloadPrompt(anyDirty); } } catch (e) { Lib.logError("Dirty.setIsDirty: setDirtyFlag failed", e); - fallback(); + syncUnloadPrompt(anyDirty); } }, exit() { - window.onbeforeunload = null; + dirtyControls.delete(this); + this._applyDirtyState(); }, renderer: { apiVersion: 2, render() {} }, }); diff --git a/run/input/core/app/z2ui5/webapp/cc/Focus.js b/run/input/core/app/z2ui5/webapp/cc/Focus.js index f205a78..ead5115 100644 --- a/run/input/core/app/z2ui5/webapp/cc/Focus.js +++ b/run/input/core/app/z2ui5/webapp/cc/Focus.js @@ -28,7 +28,7 @@ sap.ui.define( setFocusId(val) { try { this.setProperty("focusId", val); - const oElement = ViewSlots.byId("MAIN", val); + const oElement = ViewSlots.byIdOfOwner(this, val); if (oElement) oElement.applyFocusInfo(oElement.getFocusInfo()); } catch (e) { Lib.logError("Focus.setFocusId failed", e); @@ -37,7 +37,10 @@ sap.ui.define( onAfterRendering() { if (!this._pendingFocus) return; this._pendingFocus = false; - const oElement = ViewSlots.byId("MAIN", this.getProperty("focusId")); + const oElement = ViewSlots.byIdOfOwner( + this, + this.getProperty("focusId"), + ); if (!oElement) return; try { // Merge the additional selection info into the existing focus info, diff --git a/run/input/core/app/z2ui5/webapp/cc/MultiInputExt.js b/run/input/core/app/z2ui5/webapp/cc/MultiInputExt.js index 12f6eeb..deb3b37 100644 --- a/run/input/core/app/z2ui5/webapp/cc/MultiInputExt.js +++ b/run/input/core/app/z2ui5/webapp/cc/MultiInputExt.js @@ -55,7 +55,10 @@ sap.ui.define( }, renderer: { apiVersion: 2, render() {} }, setControl() { - const input = ViewSlots.byId("MAIN", this.getProperty("MultiInputId")); + const input = ViewSlots.byIdOfOwner( + this, + this.getProperty("MultiInputId"), + ); if (!input || this.getProperty("checkInit")) return; this.setProperty("checkInit", true); try { diff --git a/run/input/core/app/z2ui5/webapp/cc/Scrolling.js b/run/input/core/app/z2ui5/webapp/cc/Scrolling.js index 1051509..acf5a69 100644 --- a/run/input/core/app/z2ui5/webapp/cc/Scrolling.js +++ b/run/input/core/app/z2ui5/webapp/cc/Scrolling.js @@ -25,14 +25,14 @@ sap.ui.define( }, _getDomInnerElement(id) { - const control = ViewSlots.byId("MAIN", id); + const control = ViewSlots.byIdOfOwner(this, id); if (!control) return null; return document.getElementById(`${control.getId()}-inner`); }, _getScrollTop(item) { try { - const control = ViewSlots.byId("MAIN", item.N); + const control = ViewSlots.byIdOfOwner(this, item.N); // Some controls expose a scroll delegate; prefer it when available. const delegate = control?.getScrollDelegate?.(); if (delegate) return delegate.getScrollTop(); @@ -78,7 +78,7 @@ sap.ui.define( _restoreScrollPosition(item) { try { - const control = ViewSlots.byId("MAIN", item.N); + const control = ViewSlots.byIdOfOwner(this, item.N); if (control?.scrollTo) { control.scrollTo(item.V); return; @@ -99,7 +99,7 @@ sap.ui.define( try { for (const item of items) { - const control = ViewSlots.byId("MAIN", item.N); + const control = ViewSlots.byIdOfOwner(this, item.N); if (!control) continue; // Restore immediately when rendered, otherwise once it is. diff --git a/run/input/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js b/run/input/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js index e710ffe..ec878a9 100644 --- a/run/input/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js +++ b/run/input/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js @@ -55,6 +55,13 @@ sap.ui.define( // Mirror each range entry with the visible token text + long key // so the backend has enough info to re-render the input later. + // NOTE: this pairs the i-th range entry with the i-th token by + // position, which assumes getRangeData() and getTokens() are index + // aligned. That holds only while every token is a range token; a + // mix of plain value tokens and range tokens would misalign the + // captions. The wrapped control is the SAPUI5-only sap.ui.comp + // SmartMultiInput, so a token-identity pairing needs verification + // against that control before it can replace the index pairing. const source = oEvent.getSource(); const tokens = source.getTokens(); const rangeData = source.getRangeData() || []; @@ -104,7 +111,10 @@ sap.ui.define( }, renderer: { apiVersion: 2, render() {} }, setControl() { - const input = ViewSlots.byId("MAIN", this.getProperty("multiInputId")); + const input = ViewSlots.byIdOfOwner( + this, + this.getProperty("multiInputId"), + ); if (!input || this.getProperty("checkInit")) return; this.setProperty("checkInit", true); try { diff --git a/run/input/core/app/z2ui5/webapp/cc/Tree.js b/run/input/core/app/z2ui5/webapp/cc/Tree.js index 7d295be..f030c36 100644 --- a/run/input/core/app/z2ui5/webapp/cc/Tree.js +++ b/run/input/core/app/z2ui5/webapp/cc/Tree.js @@ -21,16 +21,31 @@ sap.ui.define( }, _getTreeBinding() { - const treeControl = ViewSlots.byId("MAIN", this.getProperty("tree_id")); + // Resolve the tree in the companion's OWN slot (main, popup, + // popover, nested): byIdOfOwner works next to a tree in a dialog, + // and unlike resolveById it never picks a same-id tree from another + // open slot. + const treeControl = ViewSlots.byIdOfOwner( + this, + this.getProperty("tree_id"), + ); return treeControl?.getBinding("items"); }, + // Snapshots are keyed by tree_id so several trees on one page (e.g. a + // main-view tree plus a tree in a popup) keep independent state - a + // single shared slot would let one companion overwrite another's. setBackend() { try { const binding = this._getTreeBinding(); - AppState.state.treeState = binding - ? binding.getCurrentTreeState() - : undefined; + const id = this.getProperty("tree_id"); + if (!id) return; + // Only overwrite the snapshot when the binding is actually + // resolvable - a momentarily missing binding must not wipe a + // still-valid snapshot for this id. + if (binding) { + AppState.state.treeStates[id] = binding.getCurrentTreeState(); + } } catch (e) { Lib.logError("Tree.setBackend: failed", e); } @@ -46,11 +61,34 @@ sap.ui.define( }, onAfterRendering() { - if (!this._pendingTreeState) return; - this._pendingTreeState = false; try { + const id = this.getProperty("tree_id"); + const snapshot = id && AppState.state.treeStates[id]; + if (!snapshot) return; const binding = this._getTreeBinding(); - if (binding) binding.setTreeState(AppState.state.treeState); + if (!binding) return; + // Restore exactly once per (snapshot, binding) pair. setBackend + // creates a fresh snapshot object each roundtrip, so a new + // snapshot or a rebuilt binding is what triggers a restore - + // NOT an incidental re-render (theme/density/parent + // invalidation), which would otherwise force a spurious + // refresh(true) and collapse the user's expansions back to the + // last roundtrip snapshot. + if ( + this._appliedSnapshot === snapshot && + this._appliedBinding === binding + ) { + return; + } + this._appliedSnapshot = snapshot; + this._appliedBinding = binding; + // setTreeState only stores an INITIAL tree state - the binding + // adapter consumes it while creating its nodes, which already + // happened during this rendering cycle. Force a rebuild so the + // snapshot actually gets applied (headless-verified: without the + // refresh the tree stays collapsed). + binding.setTreeState(snapshot); + binding.refresh(true); } catch (e) { Lib.logError("Tree.onAfterRendering: setTreeState failed", e); } @@ -63,8 +101,6 @@ sap.ui.define( oRm.style("display", "none"); oRm.openEnd(); oRm.close("span"); - if (!AppState.state.treeState) return; - oControl._pendingTreeState = true; }, }, }); diff --git a/run/input/core/app/z2ui5/webapp/cc/UITableExt.js b/run/input/core/app/z2ui5/webapp/cc/UITableExt.js index 3023339..7b7c611 100644 --- a/run/input/core/app/z2ui5/webapp/cc/UITableExt.js +++ b/run/input/core/app/z2ui5/webapp/cc/UITableExt.js @@ -43,7 +43,7 @@ sap.ui.define( }, _getTable() { - return ViewSlots.byId("MAIN", this.getProperty("tableId")); + return ViewSlots.byIdOfOwner(this, this.getProperty("tableId")); }, readFilter() { diff --git a/run/input/core/app/z2ui5/webapp/cc/UploadSetExt.js b/run/input/core/app/z2ui5/webapp/cc/UploadSetExt.js index 2ad4e6c..004e632 100644 --- a/run/input/core/app/z2ui5/webapp/cc/UploadSetExt.js +++ b/run/input/core/app/z2ui5/webapp/cc/UploadSetExt.js @@ -86,8 +86,8 @@ sap.ui.define( renderer: { apiVersion: 2, render() {} }, setControl() { - const uploadSet = ViewSlots.byId( - "MAIN", + const uploadSet = ViewSlots.byIdOfOwner( + this, this.getProperty("uploadSetId"), ); if (!uploadSet || this.getProperty("checkInit")) return; diff --git a/run/input/core/app/z2ui5/webapp/controller/View1.controller.js b/run/input/core/app/z2ui5/webapp/controller/View1.controller.js index 070187c..6c47ee4 100644 --- a/run/input/core/app/z2ui5/webapp/controller/View1.controller.js +++ b/run/input/core/app/z2ui5/webapp/controller/View1.controller.js @@ -98,6 +98,11 @@ sap.ui.define( if (!PARAMS) return; await this._displayPendingViews(PARAMS); + // 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(); @@ -205,9 +210,9 @@ sap.ui.define( return; } oFragment.setModel(oModel); - // Share the one device model (created once in Component.js, never - // destroyed) so {device>...} bindings work in popups too. - oFragment.setModel(AppState.state.oDeviceModel, "device"); + // 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(); }, @@ -232,8 +237,6 @@ sap.ui.define( return; } oFragment.setModel(oModel); - // Shared device model (see displayFragment) - for popovers too. - oFragment.setModel(AppState.state.oDeviceModel, "device"); // Find the control to attach the popover to: any open slot first, // then the global UI5 control registry as a last resort. @@ -265,8 +268,6 @@ sap.ui.define( return; } oView.setModel(oModel); - // Shared device model (see displayFragment) - for nested views too. - oView.setModel(AppState.state.oDeviceModel, "device"); const nestParams = AppState.state.oResponse?.PARAMS?.[paramKey]; if (!nestParams) { @@ -385,7 +386,7 @@ sap.ui.define( // The request body is built locally and handed explicitly through // Server.roundtrip/readHttp. It is mirrored to AppState.state.oBody right - // away so onBeforeRoundtrip hooks and the debug tool see it. + // away so onBeforeRoundtrip hooks and the developer tools see it. const oBody = { VIEWNAME: "MAIN" }; AppState.state.oBody = oBody; @@ -459,16 +460,21 @@ sap.ui.define( // 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). - const existing = oView.getModel(); - if (existing?._z2ui5Tracked) { - applyStoredSizeLimit(slotKey, existing); - existing.setData(AppState.state.oResponse?.OVIEWMODEL); + // The framework-owned JSON model is the DEFAULT model normally, but + // the NAMED "http" model when SWITCH_DEFAULT_MODEL_PATH placed an + // OData model in the default slot - update whichever one is ours and + // never overwrite the OData default with a fresh JSON model. + const isOurs = (m) => (m?._z2ui5Tracked ? m : undefined); + const tracked = + isOurs(oView.getModel()) ?? isOurs(oView.getModel("http")); + if (tracked) { + applyStoredSizeLimit(slotKey, tracked); + tracked.setData(AppState.state.oResponse?.OVIEWMODEL); return; } - // The slot's default model is not framework-owned (e.g. an - // ODataModel via SWITCH_DEFAULT_MODEL_PATH): keep the previous - // behavior and bind a fresh JSON model. + // 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); @@ -510,7 +516,6 @@ sap.ui.define( } ViewSlots.setView("MAIN", oView); - oView.setModel(AppState.state.oDeviceModel, "device"); if (switchPath) oView.setModel(oViewModel, "http"); AppState.state.oApp.removeAllPages(); AppState.state.oApp.insertPage(oView); diff --git a/run/input/core/app/z2ui5/webapp/core/AppState.js b/run/input/core/app/z2ui5/webapp/core/AppState.js index ce14d1b..5a24ec1 100644 --- a/run/input/core/app/z2ui5/webapp/core/AppState.js +++ b/run/input/core/app/z2ui5/webapp/core/AppState.js @@ -52,13 +52,13 @@ // oBody mirror of the current request payload - the body // itself travels as a parameter through // Server.roundtrip/readHttp; this record exists for -// onBeforeRoundtrip hooks and the debug tool +// onBeforeRoundtrip hooks and the developer tools // (View1.eB / Server) // oResponse last processed response { ID, PARAMS, OVIEWMODEL } // 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 debug tool) +// the developer tools) // contextId stateful session id, header transport (Server) // isBusy roundtrip in flight (View1.eB / Server) // xxChangedPaths Set of edited /XX/ model paths for the delta (View1) @@ -72,8 +72,10 @@ // timers single pending backend timer (FrontendAction) // lastScrolled last scrolled element per slot (Server.onScrollCapture) // viewSizeLimits per-slot model size limits (FrontendAction) -// treeState tree binding state across rebuilds (Tree control) -// debugTool DebugTool instance (Component, Ctrl+F12) +// 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 // onBeforeRoundtrip, onAfterRoundtrip, onAfterRendering, // onBeforeEventFrontend callback arrays, see Lib.registerCallback sap.ui.define([], () => { @@ -116,8 +118,9 @@ sap.ui.define([], () => { timers: {}, lastScrolled: {}, viewSizeLimits: {}, - treeState: null, - debugTool: null, + treeStates: {}, + developerTools: null, + lastError: null, // Callback arrays (see Lib.registerCallback / Lib.runCallbacks) onBeforeRoundtrip: [], diff --git a/run/input/core/app/z2ui5/webapp/core/DebugTool.js b/run/input/core/app/z2ui5/webapp/core/DebugTool.js deleted file mode 100644 index d334aca..0000000 --- a/run/input/core/app/z2ui5/webapp/core/DebugTool.js +++ /dev/null @@ -1,351 +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", - ], - (Control, Fragment, JSONModel, Lib, ViewSlots, AppState) => { - "use strict"; - - // Fragment id under which the debug dialog's controls are registered; - // used to resolve controls by their id instead of by content position. - const FRAGMENT_ID = "z2ui5DebugTool"; - - // 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; - const seen = new WeakSet(); - try { - return JSON.stringify( - safe, - (key, value) => { - if (typeof value === "object" && value !== null) { - if (seen.has(value)) return "[Circular]"; - seen.add(value); - } - return value; - }, - 3, - ); - } catch { - // The debug tool 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 (debug tool 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 (debug tool 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"); - } - - 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 debug 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.DebugTool", { - // Reformat an XML string with indentation. If anything goes wrong the - // original input is returned unchanged - the debug tool 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 debug - // dialog. The content shown per entry is defined declaratively in - // jsonSources / xmlSources above. - onItemSelect(oEvent) { - const selItem = oEvent.getSource().getSelectedKey(); - - if (jsonSources[selItem]) { - this.displayEditor(oEvent, toJson(jsonSources[selItem]()), "json"); - return; - } - - if (xmlSources[selItem]) { - const { xml, rendered } = xmlSources[selItem](); - this.displayEditor( - oEvent, - this.prettifyXml(xml), - "xml", - this.prettifyXml(rendered), - ); - return; - } - - if (selItem === "LOG") { - this.displayEditor(oEvent, formatErrorLog(), "text"); - return; - } - - if (selItem === "SOURCE") this.showAbapSource(oEvent); - }, - - // Show the ABAP source of the running app inside an iframe. - showAbapSource(oEvent) { - const contentControl = Fragment.byId(FRAGMENT_ID, "sourceHtml"); - if (!contentControl) return; - - const sFront = AppState.state.responseData?.S_FRONT; - const appName = sFront?.APP || ""; - const appId = encodeURIComponent(appName); - const url = `${window.location.origin}/sap/bc/adt/oo/classes/${appId}/source/main`; - contentControl.setProperty( - "content", - `