diff --git a/src/nationalarchives/analytics.mjs b/src/nationalarchives/analytics.mjs index 1e1d498c..417b2d2c 100644 --- a/src/nationalarchives/analytics.mjs +++ b/src/nationalarchives/analytics.mjs @@ -14,18 +14,28 @@ const componentAnalytics = [ class EventTracker { /** @protected */ - cookies = new Cookies(); + cookies = new (window.TNAFrontend?.Cookies || Cookies)(); /** @protected */ events = []; constructor() { - componentAnalytics.forEach((component) => { - this.addListener(component.scope, component.data, component.events); + componentAnalytics.forEach((componentConfig) => { + this.addListener( + componentConfig.scope, + componentConfig.areaName, + componentConfig.events, + ); }); } - addListener(scope, data, events) { + /** + * Add an event listener. + * @param {String|HTMLElement} scope - The element to which the listener is scoped. + * @param {String} areaName - The name of the component to pass on to the tracker. + * @param {{eventName: String, targetElement: String|undefined, onEvent: String, data: {value: Function|String|undefined, state: Function|String|undefined, [String]: any}}[]} events - The configuration of events to track along with their optional value and state which can be computed. + */ + addListener(scope, areaName, events) { let scopeArray; if (typeof scope === "string") { scopeArray = Array.from(document.querySelectorAll(scope)); @@ -40,29 +50,41 @@ class EventTracker { if (!componentTracking.onEvent) { return; } - if (componentTracking.targetElement) { Array.from( $scope.querySelectorAll(componentTracking.targetElement), ).forEach(($el) => - this.attachListener($el, componentTracking.onEvent, $scope, { - ...data, - ...componentTracking.data, - }), + this.attachListener( + $el, + componentTracking.onEvent, + $scope, + this.generateEventName(areaName, componentTracking), + componentTracking.data, + ), ); } else { - this.attachListener($scope, componentTracking.onEvent, $scope, { - ...data, - ...componentTracking.data, - }); + this.attachListener( + $scope, + componentTracking.onEvent, + $scope, + this.generateEventName(areaName, componentTracking), + componentTracking.data, + ); } }); }); } - attachListener($el, eventTrigger, $scope, eventDataInit) { + generateEventName(areaName, componentTracking) { + return `${areaName}.${ + componentTracking.eventName || componentTracking.onEvent + }`; + } + + /** @protected */ + attachListener($el, eventTrigger, $scope, eventName, eventDataInit) { $el.addEventListener(eventTrigger, (event) => - this.recordEvent({ + this.recordEvent(eventName, { ...eventDataInit, value: typeof eventDataInit.value === "function" @@ -72,35 +94,89 @@ class EventTracker { typeof eventDataInit.state === "function" ? eventDataInit.state.call(this, $el, $scope, event) : eventDataInit.state || null, + timestamp: new Date().toISOString(), + uri: window.location.pathname, }), ); } - recordEvent(data) { - const expandedData = { - ...data, - timestamp: new Date().toISOString(), - uri: window.location.pathname, - }; - console.log(expandedData); - this.events.push(expandedData); + /** @protected */ + recordEvent(eventName, data) { + this.events.push({ event: eventName, data }); } } -class GoogleAnalytics4 extends EventTracker { +/** + * Class to handle Google Analytics 4 reporting. + * @class GA4 + * @extends EventTracker + * @constructor + * @public + */ +class GA4 extends EventTracker { + trackingCodeAdded = false; + trackingEnabled = false; + gTagId; + constructor(id) { super(); - console.log(`Tracking ID: ${id}`); + this.gTagId = id; + window.dataLayer = window.dataLayer || []; + if (this.cookies.isPolicyAccepted("usage")) { + this.enableTracking(); + } + this.cookies.on("changePolicy", (policies) => { + if (Object.hasOwn(policies, "usage")) { + if (policies["usage"]) { + this.enableTracking(); + } else { + this.disableTracking(); + } + } + }); + } + + /** @protected */ + recordEvent(eventName, data) { + const ga4Data = { event: eventName, data }; + window.dataLayer.push(ga4Data); + console.log(window.dataLayer); } - _addTrackingCode() { - const script = document.createElement("script"); - script.type = "text/javascript"; - script.src = ""; - document.head.appendChild(script); + /** @protected */ + gtag() { + window.dataLayer.push(arguments); + console.log(window.dataLayer); + } + + /** @protected */ + enableTracking() { + if (!this.trackingEnabled) { + window["ga-disable-GA_MEASUREMENT_ID"] = false; + this.trackingEnabled = true; + if (!this.trackingCodeAdded) { + const script = document.createElement("script"); + script.setAttribute("async", true); + script.setAttribute( + "src", + `https://www.googletagmanager.com/gtm.js?id=${this.gTagId}&l=dataLayer`, + ); + // document.head.appendChild(script); + this.gtag("js", new Date()); + this.trackingCodeAdded = true; + } + this.gtag("set", { allow_ad_personalization_signals: false }); + } } - _removeTrackingCode() {} + /** @protected */ + disableTracking() { + if (this.trackingEnabled) { + window["ga-disable-GA_MEASUREMENT_ID"] = true; + this.gtag("set", { allow_ad_personalization_signals: true }); + this.trackingEnabled = false; + } + } } /* @@ -108,23 +184,24 @@ class GoogleAnalytics4 extends EventTracker { * TEMP TESTING * ========================================== */ -const analytics = new GoogleAnalytics4("test"); -analytics.addListener(".etna-article__sidebar", { name: "Sidebar" }, [ +const analytics = new GA4("test"); +analytics.addListener(".etna-article__sidebar", "sidebar", [ { + eventName: "scection_jump", targetElement: ".etna-article__sidebar-item", onEvent: "click", data: { - eventName: "scection_jump", value: valueGetters.text, }, }, ]); -analytics.addListener(".etna-article", { name: "Article" }, [ +analytics.addListener(".etna-article", "article", [ { + eventName: "scection_toggle", targetElement: ".etna-article__section-button", onEvent: "click", data: { - eventName: "scection_toggle", + // eslint-disable-next-line no-unused-vars state: ($el, $scope, event) => { const expanded = $el.getAttribute("aria-expanded"); if (expanded === null) { @@ -136,37 +213,28 @@ analytics.addListener(".etna-article", { name: "Article" }, [ }, }, ]); -analytics.addListener(document, { name: "Document" }, [ - // { - // onEvent: "scroll", - // data: { - // eventName: "page_scroll", - // value: ($el, $scope, event) => $scope.querySelector("html").scrollTop, - // }, - // }, -]); -analytics.addListener(document.documentElement, { name: "HTML" }, [ +analytics.addListener(document.documentElement, "doc", [ { + eventName: "double_click", onEvent: "dblclick", data: { - eventName: "double_click", state: ($el, $scope, event) => getXPathTo(event.target), value: ($el, $scope, event) => event.target.innerHTML, }, }, ]); -analytics.addListener( - document.getElementById("tna-form__search"), - { name: "Search input" }, - [ - { - onEvent: "blur", - data: { - eventName: "search_term_blur", - value: valueGetters.value, - }, - }, - ], -); +// analytics.addListener( +// document.getElementById("tna-form__search"), +// "search", +// [ +// { +// eventName: "search_term_blur", +// onEvent: "blur", +// data: { +// value: valueGetters.value, +// }, +// }, +// ], +// ); -export { GoogleAnalytics4 }; +export { EventTracker, GA4 }; diff --git a/src/nationalarchives/components/breadcrumbs/analytics.js b/src/nationalarchives/components/breadcrumbs/analytics.js index da7ae284..a0ea1b76 100644 --- a/src/nationalarchives/components/breadcrumbs/analytics.js +++ b/src/nationalarchives/components/breadcrumbs/analytics.js @@ -3,21 +3,14 @@ import { valueGetters } from "../../lib/analytics-helpers.mjs"; export default [ { scope: ".tna-breadcrumbs", - data: { - name: "Breadcrumbs", - }, + areaName: "breadcrumbs", events: [ { - targetElement: - ".tna-breadcrumbs__item:not(.tna-breadcrumbs__item--expandable) .tna-breadcrumbs__link", - onEvent: "click", - data: { eventName: "link", value: valueGetters.text }, - }, - { + eventName: "click", targetElement: ".tna-breadcrumbs__item--expandable button.tna-breadcrumbs__link", onEvent: "click", - data: { eventName: "click", state: "expand", value: valueGetters.html }, + data: { state: "expand", value: valueGetters.html }, }, ], }, diff --git a/src/nationalarchives/components/header/analytics.js b/src/nationalarchives/components/header/analytics.js index e20655ec..223b2a69 100644 --- a/src/nationalarchives/components/header/analytics.js +++ b/src/nationalarchives/components/header/analytics.js @@ -1,17 +1,14 @@ -import { valueGetters } from "../../lib/analytics-helpers.mjs"; - export default [ { scope: ".tna-header", - data: { - name: "Header", - }, + areaName: "header", events: [ { + eventName: "toggle", targetElement: ".tna-header__navigation-toggle-button", onEvent: "click", data: { - eventName: "toggle", + // eslint-disable-next-line no-unused-vars state: ($el, $scope, event) => { const expanded = $el.getAttribute("aria-expanded"); if (expanded === null) { diff --git a/src/nationalarchives/components/hero/analytics.js b/src/nationalarchives/components/hero/analytics.js index 7ea24472..8627e8ef 100644 --- a/src/nationalarchives/components/hero/analytics.js +++ b/src/nationalarchives/components/hero/analytics.js @@ -1,17 +1,14 @@ -import { valueGetters } from "../../lib/analytics-helpers.mjs"; - export default [ { scope: ".tna-hero", - data: { - name: "Hero", - }, + areaName: "hero", events: [ { + eventName: "toggle", targetElement: ".tna-hero__details-summary", onEvent: "click", data: { - eventName: "click", + // eslint-disable-next-line no-unused-vars state: ($el, $scope, event) => { const wasExpanded = $scope @@ -19,6 +16,7 @@ export default [ ?.hasAttribute("open") ?? false; return wasExpanded ? "closed" : "expanded"; }, + // eslint-disable-next-line no-unused-vars value: ($el, $scope, event) => $scope.querySelector("img[alt]")?.getAttribute("alt"), }, diff --git a/src/nationalarchives/components/picture/analytics.js b/src/nationalarchives/components/picture/analytics.js index 7b1a1681..e54f6461 100644 --- a/src/nationalarchives/components/picture/analytics.js +++ b/src/nationalarchives/components/picture/analytics.js @@ -1,17 +1,14 @@ -import { valueGetters } from "../../lib/analytics-helpers.mjs"; - export default [ { scope: ".tna-picture", - data: { - name: "Picture", - }, + areaName: "picture", events: [ { + eventName: "toggle", targetElement: ".tna-picture__toggle-transcript", onEvent: "click", data: { - eventName: "toggle", + // eslint-disable-next-line no-unused-vars state: ($el, $scope, event) => { const expanded = $el.getAttribute("aria-expanded"); if (expanded === null) { @@ -19,6 +16,7 @@ export default [ } return expanded.toString() === "true" ? "expanded" : "closed"; }, + // eslint-disable-next-line no-unused-vars value: ($el, $scope, event) => $scope.querySelector(".tna-picture__image").getAttribute("alt"), }, diff --git a/src/nationalarchives/lib/analytics-helpers.mjs b/src/nationalarchives/lib/analytics-helpers.mjs index 28b42e1f..7d04cb93 100644 --- a/src/nationalarchives/lib/analytics-helpers.mjs +++ b/src/nationalarchives/lib/analytics-helpers.mjs @@ -22,8 +22,11 @@ const getXPathTo = (element) => { } }; const valueGetters = { + // eslint-disable-next-line no-unused-vars text: ($el, $scope, event) => $el.innerText, + // eslint-disable-next-line no-unused-vars html: ($el, $scope, event) => $el.innerHTML, + // eslint-disable-next-line no-unused-vars value: ($el, $scope, event) => $el.value, }; diff --git a/src/nationalarchives/lib/cookies.mjs b/src/nationalarchives/lib/cookies.mjs index 05757afc..28acf533 100644 --- a/src/nationalarchives/lib/cookies.mjs +++ b/src/nationalarchives/lib/cookies.mjs @@ -1,5 +1,6 @@ export class CookieEventHandler { events = {}; + oneTimeEvents = {}; constructor() { if (CookieEventHandler._instance) { @@ -10,23 +11,37 @@ export class CookieEventHandler { /** * Add an event listener. - * @param {string} event - The event to add a listener for. - * @param {function} callback - The callback function to call when the event is triggered. + * @param {String} event - The event to add a listener for. + * @param {Function} callback - The callback function to call when the event is triggered. */ on(event, callback) { - if (!Object.prototype.hasOwnProperty.call(this.events, event)) { + if (!Object.hasOwn(this.events, event)) { this.events[event] = []; } this.events[event] = [...this.events[event], callback]; } + once(event, callback) { + if (!Object.hasOwn(this.oneTimeEvents, event)) { + this.oneTimeEvents[event] = []; + } + this.oneTimeEvents[event] = [...this.oneTimeEvents[event], callback]; + } + /** @protected */ trigger(event, data = {}) { - if (Object.prototype.hasOwnProperty.call(this.events, event)) { + if (Object.hasOwn(this.events, event)) { this.events[event].forEach((eventToTrigger) => eventToTrigger.call(this, data), ); } + if (Object.hasOwn(this.oneTimeEvents, event)) { + for (let i = this.oneTimeEvents[event].length - 1; i >= 0; i--) { + const eventToTrigger = this.oneTimeEvents[event][i]; + eventToTrigger.call(this, data); + this.oneTimeEvents[event].splice(i, 1); + } + } } } @@ -50,11 +65,11 @@ export default class Cookies { /** * Create a cookie handler. - * @param {string} [options.extraPolicies=[]] - The extra cookie policies to manage in addition to essential, settings and usage. - * @param {string} [options.domain=""] - The domain to register the cookie with. - * @param {string} [options.path=""] - The domain to register the cookie with. - * @param {string} [options.secure=true] - Only set cookie in HTTPS environments. - * @param {string} [options.policiesKey=cookies_policy] - The name of the cookie. + * @param {String} [options.extraPolicies=[]] - The extra cookie policies to manage in addition to essential, settings and usage. + * @param {String} [options.domain=""] - The domain to register the cookie with. + * @param {String} [options.path=""] - The domain to register the cookie with. + * @param {String} [options.secure=true] - Only set cookie in HTTPS environments. + * @param {String} [options.policiesKey=cookies_policy] - The name of the cookie. */ constructor(options = {}) { const { @@ -110,17 +125,17 @@ export default class Cookies { /** * Check to see whether a cookie exists or not. - * @param {string} key - The cookie name. - * @returns {boolean} + * @param {String} key - The cookie name. + * @returns {Boolean} */ exists(key) { - return Object.prototype.hasOwnProperty.call(this.all, key); + return Object.hasOwn(this.all, key); } /** * Check to see whether a cookie has a particular value. - * @param {string} key - The cookie name. - * @param {string|number|boolean} value - The value to check against. + * @param {String} key - The cookie name. + * @param {String|Number|Boolean} value - The value to check against. * @returns */ hasValue(key, value) { @@ -129,8 +144,8 @@ export default class Cookies { /** * Get a cookie. - * @param {string} key - The cookie name. - * @returns {string|number|boolean} + * @param {String} key - The cookie name. + * @returns {String|Number|Boolean} */ get(key) { return this.exists(key) ? decodeURIComponent(this.all[key]) : null; @@ -138,15 +153,15 @@ export default class Cookies { /** * Set a cookie. - * @param {string} key - The cookie name. - * @param {string|number|boolean} value - The cookie value. + * @param {String} key - The cookie name. + * @param {String|Number|Boolean} value - The cookie value. * @param {Object} options - * @param {number} [options.maxAge=31536000] - The maximum age of the cookie in seconds. - * @param {string} [options.path=/] - The path to register the cookie for. - * @param {string} [options.sameSite=Lax] - The sameSite attribute. - * @param {string} [options.domain=this.domain] - The domain to register the cookie with. - * @param {string} [options.path=this.path] - The path to register the cookie with. - * @param {string} [options.secure=this.secure] - Only set cookie in HTTPS environments. + * @param {Number} [options.maxAge=31536000] - The maximum age of the cookie in seconds. + * @param {String} [options.path=/] - The path to register the cookie for. + * @param {String} [options.sameSite=Lax] - The sameSite attribute. + * @param {String} [options.domain=this.domain] - The domain to register the cookie with. + * @param {String} [options.path=this.path] - The path to register the cookie with. + * @param {String} [options.secure=this.secure] - Only set cookie in HTTPS environments. */ set(key, value, options = {}) { const { @@ -179,8 +194,8 @@ export default class Cookies { /** * Delete a cookie. - * @param {string} key - The cookie name. - * @param {string} [path=/] - The path to the cookie is registered on. + * @param {String} key - The cookie name. + * @param {String} [path=/] - The path to the cookie is registered on. */ delete(key, path = "/", domain = null) { const options = { maxAge: -1, path, domain: domain || undefined }; @@ -200,7 +215,7 @@ export default class Cookies { /** * Accept a policy. - * @param {string} policy - The name of the policy. + * @param {String} policy - The name of the policy. */ acceptPolicy(policy) { this.setPolicy(policy, true); @@ -210,7 +225,7 @@ export default class Cookies { /** * Reject a policy. - * @param {string} policy - The name of the policy. + * @param {String} policy - The name of the policy. */ rejectPolicy(policy) { this.setPolicy(policy, false); @@ -220,8 +235,8 @@ export default class Cookies { /** * Set a policy. - * @param {string} policy - The name of the policy. - * @param {boolean} accepted - Whether the policy is accepted or not. + * @param {String} policy - The name of the policy. + * @param {Boolean} accepted - Whether the policy is accepted or not. */ setPolicy(policy, accepted) { if (policy === "essential") { @@ -272,21 +287,30 @@ export default class Cookies { /** * Get the acceptance status of a policy. - * @param {string} policy - The name of the policy. - * @returns {boolean} + * @param {String} policy - The name of the policy. + * @returns {Boolean} */ isPolicyAccepted(policy) { - return Object.prototype.hasOwnProperty.call(this.policies, policy) + return Object.hasOwn(this.policies, policy) ? this.policies[policy] === true : null; } /** * Add an event listener. - * @param {string} event - The event to add a listener for. - * @param {function} callback - The callback function to call when the event is triggered. + * @param {String} event - The event to add a listener for. + * @param {Function} callback - The callback function to call when the event is triggered. */ on(event, callback) { this.events.on(event, callback); } + + /** + * Add a one-time event listener. + * @param {String} event - The event to add a listener for. + * @param {Function} callback - The callback function to call when the event is triggered. + */ + once(event, callback) { + this.events.once(event, callback); + } } diff --git a/src/nationalarchives/tests/cookies.test.js b/src/nationalarchives/tests/cookies.test.js index 0c41cf07..951f7a09 100644 --- a/src/nationalarchives/tests/cookies.test.js +++ b/src/nationalarchives/tests/cookies.test.js @@ -347,6 +347,8 @@ describe("No existing cookies", () => { const testKey = "foo"; const testValue = "bar"; + expect(mockCallback.mock.calls).toHaveLength(0); + cookies1.set(testKey, testValue); expect(mockCallback.mock.calls).toHaveLength(1); @@ -357,6 +359,25 @@ describe("No existing cookies", () => { expect(mockCallback.mock.calls).toHaveLength(3); }); + test("One-time events", async () => { + const mockCallback = jest.fn(); + + const cookies = new Cookies(); + + cookies.once("setCookie", mockCallback); + + const testKey = "foo"; + const testValue = "bar"; + + expect(mockCallback.mock.calls).toHaveLength(0); + + cookies.set(testKey, testValue); + expect(mockCallback.mock.calls).toHaveLength(1); + + cookies.set(testKey, testValue); + expect(mockCallback.mock.calls).toHaveLength(1); + }); + test("Custom policies", async () => { const cookies = new Cookies({ extraPolicies: ["custom"] });