Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions assets/source/js/matomoTracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
const matomoTrackingSelector = [
"button",
"[data-matomo-category]",
].join(", ");

const defaultTrackingCategory = "UI Interaction";
const defaultTrackingAction = "Click";
const handleMatomoTrackingDomReady = () => normalizeMatomoTrackableElements();

/**
* 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();

Check warning on line 17 in assets/source/js/matomoTracking.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SA3-gBmgoU5Fb4vf&open=AZ34SA3-gBmgoU5Fb4vf&pullRequest=2013
}

/**
* 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") ||

Check failure on line 28 in assets/source/js/matomoTracking.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.dataset` over `getAttribute(…)`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SA3-gBmgoU5Fb4vg&open=AZ34SA3-gBmgoU5Fb4vg&pullRequest=2013
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;

Check warning on line 102 in assets/source/js/matomoTracking.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SA3-gBmgoU5Fb4vh&open=AZ34SA3-gBmgoU5Fb4vh&pullRequest=2013

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} 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,
{ once: true },
);
} else {
normalizeMatomoTrackableElements();
}

return () => {
document.removeEventListener("click", handleMatomoTrackingClick);

if (hasDomReadyListener) {
document.removeEventListener("DOMContentLoaded", handleMatomoTrackingDomReady);
}
};
}
152 changes: 152 additions & 0 deletions assets/source/js/matomoTracking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* @jest-environment jsdom
*/

import {
getMatomoTrackableElement,
handleMatomoTrackingClick,
initializeMatomoTracking,
normalizeMatomoTrackableElements,
} from "./matomoTracking";

describe("matomoTracking", () => {
beforeEach(() => {
document.body.innerHTML = "";
window._paq = [];

Check warning on line 15 in assets/source/js/matomoTracking.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SAyTgBmgoU5Fb4va&open=AZ34SAyTgBmgoU5Fb4va&pullRequest=2013
});

afterEach(() => {
delete window._paq;

Check warning on line 19 in assets/source/js/matomoTracking.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SAyTgBmgoU5Fb4vb&open=AZ34SAyTgBmgoU5Fb4vb&pullRequest=2013
});

it("normalizes trackable elements with derived aria-label names", () => {
document.body.innerHTML = `
<button aria-label="Open menu">
<span>Ignored text</span>
</button>
`;

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 = `
<a
href="/example"
data-matomo-category="Custom category"
data-matomo-action="Open"
data-matomo-name="Custom name"
>
Read more
</a>
`;

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 = `
<a
href="/example"
data-matomo-category="UI Interaction"
data-matomo-action="Click"
>
<span class="c-button__label-text">Read more</span>
</a>
`;

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 = `
<a
href="/example"
data-matomo-category="UI Interaction"
data-matomo-action="Click"
>
<span class="c-button__label-text">Read more</span>
</a>
`;

initializeMatomoTracking();

const target = document.querySelector(".c-button__label-text");
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));

expect(window._paq).toEqual([

Check warning on line 92 in assets/source/js/matomoTracking.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SAyTgBmgoU5Fb4vc&open=AZ34SAyTgBmgoU5Fb4vc&pullRequest=2013
["trackEvent", "UI Interaction", "Click", "Read more"],
]);
expect(document.querySelector("a").dataset.matomoName).toBe("Read more");
});

it("does not track links without matomo tracking attributes", () => {
document.body.innerHTML = `
<a class="c-button" href="/example">
<span class="c-button__label-text">Read more</span>
</a>
`;

initializeMatomoTracking();

document
.querySelector(".c-button__label-text")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));

expect(window._paq).toEqual([]);

Check warning on line 111 in assets/source/js/matomoTracking.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34mnmcm4GnQoJwPLYL&open=AZ34mnmcm4GnQoJwPLYL&pullRequest=2013
});

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([

Check warning on line 123 in assets/source/js/matomoTracking.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SAyTgBmgoU5Fb4vd&open=AZ34SAyTgBmgoU5Fb4vd&pullRequest=2013
["trackEvent", "UI Interaction", "Click", "Dynamic CTA"],
]);
expect(button.dataset.matomoName).toBe("Dynamic CTA");
});

it("returns a cleanup function that removes delegated tracking", () => {
document.body.innerHTML = '<button>Track me</button>';

const cleanup = initializeMatomoTracking();
cleanup();

document
.querySelector("button")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));

expect(window._paq).toEqual([]);

Check warning on line 139 in assets/source/js/matomoTracking.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SWr4Pja-pdu2f2Kk&open=AZ34SWr4Pja-pdu2f2Kk&pullRequest=2013
});

it("does not push events when the tracking queue is unavailable", () => {
document.body.innerHTML = '<button>Track me</button>';
delete window._paq;

Check warning on line 144 in assets/source/js/matomoTracking.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=helsingborg-stad_Municipio&issues=AZ34SAyTgBmgoU5Fb4ve&open=AZ34SAyTgBmgoU5Fb4ve&pullRequest=2013

expect(() =>
handleMatomoTrackingClick({
target: document.querySelector("button"),
}),
).not.toThrow();
});
});
4 changes: 3 additions & 1 deletion assets/source/js/municipio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,4 +21,5 @@ initializeComments();
initializeCollapsibleSearch();
initializeHashHighlightManager();
initializeHashUpdateManager(8 * 10);
initPostsListAsync();
initializeMatomoTracking();
initPostsListAsync();
49 changes: 49 additions & 0 deletions library/Controller/BaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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<string, mixed> $attributes The component attributes.
*
* @return array<string, mixed> 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<string, mixed> $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
*
Expand Down