From 30020eddb9e591a6d811f21523f617d7b0ce3e17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:08:28 +0000 Subject: [PATCH 1/6] Initial plan From 2baccc5eaefec4d906282653b8a66fbde43a2fa4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:15:13 +0000 Subject: [PATCH 2/6] fix(matomo): add global CTA click tracking Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/38bb4576-6606-427f-a9f7-566526a11135 Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com> --- assets/source/js/matomoTracking.js | 142 ++++++++++++++++++++++++ assets/source/js/matomoTracking.test.js | 116 +++++++++++++++++++ assets/source/js/municipio.js | 4 +- 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 assets/source/js/matomoTracking.js create mode 100644 assets/source/js/matomoTracking.test.js diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js new file mode 100644 index 0000000000..d21d0b113f --- /dev/null +++ b/assets/source/js/matomoTracking.js @@ -0,0 +1,142 @@ +const matomoTrackingSelector = [ + "button", + "a.btn", + "a.c-button", + 'a[role="button"]', +].join(", "); + +const defaultTrackingCategory = "UI Interaction"; +const defaultTrackingAction = "Click"; + +/** + * Normalize whitespace in tracking labels. + * + * @param {string | null | undefined} value The label to normalize. + * @returns {string} The normalized label. + */ +export function normalizeTrackingLabel(value) { + return (value || "").replace(/\s+/g, " ").trim(); +} + +/** + * Derive a Matomo tracking name for a CTA element. + * + * @param {Element} element The element to derive a name from. + * @returns {string} The derived tracking name. + */ +export function getMatomoTrackingName(element) { + return normalizeTrackingLabel( + element.getAttribute("data-matomo-name") || + element.getAttribute("aria-label") || + element.getAttribute("title") || + element.innerText || + element.textContent, + ); +} + +/** + * Apply default Matomo data attributes unless they are explicitly set. + * + * @param {HTMLElement} element The element to normalize. + * @returns {void} + */ +export function applyMatomoTrackingAttributes(element) { + const trackingName = getMatomoTrackingName(element); + + if (!element.dataset.matomoCategory) { + element.dataset.matomoCategory = defaultTrackingCategory; + } + + if (!element.dataset.matomoAction) { + element.dataset.matomoAction = defaultTrackingAction; + } + + if (!element.dataset.matomoName && trackingName) { + element.dataset.matomoName = trackingName; + } +} + +/** + * Find a trackable CTA element from an event target. + * + * @param {EventTarget | null} target The event target. + * @returns {HTMLElement | null} The closest trackable element. + */ +export function getMatomoTrackableElement(target) { + if (!(target instanceof Element)) { + return null; + } + + const element = target.closest(matomoTrackingSelector); + + return element instanceof HTMLElement ? element : null; +} + +/** + * Normalize trackable elements in the current DOM. + * + * @returns {void} + */ +export function normalizeMatomoTrackableElements() { + document + .querySelectorAll(matomoTrackingSelector) + .forEach((element) => + applyMatomoTrackingAttributes(element), + ); +} + +/** + * Handle delegated click tracking for Matomo. + * + * @param {MouseEvent} event The click event. + * @returns {void} + */ +export function handleMatomoTrackingClick(event) { + const trackableElement = getMatomoTrackableElement(event.target); + + if (!trackableElement) { + return; + } + + applyMatomoTrackingAttributes(trackableElement); + + const trackingQueue = window._paq; + + if (!trackingQueue || typeof trackingQueue.push !== "function") { + return; + } + + const trackingName = trackableElement.dataset.matomoName || ""; + + if (!trackingName) { + return; + } + + trackingQueue.push([ + "trackEvent", + trackableElement.dataset.matomoCategory || defaultTrackingCategory, + trackableElement.dataset.matomoAction || defaultTrackingAction, + trackingName, + ]); +} + +/** + * Initialize global Matomo CTA tracking. + * + * @returns {void} + */ +export function initializeMatomoTracking() { + document.addEventListener("click", handleMatomoTrackingClick); + + if (document.readyState === "loading") { + document.addEventListener( + "DOMContentLoaded", + normalizeMatomoTrackableElements, + { once: true }, + ); + + return; + } + + normalizeMatomoTrackableElements(); +} diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js new file mode 100644 index 0000000000..98f3de8479 --- /dev/null +++ b/assets/source/js/matomoTracking.test.js @@ -0,0 +1,116 @@ +/** + * @jest-environment jsdom + */ + +import { + getMatomoTrackableElement, + handleMatomoTrackingClick, + initializeMatomoTracking, + normalizeMatomoTrackableElements, +} from "./matomoTracking"; + +describe("matomoTracking", () => { + beforeEach(() => { + document.body.innerHTML = ""; + window._paq = []; + }); + + afterEach(() => { + delete window._paq; + }); + + it("normalizes trackable elements with derived aria-label names", () => { + document.body.innerHTML = ` + + `; + + normalizeMatomoTrackableElements(); + + const button = document.querySelector("button"); + + expect(button.dataset.matomoCategory).toBe("UI Interaction"); + expect(button.dataset.matomoAction).toBe("Click"); + expect(button.dataset.matomoName).toBe("Open menu"); + }); + + it("preserves explicitly provided matomo attributes", () => { + document.body.innerHTML = ` + + Read more + + `; + + normalizeMatomoTrackableElements(); + + const link = document.querySelector("a"); + + expect(link.dataset.matomoCategory).toBe("Custom category"); + expect(link.dataset.matomoAction).toBe("Open"); + expect(link.dataset.matomoName).toBe("Custom name"); + }); + + it("finds the closest trackable element for nested CTA content", () => { + document.body.innerHTML = ` + + Read more + + `; + + const target = document.querySelector(".c-button__label-text"); + const trackableElement = getMatomoTrackableElement(target); + + expect(trackableElement).toBe(document.querySelector("a")); + }); + + it("tracks clicks for delegated button-like links", () => { + document.body.innerHTML = ` + + Read more + + `; + + initializeMatomoTracking(); + + const target = document.querySelector(".c-button__label-text"); + target.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(window._paq).toEqual([ + ["trackEvent", "UI Interaction", "Click", "Read more"], + ]); + expect(document.querySelector("a").dataset.matomoName).toBe("Read more"); + }); + + it("tracks dynamically injected buttons through delegated listeners", () => { + initializeMatomoTracking(); + + const button = document.createElement("button"); + button.textContent = "Dynamic CTA"; + document.body.appendChild(button); + + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(window._paq).toEqual([ + ["trackEvent", "UI Interaction", "Click", "Dynamic CTA"], + ]); + expect(button.dataset.matomoName).toBe("Dynamic CTA"); + }); + + it("does not push events when the tracking queue is unavailable", () => { + document.body.innerHTML = ''; + delete window._paq; + + expect(() => + handleMatomoTrackingClick({ + target: document.querySelector("button"), + }), + ).not.toThrow(); + }); +}); diff --git a/assets/source/js/municipio.js b/assets/source/js/municipio.js index 0fdc96c177..88f8b0c216 100644 --- a/assets/source/js/municipio.js +++ b/assets/source/js/municipio.js @@ -6,6 +6,7 @@ import { initializeCollapsibleSearch } from './collapsibleSearch'; import { initializeSessionManager } from './sessionManager'; import { initializeHashHighlightManager } from './hashHighlightManager'; import { initializeHashUpdateManager } from './hashUpdateManager'; +import { initializeMatomoTracking } from './matomoTracking'; import { initPostsListAsync } from './postsList'; import { initializeWpApiSettingsNonceRefresh } from './restApi/wpApiSettings'; @@ -20,4 +21,5 @@ initializeComments(); initializeCollapsibleSearch(); initializeHashHighlightManager(); initializeHashUpdateManager(8 * 10); -initPostsListAsync(); \ No newline at end of file +initializeMatomoTracking(); +initPostsListAsync(); From e1dba18b247a4e6d998a8c222469c0b04befccd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:16:40 +0000 Subject: [PATCH 3/6] test(matomo): add tracking teardown coverage Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/38bb4576-6606-427f-a9f7-566526a11135 Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com> --- assets/source/js/matomoTracking.js | 12 ++++++++---- assets/source/js/matomoTracking.test.js | 13 +++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js index d21d0b113f..c79b45acb2 100644 --- a/assets/source/js/matomoTracking.js +++ b/assets/source/js/matomoTracking.js @@ -7,6 +7,7 @@ const matomoTrackingSelector = [ const defaultTrackingCategory = "UI Interaction"; const defaultTrackingAction = "Click"; +const handleMatomoTrackingDomReady = () => normalizeMatomoTrackableElements(); /** * Normalize whitespace in tracking labels. @@ -131,12 +132,15 @@ export function initializeMatomoTracking() { if (document.readyState === "loading") { document.addEventListener( "DOMContentLoaded", - normalizeMatomoTrackableElements, + handleMatomoTrackingDomReady, { once: true }, ); - - return; + } else { + normalizeMatomoTrackableElements(); } - normalizeMatomoTrackableElements(); + return () => { + document.removeEventListener("click", handleMatomoTrackingClick); + document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady); + }; } diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js index 98f3de8479..72707d2ce4 100644 --- a/assets/source/js/matomoTracking.test.js +++ b/assets/source/js/matomoTracking.test.js @@ -103,6 +103,19 @@ describe("matomoTracking", () => { expect(button.dataset.matomoName).toBe("Dynamic CTA"); }); + it("returns a cleanup function that removes delegated tracking", () => { + document.body.innerHTML = ''; + + const cleanup = initializeMatomoTracking(); + cleanup(); + + document + .querySelector("button") + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(window._paq).toEqual([]); + }); + it("does not push events when the tracking queue is unavailable", () => { document.body.innerHTML = ''; delete window._paq; From 6795ef93ab2cb1cee811b590770814a48cebe689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:17:57 +0000 Subject: [PATCH 4/6] fix(matomo): tighten cleanup listener handling Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/38bb4576-6606-427f-a9f7-566526a11135 Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com> --- assets/source/js/matomoTracking.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js index c79b45acb2..88bb12dbc8 100644 --- a/assets/source/js/matomoTracking.js +++ b/assets/source/js/matomoTracking.js @@ -124,12 +124,15 @@ export function handleMatomoTrackingClick(event) { /** * Initialize global Matomo CTA tracking. * - * @returns {void} + * @returns {() => void} Cleanup function for removing tracking listeners. */ export function initializeMatomoTracking() { + let hasDomReadyListener = false; + document.addEventListener("click", handleMatomoTrackingClick); if (document.readyState === "loading") { + hasDomReadyListener = true; document.addEventListener( "DOMContentLoaded", handleMatomoTrackingDomReady, @@ -141,6 +144,9 @@ export function initializeMatomoTracking() { return () => { document.removeEventListener("click", handleMatomoTrackingClick); - document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady); + + if (hasDomReadyListener) { + document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady); + } }; } From 65e53928e7cbcb01e1192ba301172295c5a7ca63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 14:45:11 +0000 Subject: [PATCH 5/6] fix(matomo): move component targeting to backend filters Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/fe32e8a8-e44f-4b7c-9744-c6e47772ea4a Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com> --- assets/source/js/matomoTracking.js | 4 +- assets/source/js/matomoTracking.test.js | 29 +++++++++++++-- library/Controller/BaseController.php | 49 +++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/assets/source/js/matomoTracking.js b/assets/source/js/matomoTracking.js index 88bb12dbc8..ebbafa19bc 100644 --- a/assets/source/js/matomoTracking.js +++ b/assets/source/js/matomoTracking.js @@ -1,8 +1,6 @@ const matomoTrackingSelector = [ "button", - "a.btn", - "a.c-button", - 'a[role="button"]', + "[data-matomo-category]", ].join(", "); const defaultTrackingCategory = "UI Interaction"; diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js index 72707d2ce4..67cc8af1b9 100644 --- a/assets/source/js/matomoTracking.test.js +++ b/assets/source/js/matomoTracking.test.js @@ -38,7 +38,6 @@ describe("matomoTracking", () => { it("preserves explicitly provided matomo attributes", () => { document.body.innerHTML = ` { it("finds the closest trackable element for nested CTA content", () => { document.body.innerHTML = ` - + Read more `; @@ -72,7 +75,11 @@ describe("matomoTracking", () => { it("tracks clicks for delegated button-like links", () => { document.body.innerHTML = ` - + Read more `; @@ -88,6 +95,22 @@ describe("matomoTracking", () => { expect(document.querySelector("a").dataset.matomoName).toBe("Read more"); }); + it("does not track links without backend-provided matomo markers", () => { + document.body.innerHTML = ` + + Read more + + `; + + initializeMatomoTracking(); + + document + .querySelector(".c-button__label-text") + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(window._paq).toEqual([]); + }); + it("tracks dynamically injected buttons through delegated listeners", () => { initializeMatomoTracking(); diff --git a/library/Controller/BaseController.php b/library/Controller/BaseController.php index 6211e09aea..d66d24b051 100644 --- a/library/Controller/BaseController.php +++ b/library/Controller/BaseController.php @@ -448,6 +448,13 @@ function ($attributes) { ); } + $this->wpService->addFilter( + 'ComponentLibrary/Component/Attribute', + [$this, 'filterMatomoTrackingAttributes'], + 20, + 1, + ); + //Wordpress hooks $this->data['hook'] = (object) array( 'innerLoopStart' => $this->hook('inner_loop_start'), @@ -511,6 +518,48 @@ private function getCurrentUrl(array $queryParam = []): string return urldecode($permalink ?? ''); } + /** + * Add default Matomo tracking attributes to rendered component links and buttons. + * + * @param array $attributes The component attributes. + * + * @return array The modified component attributes. + */ + public function filterMatomoTrackingAttributes(array $attributes): array + { + if (!$this->shouldAddMatomoTrackingAttributes($attributes)) { + return $attributes; + } + + if (empty($attributes['data-matomo-category'])) { + $attributes['data-matomo-category'] = 'UI Interaction'; + } + + if (empty($attributes['data-matomo-action'])) { + $attributes['data-matomo-action'] = 'Click'; + } + + return $attributes; + } + + /** + * Determine whether a component should receive default Matomo tracking attributes. + * + * @param array $attributes The component attributes. + * + * @return bool True when Matomo tracking attributes should be added. + */ + private function shouldAddMatomoTrackingAttributes(array $attributes): bool + { + $buttonTypes = ['button', 'submit', 'reset']; + + return !empty($attributes['href']) + || in_array($attributes['type'] ?? null, $buttonTypes, true) + || ($attributes['role'] ?? null) === 'button' + || !empty($attributes['data-open']) + || !empty($attributes['aria-controls']); + } + /** * Get the emblem to use * From c682509d98f08216456ddf0638fb60ea404d2ba6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 14:47:08 +0000 Subject: [PATCH 6/6] test(matomo): clarify backend targeting assertion Agent-Logs-Url: https://github.com/helsingborg-stad/Municipio/sessions/fe32e8a8-e44f-4b7c-9744-c6e47772ea4a Co-authored-by: sebastianthulin <797129+sebastianthulin@users.noreply.github.com> --- assets/source/js/matomoTracking.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/source/js/matomoTracking.test.js b/assets/source/js/matomoTracking.test.js index 67cc8af1b9..decc872d4d 100644 --- a/assets/source/js/matomoTracking.test.js +++ b/assets/source/js/matomoTracking.test.js @@ -95,7 +95,7 @@ describe("matomoTracking", () => { expect(document.querySelector("a").dataset.matomoName).toBe("Read more"); }); - it("does not track links without backend-provided matomo markers", () => { + it("does not track links without matomo tracking attributes", () => { document.body.innerHTML = ` Read more