diff --git a/contentcuration/contentcuration/frontend/shared/analytics/plugin.js b/contentcuration/contentcuration/frontend/shared/analytics/plugin.js index 806e47aa22..1a23f4af66 100644 --- a/contentcuration/contentcuration/frontend/shared/analytics/plugin.js +++ b/contentcuration/contentcuration/frontend/shared/analytics/plugin.js @@ -1,178 +1,11 @@ -import Vue from 'vue'; -import isFunction from 'lodash/isFunction'; - -/** - * Analytics class for handling anything analytics related, exposed in Vue as $analytics - */ -class Analytics { - /** - * GTM uses an array-like structure called the `dataLayer` - * - * @param {Array} dataLayer - */ - constructor(dataLayer) { - this.counter = 0; - this.dataLayer = dataLayer; - this.lastLength = 0; - - // add interval to trigger resets every 5 minutes - this.resetInterval = setInterval( - () => { - this.reset(); - }, - 5 * 60 * 1000, - ); - } - - /** - * Resets the datalayer and cleans up elements - */ - reset() { - // If the dataLayer hasn't changed since our last reset, skip - if (this.dataLayer.length === this.lastLength) { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.info('Skipping Analytics.reset()'); - } - return; - } - - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.info('Analytics.reset()'); - } - - // Add local variable so the pushed reset function can access the datalayer - const dataLayer = this.dataLayer; - const resetCounter = this.counter; - this.counter++; - - // This can't use an arrow function, it will be called with a different - // context to access `this.reset()` - const dataLayerReset = function () { - // See https://developers.google.com/tag-platform/devguides/datalayer#reset - this.reset(); - - for (const item of dataLayer) { - // Do our own reset of the `gtm.element` variable - if (item['gtm.element']) { - item['gtm.element'] = null; - } - // Be sure we stop when we reach this function's position in the dataLayer. The dataLayer - // is like a FIFO queue, so by queuing this function and having the GTM execute it, we're - // ensuring that everything prior to this function was processed. For this reason, we stop - // once we reach the position of this function to avoid manipulating events that have been - // added afterwards - if (isFunction(item) && item['counter'] === resetCounter) { - break; - } - } - }; - dataLayerReset.counter = resetCounter; - - this.dataLayer.push(dataLayerReset); - this.lastLength = this.dataLayer.length; - } - - /** - * Push data onto the datalayer - * @param {object|function} args - */ - push(...args) { - this.dataLayer.push(...args); - } - - /** - * Push an event into the dataLayer - * - * These events could be standard GA events, or custom events that trigger tags within GTM - * - * @param {String} event - * @param {{:*}} data - */ - trackEvent(event, data = {}) { - this.push({ - ...data, - event, - }); - - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.info(`Analytics.trackEvent("${event}", ${JSON.stringify(data)})`); - } - } - - /** - * Tracks event with specific action - * - * @param {String} event - * @param {String} eventAction - * @param {{:*}} data - */ - trackAction(event, eventAction, data = {}) { - this.trackEvent(event, { ...data, eventAction }); - } - - /** - * Tracks event with click action - * - * @param {String} event - * @param {String} eventLabel - * @param {{:*}} data - */ - trackClick(event, eventLabel, data = {}) { - this.trackAction(event, 'Click', { ...data, eventLabel }); - } - - /** - * Pushes current channel data into the data layer for GTM to associate with events - * - * @param {Object} channel - * @param {Boolean} staging - */ - trackCurrentChannel(channel, staging = false) { - // We only want to track once, since a page reload is required to open a new channel - const hasCurrentChannel = this.dataLayer.find(data => Boolean(data['currentChannel'])); - if (hasCurrentChannel) { - return; - } - - this.push({ - currentChannel: { - id: channel.id, - name: channel.name, - lastPublished: channel.last_published, - isPublic: channel.public, - allowEdit: channel.edit, - staging, - // Skipping this field for now as we don't have this info on the frontend by default - // hasEditors: - }, - }); - } - - /** - * Cleans up the datalayer and removes the interval to call dataLayer.reset() - */ - destroy() { - clearInterval(this.resetInterval); - this.dataLayer = null; - } -} +// the new home for analytics +import { Analytics } from 'shared/composables/useAnalytics'; /** * @param Vue - * @param {Object} options - * @param {Array} options.dataLayer */ -export default function AnalyticsPlugin(Vue, options = {}) { - const analytics = new Analytics(options.dataLayer); - - // Merge in old dataLayer - if (Vue.$analytics) { - analytics.dataLayer.push(...Vue.$analytics.dataLayer); - Vue.$analytics.destroy(); - } +export default function AnalyticsPlugin(Vue) { + const analytics = Analytics.getInstance(); Vue.$analytics = analytics; Vue.mixin({ @@ -182,6 +15,3 @@ export default function AnalyticsPlugin(Vue, options = {}) { }, }); } - -// Initialize with empty dataLayer -AnalyticsPlugin(Vue, { dataLayer: [] }); diff --git a/contentcuration/contentcuration/frontend/shared/app.js b/contentcuration/contentcuration/frontend/shared/app.js index 2a4bc1c573..eefc9d12b0 100644 --- a/contentcuration/contentcuration/frontend/shared/app.js +++ b/contentcuration/contentcuration/frontend/shared/app.js @@ -252,7 +252,7 @@ Vue.use(Vuetify, { Vue.use(KThemePlugin); // Register analytics plugin with dataLayer that should already be defined -Vue.use(AnalyticsPlugin, { dataLayer: window.dataLayer }); +Vue.use(AnalyticsPlugin); // Register global components Vue.component('ActionLink', ActionLink); diff --git a/contentcuration/contentcuration/frontend/shared/composables/__tests__/useAnalytics.spec.js b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useAnalytics.spec.js new file mode 100644 index 0000000000..7ab17b3631 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/__tests__/useAnalytics.spec.js @@ -0,0 +1,294 @@ +import useAnalytics, { Analytics } from '../useAnalytics'; + +describe('Studio Analytics Utilities', () => { + let mockDataLayer; + let resetIntervals = 0; + + beforeEach(() => { + Analytics.destroyInstance(); + + // Create mock dataLayer + mockDataLayer = []; + + // Mock window.dataLayer + Object.defineProperty(window, 'dataLayer', { + value: mockDataLayer, + writable: true, + configurable: true, + }); + + // Mock setInterval to track intervals for cleanup + jest.spyOn(global, 'setInterval').mockImplementation(() => { + return resetIntervals++; + }); + + jest.spyOn(global, 'clearInterval').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + resetIntervals = 0; + }); + + describe('useAnalytics', () => { + describe('trackEvent', () => { + it('should push event object to dataLayer', () => { + const { trackEvent } = useAnalytics(); + trackEvent('test_event', { foo: 'bar' }); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0]).toEqual({ + event: 'test_event', + foo: 'bar', + }); + }); + + it('should handle event with no data', () => { + const { trackEvent } = useAnalytics(); + trackEvent('simple_event'); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0]).toEqual({ + event: 'simple_event', + }); + }); + + it('should handle event with empty data object', () => { + const { trackEvent } = useAnalytics(); + trackEvent('empty_data_event', {}); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0]).toEqual({ + event: 'empty_data_event', + }); + }); + }); + + describe('trackAction', () => { + it('should push event with eventAction', () => { + const { trackAction } = useAnalytics(); + trackAction('channel_editor', 'Open', { nodeId: '123' }); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0]).toEqual({ + event: 'channel_editor', + eventAction: 'Open', + nodeId: '123', + }); + }); + + it('should handle action with no extra data', () => { + const { trackAction } = useAnalytics(); + trackAction('simple_action', 'Execute'); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0]).toEqual({ + event: 'simple_action', + eventAction: 'Execute', + }); + }); + }); + + describe('trackClick', () => { + it('should push click event with eventLabel', () => { + const { trackClick } = useAnalytics(); + trackClick('clipboard', 'Copy', { source: 'toolbar' }); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0]).toEqual({ + event: 'clipboard', + eventAction: 'Click', + eventLabel: 'Copy', + source: 'toolbar', + }); + }); + + it('should handle click with no extra data', () => { + const { trackClick } = useAnalytics(); + trackClick('button', 'Submit'); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0]).toEqual({ + event: 'button', + eventAction: 'Click', + eventLabel: 'Submit', + }); + }); + }); + + describe('trackCurrentChannel', () => { + const mockChannel = { + id: 'abc123', + name: 'Test Channel', + last_published: '2024-01-01', + public: true, + edit: false, + }; + + it('should push channel data to dataLayer', () => { + const { trackCurrentChannel } = useAnalytics(); + trackCurrentChannel(mockChannel, false); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0].currentChannel).toEqual({ + id: 'abc123', + name: 'Test Channel', + lastPublished: '2024-01-01', + isPublic: true, + allowEdit: false, + staging: false, + }); + }); + + it('should not push duplicate channel data', () => { + const { trackCurrentChannel } = useAnalytics(); + trackCurrentChannel(mockChannel); + trackCurrentChannel(mockChannel); + + expect(mockDataLayer).toHaveLength(1); + }); + + it('should handle staging flag as true', () => { + const { trackCurrentChannel } = useAnalytics(); + trackCurrentChannel(mockChannel, true); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0].currentChannel.staging).toBe(true); + }); + }); + + describe('push', () => { + it('should push arbitrary data to dataLayer', () => { + const { push } = useAnalytics(); + push({ custom: 'data' }); + + expect(mockDataLayer).toHaveLength(1); + expect(mockDataLayer[0]).toEqual({ custom: 'data' }); + }); + + it('should push multiple arguments', () => { + const { push } = useAnalytics(); + push({ first: 1 }, { second: 2 }); + + expect(mockDataLayer).toHaveLength(2); + expect(mockDataLayer[0]).toEqual({ first: 1 }); + expect(mockDataLayer[1]).toEqual({ second: 2 }); + }); + }); + + describe('reset', () => { + it('should skip reset if dataLayer unchanged', () => { + const { reset } = useAnalytics(); + const consoleSpy = jest.spyOn(console, 'info').mockImplementation(); + + reset(); + + expect(consoleSpy).toHaveBeenCalledWith('Skipping Analytics.reset()'); + consoleSpy.mockRestore(); + }); + + it('should execute reset when dataLayer has changed', () => { + const { push, reset } = useAnalytics(); + push({ test: 'data' }); + + reset(); + + // Reset function should be pushed to dataLayer + expect(mockDataLayer.length).toBeGreaterThan(1); + }); + + it('should push reset function to dataLayer with counter', () => { + const { push, reset } = useAnalytics(); + push({ test: 'data' }); + + reset(); + + // Reset pushes a function to dataLayer + const resetFn = mockDataLayer.find(item => typeof item === 'function'); + expect(resetFn).toBeDefined(); + expect(typeof resetFn.counter).toBe('number'); + }); + }); + + describe('initialization', () => { + it('should set up reset interval on creation', () => { + useAnalytics(); + + expect(global.setInterval).toHaveBeenCalledWith(expect.any(Function), 5 * 60 * 1000); + }); + }); + + describe('method signatures', () => { + it('should export all required methods', () => { + const analytics = useAnalytics(); + + expect(analytics).toHaveProperty('trackEvent'); + expect(analytics).toHaveProperty('trackAction'); + expect(analytics).toHaveProperty('trackClick'); + expect(analytics).toHaveProperty('trackCurrentChannel'); + expect(analytics).toHaveProperty('push'); + expect(analytics).toHaveProperty('reset'); + }); + + it('should have trackEvent as a function', () => { + const { trackEvent } = useAnalytics(); + expect(typeof trackEvent).toBe('function'); + }); + + it('should have trackAction as a function', () => { + const { trackAction } = useAnalytics(); + expect(typeof trackAction).toBe('function'); + }); + + it('should have trackClick as a function', () => { + const { trackClick } = useAnalytics(); + expect(typeof trackClick).toBe('function'); + }); + + it('should have trackCurrentChannel as a function', () => { + const { trackCurrentChannel } = useAnalytics(); + expect(typeof trackCurrentChannel).toBe('function'); + }); + + it('should have push as a function', () => { + const { push } = useAnalytics(); + expect(typeof push).toBe('function'); + }); + + it('should have reset as a function', () => { + const { reset } = useAnalytics(); + expect(typeof reset).toBe('function'); + }); + }); + }); + + describe('Analytics', () => { + describe('getInstance', () => { + it('should create a new instance if one does not exist', () => { + const instance = Analytics.getInstance(); + expect(instance).toBeInstanceOf(Analytics); + }); + + it('should return the same instance on subsequent calls', () => { + const instance1 = Analytics.getInstance(); + const instance2 = Analytics.getInstance(); + expect(instance1).toBe(instance2); + }); + + it('should use window.dataLayer if available', () => { + const instance = Analytics.getInstance(); + expect(instance.dataLayer).toBe(mockDataLayer); + }); + }); + + describe('destroyInstance', () => { + it('should nullify the instance and clear interval', () => { + const instance = Analytics.getInstance(); + const spy = jest.spyOn(instance, 'destroy'); + Analytics.destroyInstance(); + expect(spy).toHaveBeenCalled(); + expect(global.clearInterval).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/composables/useAnalytics.js b/contentcuration/contentcuration/frontend/shared/composables/useAnalytics.js new file mode 100644 index 0000000000..eb954c5e2a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/composables/useAnalytics.js @@ -0,0 +1,205 @@ +import isFunction from 'lodash/isFunction'; + +/** + * @type {Analytics|null} + * @private + */ +let _instance = null; + +/** + * Analytics class for handling anything analytics related, exposed through the composable + */ +export class Analytics { + /** + * GTM uses an array-like structure called the `dataLayer` + * + * @param {Array} dataLayer + */ + constructor(dataLayer) { + this.counter = 0; + this.dataLayer = dataLayer; + this.lastLength = 0; + + // add interval to trigger resets every 5 minutes + this.resetInterval = setInterval( + () => { + this.reset(); + }, + 5 * 60 * 1000, + ); + } + + /** + * Returns an existing or new singleton analytics instance + * @returns {Analytics} + */ + static getInstance() { + if (!_instance) { + _instance = new Analytics(window.dataLayer || []); + } + return _instance; + } + + /** + * Destroys the static singleton instance if it exists + */ + static destroyInstance() { + if (_instance) { + _instance.destroy(); + _instance = null; + } + } + + /** + * Resets the datalayer and cleans up elements + */ + reset() { + // If the dataLayer hasn't changed since our last reset, skip + if (this.dataLayer.length === this.lastLength) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.info('Skipping Analytics.reset()'); + } + return; + } + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.info('Analytics.reset()'); + } + + // Add local variable so the pushed reset function can access the datalayer + const dataLayer = this.dataLayer; + const resetCounter = this.counter; + this.counter++; + + // This can't use an arrow function, it will be called with a different + // context to access `this.reset()` + const dataLayerReset = function () { + // See https://developers.google.com/tag-platform/devguides/datalayer#reset + this.reset(); + + for (const item of dataLayer) { + // Do our own reset of the `gtm.element` variable + if (item['gtm.element']) { + item['gtm.element'] = null; + } + // Be sure we stop when we reach this function's position in the dataLayer. The dataLayer + // is like a FIFO queue, so by queuing this function and having the GTM execute it, we're + // ensuring that everything prior to this function was processed. For this reason, we stop + // once we reach the position of this function to avoid manipulating events that have been + // added afterwards + if (isFunction(item) && item['counter'] === resetCounter) { + break; + } + } + }; + dataLayerReset.counter = resetCounter; + + this.dataLayer.push(dataLayerReset); + this.lastLength = this.dataLayer.length; + } + + /** + * Push data onto the datalayer + * @param {object|function} args + */ + push(...args) { + this.dataLayer.push(...args); + } + + /** + * Push an event into the dataLayer + * + * These events could be standard GA events, or custom events that trigger tags within GTM + * + * @param {String} event + * @param {{:*}} data + */ + trackEvent(event, data = {}) { + this.push({ + ...data, + event, + }); + + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.info(`Analytics.trackEvent("${event}", ${JSON.stringify(data)})`); + } + } + + /** + * Tracks event with specific action + * + * @param {String} event + * @param {String} eventAction + * @param {{:*}} data + */ + trackAction(event, eventAction, data = {}) { + this.trackEvent(event, { ...data, eventAction }); + } + + /** + * Tracks event with click action + * + * @param {String} event + * @param {String} eventLabel + * @param {{:*}} data + */ + trackClick(event, eventLabel, data = {}) { + this.trackAction(event, 'Click', { ...data, eventLabel }); + } + + /** + * Pushes current channel data into the data layer for GTM to associate with events + * + * @param {Object} channel + * @param {Boolean} staging + */ + trackCurrentChannel(channel, staging = false) { + // We only want to track once, since a page reload is required to open a new channel + const hasCurrentChannel = this.dataLayer.find(data => Boolean(data['currentChannel'])); + if (hasCurrentChannel) { + return; + } + + this.push({ + currentChannel: { + id: channel.id, + name: channel.name, + lastPublished: channel.last_published, + isPublic: channel.public, + allowEdit: channel.edit, + staging, + // Skipping this field for now as we don't have this info on the frontend by default + // hasEditors: + }, + }); + } + + /** + * Cleans up the datalayer and removes the interval to call dataLayer.reset() + */ + destroy() { + clearInterval(this.resetInterval); + this.dataLayer = null; + } +} + +/** + * Composable for handling analytics tracking via Google Tag Manager dataLayer + * + * @returns {Object} Analytics methods + */ +export default function useAnalytics() { + const analytics = Analytics.getInstance(); + + return { + trackEvent: analytics.trackEvent.bind(analytics), + trackAction: analytics.trackAction.bind(analytics), + trackClick: analytics.trackClick.bind(analytics), + trackCurrentChannel: analytics.trackCurrentChannel.bind(analytics), + push: analytics.push.bind(analytics), + reset: analytics.reset.bind(analytics), + }; +} diff --git a/jest_config/setup.js b/jest_config/setup.js index bfff655eef..17aa526670 100644 --- a/jest_config/setup.js +++ b/jest_config/setup.js @@ -52,9 +52,7 @@ Vue.use(Vuetify, { }); // Register kolibri-design-system plugin Vue.use(KThemePlugin); - -// Register analytics plugin with plain array -Vue.use(AnalyticsPlugin, { dataLayer: [] }); +Vue.use(AnalyticsPlugin); // Register global components Vue.component('ActionLink', ActionLink);