Source: tracking/Tracking.js

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

    /**
     * @namespace connector
     * @memberof Help4.tracking
     */
    Help4.tracking.connector = {};

    /**
     * @typedef {Object} Help4.tracking.Tracking.Params
     * @property {Help4.controller.Controller} controller
     * @property {string} [sessionId]
     */

    /**
     * @typedef {Object} Help4.tracking.Tracking.Connector
     * @property {string} type
     * @property {string} [url]
     * @property {string} [token]
     * @property {function} [callback]
     */

    /**
     * @typedef {Object} Help4.tracking.Tracking.Data
     * @property {{id: string}} verb
     * @property {Help4.tracking.Tracking.Object} object
     * @property {{id: string, app: string}} context
     */

    /**
     * @typedef {Object} Help4.tracking.Tracking.Object
     * @property {string} screenId
     * @property {string} product
     * @property {string} version
     * @property {string} system
     * @property {?string} role
     * @property {boolean} editor
     * @property {string} language
     * @property {string} playbackTag
     * @property {string} sessionId
     * @property {string} solution
     * @property {?string} customer_tenant
     * @property {?string} id
     * @property {?string} name
     * @property {Help4.tracking.Tracking.ModelInfo} _modelInfo
     * @property {string} [theme]
     * @property {boolean} [mobile]
     * @property {boolean} [rtl]
     */

    /**
     * @typedef {Object} Help4.tracking.Tracking.ModelInfo
     * @property {?string} projectTitle
     * @property {string} backendType
     * @property {?string} projectId
     * @property {{project: string, tile: string}} _prefix
     */

    /**
     * @typedef {
     *  Help4.tracking.Tracking.TrackParamsBasic|
     *  Help4.tracking.Tracking.TrackParamsHelpOpenClose|
     *  Help4.tracking.Tracking.TrackParamsTile|
     *  Help4.tracking.Tracking.TrackParamsLink|
     *  Help4.tracking.Tracking.TrackParamSearch|
     *  Help4.tracking.Tracking.TrackParamTourOpen|
     *  Help4.tracking.Tracking.TrackParamTourClose|
     *  Help4.tracking.Tracking.TrackParamTourStep|
     *  Help4.tracking.Tracking.TrackParamLearningApp
     * } Help4.tracking.Tracking.TrackParams
     */

    /**
     * @typedef {Object} Help4.tracking.Tracking.TrackParamsBasic
     * @property {string} verb
     * @property {string} [type]
     */

    /**
     * @typedef {Help4.tracking.Tracking.TrackParamsBasic} Help4.tracking.Tracking.TrackParamsHelpOpenClose
     * @property {boolean} editMode
     */

    /**
     * @typedef {Help4.tracking.Tracking.TrackParamsBasic} Help4.tracking.Tracking.TrackParamsTile
     * @property {string} tile
     * @property {string} title
     */

    /**
     * @typedef {Help4.tracking.Tracking.TrackParamsBasic} Help4.tracking.Tracking.TrackParamsLink
     * @property {string} url
     * @property {string} title
     * @property {string} url
     */

    /**
     * @typedef {Help4.tracking.Tracking.TrackParamsBasic} Help4.tracking.Tracking.TrackParamSearch
     * @property {string} term
     * @property {function} getResults
     */

    /**
     * @typedef {Help4.tracking.Tracking.TrackParamsBasic} Help4.tracking.Tracking.TrackParamTourOpen
     * @property {boolean} editMode
     */

    /**
     * @typedef {Help4.tracking.Tracking.TrackParamsBasic} Help4.tracking.Tracking.TrackParamTourClose
     * @property {boolean} editMode
     * @property {string} step
     * @property {boolean} finished
     * @property {number} stepIndex
     * @property {number} totalSteps
     */

    /**
     * @typedef {Help4.tracking.Tracking.TrackParamsBasic} Help4.tracking.Tracking.TrackParamTourStep
     * @property {string} step
     * @property {string} title
     */

    /**
     * @typedef {Help4.tracking.Tracking.TrackParamsBasic} Help4.tracking.Tracking.TrackParamLearningApp
     * @property {string} [objectType]
     * @property {string} id
     * @property {string} name
     */

    /**
     * @augments Help4.jscore.ControlBase
     * @property {Help4.controller.Controller} controller
     * @property {string} sessionId
     * @property {?number} _searchInterval
     * @property {Object[]} _connectors
     */
    Help4.tracking.Tracking = class extends Help4.jscore.ControlBase {
        /**
         * @override
         * @param {Help4.tracking.Tracking.Params} params
         */
        constructor(params) {
            const {TYPES: T} = Help4.jscore.ControlBase;

            params.sessionId ||= Help4.createId().replace(/_/g, '');

            super(params, {
                params: {
                    controller: {type: T.instance, mandatory: true, readonly: true},
                    sessionId:  {type: T.string, mandatory: true}
                },
                statics: {
                    _searchInterval: {init: null, destroy: false},
                    _connectors:     {init: [], destroy: false}
                }
            });
        }

        static TRACKING_DATA = ['screenId', 'product', 'version', 'system', 'role', 'editor', 'language', 'playbackTag'];
        static HELP_BUTTON_DATA = ['theme', 'mobile', 'rtl'];
        static SEARCH_INTERVAL_TIME = 1500;

        /** @override */
        destroy() {
            const {_searchInterval} = this;
            _searchInterval && clearTimeout(_searchInterval);

            super.destroy();
        }

        /** @returns {Object} */
        serialize() {
            return {i: this.sessionId};
        }

        /** @param {?Object} data */
        deserialize(data) {
            if (data?.i) this.sessionId = data.i;
        }

        /** @returns {string} */
        getSessionId() {
            return this.sessionId;
        }

        /** @param {Help4.tracking.Tracking.Connector[]} connectors */
        addConnector(connectors) {
            const {connector} = Help4.tracking;
            const {_connectors} = this;
            for (const con of connectors) {
                if (connector.hasOwnProperty(con.type)) {
                    _connectors.push(new connector[con.type](con));
                }
            }
        }

        /**
         * tracks Help ? button interactions
         * @returns {?Promise<void>} returns Promise for CMP4 only
         */
        async trackHelpButton() {
            const {/** @type {Help4.controller.Controller} */ controller} = this;
            const {CMP4} = controller.getConfiguration();
            const modelInfo = /** @type {Help4.tracking.Tracking.ModelInfo} */ CMP4
                ? await _getModelInfoCMP4.call(this)
                : _getModelInfoCMP3.call(this);

            const object = _getObject.call(this, modelInfo, this.constructor.HELP_BUTTON_DATA);
            object.objectType = 'button';

            _send.call(this, {
                verb: {id: 'start'},
                context: {id: object.id, app: 'WA'},
                object
            });
        }

        async trackVideoPlay(videoSrc) {
            return this.trackProject({type: 'help', verb: 'video', url: videoSrc})
        }

        async trackExternalLink(url) {
            return this.trackProject({type: 'help', verb: 'external-link', url})
        }

        /**
         * tracks help or tour projects
         * @param {Help4.tracking.Tracking.TrackParams} params
         * @returns {?Promise<void>} returns Promise for CMP4 only
         */
        async trackProject(params) {
            const {/** @type {Help4.controller.Controller} */ controller} = this;
            const {CMP4} = controller.getConfiguration();
            const modelInfo = /** @type {Help4.tracking.Tracking.ModelInfo} */ CMP4
                ? await _getModelInfoCMP4.call(this)
                : _getModelInfoCMP3.call(this);

            const object = _getObject.call(this, modelInfo);
            const {type, editMode, verb} = params;
            const {_modelInfo: {_prefix}} = object;
            object.objectType = `project:${type}`;

            const {TRACK_STATUS} = Help4.widget;
            const data = {verb: {id: verb}, object, context: {id: object.id, app: 'WA'}};
            const model = CMP4 ? null : controller.getService('model');

            switch (`${type}-${verb}`) {
                case 'help-open':
                    object.editMode = editMode;
                    if (object.id) {
                        TRACK_STATUS.object = Help4.cloneObject(object);
                        _send.call(this, data);
                    }
                    break;
                case 'help-close': {
                    const {object} = TRACK_STATUS;
                    if (object?.id) {
                        TRACK_STATUS.object = {};  // reset
                        const data = {verb: {id: verb}, object, context: {id: object.id, app: 'WA'}};
                        _send.call(this, data);
                    }
                    break;
                }

                case 'help-video': {
                    const {url} = params;
                    object.objectType = `help:${verb}`;
                    object.name = '';
                    object.id = url;
                    data.verb.id = 'play';
                    _send.call(this, data);
                    break;
                }

                case 'help-external-link': {
                    const {url} = params;
                    object.objectType = `help:${verb}`;
                    object.name = '';
                    object.id = url;
                    data.verb.id = 'open';
                    _send.call(this, data);
                    break;
                }

                case 'help-tile':
                case 'help-link': {
                    const {tile, url} = params;
                    const {title} = CMP4
                        ? params
                        : model.getTile(tile) || {};

                    object.objectType = `help:${verb}`;
                    object.name = title || '';
                    object.id = verb === 'tile'
                        ? _prefix.tile + tile
                        : url;
                    data.verb.id = 'open';
                    _send.call(this, data);
                    break;
                }

                case 'help-search':
                    _trackSearch.call(this, 'webassistant', data, params);
                    break;

                case 'tour-open':
                case 'tour-close':
                    if (!(object.editMode = editMode)) {
                        if (verb === 'close') {
                            const {finished, step, stepIndex, totalSteps} = params;
                            if (!(object.finished = finished)) {
                                object.step = _prefix.tile + step;
                                object.stepIndex = stepIndex;
                                object.totalSteps = totalSteps;
                            }
                        }
                        data.verb.id = {open: 'start', close: 'stop'}[verb];
                    }
                    _send.call(this, data);
                    break;

                case 'tour-step': {
                    const {step} = params;
                    const {title} = CMP4
                        ? params
                        : model.getTile(step) || {};
                    data.verb.id = 'open';
                    object.objectType = 'tour:step';
                    object.id = _prefix.tile + step;
                    object.name = title || '';
                    _send.call(this, data);
                    break;
                }
            }
        }

        /**
         * tracks LearningApp interactions
         * @param {Help4.tracking.Tracking.TrackParamLearningApp|Help4.tracking.Tracking.TrackParamSearch} params
         * @returns {?Promise<void>} returns Promise for CMP4 only
         */
        async trackLearningApp(params) {
            const {/** @type {Help4.controller.Controller} */ controller} = this;
            const {CMP4} = controller.getConfiguration();
            const modelInfo = /** @type {Help4.tracking.Tracking.ModelInfo} */ CMP4
                ? await _getModelInfoCMP4.call(this)
                : _getModelInfoCMP3.call(this);

            const object = _getObject.call(this, modelInfo);
            object.objectType = `learningApp:${params.objectType || params.type}`;

            const data = {
                verb: {id: params.verb},
                context: {id: object.id, app: 'WA'},
                object
            };

            if (params.type === 'content') {
                object.id = params.id;
                object.name = params.name;
            }

            params.verb === 'search'
                ? _trackSearch.call(this, 'learningApp', data, params)
                : _send.call(this, data);
        }
    }

    /**
     * @memberof Help4.tracking.Tracking#
     * @private
     * @param {Help4.tracking.Tracking.ModelInfo} modelInfo
     * @param {string[]} [additionalMap = []]
     * @returns {Help4.tracking.Tracking.Object}
     */
    function _getObject(modelInfo, additionalMap = []) {
        const {
            constructor: {/** @type {string[]} */ TRACKING_DATA},
            /** @type {Help4.controller.Controller} */ controller,
            /** @type {string} */ sessionId
        } = this;

        const configuration = controller.getConfiguration();
        const map = TRACKING_DATA.concat(additionalMap);
        const result = Help4.filterObject(configuration, map);
        if (result.language?.wpb) result.language = result.language.wpb;

        const {projectId: id, projectTitle: name, _prefix} = modelInfo;
        let {solution, tenant} = configuration.core || {};

        // CMP and learningApp config
        solution ||= configuration.solution;
        tenant ||= configuration.tenant;

        return Help4.extendObject(result, {
            sessionId,
            id: id ? _prefix.project + id : id,
            name,
            solution,
            customer_tenant: tenant,
            _modelInfo: modelInfo
        });
    }

    /**
     * @memberof Help4.tracking.Tracking#
     * @private
     * @returns {Help4.tracking.Tracking.ModelInfo}
     */
    function _getModelInfoCMP3() {
        const {/** @type {Help4.controller.Controller} */ controller} = this;
        const model = /** @type {Help4.model.Model} */ controller.getService('model');
        const project = model.getProject() || {id: null, title: null, _ext: 'ro'};
        return _completeModelInfo.call(this, project);
    }

    /**
     * @memberof Help4.tracking.Tracking#
     * @private
     * @returns {Promise<Help4.tracking.Tracking.ModelInfo>}
     */
    async function _getModelInfoCMP4() {
        const {/** @type {Help4.controller.Controller} */ controller} = this;
        const configuration = controller.getConfiguration();
        let project = null;

        /** @type {{
         *  help: ?Help4.widget.help.Widget,
         *  tour: ?Help4.widget.tour.Widget,
         *  learning: ?Help4.widget.learning.Widget
         * }}
         */
        const {help, tour, whatsnew, filter} = Help4.widget.getInstance();
        const hasActiveInstance = !!Help4.widget.getActiveInstance();

        const isBackendProject = project => project._catalogueType === 'UACP' || project._catalogueType === 'SEN' || project._catalogueType === 'SEN2';

        const getTourProject = () => {
            const {Core} = Help4.widget.companionCore;
            const context = /** @type {Help4.widget.tour.Widget.Context} */ tour.getContext();
            const {/** @type {Help4.widget.help.Data} */ data} = context.widget.help || {};
            const {/** @type {string} */ projectId} = tour;
            const catalogueKey = Core.getCatalogueKey({configuration});
            return /** @type {?Help4.widget.help.CatalogueProject} */ data.getCatalogueProject(projectId, catalogueKey);
        }

        const getWhatsnewProject = async () => {
            const context = /** @type {Help4.widget.whatsnew.Widget.Context} */ whatsnew.getContext();
            const {/** @type {Help4.widget.whatsnew.Data} */ data} = context.widget.help || {};
            const projects = /** @type {Help4.widget.help.Project[]} */ await data.getHelp();  // CMP4 is multi-project capable
            return /** @type {?Help4.widget.help.Project} */ projects.find(isBackendProject);
        }

        const getHelpProject = async () => {
            const context = /** @type {Help4.widget.help.Widget.Context} */ help?.getContext();
            const {/** @type {Help4.widget.help.Data} */ data} = context?.widget.help || {};
            if (!data) return null;

            const projects = /** @type {Help4.widget.help.Project[]} */ await data.getHelp();  // CMP4 is multi-project capable
            return /** @type {?Help4.widget.help.Project} */ projects.find(isBackendProject);
        }

        if (!hasActiveInstance) {  // home screen
            project = await getHelpProject();
        } else if (tour?.isActive()) {
            project = getTourProject();
        } else if (whatsnew?.isActive()) {
            project = await getWhatsnewProject();
        } else if (help?.isActive()) {
            project = await getHelpProject();
        } else if (filter?.isActive()) {
            project = await getHelpProject() || await getWhatsnewProject();
        }

        return _completeModelInfo.call(this, project);
    }

    /**
     * @memberof Help4.tracking.Tracking#
     * @private
     * @param {?{id: ?string, title: ?string, _ext: ?string, _rw: ?string}} project
     * @returns {Help4.tracking.Tracking.ModelInfo}
     */
    function _completeModelInfo(project) {
        const {/** @type {Help4.controller.Controller} */ controller} = this;
        const configuration = controller.getConfiguration();
        const {serviceLayer, roModel} = configuration.help || configuration;  // make sure it works in learning app

        const prefixMap = {
            uacp: {project: 'loio!', tile: 'loio!'},
            wpb:  {project: 'project!', tile: 'macro!'}
        };

        project ||= {id: null, title: null, _ext: 'ro', _rw: null};

        const {id, title, _ext, _rw} = project;
        const prefixKey = serviceLayer === 'ext'
            ? _ext === 'ro' && roModel !== 'wpb' ? 'uacp' : 'wpb'
            : serviceLayer || 'uacp';

        return {
            backendType: serviceLayer,
            projectId: serviceLayer === 'ext' ? _rw || id : id,
            projectTitle: title,
            _prefix: prefixMap[prefixKey]
        };
    }

    /**
     * @memberof Help4.tracking.Tracking#
     * @private
     * @param {string} appName
     * @param {Help4.tracking.Tracking.Data} data
     * @param {Help4.tracking.Tracking.TrackParamSearch} params
     */
    function _trackSearch(appName, data, params) {
        const callback = () => {
            if (this.isDestroyed()) return;
            this._searchInterval = null;

            const results = params.getResults();  // read the latest tile count shown in the UI
            // XRAY-4491: handler is destroyed do not send track data since tile count will be incorrect
            if (results == null) return;

            const {object} = data;
            object.objectType = `${appName}:search`;
            object.id = `${appName}!search`;
            object.name = 'search';
            object.search = params.term;
            object.results = results;
            _send.call(this, data);
        }

        const {_searchInterval, constructor: {SEARCH_INTERVAL_TIME}} = this;
        _searchInterval && clearTimeout(_searchInterval);
        this._searchInterval = setTimeout(callback, SEARCH_INTERVAL_TIME);
    }

    /**
     * @memberof Help4.tracking.Tracking#
     * @private
     * @param {Help4.tracking.Tracking.Data} data
     */
    function _send(data) {
        // remove all internal and undefined information
        Object.entries(data.object).forEach(([key, value]) => {
            if (key.charAt(0) === '_' || value == null) delete data.object[key];
        });

        // track using all connectors
        const {_connectors} = this;
        for (const connector of _connectors) {
            connector.track(data).catch(xhr => {
                // XXX: error handling
            });
        }
    }
})();