(function() {
/** Update texts handling for UR harmonization */
Help4.engine.ur.Update = class {
/**
* Hotspot icon position options (see i18n.properties)
* @type {{middle: {START: string, END: string}, top: {CENTERED: string, start: {OUTSIDE: string, ABOVE: string}, end: {OUTSIDE: string, ABOVE: string}, START: string, END: string}}}
*/
static ICON_POSITION = {
top: {
start: {
OUTSIDE: 'A',
ABOVE: 'B'
},
CENTERED: 'C',
end: {
ABOVE: 'D',
OUTSIDE: 'E',
},
START: 'F',
END: 'G'
},
middle: {
START: 'H',
END: 'I'
}
};
/**
* Handle hotspot update
* sap.companion.services.UpdateHotspots (API v2), sap.webassistant.services.updateHotspots (API v1)
* @param {Help4.engine.ur.UrHarmonizationEngine} engine
* @param {Help4.engine.ur.UrHarmonizationEngine.UrDataV1 | Help4.engine.ur.UrHarmonizationEngine.UrDataV2} data
*/
static handle(engine, data) {
let {technology, elements, hotspots} = data;
if (typeof technology === 'string' && Help4.isArray(elements)) hotspots = elements;
if (!hotspots || !Help4.isArray(hotspots)) hotspots = [];
const {APP_TYPE} = Help4.engine.ur.Connection;
const {_currentApp, _tiles, _tileStatus, _urHotspots, constructor: {QUERIES}} = engine;
const isUI5 = _currentApp === APP_TYPE.UI5;
// changedHotspots - true:
// - a new hotspot has been added; could affect existing assignments OR
// - an existing hotspot has been changed OR
// - an existing hotspot has been removed
let changedHotspots = false;
// removedTiles - true: an existing tile has been removed
let removedTiles = false;
// update existing hotspots or add new ones in new collection to keep incoming data ordering
const newUrHotspots = /** @type {Map<string, Object>} */ new Map();
for (const hotspot of hotspots) {
if (isUI5) hotspot.backendHelpCDSQuery ??= QUERIES.UI5;
const {_stableId, _type, _urId} = _harmonize(hotspot, technology, hotspots);
const harmonizedId = hotspot.harmonizedId = _stableId || _urId;
const old = _urHotspots.get(harmonizedId) || newUrHotspots.get(harmonizedId);
// new hotspot
if (!old) {
changedHotspots = true;
newUrHotspots.set(harmonizedId, {...hotspot, _id: Help4.createId(), _stableId, _type, _urId});
continue;
}
// old or duplicate hotspot
// in UI5 only! check old/duplicate hotspots for additional help keys, no position handling
if (isUI5) {
if (old._urId !== _urId && !old.additionalHelpKeys?.includes(_urId)) {
old.additionalHelpKeys?.push(_urId) || (old.additionalHelpKeys = [_urId]);
}
newUrHotspots.set(harmonizedId, old);
continue;
}
// check if position has changed, then update status
if (!this.positionsEqual(old.position, hotspot.position)) {
old.position = hotspot.position;
old._urMoved = true;
changedHotspots = true;
}
newUrHotspots.set(harmonizedId, old);
}
// remove old tiles without matching hotspots
for (const key of _urHotspots.keys()) {
if (!hotspots.some(({harmonizedId}) => harmonizedId === key)) {
const {_id} = _urHotspots.get(key);
removedTiles = _tiles.delete(_id) || removedTiles;
delete _tileStatus[_id];
changedHotspots = true;
}
}
// update hotspot collection
engine._urHotspots = newUrHotspots;
_getHelpTexts(engine)
.then(() => {
// wait for texts to be available then updateTiles now before setting timestamp
// otherwise a state without tiles (having texts) will be cached
// no need to update tiles again in getTile
const addedTiles = _updateTiles(engine);
// structural: redraw needed
const structural = addedTiles || removedTiles;
// moved: hotspots have been moved
const moved = structural || changedHotspots;
if (moved) {
engine._log?.('CMP: Update ' + (addedTiles ? '| Tiles added ' : '') + (removedTiles ? '| Tiles removed ' : '') + (changedHotspots ? '| Hotspots changed ' : ''));
}
engine._sendUpdateNotification({structural, moved});
});
}
static positionsEqual(a, b) {
return a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
};
/**
* Prepares ids and values for text retrieval
* @private
* @param {Help4.engine.ur.UrHarmonizationEngine.UrHotspot|Help4.engine.ur.UrHarmonizationEngine.UrElement} hotspot
* @param {?string} technology
* @returns {{_stableId: ?string, _type: ?string, _urId: ?string}|null} - harmonized hotspotData
*/
function _harmonize(hotspot, technology) {
const {hotspotId, id, backendHelpKey, backendHelpCDSQuery} = hotspot;
// API V2
if (hotspotId) {
// get _type and _urId that are needed for Help4.ajax.UrHarmonization
// _stableId will be used later for XRAY-3663
// converting empty objects to empty strings to filter later
const _urId = typeof backendHelpKey === 'object' && Object.keys(backendHelpKey).length
? Help4.JSON.stringify(backendHelpKey)
: '';
return {_stableId: hotspotId, _type: backendHelpCDSQuery, _urId};
}
// API V1
if (id) {
let _urId;
let _stableId;
if (typeof id === 'object') {
if (technology === 'WDA') {
_urId = id.dtel || null;
_stableId = id.id || null;
} else {
_urId = _stableId = Help4.JSON.stringify(id);
}
} else { // old WDA, does not support stable IDs
_urId = /** @type {string} */ id;
_stableId = null;
}
const {QUERIES} = Help4.engine.ur.UrHarmonizationEngine;
return {_stableId, _type: QUERIES[technology], _urId};
}
return null;
}
/**
* @private
* @param {Help4.engine.ur.UrHarmonizationEngine} engine
* @returns {Help4.Promise<boolean>}
*/
async function _getHelpTexts(engine) {
const {_docu_texts, _ignoreLongText, _mock, _texts, _urHotspots, _useABAPHelpTexts} = engine;
const newItems = [];
for (const {_urId, additionalHelpKeys = [], backendHelpCDSQuery} of _urHotspots.values()) {
// UI5 hotspots can have multiple help texts, only _urId otherwise
for (const key of [_urId, ...additionalHelpKeys] || []) {
if (key && !_texts.hasOwnProperty(key)) {
_texts[key] = null;
newItems.push({_type: backendHelpCDSQuery, _urId: key});
}
}
}
if (!newItems.length) return false;
const {
ajax: {UrHarmonization},
engine: {ur: {Transformation}}
} = Help4;
/** @type {?Help4.engine.ur.UrHarmonizationEngine.UrTextResponse[] | ?Help4.engine.ur.UrHarmonizationEngine.ErrorResponse[]} */
const helpTexts = _useABAPHelpTexts && await UrHarmonization.requestMultipartContent(newItems, _mock) || [];
_useABAPHelpTexts
? engine._log?.('CMP: received help texts', undefined, ()=>helpTexts)
: engine._log?.('CMP: useABAPHelpTexts is disabled');
if (!Help4.isArray(helpTexts)) return false;
let hasDocuLinks = false;
for (const text of helpTexts) {
const {
dtel,
//error,
esid,
hotspotId,
long_text
} = text;
// different queries use different key names: hotspotId, esid, dtel
const key = hotspotId || esid || dtel;
if (long_text || _ignoreLongText) {
// WARNING: this is not UACP format do not use Help4.control.input.HtmlEditor.transformFromUACP
const {content, docuLinks, size} = Transformation.format(long_text) || {};
text.content = content;
text.size = size;
docuLinks.forEach(key => {
if (!_docu_texts.hasOwnProperty(key)) {
_docu_texts[key] = null;
hasDocuLinks ||= true;
}
});
_texts[key] = text;
}
}
if (hasDocuLinks) {
// no await, texts are needed when links in the bubble are clicked, not to show the bubble in the first place
_getDocuTexts(engine);
}
return true;
}
async function _getDocuTexts(engine) {
const {
ajax: {UrHarmonization},
engine: {ur: {Transformation, UrHarmonizationEngine: {QUERIES}}}
} = Help4;
// collect missing DOCU_LINK texts
const newTexts = [];
const backendHelpCDSQuery = QUERIES.DOC;
for (const [backendHelpKey, value] of Object.entries(engine._docu_texts)) {
if (value === null) {
newTexts.push({_type: backendHelpCDSQuery, _urId: backendHelpKey});
}
}
if (newTexts.length === 0) {
return;
}
// request texts
engine._log?.('CMP: found new DOCU_LINKS', undefined, ()=>newTexts.map(({_urId}) => _urId));
const {_docu_texts, _mock} = engine;
const response = await UrHarmonization.requestMultipartContent(newTexts, _mock);
if (!Help4.isArray(response)) {
engine._log?.('CMP: unexpected response to request', undefined, ()=>({backendHelpCDSQuery, response}));
return;
}
engine._log?.('CMP: received DOCU_LINK texts', undefined, ()=>response);
// transform and extract new DOCU_LINK texts
let hasDocuLinks = false;
for (const {id_name, label_text, long_text, res_langu, short_text} of response) {
// WARNING: this is not UACP format do not use Help4.control.input.HtmlEditor.transformFromUACP
const {content, docuLinks, size} = Transformation.format(long_text) || {};
_docu_texts[id_name] = {content, label_text, res_langu, short_text, size};
// check for new DOCU_LINKs
docuLinks.forEach(key => {
if (!_docu_texts.hasOwnProperty(key)) {
_docu_texts[key] = null
hasDocuLinks ||= true;
}
});
}
// recursive check
if (hasDocuLinks) await _getDocuTexts(engine);
}
/**
* Update existing tiles if the position of the hotspot has changed,
* create new tiles for new hotspots
* @private
* @param {Help4.engine.ur.UrHarmonizationEngine} engine
* @returns {boolean}
*/
function _updateTiles(engine) {
const {
Feature: {UrDebugging},
selector: {Selector, methods},
service: {recording: {SameWindowPlayback}}
} = Help4;
const {
Connection: {APP_TYPE},
Update: {ICON_POSITION: {top}},
} = Help4.engine.ur;
const {_currentApp, _urHotspots, _texts, _tiles, _tileStatus, controller} = engine;
const appFrame = engine.getAppFrame();
const isUI5 = _currentApp === APP_TYPE.UI5;
// do not store tile status for UI5
const selectorToInfo = (selector, tile) => isUI5
? null
: SameWindowPlayback.urSelectorToInfo({
appFrame,
appFrameRect: appFrame.getBoundingClientRect(),
selector,
position: tile._apiData.position,
window: {document: appFrame.ownerDocument}
});
const tilePositions = new Set(); // XRAY-5205 - avoid duplicate entries based on position
// setting the offset to aid the topmost check when updating hotspot status;
// the hotspot will be Top End (Above), so the test point should be in the top right corner
const offset = {x: 0.95, y: 0};
const posToString = ({x, y, width: w, height: h}) => `${x}:${y}-${w}x${h}`;
// update moved tiles
// don't check for existing tile positions here - tiles are not considered duplicates, if they move on top of each other
const updateMovedTile = (hotspot) => {
const {_id, position} = hotspot;
const tile = _tiles.get(_id);
tile._apiData.position = position;
tilePositions.add(posToString(position));
const selector = {rule: 'UrHarmonizationTileSelector', offset, value: _id};
_tileStatus[_id] = selectorToInfo(selector, tile);
return tile;
};
const {core: {screenId}} = controller.getConfiguration();
const def = engine._getDefaults();
// create new tiles
// consider duplicates, i.e. when the position is already filled by another tile
const createNewTile = (hotspot) => {
const {_id, hotspotId, labelText, position, multiContent, contentSize, text} = hotspot;
let {content, label_text, short_text, res_langu: language, size} = text;
let selector;
let alignWithText = false;
let hotspotIconPos = top.end.ABOVE;
if (isUI5) {
/** use provided hotspotId directly instead of running {@link Help4.selector.methods.IdSelectorUI5.getSelector}, the selector is only used for UR playback;
* New UI5 assignments use {@link Help4.Help4.service.recording.SameWindowPlayback.pointToElement}, instead of {@link Help4.service.recording.IFrameService.pointToElement}.
* Playback uses _getHotspotStatus instead of _getUrHotspotStatus in {@link Help4.service.recording.PlaybackService.update}.
*/
selector = {rule: 'IdSelectorUI5', offset, value: '#' + methods.$E(hotspotId)};
// XRAY-6490 - improve hotspot icon position or use default
const adjust = _adjustHotspotPos(hotspotId, selector);
alignWithText = adjust?.alignWithText || alignWithText;
hotspotIconPos = adjust?.hotspotIconPos || hotspotIconPos;
selector = adjust?.selector || selector;
} else {
const p = posToString(position);
if (tilePositions.has(p)) return null;
tilePositions.add(p);
selector = {rule: 'UrHarmonizationTileSelector', offset, value: _id};
}
const tile = /** @type {Help4.widget.help.ProjectTile} */ Help4.cloneObject(def);
// size found when transforming text or fallback calculation
const bubbleSize = contentSize || size || _calcBubbleSize(multiContent || content);
const label = labelText || label_text; // use labelText from updateHotspots if available, backend text otherwise
let title = label || short_text; // use labelText from updateHotspots if available, use short text as fallback
if (UrDebugging) title = (multiContent ? '🚧📎' : '🚧') + title; // mark UR tiles for easier spotting in panel
Help4.extendObject(tile, {
_apiData: hotspot,
_dataId: _id,
_isUR: true,
_standalone: true,
alignWithText,
bubbleSize,
content: multiContent || content,
hotspotAnchor: Selector.utf8ToBase64(selector),
hotspotIconPos,
hotspotIconType: 'help',
hotspotId: _id + '-hs',
hotspotStyle: 'ICON',
hotspotSize: '1',
hotspotTrianglePos: 'bottomend',
id: _id,
language,
loio: _id,
pageUrl: screenId,
showTitleBar: true, // be compliant with S/4 defaults; XRAY-4292
summaryText: label ? short_text : '', // use short text unless we don't have a title, then use short text as title instead
title,
type: 'help'
});
_tileStatus[_id] = selectorToInfo(selector, tile);
return tile;
};
let structural = false;
// create a new tile map sorted in order of _urHotspots to replace previous _tiles
const newTiles = /** @type {Map<string, Help4.widget.help.ProjectTile>} */ new Map();
// do NOT use forEach here; as new Map().values().forEach is not supported in FF and Safari!
for (const hotspot of _urHotspots.values()) {
const {_id, _urId, _urMoved, additionalHelpKeys} = hotspot;
let tile = _tiles.get(_id);
const text = _texts[_urId];
if (tile && _urMoved) {
delete hotspot._urMoved;
tile = updateMovedTile(hotspot);
} else if (!tile && text) {
hotspot.text = text;
const {multiContent, contentSize} = _combineMultiContent(engine, _urId, additionalHelpKeys);
hotspot.multiContent = multiContent;
hotspot.contentSize = contentSize;
tile = createNewTile(hotspot);
structural ||= !!tile;
}
if (tile) newTiles.set(_id, tile);
}
// replace existing tiles with new collection
engine._tiles = newTiles;
return structural;
}
/**
* UI5 hotspot icon position can be improved from the standard top end above, because we have access to the elements
* shrink hotspot to text nodes if possible, except for input fields and lists - this shows hotspots closer to their context,
* prevent overflow to the start when the text is very short (in texts/links, empty indicators, and ObjectStatus/ObjectNumber)
*
* @param {string} hotspotId
* @param {Object} selector
* @return {?Object} - can return updates to the selector, alignWithText, hotspotIconPos
* @private
*/
function _adjustHotspotPos(hotspotId, selector) { // XRAY-6490
const element = document.getElementById(hotspotId);
if (!element) return null;
const {top, middle} = Help4.engine.ur.Update.ICON_POSITION;
const {methods} = Help4.selector;
const {classList} = element;
if (classList?.contains('sapMList')) { // table
return {alignWithText: true, hotspotIconPos: top.start.OUTSIDE};
} else if (element.matches('.sapMInputBase, :has(> .sapMInputBase)')) { // input fields and their wrappers
return null; // keep defaults
} else if (element.matches('.sapMText, .sapMLnk, .sapMObjStatus, .sapMObjectNumber')) { // values
// move hotspot behind unit of measure (outside the smart field)
const closest = element.closest('.sapUiCompSmartField');
if (closest) selector.value = '#' + methods.$E(closest.id);
return {selector, alignWithText: true, hotspotIconPos: middle.END};
} else if (classList?.contains('sapMCb')) { // checkbox
// not checking if element exists, it can still be loading when Companion is already open (lazy loading data)
selector.offset = {x: 0.6, y: 0.5};
selector.value += ' .sapMCbBg';
return {selector, hotspotIconPos: middle.END}; // align with icon (no padding)
} else if (classList?.contains('sapMRb')) { // radio button
// not checking if element exists, it can still be loading when Companion is already open (lazy loading data)
selector.offset = {x: 0.6, y: 0.5};
selector.value += ' .sapMRbSvg';
return {selector, hotspotIconPos: middle.END}; // align with icon (no padding)
} else if (classList?.contains('sapMSwtCont')) { // switch container
selector.offset = {x: 0.6, y: 0.5};
selector.value += ' .sapMSwt';
return {selector, hotspotIconPos: middle.END}; // align with icon (no padding)
} else if (classList?.contains('sapMSwt')) { // switch
selector.offset = {x: 0.6, y: 0.5};
return {selector, hotspotIconPos: middle.END}; // align with icon (no padding)
}
return {alignWithText: true};
}
function _combineMultiContent (engine, mainKey, additionalKeys) {
const {Connection: {APP_TYPE}, Transformation} = Help4.engine.ur;
const {_currentApp, _texts} = engine;
if (_currentApp !== APP_TYPE.UI5 || !additionalKeys?.length) return {};
const sizeMap = {
's': 1,
'm': 2,
'l': 3,
'xl': 4
}
const {content, label_text, short_text, size} = _texts[mainKey] || {};
let contentSize = size || 's';
const tooltip = label_text && short_text ? ` title="${short_text}"` : '';
let multiContent = `<h2${tooltip}>${label_text || short_text || '...'}</h2>${Transformation.reduceHeadingLevels(content, 3)}`;
for (const key of additionalKeys) {
const {content, label_text, short_text, size} = _texts[key] || {};
if (content) {
const tooltip = label_text && short_text ? ` title="${short_text}"` : '';
if (size && sizeMap[size] > sizeMap[contentSize]) contentSize = size;
multiContent += `<br><h2${tooltip}>${label_text || short_text || '...'}</h2>${Transformation.reduceHeadingLevels(content, 3)}`;
}
}
return {multiContent, contentSize};
}
/**
* adjust bubble size to content
* removing line breaks and splitting at tags, basically trying to find the longest line,
* but this ignores inline tags being inline
* @private
* @param {string} content
* @returns {'xs'|'s'|'m'|'l'|'xl'} size
*/
function _calcBubbleSize(content) {
const bubbleSize = content.match(/ data-bubble-size="(xs|s|m|l|xl)"/)?.[1];
if (bubbleSize) return bubbleSize;
const contentSize = content
.replace(/\n/g, '')
.split(/</)
.reduce((previousValue, currentValue) => Math.max(previousValue, currentValue.length), 0);
return contentSize > 150 ? 'm' : 's';
}
})();