(function() {
// Unified Rendering Help Harmonization Engine
// Used for WDA and WebGUI: XRAY-2097
/**
* @namespace ur
* @memberof Help4.engine
*/
Help4.engine.ur = {};
/**
* Elements in postMessage API version 1
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrElement
* @property {Object} id - key for backend query
* @property {DOMRect} position
* @property {string} ctrlId
* @property {string} [labelText] - to use instead of backend label_text
*/
/**
* Hotspots instead of elements in postMessage API version 2
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrHotspot
* @property {Object} backendHelpKey - backend query key
* @property {string} [backendHelpCDSQuery] - backend query name
* @property {DOMRect} [position] - not sent in UI5, using element from hotspotId
* @property {string} hotspotId
* @property {string} [labelText] - to use instead of backend label_text
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrDataV1
* @property {string} technology - GUI | WDA
* @property {Help4.engine.ur.UrHarmonizationEngine.UrElement[]} elements
*/
/***
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrDataV2
* @property {Help4.engine.ur.UrHarmonizationEngine.UrHotspot[]} hotspots
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.BackendQueryData
* @property {string} _type - name of the query
* @property {string} _urId - key / parameters of the query
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrTextResponse
* @property {string} [esid] - key name SAPGUI
* @property {string} [dtel] - key name WDA
* @property {string} label_text - tile title text
* @property {string} short_text - tile text
* @property {string} long_text - bubble text
* @property {string} [res_langu] - result language of long_text; important in case the requested language wasn't found
* @property {string} [poh] - Process On Help; dynamic help based on current context
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrLinkResponse
* @property {string} esid - key name SAPGUI (WDA sends url directly in postMessage, no query needed)
* @property {string} help_link - url to be used as link target in What's This App? tile (WTA)
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.QueryException
* @property {string} @SAP__common.ExceptionCategory
* @property {string} code
* @property {Object} innererror
* @property {string} message
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.ErrorResponse
* @property {Help4.engine.ur.UrHarmonizationEngine.QueryException} error
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest
* @property {string} rule - selector used
* @property {string} value - actual selector value
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.UrMigrationResponse
* @property {Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest} selector - {rule, value}, the migrated selector
* @property {string} [hotspotId] - hotspot id of the element the selector maps to (updated with UpdateHotspots)
* @property {DOMRect} [position] - alternative value, if the selector doesn't map to an existing help element
* @property {string} [selectorString] - JSON.stringified selector value
*/
/**
* @typedef {Object} Help4.engine.ur.UrHarmonizationEngine.Params
* @property {Help4.controller.Controller} controller
* @property {Help4.EventBus} eventBus
*/
/**
* UR Harmonization Engine
* @augments Help4.jscore.ControlBase
* @property {Help4.controller.Controller} controller
* @property {Help4.EventBus} eventBus
* @property {boolean} _started
* @property {?HTMLElement} _appFrame
* @property {?string} _currentApp
* @property {boolean} _connected
* @property {?Generator<number>} _connections
* @property {?number} _apiVersion
*
* updated from controller configuration each navigation
* @property {boolean} _useURHotspots - help and tour assignments
* @property {boolean} _useABAPHelpTexts - What's This App? and help tiles with ABAP help texts
* @property {boolean} _useUI5HelpTexts - help tiles with ABAP help texts in UI5 apps
*
* @property {boolean} _running
* @property {boolean} _runClosed
* @property {boolean} _assignMode
* @property {?string} _failedIconBase
* @property {boolean} _mock
* @property {?Object} _filter
* @property {Map<string, Help4.widget.help.ProjectTile>} _tiles
* @property {boolean} _ignoreLongText
* @property {?number} _useFixedVersion
* @property {Map<string, Object>} _urHotspots
* @property {Object} _texts
* @property {Object} _docu_texts - linked documentation in long_text
* @property {Object} _tileStatus
* @property {Help4.engine.ur.UrHarmonizationEngine.UrMigrationResponse[]} _migratedHotspots
* @property {?string} _wtaUrl
* @property {?Help4.widget.help.ProjectTile} _wtaTile
* @property {Help4.observer.EventBusObserver} _eventBusObserver
* @property {Help4.observer.MessageEventObserver} _messageObserver
* @property {Help4.observer.MutationObserver} _mutationObserver
* @property {?Function} _log - using UI5 logging {@link Help4.engine.ur.Connection.connect} */
Help4.engine.ur.UrHarmonizationEngine = class extends Help4.jscore.ControlBase {
/**
* @constructor
* @param {Help4.engine.ur.UrHarmonizationEngine.Params} params
*/
constructor(params) {
const {
engine: {ur: {Connection}},
jscore: {ControlBase: {TYPES: T}},
observer: {MessageEventObserver, EventBusObserver, MutationObserver}
} = Help4;
super(params, {
params: {
controller: {type: T.instance},
eventBus: {type: T.instance}
},
statics: {
_started: {init: false, destroy: false},
_appFrame: {init: null, destroy: false},
_currentApp: {init: null, destroy: false},
_connected: {init: false, destroy: false},
_connections: {init: null, destroy: false},
_apiVersion: {init: null, destroy: false},
_useABAPHelpTexts: {init: false, destroy: false},
_useUI5HelpTexts: {init: false, destroy: false},
_useURHotspots: {init: false, destroy: false},
_running: {init: false, destroy: false},
_runClosed: {init: false, destroy: false},
_assignMode: {init: false, destroy: false}, // no tiles during assign mode
_failedIconBase: {init: null, destroy: false},
_mock: {init: false, destroy: false},
_filter: {init: null, destroy: false},
_tiles: {init: new Map(), destroy: false},
_ignoreLongText: {init: false, destroy: false},
_useFixedVersion: {init: null, destroy: false},
_urHotspots: {init: new Map(), destroy: false},
_texts: {init: {}, destroy: false},
_docu_texts: {init: {}, destroy: false},
_tileStatus: {init: {}, destroy: false},
_migratedHotspots: {init: [], destroy: false},
_wtaUrl: {init: null, destroy: false},
_wtaTile: {init: null, destroy: false},
_eventBusObserver: {init: new EventBusObserver(event => _onEventBus.call(this, event))},
_messageObserver: {init: new MessageEventObserver(event => Connection.onMessage(this, event))},
_mutationObserver: {init: new MutationObserver(record => _onMutation.call(this, record))},
_log: {init: null, destroy: false}
}
});
}
static ICON_LIB_ID = 'SAPGUI-icons-h4';
// XRAY-6098: FLP configures application frame containers in two different ways - URL query sap-post=true/false
static APP_FRAME_SELECTOR_POST = '.sapUShellApplicationContainer:not(.sapUShellApplicationContainerIframeHidden) iframe';
static APP_FRAME_SELECTOR = 'iframe.sapUShellApplicationContainer:not(.sapUShellApplicationContainerIframeHidden)';
static MAX_DELAY = 10000;
static MSG_TYPE = {
v1: {
// sending
ping: 'sap.webassistant.services.ping', // connection request
start: 'sap.ls.services.startWebAssistantUpdates',
stop: 'sap.ls.services.stopWebAssistantUpdates',
// receiving
update: 'sap.webassistant.services.updateHotspots', // update hotspot data
app: 'sap.webassistant.services.whatsThisApp'
},
v2: { // version 2: https://github.wdf.sap.corp/pages/ur/docs/documents/sapcompanion/HelpHarmonization_v2_postmessage/
// sending
start: 'sap.companion.services.StartCompanion',
stop: 'sap.companion.services.StopCompanion',
startAssign: 'sap.companion.services.StartHotspotAssignment',
stopAssign: 'sap.companion.services.StopHotspotAssignment',
startTour: 'sap.companion.services.StartTour',
stopTour: 'sap.companion.services.StopTour',
scrollIntoView: 'sap.companion.services.ScrollIntoView',
migrate: 'sap.companion.services.MigrateSelectors',
// receiving
update: 'sap.companion.services.UpdateHotspots',
app: 'sap.companion.services.WhatsThisApp',
activateElement: 'sap.companion.services.ActivateElement',
hoverElement: 'sap.companion.services.HoverElement',
leaveElement: 'sap.companion.services.LeaveElement',
interactOnElement: 'sap.companion.services.InteractOnElement',
hotkeyPress: 'sap.companion.services.HotkeyPress',
fromSelectors: 'sap.companion.services.HotspotsFromSelectors'
}
};
static QUERIES = {
WDA: 'SDOC_HLP_CE_WD_DTEL', // - Help texts for WDA
GUI: 'SDOC_HLP_CE_WG_ESID', // - Help texts for GUI
GUI_AH: 'SDOC_HLP_CE_WG_ESID_AH', // - App Help / What's This App? for GUI
UI5: 'SDOC_HLP_CE_UI5_ESID', // - Help texts for UI5
UI5_AH: 'SDOC_HLP_CE_UI5_AH', // - App Help / What's This App? for UI5
DOC: 'SDOC_HLP_CE_ANY_DOCU' // - Linked documentation in long_text - XRAY-4837
};
/** starts the engine */
start() {
if (this._started) return;
this._started = true;
const {controller, eventBus, _mock, _eventBusObserver, _mutationObserver} = this;
const {help: {
useABAPHelpTexts,
useURHotspots,
useUI5HelpTexts
}} = controller.getConfiguration();
this._useABAPHelpTexts = useABAPHelpTexts;
this._useUI5HelpTexts = useUI5HelpTexts;
this._useURHotspots = useURHotspots;
if (!_mock && !useURHotspots && !useABAPHelpTexts) return;
const {TYPES: T} = eventBus;
_eventBusObserver
.observe(eventBus, {type: [
T.controllerOpen,
T.controllerClose,
T.controllerAfterNavigate,
T.hotspotAssignStart,
T.hotspotAssignPostpone,
T.hotspotAssignResume,
T.hotspotAssignStop,
T.tourOpen,
T.tourClose
]});
const viewport = document.getElementById('viewPortContainer');
viewport && _mutationObserver.observe(viewport, {childList: true});
Help4.engine.ur.IconTemplate.prepare(this);
this.run();
}
/** stops the engine */
stop() {
if (!this._started) return;
this._started = false;
this.run(false);
_clean.call(this);
const {_eventBusObserver, _mutationObserver, _messageObserver} = this;
[_eventBusObserver, _mutationObserver, _messageObserver]
.forEach(observer => observer.disconnect?.());
Help4.engine.ur.Mock.stop(this);
}
/** start mock */
startMock() {
Help4.engine.ur.Mock.start(this);
}
/** update mock element positions or send specific guided tour message */
updateMock({messageType, hotspotId, key, shift, ctrl, alt}) {
if (messageType && (hotspotId || key)) {
Help4.engine.ur.Mock.sendTourMessage(this, messageType, {hotspotId, key, shift, ctrl, alt});
} else {
Help4.engine.ur.Mock.update(this);
}
}
/** @param {?Object} filter */
setFilter(filter) {
Help4.engine.ur.Tile.setFilter(this, filter);
}
/**
* @param {?(string|number)} [id = null] - id or index
* @returns {?(Help4.widget.help.ProjectTile|Help4.widget.help.ProjectTile[])}
*/
getTile(id = null) {
return Help4.engine.ur.Tile.getTile(this, id);
}
/** @returns {boolean} */
hasTiles() {
return Help4.engine.ur.Tile.hasTiles(this);
}
/**
* @param {{point: Help4.control2.PositionXY}} param
* @returns {Promise<Object>}
*/
pointToSelector({point}) {
return Help4.engine.ur.Selector.pointToSelector(this, {point});
}
/**
* @param {{selector: Object, useOffset: boolean}} param
* @returns {Promise<Object>}
*/
selectorToInfo({selector, useOffset}) {
return Help4.engine.ur.Selector.selectorToInfo(this, {selector, useOffset});
}
/**
* @param {string} hotspotId
*/
scrollIntoView(hotspotId) {
Help4.engine.ur.Tour.scrollIntoView(this, hotspotId);
}
/**
* Collect non-UR selectors to migrate to UR hotspots / positions
* @param {Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest} selector
* @returns {boolean} - true if selector was added, false if it already existed
*/
addSelectorToMigrate(selector) {
return Help4.engine.ur.Migration.addSelector(this, selector);
}
/**
* Get hotspot values for migrated selector
* @param {Help4.engine.ur.UrHarmonizationEngine.UrMigrationRequest} selector
* @returns {Help4.engine.ur.UrHarmonizationEngine.UrMigrationResponse} - contains {string} hotspotId or {string} position
*/
getMigratedHotspot(selector) {
return Help4.engine.ur.Migration.selectorToUrHotspot(this, selector);
}
/**
* Run
* general entry point for each state
* 1a. specific state change (boolean argument) or
* 1b. determine if we are in an accepted fiori app (HTMLGUI | WDA | UI5)
* 2. find the correct frame and connect or try running again
* @private
* @param {false|undefined} [run = null]
*/
run(run = undefined) {
const {controller, _running, _runClosed, _useABAPHelpTexts, _useUI5HelpTexts, _useURHotspots, _started} = this;
if (!_started) return;
const {Connection} = Help4.engine.ur;
if (typeof run !== 'boolean') {
this._currentApp = Connection.getUI5AppType(this);
const {isOpen = false} = this._currentApp && controller?.getConfiguration() || {};
run = (_useABAPHelpTexts || _useURHotspots) && (isOpen || _runClosed);
}
if (!run) {
if (_running) _halt.call(this);
return;
}
// XRAY-4251 assumptions:
// - in mock case we only have one mock frame with fixed id
// - in live case we can have multiple frames, but only one is visible at a time, others are hidden
const frame = _findAppFrame.call(this);
if (_running) {
// XRAY-5437: navigation from one app directly into another while CMP is running
if (frame !== this._appFrame) {
_halt.call(this);
this.run();
}
} else {
if (frame) {
// try to connect to the frame
this._running = true;
this._appFrame = frame;
this._messageObserver.observe();
Connection.connect(this);
} else {
// retry later when a frame is found
_halt.call(this);
setTimeout(() => this.run(), 500);
}
}
}
/**
* support use of UR hotspots even when the panel is closed,
* e.g. for instant hotspots or callouts or
* for {@link Help4.API.record}
* @param {boolean} run - toggle
* @returns {boolean} - previous state
*/
runClosed(run = true) {
const {_runClosed, _running, _started} = this;
if (!_started) return false;
const previous = _runClosed;
if (run) {
this._runClosed = true;
this.run();
} else if (_running) {
_halt.call(this);
}
return previous;
}
/**
* check if appropriate hotspots are available
* a) check for any hotspots passing no selector in @link{Help4.service.recording.IFrameService.pointToSelector} or
* if the selector is a UrHarmonizationSelector in @link{Help4.service.recording.IFrameService.selectorToInfo}
* b) check if the specific non-UR selector is available in the migrated hotspots in @link{Help4.service.recording.IFrameService.selectorToInfo}
* @param {string} [rule = 'UrHarmonizationSelector']
* @param {string} [value]
* @returns {boolean}
*/
hasHotspots({rule = 'UrHarmonizationSelector', value} = {}) {
const {_migratedHotspots, _started, _urHotspots} = this;
if (!_started) return false;
return rule === 'UrHarmonizationSelector'
? _urHotspots.size > 0
: _migratedHotspots.some(({selector: {rule: r, value: v}}) => rule === r && value === v);
}
/** @returns {?HTMLElement} */
getAppFrame() {
return this._started ? this._appFrame : null;
}
/** @param {boolean} ignore */
ignoreLongText(ignore) {
this._ignoreLongText = ignore;
this._texts = {}; // reset
this._docu_texts = {}; // reset
}
/** @param {number} version */
useFixedVersion(version) {
this._useFixedVersion = version;
}
/**
* Status for backend tile playback in WDA and HTMLGUI apps;
* in UI5 apps we are using the DOM to check the tile status (same-origin access)
* @param {string} tileId
* @returns {*|null}
*/
getTileStatus(tileId) {
const appFrame = this.getAppFrame();
if (!appFrame) return null;
const {
engine: {ur: {Tile}},
selector: {Selector},
service: {recording: {SameWindowPlayback}}
} = Help4;
const tile = Tile.getTile(this, tileId);
const selector = tile && Selector.base64ToUtf8(tile.hotspotAnchor);
if (!selector) return null;
// XRAY-4854: update from previous status, only to check if appFrame is still topmost
const {_tileStatus} = this;
return _tileStatus[tileId] = /** @type {Help4.data.HotspotStatusData} */ SameWindowPlayback.urSelectorToInfo({
appFrame,
previousStatus: _tileStatus[tileId],
selector,
window: {document: appFrame.ownerDocument}
});
}
/** return DOCU_LINK texts
* @param {string} docuLink
* @returns {?{content:string, res_langu:string}}
*/
getDocuLinkContent(docuLink) {
// get content from DOCU_LINK
const texts = this._docu_texts[docuLink] || {};
if (!texts.content) {
this._log?.('CMP: DOCU_LINK not found', undefined, ()=>({docuLink: docuLink}));
return null;
}
return texts;
}
/**
* @private
* @returns {Object}
*/
_getDefaults() {
const {DEFAULTS} = Help4;
const def = {};
for (const key of Object.keys(DEFAULTS)) {
def[key] = DEFAULTS[key].d;
}
return def;
}
/**
* @private
* @param {{structural: boolean, moved: boolean}}
* @returns {Promise<void>}
*/
async _sendUpdateNotification({structural, moved, clean}) {
// new help texts could mean that new tiles have to be shown
// without new texts the current tiles might need to be updated
const {controller} = this;
const {isEditMode, mode, CMP4} = controller.getConfiguration();
if (isEditMode) return;
const {
controller: {MODES: {help}},
CAROUSEL_MODES: {help: CM_help},
widget: {help: {project: {UR}}}
} = Help4;
if (CMP4) {
const tiles = this.getTile() || [];
await UR.setData(tiles, structural || clean, moved);
return;
}
// CMP3
if (structural && mode === help) { // only when a tile update is needed and only in help playback mode
const handler = controller.getHandler();
// help tab can be hidden when no standard content is available; enforce recalculation so that it is shown
// help tab could be wrongly displayed in case UR help is gone; enforce recalculation to hide it
handler?.setCarouselTab({force: true, reason: 'urHarmonization'});
// enforce a complete redraw of the help tiles to add ours
const isHelpTab = handler?.getCarouselTab() === CM_help;
isHelpTab && handler?.setTiles();
}
}
}
/**
* @memberof Help4.engine.ur.UrHarmonizationEngine#
* @returns {?HTMLElement}
* @private
*/
function _findAppFrame(target = document) {
const {
UrHarmonizationEngine: {
APP_FRAME_SELECTOR_POST,
APP_FRAME_SELECTOR
},
Connection: {APP_TYPE},
Mock: {MOCK_FRAME_ID}
} = Help4.engine.ur;
const {
_currentApp,
_useUI5HelpTexts
} = this;
let frame = null;
switch(_currentApp) {
case APP_TYPE.MOCK:
frame = target.getElementById(MOCK_FRAME_ID);
break;
case APP_TYPE.UI5:
if (!_useUI5HelpTexts) {
this._log?.('CMP: Help Texts disabled for UI5', undefined, () => ({useUI5HelpTexts: _useUI5HelpTexts}));
break;
}
// treat the top window as content window for UI5 apps
const shell = Help4.getShell();
const service = shell.getUI5Service('AppLifeCycle');
const {componentInstance} = service.getCurrentApplication() || {};
const app = componentInstance?.oContainer?.getDomRef().closest(':not(.sapUShellApplicationContainer)>.sapUShellApplicationContainer');
frame = /** @type{?HTMLElement} */ app && {id: app.id, contentWindow: window};
break;
default:
frame = target.querySelector(APP_FRAME_SELECTOR_POST) || target.querySelector(APP_FRAME_SELECTOR);
}
return frame;
}
/**
* @memberof Help4.engine.ur.UrHarmonizationEngine#
* @private
*/
function _halt() {
Help4.engine.ur.Connection.disconnect(this);
this._running = false;
this._appFrame = null;
this._currentApp = null;
_cleanData.call(this);
}
/**
* @memberof Help4.engine.ur.UrHarmonizationEngine#
* @private
*/
function _cleanData() {
this._urHotspots.clear();
this._tiles.clear();
this._tileStatus = {};
this._migratedHotspots.length = 0;
this._wtaUrl = null;
this._wtaTile = null;
this._sendUpdateNotification({clean: true});
}
/**
* @memberof Help4.engine.ur.UrHarmonizationEngine#
* @private
*/
function _clean() {
_cleanData.call(this);
this._texts = {};
this._docu_texts = {};
this._connections = null;
this._apiVersion = null;
}
/**
* @memberof Help4.engine.ur.UrHarmonizationEngine#
* @private
* @param {{type: string}} event
*/
function _onEventBus({type}) {
const {IconTemplate, Connection} = Help4.engine.ur;
const {
_currentApp,
controller,
constructor: {MSG_TYPE: {v2: M}},
eventBus: {TYPES: T}
} = this;
// in UI5 only some events are supported
if (_currentApp === Connection.APP_TYPE.UI5 && ![T.controllerOpen, T.controllerClose, T.controllerAfterNavigate].includes(type)) {
return;
}
switch (type) {
case T.controllerOpen:
IconTemplate.prepare(this);
// intentional fallthrough, no break
case T.controllerClose:
this.run();
break;
case T.controllerAfterNavigate:
// update "use" settings from controller configuration
const {useABAPHelpTexts, useUI5HelpTexts, useURHotspots} = controller.getConfiguration();
this._useABAPHelpTexts = useABAPHelpTexts;
this._useUI5HelpTexts = useUI5HelpTexts;
this._useURHotspots = useURHotspots;
this.run();
_clean.call(this);
if (this._mock && this._connected) {
this._connected = false;
Connection.connect(this);
}
break;
// XRAY-5206
case T.hotspotAssignStart:
case T.hotspotAssignResume:
/** Send a message to the app frame that assigning has started.
* The app should then respond with UpdateHotspot messages. */
this._log?.('CMP: sending message', '⏺️' + M.startAssign);
Connection.messageAppFrame(this, M.startAssign, true);
break;
case T.hotspotAssignStop:
case T.hotspotAssignPostpone:
/** Send a message to the app frame that assigning has stopped. */
this._log?.('CMP: sending message', '⏹️' + M.stopAssign);
Connection.messageAppFrame(this, M.stopAssign, false);
break;
// XRAY-5250
case T.tourOpen:
this._log?.('CMP: sending message', '▶️' + M.startTour);
Connection.messageAppFrame(this, M.startTour);
break;
case T.tourClose:
this._log?.('CMP: sending message', '⏹️' + M.stopTour);
Connection.messageAppFrame(this, M.stopTour);
break;
}
}
/**
* Handle mutations of viewport element
* update running status if the frame has changed
* @private
* @param {MutationRecord} record
*/
function _onMutation(record) {
const frame = _findAppFrame.call(this, record[0]?.target);
if (frame && frame.id !== this._appFrame?.id) this.run();
}
})();