Source: widget/tour/Widget.js

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

    /**
     * @typedef {Help4.widget.Widget.SerializedStatus} Help4.widget.tour.Widget.SerializedStatus
     * @property {Help4.widget.tour.View.SerializedStatus} full.data
     */

    /**
     * @typedef {Object} Help4.widget.tour.Widget.StartStatus
     * @property {string} projectId
     * @property {Help4.widget.help.CatalogueKeys} catalogueKey
     * @property {Help4.widget.help.CatalogueTypes} catalogueType
     * @property {Help4.widget.help.DataTypes} dataType
     * @property {boolean} [whatsnew]
     */

    /**
     * @typedef {Object} Help4.widget.tour.Widget.AutoStartInfo
     * @property {?Help4.widget.help.Project} project
     * @property {Help4.widget.help.CatalogueKeys} [catalogueKey]
     * @property {string} [alias]
     */

    const NAME = 'tour';
    const HELP_NAME = 'help';
    const LIST_NAME = 'tourlist';

    /**
     * @typedef {Help4.widget.Widget.Context} Help4.widget.tour.Widget.Context
     * @property {Object} widget.help
     * @property {Help4.widget.help.Data} widget.help.data
     * @property {Object} widget.tour
     * @property {Help4.widget.tour.View} widget.tour.View
     */

    /**
     * Guided Tour playback functionality widget
     * @augments Help4.widget.Widget
     * @property {?string} projectId
     * @property {{alias: ?string, playing: boolean}} _autoStart
     * @property {?Help4.widget.tour.View} _view
     * @property {?Help4.widget.tour.Widget.StartStatus} _startStatus
     */
    Help4.widget.tour.Widget = class extends Help4.widget.Widget {
        /** @override */
        constructor() {
            const {TYPES: T} = Help4.jscore.ControlBase;

            super({
                params: {
                    visible:   {init: true},
                    projectId: {type: T.string_null}
                },
                statics: {
                    _autoStart:   {init: {alias: null, playing: false}, destroy: false},
                    _view:        {},
                    _startStatus: {destroy: false}
                }
            });
        }

        /**
         * see {@link Help4.widget.tour.Widget#_onSystemNavigate}<br>
         * see {@link Help4.widget.tour.Observer#onElementEvent}
         * @memberof Help4.widget.tour.Widget
         * @type {number}
         */
        static NAVIGATE_WAIT_TIME = 500;

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

        /**
         * @override
         * @returns {Help4.widget.tour.Widget.Context}
         */
        getContext() {
            const helpWidget = /** @type {Help4.widget.help.Widget} */ Help4.widget.getInstance(HELP_NAME);
            /** @type {Help4.widget.help.Widget.Context} */
            const helpContext = helpWidget?.getContext() || {};
            /** @type {Help4.widget.Widget.Context} */
            const context = super.getContext();

            context.widget.help = {
                data: helpContext?.widget?.help?.data
            };
            context.widget.tour = {
                view: this._view
            };
            return context;
        }

        /**
         * @override
         * @returns {Promise<Help4.widget.Widget.Descriptor>}
         */
        async _onGetDescriptor() {
            const {WM} = this.getContext().configuration;

            return {
                id: NAME,
                enabled: WM < 2,
                showPanel: false,
                requires: {
                    namespaces: [
                        'Help4.widget.companionCore.Core',
                        'Help4.widget.companionCore.SEN',
                        'Help4.widget.companionCore.UACP',
                        'Help4.data.TileData',
                        'Help4.data.HotspotData',
                        'Help4.data.HotspotStatusData',
                        'Help4.service.recording.PlaybackService',
                        'Help4.History',
                        'Help4.Placeholder',
                        'Help4.jscore.MediaWatcher',
                        'Help4.Element',
                        'Help4.control.input.HtmlEditor'
                    ],
                    instances: [HELP_NAME, LIST_NAME]
                },
                autostart: [LIST_NAME]
            };
        }

        /** @override */
        focus() {
            this._view?.focus();
        }

        /** @returns {Promise<boolean>} */
        async focusApp() {
            return await this._view?.focusApp();
        }

        /**
         * @param {Help4.widget.tour.Widget.StartStatus} status
         * @returns {Promise<void>}
         */
        async startTour(status) {
            this._cleanInfobar();

            this._startStatus = status;
            this.isActive()
                ? await this.switchTour()
                : await this.activate();
        }

        /** @returns {Promise<void>} */
        async switchTour() {
            if (await _resumePlayback.call(this, {switchTour: true})) return;
            await this.deactivate();
        }

        /** @override */
        async _onAfterActivate() {
            // priorities:
            // 1. resume ongoing tour playback
            // 2. start autostart tour
            // 3. unable to start a tour; try to show tourlist

            if (await _resumePlayback.call(this)) return;
            if (this.isDestroyed() || !this.isActive()) return;

            if (await _autoStartPlayback.call(this)) return;
            if (this.isDestroyed()) return;

            await this.deactivate();
        }

        /** @override */
        async _onBeforeDeactivate() {
            this._destroyControl('_view');
        }

        /** @returns {?string[]} */
        getAutoProgressKeys() {
            return this._view?.getAutoProgressKeys();
        }

        /** @override */
        async _onSystemNavigate() {
            if (this.isActive()) {
                const {NAVIGATE_WAIT_TIME} = this.constructor;

                // I am active - resume tour playback
                // XRAY-5373 - do not execute immediately but wait for some time
                await new Help4.Promise(resolve => {
                    setTimeout(async () => {
                        await this._view?.afterNavigate();
                        resolve();
                    }, NAVIGATE_WAIT_TIME);
                });
            } else {
                // I am not active; check autostart tour
                await _handleAutoStart.call(this);
            }
        }

        /**
         * @override
         * @returns {Promise<Help4.widget.Widget.SerializedData|false>}
         */
        async _onSerialize() {
            const {State} = Help4.widget.companionCore;

            // do not lose view status in case view not yet there
            // receive from stored status
            const {full: {data} = {}} = State.get(NAME) || {};
            const full = this._view?.serialize() || data || {};
            return {full};
        }

        /**
         * is called in case an autostart tour is configured and not yet played
         * @param {string} alias
         */
        setAutoStartTour(alias) {
            this._autoStart.alias = alias;
            setTimeout(() => _handleAutoStart.call(this), 1);
        }

        /**
         * @param {string} screenId
         * @returns {boolean}
         */
        vetoNavigation(screenId) {
            return this._view?.vetoNavigation(screenId) || false;
        }

        /** @override */
        async getTexts() {
            if (this.isActive()) {
                const {_view} = this;
                if (_view) return _view.getTexts();
            }
        }

        /**
         * @override
         * @param {Object} texts
         */
        async setTexts(texts) {
            this.isActive() && this._view?.setTexts(texts);
        }
    }

    /**
     * @memberof Help4.widget.tour.Widget#
     * @private
     * @returns {Promise<void>}
     */
    async function _handleAutoStart() {
        // check current catalogue whether tour is playable on this screen
        const {project} = /** @type {Help4.widget.tour.Widget.AutoStartInfo} */ await _autoStartPlayable.call(this);
        if (project) {
            // tour is playable, take control

            /** in case tour widget is not already activated:
             * 1. activate myself
             * 2. tour playback is then handled in {@link _onAfterActivate}
             */

            const instance = Help4.widget.getActiveInstance();
            if (instance !== this) {  // no widget or another one is currently running
                await instance?.awaitActivated();  // wait for widget to properly activate
                await this.activate();  // activate myself
            }
        }
    }

    /**
     * @memberof Help4.widget.tour.Widget#
     * @private
     * @param {Object} [options = {}]
     * @param {boolean} [options.switchTour = false]
     * @returns {Promise<boolean>}
     */
    async function _resumePlayback({switchTour = false} = {}) {
        const {State} = Help4.widget.companionCore;
        const {full: {
            /** @type {?Help4.widget.tour.View.SerializedStatus} */ data
        } = {}} = State.get(NAME) || {};

        const {/** @type {?Help4.widget.tour.Widget.StartStatus} */ _startStatus} = this;
        this._startStatus = null;  // reset after 1-time-usage

        const {
            projectId,
            catalogueType,
            catalogueKey,
            step: startAfter,
            history,
            whatsnew
        } = _startStatus || data || {};

        return await _startTour.call(this, {projectId, catalogueKey, catalogueType, startAfter, history, whatsnew}, {switchTour});
    }

    /**
     * @memberof Help4.widget.tour.Widget#
     * @private
     * @returns {Promise<boolean>}
     */
    async function _autoStartPlayback() {
         const {
             project,
             catalogueKey,
             alias
        } = /** @type {Help4.widget.tour.Widget.AutoStartInfo} */ await _autoStartPlayable.call(this);

        if (project && await _startTour.call(this, {projectId: project.id, catalogueKey, catalogueType: project._catalogueType, whatsnew: !!project._whatsnew})) {
            const {
                /** @type {Help4.controller.Controller} */ controller,
                /** @type {Help4.EventBus} */ eventBus
            } = this.getContext();

            this._autoStart.playing = true;

            // autostart tour will play even if CMP is closed
            // therefore open, if needed
            if (!controller.isOpen()) controller.open();

            // notify watchers
            const type = eventBus.TYPES.autoStartTour;
            eventBus.fire({type, alias, catalogueKey});

            return true;
        }

        return false;
    }

    /**
     * @memberof Help4.widget.tour.Widget#
     * @private
     * @returns {Promise<Help4.widget.tour.Widget.AutoStartInfo>}
     */
    async function _autoStartPlayable() {
        const {/** @type {?string} */ alias} = this._autoStart;

        if (alias) {
            const {
                /** @type {Help4.typedef.SystemConfiguration} */ configuration,
                /** @type {Help4.controller.Controller} */ controller,
                service: {/** @type {Help4.service.ConditionService} */ conditionService}
            } = this.getContext();

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

            // autostart tour START is always from current catalogue
            // resume of autostart tours on other screens will use standard mechanics and not the autostart one
            /** @type {?Help4.widget.help.Project} */
            const project = await _getCatalogueProject.call(this, alias, catalogueKey, 'alias');
            if (project && (await conditionService.checkConditions(project))) {
                return {alias, catalogueKey, project};
            }
        }

        return {project: null};
    }

    /**
     * @memberof Help4.widget.tour.Widget#
     * @private
     * @returns {Promise<Help4.widget.help.Data>}
     */
    async function _waitHelpCataloguesLoaded() {
        const {
            configuration: {core: {screenId}},
            widget: {help: {/** @type {Help4.widget.help.Data} */ data}}
        } = this.getContext();

        await data.waitCataloguesLoaded(screenId);
        return data;
    }

    /**
     * @memberof Help4.widget.tour.Widget#
     * @private
     * @param {string} search
     * @param {?Help4.widget.help.CatalogueKeys} [catalogueKey = null]
     * @param {?string} [attributeName = 'id']
     * @returns {Help4.widget.help.CatalogueProject|null}
     */
    async function _getCatalogueProject(search, catalogueKey = null, attributeName = 'id') {
        /** @type {Help4.widget.help.Data} */ const helpData = await _waitHelpCataloguesLoaded.call(this);
        return helpData.getCatalogueProject(search, catalogueKey, attributeName);
    }

    /**
     * will start a tour with <projectId> from either "pub" or "head"
     * @memberof Help4.widget.tour.Widget#
     * @private
     * @param {Object} params
     * @param {string} params.projectId - the tour project ID
     * @param {Help4.widget.help.CatalogueKeys} params.catalogueKey
     * @param {Help4.widget.help.CatalogueTypes} params.catalogueType
     * @param {?string} [params.startAt]
     * @param {?string} [params.startAfter]
     * @param {?string} [params.history]
     * @param {boolean} [params.whatsnew = false]
     * @param {Object} [options = {}]
     * @param {boolean} [options.switchTour = false]
     * @returns {Promise<boolean>}
     */
    async function _startTour({
        projectId,
        catalogueKey,
        catalogueType,
        startAt,
        startAfter,
        history,
        whatsnew = false
    } = {}, {
        switchTour = false
    } = {}) {
        // load the project data
        const {/** @type {Help4.widget.help.Data} */ data} = this.getContext().widget.help;
        const {/** @type {?Help4.widget.help.Project} */ [catalogueKey]: project} = await data.loadProject(projectId, catalogueType) || {};
        if (!project || this.isDestroyed()) return false;  // project does not exist

        if (!this.isActive()) {
            this._destroyControl('_view');
            return false;
        }

        const shutdownTour = async () => {
            // track tour close
            const {State} = Help4.widget.companionCore;
            const status = State.get(NAME);
            await _track.call(this, 'close', status);
            if (this.isDestroyed()) return false;

            // remove all view information from state
            const context = this.getContext();
            const {whatsnew} = status.full.data;
            status.full.data = null;
            await State.set(NAME, status, context);
            if (this.isDestroyed()) return false;

            this._destroyControl('_view');

            return whatsnew;
        }

        const {/** @type {Help4.controller.Controller} */ controller} = this.getContext();
        const {tiles} = project;
        controller.checkMTDisclaimer(tiles);

        if (switchTour) {
            await shutdownTour();
            if (this.isDestroyed()) return false;
        }

        this.projectId = project.id;

        await _track.call(this, 'open');
        if (this.isDestroyed()) return false;

        // start tour playback
        this._view = new Help4.widget.tour.View({
            widget: this,
            projectId,
            catalogueKey,
            project,
            startAt,
            startAfter,
            history,
            whatsnew
        })
        .addListener('updateTour', () => this._setStatus())
        .addListener('stopTour', async () => {
            const context = this.getContext();
            const {tourClose} = Help4.EventBus.TYPES;
            const {controller} = context;
            controller.getService('eventBus').fire({type: tourClose});

            const whatsnew = await shutdownTour();
            if (this.isDestroyed()) return;

            if (this._autoStart.playing) {
                // reset autoStart information
                this._autoStart = {alias: null, playing: false};

                // fully close help after an autostart tour has been played
                await controller.close();
                if (this.isDestroyed()) return;
            }

            await this.deactivate({whatsnew});
        });

        await this._setStatus();

        // project playback started successfully
        return true;
    }

    /**
     * @memberof Help4.widget.tour.Widget#
     * @private
     * @param {string} mode
     * @param {Object} [status]
     * @returns {Promise<void>}
     */
    async function _track(mode, status) {
        const {controller} = this.getContext();
        const tracking = /** @type {Help4.tracking.Tracking} */ controller.getService('tracking');

        const params = {verb: mode, type: 'tour', editMode: false};
        if (mode === 'close') {
            const {_view} = this;
            const {step} = status.full.data;
            const index = _view.getStep() + 1;
            params.step = step;
            params.stepIndex = index;
            params.totalSteps = _view.getSteps();
            params.finished = index === params.totalSteps;
        }

        mode === 'open' || mode === 'close'
            ? await Help4.widget.trackOpenClose(this, params)
            : await tracking?.trackProject(params);
    }
})();