uPortal-Project / uPortal

Enterprise open source portal built by and for the higher education community.
https://www.apereo.org/projects/uportal
Apache License 2.0
272 stars 273 forks source link

Replace up-ga.js file with this code once JS tooling is updated #2855

Open bjagg opened 4 weeks ago

bjagg commented 4 weeks ago
          I'd be okay adding some info and warning logs.

Maybe adding some around the null checks to let adopters know configuration may be missing, and info at some key points? Also if this is an area we expect adopters to review more, we could also beef up the JSDocs a bit to give more context.

/*
 * Licensed to Apereo under one or more contributor license
 * agreements. See the NOTICE file distributed with this work
 * for additional information regarding copyright ownership.
 * Apereo licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License.  You may obtain a
 * copy of the License at the following location:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
var uportal = uportal || {};

(function ($) {
    /**
     * Finds the appropriate property configuration for the current institution.
     * @returns {Object|null} The property configuration object or null if not found.
     */
    var findPropertyConfig = function () {
        if (up.analytics.model == null) {
            console.error("[Analytics] No analytics model found.");
            return null;
        }

        if (Array.isArray(up.analytics.model.hosts)) {
            var hosts = up.analytics.model.hosts;
            var propertyConfig = null;
            for (var i = 0; i < hosts.length; i++) {
                var propConfig = hosts[i];
                if (propConfig.name == up.analytics.host) {
                    propertyConfig = propConfig;
                    break;
                }
            }

            if (propertyConfig != null) {
                console.info("[Analytics] Found property configuration for host:", up.analytics.host);
                return propertyConfig;
            }
        }

        console.info("[Analytics] Using default property configuration.");
        return up.analytics.model.defaultConfig;
    };

    /**
     * Sets global settings from the property configuration.
     * @param {Object} propertyConfig - The property configuration object.
     */
    var configureDefaults = function (propertyConfig) {
        var defaults = propertyConfig.config || [];
        defaults.forEach(function (setting) {
            Object.keys(setting).forEach(function (key) {
                up.gtag('set', key, setting[key]);
            });
        });
    };

    /**
     * Retrieves dimensions that apply to the current user.
     * @param {Object} propertyConfig - The property configuration object.
     * @returns {Object} An object containing dimension key-value pairs.
     */
    var getDimensions = function (propertyConfig) {
        var dimensions = {};
        var dimensionGroups = propertyConfig.dimensionGroups || [];
        dimensionGroups.forEach(function (setting) {
            dimensions['dimension' + setting.name] = setting.value;
        });
        console.info("[Analytics] Dimensions set:", dimensions);
        return dimensions;
    };

    /**
     * Creates the Google Analytics tracker with the specified property ID and configuration.
     * @param {Object} propertyConfig - The property configuration object.
     */
    var createTracker = function (propertyConfig) {
        var createSettings = {};
        var configSettings = propertyConfig.config || [];
        configSettings.forEach(function (setting) {
            if (setting.name != 'name') {
                createSettings[setting.name] = setting.value;
            }
        });
        up.gtag('config', propertyConfig.propertyId, {
            send_page_view: false,
        });
        console.info("[Analytics] Tracker created with property ID:", propertyConfig.propertyId);
    };

    /**
     * Builds the page URI for a tab.
     * @param {string} [fragmentName] - The fragment name.
     * @param {string} [tabName] - The tab name.
     * @returns {string} The constructed tab URI.
     */
    var getTabUri = function (fragmentName, tabName) {
        if (up.analytics.pageData.tab != null) {
            fragmentName =
                fragmentName || up.analytics.pageData.tab.fragmentName;
            tabName = tabName || up.analytics.pageData.tab.tabName;
        }

        var uri = '/';

        if (fragmentName != null) {
            uri += 'tab/' + fragmentName;

            if (tabName != null) {
                uri += '/' + tabName;
            }
        }

        return uri;
    };

    /**
     * Retrieves variables specific to the current page.
     * @param {string} [fragmentName] - The fragment name.
     * @param {string} [tabName] - The tab name.
     * @returns {Object} An object containing page variables.
     */
    var getPageVariables = function (fragmentName, tabName) {
        if (up.analytics.pageData.tab != null) {
            fragmentName =
                fragmentName || up.analytics.pageData.tab.fragmentName;
            tabName = tabName || up.analytics.pageData.tab.tabName;
        }

        var title;
        if (tabName != null) {
            title = 'Tab: ' + tabName;
        } else if (up.analytics.pageData.urlState == null) {
            title = 'Portal Home';
        } else {
            title = 'No Tab';
        }
        return {
            page_location: getTabUri(fragmentName, tabName),
            page_title: title,
        };
    };

    /**
     * Safely resolves the portlet's fname from the windowId.
     * Falls back to using the windowId if no fname is found.
     * @param {string} windowId - The window ID of the portlet.
     * @returns {string} The fname of the portlet.
     */
    var getPortletFname = function (windowId) {
        var portletData = up.analytics.portletData[windowId];
        if (portletData == null) {
            return windowId;
        }

        return portletData.fname;
    };

    /**
     * Safely resolves the portlet's title from the windowId.
     * Falls back to using getPortletFname(windowId) if the title can't be found.
     * @param {string} windowId - The window ID of the portlet.
     * @returns {string} The title of the portlet.
     */
    var getRenderedPortletTitle = function (windowId) {
        var portletWindowWrapper = $(
            'div.up-portlet-windowId-content-wrapper.' + windowId
        );
        if (portletWindowWrapper.length == 0) {
            return getPortletFname(windowId);
        }

        var portletWrapper = portletWindowWrapper.parents(
            'div.up-portlet-wrapper-inner'
        );
        if (portletWrapper.length == 0) {
            return getPortletFname(windowId);
        }

        var portletTitle = portletWrapper.find('div.up-portlet-titlebar h2 a');
        if (portletTitle.length == 0) {
            return getPortletFname(windowId);
        }

        return portletTitle.text().trim();
    };

    /**
     * Builds the portlet URI for the specified portlet.
     * @param {string} fname - The fname of the portlet.
     * @returns {string} The constructed portlet URI.
     */
    var getPortletUri = function (fname) {
        return '/portlet/' + fname;
    };

    /**
     * Retrieves variables specific to the specified portlet.
     * @param {string} windowId - The window ID of the portlet.
     * @param {Object} [portletData] - The data object of the portlet.
     * @returns {Object} An object containing portlet variables.
     */
    var getPortletVariables = function (windowId, portletData) {
        var portletTitle = getRenderedPortletTitle(windowId);

        if (portletData == null) {
            portletData = up.analytics.portletData[windowId];
        }
        return {
            page_title: 'Portlet: ' + portletTitle,
            page_location: getPortletUri(portletData.fname),
        };
    };

    /**
     * Retrieves the first class name from an element that is not in the excludedClasses array.
     * @param {Function} selectorFunction - A function that returns a jQuery element.
     * @param {string|string[]} excludedClasses - Class name or array of class names to exclude.
     * @returns {string|null} The first class name not in excludedClasses, or null if none found.
     */
    var getInfoClass = function (selectorFunction, excludedClasses) {
        // Ensure excludedClasses is an array
        if (!Array.isArray(excludedClasses)) {
            excludedClasses = [excludedClasses];
        }

        var classAttribute = selectorFunction().attr('class');
        if (classAttribute == null) {
            return null;
        }

        var classes = classAttribute.split(/\s+/);
        for (var i = 0; i < classes.length; i++) {
            var cls = classes[i];
            if (excludedClasses.indexOf(cls) === -1) {
                return cls;
            }
        }
        return null;
    };

    /**
     * Determines the fname of the portlet the clicked flyout was rendered for.
     * @param {Object} clickedLink - The jQuery object representing the clicked link.
     * @returns {string|null} The fname of the portlet, or null if not found.
     */
    var getFlyoutFname = function (clickedLink) {
        return getInfoClass(function () {
            return clickedLink.parents('div.up-portlet-fname-subnav-wrapper');
        }, 'up-portlet-fname-subnav-wrapper');
    };

    /**
     * Determines the windowId of the portlet the clicked external link was rendered for.
     * @param {Object} clickedLink - The jQuery object representing the clicked link.
     * @returns {string|null} The windowId of the portlet, or null if not found.
     */
    var getExternalLinkWindowId = function (clickedLink) {
        return getInfoClass(function () {
            return clickedLink.parents(
                'div.up-portlet-windowId-content-wrapper'
            );
        }, 'up-portlet-windowId-content-wrapper');
    };

    /**
     * Handles link click events for analytics tracking.
     * Sends an analytics event and manages navigation behavior.
     * @param {Object} event - The jQuery event object.
     * @param {Object} clickedLink - The jQuery object representing the clicked link.
     * @param {Object} eventOptions - Additional options for the analytics event.
     */
    var handleLinkClickEvent = function (event, clickedLink, eventOptions) {
        // Determine if the click will open in a new window
        var newWindow =
            event.button == 1 ||
            event.metaKey ||
            event.ctrlKey ||
            clickedLink.attr('target') != null;

        var clickFunction;
        clickFunction = newWindow
            ? function () {}
            : function () {
                  document.location = clickedLink.attr('href');
              };

        up.gtag(
            'event',
            'page_view',
            $.extend(
                {
                    event_callback: clickFunction,
                },
                eventOptions
            )
        );

        // If not opening a new window, prevent default behavior and set a fallback
        if (!newWindow) {
            // Fallback in case event_callback is not called promptly
            setTimeout(clickFunction, 200);

            event.preventDefault();
        }
    };

    /**
     * Adds click handlers to flyout menus to fire analytics events when used.
     */
    var addFlyoutHandlers = function () {
        $('ul.fl-tabs li.portal-navigation a.portal-subnav-link').click(
            function (event) {
                var clickedLink = $(this);

                // Get the target portlet's title
                var portletFlyoutTitle = clickedLink
                    .find('span.portal-subnav-label')
                    .text();

                // Get the target portlet's fname
                var fname = getFlyoutFname(clickedLink);

                // Setup page-level variables
                var pageVariables = getPageVariables();

                // Send the analytics event and handle the click
                handleLinkClickEvent(
                    event,
                    clickedLink,
                    $.extend(
                        {
                            event_category: 'Flyout Link',
                            event_action: getPortletUri(fname),
                            event_label: portletFlyoutTitle,
                        },
                        pageVariables
                    )
                );
            }
        );
    };

    /**
     * Adds handlers to inspect clicks on links and track outbound link events.
     */
    var addExternalLinkHandlers = function () {
        $('a').click(function (event) {
            var clickedLink = $(this);

            var linkHost = clickedLink.prop('hostname');
            if (linkHost != '' && linkHost != document.domain) {
                var windowId = getExternalLinkWindowId(clickedLink);
                var eventVariables = null;
                eventVariables =
                    windowId == null
                        ? getPageVariables()
                        : getPortletVariables(windowId);

                // Send the analytics event and handle the click
                handleLinkClickEvent(
                    event,
                    clickedLink,
                    $.extend(
                        {
                            event_category: 'Outbound Link',
                            event_action: clickedLink.prop('href'),
                            event_label: clickedLink.text(),
                        },
                        eventVariables
                    )
                );
            }
        });
    };

    /**
     * Adds handlers to track "tab" clicks in the mobile accordion view.
     */
    var addMobileListTabHandlers = function () {
        $('ul.up-portal-nav li.up-tab').click(function () {
            var clickedTab = $(this);

            // Ignore clicks on already open tabs
            if (clickedTab.hasClass('up-tab-open')) {
                return;
            }

            var fragmentName = getInfoClass(function () {
                return clickedTab.find('div.up-tab-owner');
            }, 'up-tab-owner');

            var tabName = clickedTab.find('span.up-tab-name').text().trim();

            var pageVariables = getPageVariables(fragmentName, tabName);

            up.gtag('event', 'page_view', pageVariables);
        });
    };

    $(document).ready(function () {
        // Initialize property configuration
        var propertyConfig = findPropertyConfig();

        // No property config means analytics cannot proceed
        if (propertyConfig == null) {
            console.error("[Analytics] No property configuration found. Analytics will not be initialized.");
            return;
        }

        // Set default configuration
        configureDefaults(propertyConfig);

        // Create the tracker
        createTracker(propertyConfig);

        // Set dimensions for the current user
        var dimensions = getDimensions(propertyConfig);

        up.gtag('event', 'page_view', dimensions);

        // Prepare page-level variables
        var pageVariables = getPageVariables();

        // Send page view event unless in MAX WindowState
        if (up.analytics.pageData.urlState != 'MAX') {
            up.gtag('event', 'page_view', $.extend(pageVariables, dimensions));
        }

        // Send timing event for page load
        up.gtag(
            'event',
            'timing_complete',
            $.extend(
                {
                    event_category: 'tab',
                    name: getTabUri(),
                    value: up.analytics.pageData.executionTimeNano,
                },
                pageVariables,
                dimensions
            )
        );

        // Send events for each portlet
        for (var windowId in up.analytics.portletData) {
            if (up.analytics.portletData.hasOwnProperty(windowId)) {
                var portletData = up.analytics.portletData[windowId];
                // Skip excluded portlets
                if (portletData.fname == 'google-analytics-config') {
                    console.info("[Analytics] Skipping portlet:", portletData.fname);
                    continue;
                }

                var portletVariables = getPortletVariables(windowId, portletData);
                up.gtag('event', 'page_view', portletVariables);
                up.gtag(
                    'event',
                    'timing_complete',
                    $.extend(
                        {
                            event_category: 'tab',
                            name: getTabUri(),
                            value: up.analytics.pageData.executionTimeNano,
                        },
                        portletVariables,
                        dimensions
                    )
                );
            }
        }

        // Add event handlers
        addFlyoutHandlers();
        addExternalLinkHandlers();
        addMobileListTabHandlers();
    });
})(jQuery);

_Originally posted by @ChristianMurphy in https://github.com/uPortal-Project/uPortal/pull/2849#discussion_r1763533221_