Source: widget/help/Widget.js

(function() {
    /**
     * @namespace help
     * @memberof Help4.widget
     */
    Help4.widget.help = {};

    /**
     * @namespace catalogues
     * @memberof Help4.widget.help
     */
    Help4.widget.help.catalogues = {};

    /**
     * @namespace project
     * @memberof Help4.widget.help
     */
    Help4.widget.help.project = {};

    /**
     * @typedef {Help4.widget.Widget.SerializedStatus} Help4.widget.help.Widget.SerializedStatus
     * @property {Object} full.data
     * @property {Help4.widget.help.view2.SerializedStatus} full.data.contentView
     * @property {Object} interaction.data
     * @property {Help4.widget.help.view2.SerializedStatus} interaction.data.contentView
     */

    /**
     * @typedef {'tiles'|'content'} Help4.widget.help.Widget.Sources
     */

    /**
     * @typedef {Object} Help4.widget.help.Widget.UpdateView2Params
     * @property {Help4.widget.Widget} [next]
     * @property {Help4.widget.help.view2.Tile.Descriptor} [selectTile]
     */

    /**
     * @typedef {Help4.widget.Widget.Context} Help4.widget.help.Widget.Context
     * @property {Object} widget.help
     * @property {Help4.widget.help.Data} widget.help.data
     * @property {?Help4.widget.help.view2.View} widget.help.view
     * @property {Help4.widget.help.CatalogueKeys} widget.help.catalogueKey
     * @property {string} widget.help.screenId
     * @property {string[]} widget.help.helpIds
     */

    /**
     * SEN On-Screen Help functionality widget
     * @augments Help4.widget.Widget
     * @property {?Help4.widget.help.Data} _data
     * @property {?Help4.widget.help.view2.View} _view2
     * @property {Function} _onUpdateData
     * @property {boolean} _blockSerialization
     */
    Help4.widget.help.Widget = class extends Help4.widget.Widget {
        static NAME = 'help';
        static DATA = 'Help4.widget.help.Data';

        /**
         * @override
         * @param {Help4.jscore.ControlBase.Params} [derived]
         */
        constructor(derived) {
            const onUpdateData = async (event) => {
                const {name, value} = event;
                if (name === 'help') {
                    // XRAY-6047: check for MT disclaimer in project tiles. both help and wn tiles are checked here
                    const {
                        /** @type {Help4.typedef.SystemConfiguration} */ configuration,
                        /** @type {Help4.controller.Controller} */ controller
                    } = this.getContext();
                    const {Core} = Help4.widget.companionCore;
                    const catalogueKey = Core.getCatalogueKey({configuration});
                    value[catalogueKey].forEach(({_catalogueType, tiles}) => _catalogueType === 'UACP' && controller.checkMTDisclaimer(tiles));

                    // update views
                    await this._updateView2();
                }
            }

            super({
                statics: {
                    _data:               {},
                    _view2:              {},
                    _onUpdateData:       {init: onUpdateData, destroy: false},
                    _blockSerialization: {init: false, destroy: false}
                },
                derived
            });
        }

        /**
         * @param {Help4.widget.help.TileDescriptor} data
         * @returns {Promise<void>}
         */
        async startFromFilter(data) {
            if (this.isActive()) {
                this.selectTile(data)
            } else {
                const {view2} = Help4.widget.help;
                const selectTile = /** @type {Help4.widget.help.view2.Tile.Descriptor} */ view2.convertTileDescriptor(data);
                await this.activate({selectTile});
            }
        }

        /** @override */
        getName() {
            return this.constructor.NAME;
        }

        /**
         * @override
         * @returns {Help4.widget.help.Widget.Context}
         */
        getContext() {
            const {_data: data, _view2: view} = this;

            const {Core} = Help4.widget.companionCore;
            const context = super.getContext();
            const catalogueKey = Core.getCatalogueKey({context});
            const {screenId} = context.configuration.core;
            const helpIds = /** @type {string[]} */ data?.helpIds?.[catalogueKey] || [];

            context.widget.help = {data, view, catalogueKey, helpIds, screenId};
            return context;
        }

        /**
         * @override
         * @returns {Promise<Help4.widget.Widget.Descriptor>}
         */
        async _onGetDescriptor() {
            const {
                Localization,
                control2: {ICONS},
                widget: {COLOR}
            } = Help4;

            const {NAME} = this.constructor;
            const text = Localization.getText('button.widget.help');

            return {
                id: NAME,
                enabled: true,
                showPanel: true,
                requires: {
                    namespaces: [
                        'Help4.widget.companionCore.Core',
                        'Help4.widget.companionCore.SEN',
                        'Help4.widget.companionCore.UACP',
                        'Help4.model.wpb.HeadersXml',
                        'Help4.CtxWPB',
                        'Help4.control.input.HtmlEditor'
                    ]
                },
                tile: {
                    text,
                    title: text,
                    icon: ICONS.bubble_n,
                    color: COLOR.color1,
                    position: 1
                }
            };
        }

        /** @override */
        async _onBeforeDestroy() {
            const {_onUpdateData, _data} = this;
            _data?.removeListener('dataChange', _onUpdateData);
        }

        /** @override */
        async _onBeforeInit() {
            const Data = _getObject(this.constructor.DATA);
            const {CatalogueBackend: catalogueBackend} = Help4.widget.help;
            this._data = /** @type {Help4.widget.help.Data} */ new Data({widget: this, catalogueBackend});
        }

        /** @override */
        async _onAfterInit() {
            const {
                /** @type {Help4.widget.help.Data} */ _data,
                /** @type {Function} */ _onUpdateData
            } = this;

            // monitor catalogue updates
            _data.addListener('dataChange', _onUpdateData);

            // initialize catalogue information
            await _data.initialize();
            if (this.isDestroyed()) return;

            // set visibility of widget tile
            await this._setVisible();
            if (this.isDestroyed()) return;

            // initialize view to show content with closed panel
            // e.g. callouts or instant hotspots
            await this._updateView2();
        }

        /** @override */
        async _onSystemNavigate() {
            const {widget} = Help4;

            const block = block => {
                const {_view2} = this;
                _view2 && (_view2.blockUpdate = block);
                this._blockSerialization = block;
            }

            // block all updates during data update
            block(true);

            const isScreenChange = !this._data.onScreen();  // navigation to different screen
            let tracking = isScreenChange && this.isActive();

            if (isScreenChange) {
                // track close for current help
                tracking && await widget.trackOpenClose(this, {verb: 'close'});
                if (this.isDestroyed()) return;

                // clean all API tiles
                const {API} = widget.help.project;
                await API.clearData(false);
                if (this.isDestroyed()) return;
            }

            // update data
            await this._data.update();
            if (this.isDestroyed()) return;

            // unblock
            block(false);

            // update visibility status based on new data
            await this._setVisible();
            if (this.isDestroyed()) return;

            // track open for new help
            tracking && this.isActive() && await widget.trackOpenClose(this, {verb: 'open'});
        }

        /**
         * @override
         * @param {?Help4.widget.help.Widget.UpdateView2Params} [data = null]
         * @returns {Promise<void>}
         */
        async _onAfterActivate(data = null) {
            await this._updateView2(data);
            if (this.isDestroyed()) return;

            this._view2?.onWidgetUpdate({type: 'activate'});
        }

        /**
         * @override
         * @returns {Promise<boolean|void>}
         */
        async _onBeforeDeactivate() {
            _resetPanelPublishedState.call(this);
        }

        /**
         * @override
         * @param {?Help4.widget.help.Widget.UpdateView2Params} [data = null]
         * @returns {Promise<void>}
         */
        async _onAfterDeactivate(data = null) {
            await this._updateView2(data);
            if (this.isDestroyed()) return;

            this._view2?.onWidgetUpdate({type: 'deactivate'});
        }

        /**
         * @override
         * @param {boolean} value
         */
        async _onControllerEditMode(value) {
            value && await this._updateView2();
        }

        /**
         * @override
         * @param {boolean} value
         */
        async _onControllerPlaybackActive(value) {
            if (value && !this.isActive()) {
                await this._data.waitHelpLoaded();
                if (this.isDestroyed()) return;

                await this._updateView2();
                if (this.isDestroyed()) return;

                this._view2?.onWidgetUpdate({type: 'playbackActive'});
            }
        }

        /** @override */
        async _onControllerOpen() {
            this._view2?.onWidgetUpdate({type: 'controllerOpen'});
        }

        /** @override */
        async _onControllerClose() {
            this._view2?.onWidgetUpdate({type: 'controllerClose'});
        }

        /** @override */
        async redraw() {
            await super.redraw();
            if (this.isDestroyed()) return;

            // visibility could change, e.g. through API tiles or UrHarmonization tiles
            await this._setVisible();
            if (this.isDestroyed()) return;

            // update view
            await this._updateView2();
            if (this.isDestroyed()) return;

            this._view2?.onWidgetUpdate({type: 'redraw'});
        }

        /**
         * @override
         * @param {Help4.widget.Widget} widget
         */
        async _onWidgetActivate(widget) {
            await this._updateView2();
        }

        /**
         * @override
         * @param {Help4.widget.Widget} widget
         * @param {*} [data]
         * @returns {Promise<void>}
         */
        async _onWidgetDeactivate(widget, data) {
            if (!(widget instanceof Help4.widget.filter.Widget && data?.help)) {
                await this._updateView2();
                if (this.isDestroyed()) return;

                this._view2?.onWidgetUpdate({type: 'widgetDeactivate'});
            // } else {
                // XRAY-6436:
                // filter widget is closing and currently opening help through Help4.widget.help.Widget#startFromFilter
                // do not interfere with my own activation
            }
        }

        /**
         * @override
         * @returns {Promise<Help4.widget.Widget.SerializedData|false>}
         */
        async _onSerialize() {
            if (this._blockSerialization) return false;

            const {State} = Help4.widget.companionCore;
            const {constructor: {NAME}, _view2} = this;

            // do not loose state in case a view is not yet created
            // use stored information in this case
            const {
                full: {data: fd = {}} = {},
                interaction: {data: id = {}} = {}
            } = State.get(NAME) || {};

            let viewData = _view2?.serialize();
            if (viewData && !Object.keys(viewData).length) viewData = null;

            const view = viewData || fd?.view || id?.view;
            const hasData = !!view && !!Object.keys(view).length;
            return hasData
                ? {full: {view}, interaction: {view}}
                : {full: {}, interaction: {}};
        }

        /**
         * @override
         * @returns {Promise<{panel: ?Object|undefined, content: ?Object}>}
         */
        async getTexts() {
            const {_view2} = this;
            const {content} = _view2?.getTexts() || {};
            if (this.isActive()) {
                const {panel} = this.getContext();
                return {panel: panel.getTexts(), content};
            } else if (_view2) {
                return {content};
            }
        }

        /**
         * @override
         * @param {{panel: ?Object|undefined, content: ?Object}} texts
         * @returns {Promise<void>}
         */
        async setTexts({panel: panelTexts, content}) {
            const {_view2} = this;

            if (this.isActive()) {
                const {panel} = this.getContext();
                panelTexts && panel.setTexts(panelTexts);
                content && _view2?.setTexts({content});
            } else if (_view2 && content) {
                _view2.setTexts({content});
            }
        }

        /**
         * @override
         * @param {Help4.widget.Widget.SearchFilter} search
         * @returns {Promise<Help4.widget.Widget.SearchResult[]>}
         */
        async filter({fulltext}) {
            const {
                companionCore: {data: {Help}, Core},
                whatsnew: {Widget: WhatsnewWidget},
                help: {view2: {HotspotScan}},
            } = Help4.widget;
            const {Placeholder, data: {HotspotData}} = Help4;

            const whatsnew = this instanceof WhatsnewWidget;
            const {configuration} = this.getContext();
            const {core: {screenId}} = configuration;

            const tiles = await Help.getAvailableTiles({whatsnew, screenId});
            if (this.isDestroyed()) return [];

            const urFilteredTiles = Help.filterUrTiles(tiles);

            const filteredTiles = /** @type {Help4.widget.help.ProjectTile[]} */ [];
            for (const tile of urFilteredTiles) {
                const {title, summaryText, content} = tile;
                if (this._fulltextMatchesText(fulltext, Placeholder.resolve(title)) ||
                    this._fulltextMatchesText(fulltext, Placeholder.resolve(summaryText)) ||
                    this._fulltextMatchesHtml(fulltext, Placeholder.resolve(content)))
                {
                    filteredTiles.push(tile);
                }
            }

            const conditionTiles = await Help.filterTilesByCondition(filteredTiles, {whatsnew});
            if (this.isDestroyed()) return [];

            const tilesWithHotspots = conditionTiles.filter(projectTile => projectTile.type === 'help' && HotspotData.hasHotspot(projectTile));
            const hotspotStatus = await HotspotScan.scan(this, tilesWithHotspots);
            if (this.isDestroyed()) return [];

            return conditionTiles
            .filter(({id, type}) => !hotspotStatus[id] || HotspotData.isHotspotVisible(hotspotStatus[id]))
            .map(data => {
                const {
                    title: caption,
                    type,
                    language: contentLanguage,
                    _catalogueKey: catalogueKey,
                    _catalogueType: catalogueType,
                    _dataType: dataType
                } = data;

                if (data.type === 'tour') {
                    return {caption, type, contentLanguage, projectId: data.id, catalogueKey, catalogueType, dataType};
                } else {
                    const {summaryText: description, _projectId: projectId, id: tileId, tileIcon: icon, showAsButton} = data;
                    return {caption, contentLanguage, description, type, projectId, tileId, catalogueKey, catalogueType, dataType, icon, showAsButton};
                }
            });
        }

        /**
         * closes an open lightbox
         * see {@link Help4.receiveMessage}
         */
        closeLightbox() {
            this._view2?.closeLightbox();
        }

        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _setVisible() {
            // show widget in case content exist
            this.__visible = await this._hasData();
        }

        /**
         * @returns {Promise<boolean>}
         * @protected
         */
        async _hasData() {
            const projects = /** @type {Help4.widget.help.Project[]} */ await this._data.getHelp();
            if (this.isDestroyed()) return;

            const {Core} = Help4.widget.companionCore;
            const {configuration} = this.getContext();
            const catalogueKey = Core.getCatalogueKey({configuration});

            /**
             * filter the special projects in case there are no tiles added.
             * @param {Help4.widget.help.Project} project
             * @returns {boolean}
             */
            const filterSpecial = ({id}) => {
                const {project, catalogues} = Help4.widget.help;
                const {_Special} = project;

                for (const handler of Object.values(project)) {
                    if (_Special.isPrototypeOf(handler)) {
                        const {ID} = catalogues[handler.NAME];
                        if (id === ID && !handler.hasData(catalogueKey)) return false;
                    }
                }

                return true;
            }

            // return true in case content exist
            return !!projects.filter(filterSpecial).length;
        }

        /** @param {Help4.widget.help.TileDescriptor} data */
        selectTile(data) {
            this._view2?.select(data);
        }

        /** shows quick tour, if available */
        showQuickTour() {
            const {ID, CATALOGUE_TYPE} = Help4.widget.help.catalogues.QuickTour;
            setTimeout(() => this._view2?.showQuickTour({tileId: ID, projectId: ID, catalogueType: CATALOGUE_TYPE}), 100);
        }

        /** @override */
        focus() {
            /**
             * maybe:
             * make sure this works for the case where help widget is active
             * and where it is inactive (focus instant help and callouts)
             * see {@link Help4.widget.focusHelp4}
             */
            this._view2?.focus();
        }

        /**
         * @override
         * @param {string} direction
         */
        focusListItem(direction) {
            this._view2?.focusListItem(direction);
        }

        /**
         * @protected
         * @param {Help4.widget.help.TileDescriptor} data
         * @param {Help4.widget.help.ProjectTile[]} [tiles = null]
         * @returns {Promise<void>}
         */
        async _trackProject({tileId}, tiles = null) {
            tiles ||= await this._data.getHelpTiles();
            if (this.isDestroyed()) return;

            const tile = tiles.find(({id}) => id === tileId);
            if (tile) {
                const {controller} = this.getContext();
                const tracking = /** @type {Help4.tracking.Tracking} */ controller.getService('tracking');
                const {type, id, title, linkTo: url} = tile;
                const verb = type === 'help' ? 'tile' : 'link';
                await tracking?.trackProject({type: 'help', verb, tile: id, title, url});
            }
        }

        /**
         * @protected
         * @param {?Help4.widget.help.view2.SerializedStatus} status
         * @param {?Help4.widget.help.Widget.UpdateView2Params} [data = null]
         * @param {Object} [params = {}]
         * @param {boolean} [params.stealth]
         * @returns {Promise<void>}
         */
        async _createView2(status, data = null, {stealth} = {}) {
            const {View} = Help4.widget.help.view2;

            this._view2 = /** @type {Help4.widget.help.view2.View} */ new View({widget: this, stealth})
            .addListener(['stopAnimation', 'stopCallout', 'stopAnnouncement', 'fixStatus'], () =>  this._setStatus())
            .addListener('select', ({data}) => data && this._trackProject(data));

            await this._view2.init(status, data?.selectTile);
        }

        /** @protected */
        _destroyView2() {
            this._destroyControl('_view2');
        }

        /**
         * @protected
         * @returns {?Help4.widget.help.view2.SerializedStatus}
         */
        _getView2Status() {
            const {State} = Help4.widget.companionCore;
            const {NAME} = this.constructor;
            const {
                full: {data: {view: fullStatus} = {}} = {},
                interaction: {data: {view: interactionStatus} = {}} = {}
            } = State.get(NAME) || {};

            return fullStatus || interactionStatus;
        }

        /**
         * @protected
         * @param {boolean} stealth
         * @returns {Promise<void>}
         */
        async _view2Stealth(stealth) {
            const {_view2} = this;

            if (_view2) {
                // view exists: set stealth mode
                _view2.stealth = stealth;
            } else if (stealth) {
                // view does not exist but stealth mode is needed: create view and go to stealth
                const status = this._getView2Status();
                await this._createView2(status, null, {stealth});
            }
        }

        /**
         * @protected
         * @param {?Help4.widget.help.Widget.UpdateView2Params} [data = null]
         * @returns {{visible: boolean, isEditMode: boolean, isActiveCMP4: boolean}}
         */
        _isView2Visible(data = null) {
            const {configuration: {core: {isEditMode, isActiveCMP4}}} = this.getContext();
            const homeScreen = !Help4.widget.getActiveInstance();  // no widget active = home screen
            const isActive = this.isActive();

            const {next} = data || {};
            const otherWidgetStarting = next instanceof Help4.widget.Widget;

            // show view if
            // - not in edit mode (CMP3)
            // - no other widget is active
            // - my widget is active
            // - not during an activation for another widget
            return {
                visible: !isEditMode && isActiveCMP4 && (isActive || homeScreen) && !otherWidgetStarting,
                isEditMode,
                isActiveCMP4
            }
        }

        /**
         * @protected
         * @param {?Help4.widget.help.Widget.UpdateView2Params} [data = null]
         * @returns {Promise<void>}
         */
        async _updateView2(data = null) {
            // show view if
            // - not in edit mode (CMP3)
            // - no other widget is active
            // - my widget is active
            const {visible, isEditMode, isActiveCMP4} = this._isView2Visible(data);

            if (visible) {
                // get last status of content view
                const status = this._getView2Status();
                const params = {status, stealth: false};
                data?.selectTile && (params.select = data.selectTile);

                // create or update view
                const {_view2} = this;
                _view2
                    ? await _view2.update(params)
                    : await this._createView2(status, data);
                if (this.isDestroyed()) return;

                // update publish view indicator
                await _updatePanelPublishedState.call(this);
            } else if (isEditMode || !isActiveCMP4) {
                this._destroyView2();
            } else {
                await this._view2Stealth(true);
            }
        }
    }

    /**
     * @memberof Help4.widget.help.Widget#
     * @private
     * @returns {Promise<void>}
     */
    async function _updatePanelPublishedState() {
        if (!this.isActive()) return;

        const {
            panel,
            widget: {help: {data, catalogueKey}},
            configuration: {core: {editor}}
        } = this.getContext();

        if (!panel || !editor) return;

        if (catalogueKey === 'pub') {
            // do not show indicator when toggled to published mode
            _resetPanelPublishedState.call(this);
            return;
        }

        const {
            published: psPublished,
            new: psNew,
            updated: psUpdated
        } = Help4.widget.help.PUBLISHED_STATUS;

        const allProjects = await data.getHelp();
        if (this.isDestroyed()) return;

        const filteredProjects = allProjects.filter(
            // SEN HELP projects only
            ({published, _catalogueType}) => !!published && (_catalogueType === 'UACP' || _catalogueType === 'SEN' || _catalogueType === 'SEN2')
        );

        if (filteredProjects.length) {
            const allPublished = filteredProjects.every(({published}) => published === psPublished);
            const someNew = filteredProjects.some(({published}) => published === psNew);
            panel.publishedState = allPublished ? psPublished : someNew ? psNew : psUpdated;
        } else {
            _resetPanelPublishedState.call(this);
        }
    }

    /**
     * @memberof Help4.widget.help.Widget#
     * @private
     */
    function _resetPanelPublishedState() {
        const {
            configuration: {core: {editor}},
            panel
        } = this.getContext();

        if (panel && editor) panel.publishedState = null;
    }

    /**
     * @memberof Help4.widget.help.Widget
     * @private
     * @param {string} string
     */
    function _getObject(string) {
        return string
        .split('.')
        .reduce((object, key) => object[key], window);
    }
})();