Skip to content
Snippets Groups Projects
Commit 51eaad76 authored by Bjarke Madsen's avatar Bjarke Madsen
Browse files

Add matomo tracking support

Uses code snippets partly from matomo-tracker-react since it's no longer maintained
parent 946fe2d7
Branches
Tags
1 merge request!128Add matomo tracking support
Showing
with 491 additions and 59 deletions
......@@ -6,23 +6,31 @@ import FilterSelectionProvider from "./helpers/FilterSelectionProvider";
import ChartContainerProvider from "./helpers/ChartContainerProvider";
import PreviewProvider from "./helpers/PreviewProvider";
import NrenProvider from "./helpers/NrenProvider";
import MatomoProvider from "./matomo/MatomoProvider";
import { createInstance } from "./matomo/MatomoTracker";
const matomoInstance = createInstance({
urlBase: 'https://uat-swd-webanalytics01.geant.org/',
siteId: 2,
});
function Providers({ children }): ReactElement {
return (
<SidebarProvider>
<UserProvider>
<FilterSelectionProvider>
<ChartContainerProvider>
<PreviewProvider>
<NrenProvider>
{children}
</NrenProvider>
</PreviewProvider>
</ChartContainerProvider>
</FilterSelectionProvider>
</UserProvider>
</SidebarProvider>
<MatomoProvider value={matomoInstance}>
<SidebarProvider>
<UserProvider>
<FilterSelectionProvider>
<ChartContainerProvider>
<PreviewProvider>
<NrenProvider>
{children}
</NrenProvider>
</PreviewProvider>
</ChartContainerProvider>
</FilterSelectionProvider>
</UserProvider>
</SidebarProvider>
</MatomoProvider>
);
}
......
......@@ -13,6 +13,7 @@ import NetworkSidebar from "./NetworkSidebar";
import ConnectedUsersSidebar from "./ConnectedUsersSidebar";
import ServicesSidebar from "./ServicesSidebar";
import DownloadContainer from "../components/DownloadContainer";
import useMatomo from "../matomo/UseMatomo";
ChartJS.defaults.font.size = 16;
......@@ -21,7 +22,7 @@ ChartJS.defaults.font.weight = 700;
interface inputProps {
title: string,
description?: string|ReactElement,
description?: string | ReactElement,
filter: ReactElement,
children: ReactElement,
category: Sections,
......@@ -33,6 +34,14 @@ function DataPage({ title, description, filter, children, category, data, filena
const preview = usePreview();
const locationWithoutPreview = window.location.origin + window.location.pathname;
const { trackPageView } = useMatomo()
React.useEffect(() => {
trackPageView({
documentTitle: title
})
}, [trackPageView])
return (
<>
{category === Sections.Organisation && <OrganizationSidebar />}
......@@ -53,7 +62,7 @@ function DataPage({ title, description, filter, children, category, data, filena
<p className="p-md-4">{description}</p>
</Row>
{data && filename &&
<Row align="right" style={{position: 'relative'}}>
<Row align="right" style={{ position: 'relative' }}>
<DownloadContainer data={data} filename={filename} />
</Row>}
<Row>
......
import React, { createContext } from 'react'
import MatomoTracker from './MatomoTracker'
export interface MatomoProviderProps {
children?: React.ReactNode
value: MatomoTracker
}
export const MatomoContext = createContext<MatomoTracker | null>(null)
const MatomoProvider: React.FC<MatomoProviderProps> = function ({
children,
value,
}) {
const Context = MatomoContext
return <Context.Provider value={value}>{children}</Context.Provider>
}
export default MatomoProvider
\ No newline at end of file
import { TRACK_TYPES } from './constants'
import {
CustomDimension,
TrackEventParams,
TrackLinkParams,
TrackPageViewParams,
TrackParams,
UserOptions,
} from './types'
class MatomoTracker {
mutationObserver?: MutationObserver
constructor(userOptions: UserOptions) {
if (!userOptions.urlBase) {
throw new Error('Matomo urlBase is required.')
}
if (!userOptions.siteId) {
throw new Error('Matomo siteId is required.')
}
this.initialize(userOptions)
}
private initialize({
urlBase,
siteId,
userId,
trackerUrl,
srcUrl,
disabled,
heartBeat,
requireConsent = false,
configurations = {},
}: UserOptions) {
const normalizedUrlBase =
urlBase[urlBase.length - 1] !== '/' ? `${urlBase}/` : urlBase
if (typeof window === 'undefined') {
return
}
// @ts-expect-error - _paq is defined in the Matomo script
window._paq = window._paq || []
// @ts-expect-error - _paq is defined in the Matomo script
if (window._paq.length !== 0) {
return
}
if (disabled) {
return
}
if (requireConsent) {
this.pushInstruction('requireConsent')
}
this.pushInstruction(
'setTrackerUrl',
trackerUrl ?? `${normalizedUrlBase}matomo.php`,
)
this.pushInstruction('setSiteId', siteId)
if (userId) {
this.pushInstruction('setUserId', userId)
}
Object.entries(configurations).forEach(([name, instructions]) => {
if (instructions instanceof Array) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.pushInstruction(name, ...instructions)
} else {
this.pushInstruction(name, instructions)
}
})
// accurately measure the time spent on the last pageview of a visit
if (!heartBeat || (heartBeat && heartBeat.active)) {
this.enableHeartBeatTimer((heartBeat && heartBeat.seconds) ?? 15)
}
const doc = document
const scriptElement = doc.createElement('script')
const scripts = doc.getElementsByTagName('script')[0]
scriptElement.type = 'text/javascript'
scriptElement.async = true
scriptElement.defer = true
scriptElement.src = srcUrl || `${normalizedUrlBase}matomo.js`
if (scripts && scripts.parentNode) {
scripts.parentNode.insertBefore(scriptElement, scripts)
}
}
enableHeartBeatTimer(seconds: number): void {
this.pushInstruction('enableHeartBeatTimer', seconds)
}
private trackEventsForElements(elements: HTMLElement[]) {
if (elements.length) {
elements.forEach((element) => {
element.addEventListener('click', () => {
const { matomoCategory, matomoAction, matomoName, matomoValue } =
element.dataset
if (matomoCategory && matomoAction) {
this.trackEvent({
category: matomoCategory,
action: matomoAction,
name: matomoName,
value: Number(matomoValue),
})
} else {
throw new Error(
`Error: data-matomo-category and data-matomo-action are required.`,
)
}
})
})
}
}
// Tracks events based on data attributes
trackEvents(): void {
const matchString = '[data-matomo-event="click"]'
let firstTime = false
if (!this.mutationObserver) {
firstTime = true
this.mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
// only track HTML elements
if (!(node instanceof HTMLElement)) return
// check the inserted element for being a code snippet
if (node.matches(matchString)) {
this.trackEventsForElements([node])
}
const elements = Array.from(
node.querySelectorAll<HTMLElement>(matchString),
)
this.trackEventsForElements(elements)
})
})
})
}
this.mutationObserver.observe(document, { childList: true, subtree: true })
// Now track all already existing elements
if (firstTime) {
const elements = Array.from(
document.querySelectorAll<HTMLElement>(matchString),
)
this.trackEventsForElements(elements)
}
}
stopObserving(): void {
if (this.mutationObserver) {
this.mutationObserver.disconnect()
}
}
// Tracks events
// https://matomo.org/docs/event-tracking/#tracking-events
trackEvent({
category,
action,
name,
value,
...otherParams
}: TrackEventParams): void {
if (category && action) {
this.track({
data: [TRACK_TYPES.TRACK_EVENT, category, action, name, value],
...otherParams,
})
} else {
throw new Error(`Error: category and action are required.`)
}
}
// Gives consent for tracking, this is required for Matomo to start tracking when requireConsent is set to true
// https://developer.matomo.org/guides/tracking-consent
giveConsent(): void {
this.pushInstruction('setConsentGiven')
}
// Tracks outgoing links to other sites and downloads
// https://developer.matomo.org/guides/tracking-javascript-guide#enabling-download-outlink-tracking
trackLink({ href, linkType = 'link' }: TrackLinkParams): void {
this.pushInstruction(TRACK_TYPES.TRACK_LINK, href, linkType)
}
// Tracks page views
// https://developer.matomo.org/guides/spa-tracking#tracking-a-new-page-view
trackPageView(params?: TrackPageViewParams): void {
this.track({ data: [TRACK_TYPES.TRACK_VIEW], ...params })
}
// Sends the tracked page/view/search to Matomo
track({
data = [],
documentTitle = window.document.title,
href,
customDimensions = false,
}: TrackParams): void {
if (data.length) {
if (
customDimensions &&
Array.isArray(customDimensions) &&
customDimensions.length
) {
customDimensions.map((customDimension: CustomDimension) =>
this.pushInstruction(
'setCustomDimension',
customDimension.id,
customDimension.value,
),
)
}
this.pushInstruction('setCustomUrl', href ?? window.location.href)
this.pushInstruction('setDocumentTitle', documentTitle)
this.pushInstruction(...(data as [string, ...any[]])) // eslint-disable-line @typescript-eslint/no-explicit-any
}
}
/**
* Pushes an instruction to Matomo for execution, this is equivalent to pushing entries into the `_paq` array.
*
* For example:
*
* ```ts
* pushInstruction('setDocumentTitle', document.title)
* ```
* Is the equivalent of:
*
* ```ts
* _paq.push(['setDocumentTitle', document.title]);
* ```
*
* @param name The name of the instruction to be executed.
* @param args The arguments to pass along with the instruction.
*/
pushInstruction(name: string, ...args: any[]): MatomoTracker { // eslint-disable-line @typescript-eslint/no-explicit-any
if (typeof window !== 'undefined') {
// @ts-expect-error - _paq is defined in the Matomo script
window._paq.push([name, ...args])
}
return this
}
}
export function createInstance(params: UserOptions): MatomoTracker {
// if on localhost or not production,
// disable Matomo tracking
if (process.env.NODE_ENV !== 'production' || window.location.hostname === 'localhost') {
params.disabled = true
}
return new MatomoTracker(params)
}
export default MatomoTracker
\ No newline at end of file
import { useCallback, useContext } from 'react'
import { MatomoContext } from './MatomoProvider'
import {
TrackEventParams,
TrackLinkParams,
TrackPageViewParams
} from './types'
function useMatomo() {
const instance = useContext(MatomoContext)
const trackPageView = useCallback(
(params?: TrackPageViewParams) => instance?.trackPageView(params),
[instance],
)
const trackEvent = useCallback(
(params: TrackEventParams) => instance?.trackEvent(params),
[instance],
)
const trackEvents = useCallback(() => instance?.trackEvents(), [instance])
const trackLink = useCallback(
(params: TrackLinkParams) => instance?.trackLink(params),
[instance],
)
const enableLinkTracking = useCallback(() => {
// no link tracking for now
}, [])
const pushInstruction = useCallback(
(name: string, ...args: any[]) => { // eslint-disable-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
instance?.pushInstruction(name, ...args)
},
[instance],
)
return {
trackEvent,
trackEvents,
trackPageView,
trackLink,
enableLinkTracking,
pushInstruction,
}
}
export default useMatomo
\ No newline at end of file
export const TRACK_TYPES = {
TRACK_EVENT: 'trackEvent',
TRACK_LINK: 'trackLink',
TRACK_VIEW: 'trackPageView',
}
\ No newline at end of file
export interface CustomDimension {
id: number
value: string
}
export interface UserOptions {
urlBase: string
siteId: number
userId?: string
trackerUrl?: string
srcUrl?: string
disabled?: boolean
heartBeat?: {
active: boolean
seconds?: number
}
linkTracking?: boolean
configurations?: {
[key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
},
requireConsent?: boolean
}
export interface TrackPageViewParams {
documentTitle?: string
href?: string | Location
customDimensions?: boolean | CustomDimension[]
}
export interface TrackParams extends TrackPageViewParams {
data: any[] // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface TrackEventParams extends TrackPageViewParams {
category: string
action: string
name?: string
value?: number
}
export interface TrackLinkParams {
href: string
linkType?: 'download' | 'link'
}
\ No newline at end of file
......@@ -6,10 +6,20 @@ import Banner from "../components/global/Banner";
import { Link } from "react-router-dom";
import { Sections } from "../helpers/constants";
import { usePreview } from "../helpers/usePreview";
import useMatomo from "../matomo/UseMatomo";
function CompendiumData(): ReactElement {
usePreview();
const { trackPageView } = useMatomo()
React.useEffect(() => {
trackPageView({
documentTitle: 'Compendium Data'
})
}, [trackPageView])
return (
<main className="grow">
<PageHeader type={'data'} />
......
import React, { ReactElement } from "react";
import React, { ReactElement, useEffect } from "react";
import { Link } from "react-router-dom";
import { Card, Container, Row, Col } from "react-bootstrap";
import SectionDataLogo from "../images/home_data_icon.svg";
import SectionReportsLogo from "../images/home_reports_icon.svg";
import useMatomo from "../matomo/UseMatomo";
function Landing(): ReactElement {
const { trackPageView } = useMatomo();
useEffect(() => {
trackPageView({ documentTitle: "GEANT Compendium Landing Page" });
}, [trackPageView]);
return (
<Container className="py-5 grey-container">
<Row>
......
......@@ -2,7 +2,6 @@ import React, { ReactElement } from "react";
import { Col, Container, Row } from "react-bootstrap";
import GeantLogo from "../images/geant_logo_f2020_new.svg";
function ExternalPageNavBar(): ReactElement {
return (
<div className={'external-page-nav-bar'}>
......
......@@ -5,9 +5,13 @@ import { userContext } from "../helpers/UserProvider";
import { fetchSurveys, fetchActiveSurveyYear } from "./api/survey";
import { Survey } from "./api/types";
import * as XLSX from "xlsx";
import useMatomo from "../matomo/UseMatomo";
function Landing(): ReactElement {
const { trackPageView } = useMatomo();
const { user } = useContext(userContext);
const navigate = useNavigate();
......@@ -16,7 +20,7 @@ function Landing(): ReactElement {
const activeNren = hasNren ? user.nrens[0] : '';
const isAdmin = loggedIn ? user.permissions.admin : false;
const isObserver = loggedIn ? user.role === 'observer' : false;
const [activeSurveyYear, setActiveSurveyYear] = useState<string | null>(null);
useEffect(() => {
......@@ -26,7 +30,9 @@ function Landing(): ReactElement {
};
fetchData();
}, []);
trackPageView({ documentTitle: "GEANT Survey Landing Page" });
}, [trackPageView]);
const moveToSurvey = () => {
try {
......@@ -65,40 +71,40 @@ function Landing(): ReactElement {
function convertToExcel(jsonData: { name: string, data: any, meta: any }[]): Blob {
const wb = XLSX.utils.book_new();
jsonData.forEach(sheet=>{
jsonData.forEach(sheet => {
const ws = XLSX.utils.json_to_sheet(sheet.data);
if(sheet.meta){
applyCustomFormat(ws,sheet.meta.columnName,sheet.meta.format);
if (sheet.meta) {
applyCustomFormat(ws, sheet.meta.columnName, sheet.meta.format);
}
XLSX.utils.book_append_sheet(wb, ws, sheet.name);
})
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'binary' });
const buffer = new ArrayBuffer(wbout.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < wbout.length; i++) {
// Convert each character of the binary workbook string to an 8-bit integer and store in the Uint8Array 'view' for blob creation.
view[i] = wbout.charCodeAt(i) & 0xFF;
}
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8' });
}
function fetchDataAndConvertToExcel() {
const apiEndpoint = '/api/data-download';
fetch(apiEndpoint)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
return response.json();
})
.then(data => {
// Call convertToExcel function with the retrieved data
const excelBlob = convertToExcel(data);
// Create a download link and trigger a click event to download the file
const downloadLink = document.createElement('a');
downloadLink.href = URL.createObjectURL(excelBlob);
......@@ -111,11 +117,11 @@ function Landing(): ReactElement {
console.error('Error fetching data:', error);
});
}
const SurveyTable = () => {
......@@ -177,7 +183,7 @@ function Landing(): ReactElement {
<li><span>Click <Link to="/survey/admin/users">here</Link> to access the user management page.</span></li>
<li><span>Click <a href="#" onClick={fetchDataAndConvertToExcel}>here</a> to do the full data download.</span></li>
</ul> : <ul>
{activeSurveyYear &&!isAdmin && !isObserver && hasNren && moveToSurvey()}
{activeSurveyYear && !isAdmin && !isObserver && hasNren && moveToSurvey()}
{loggedIn ? <li><span>You are logged in</span></li> : <li><span>You are not logged in</span></li>}
{loggedIn && !isObserver && !hasNren && <li><span>Your access to the survey has not yet been approved</span></li>}
{loggedIn && !isObserver && !hasNren && <li><span>Once you have been approved, you will immediately be directed to the relevant survey upon visiting this page</span></li>}
......
......@@ -9,6 +9,7 @@ import { VerificationStatus } from './Schema';
import Prompt from "./Prompt";
import "survey-core/modern.min.css";
import './survey.scss';
import useMatomo from "../matomo/UseMatomo";
Serializer.addProperty("itemvalue", "customDescription:text");
Serializer.addProperty("question", "hideCheckboxLabels:boolean");
......@@ -19,6 +20,8 @@ function SurveyContainerComponent({ loadFrom }) {
const { year, nren } = useParams(); // nren stays empty for inspect and try
const [error, setError] = useState<string>('loading survey...');
const { trackPageView } = useMatomo();
const beforeUnloadListener = useCallback((event) => {
event.preventDefault();
return (event.returnValue = "");
......@@ -72,7 +75,9 @@ function SurveyContainerComponent({ loadFrom }) {
setSurveyModel(survey);
}
getModel().catch(error => setError('Error when loading survey: ' + error.message))
getModel().catch(error => setError('Error when loading survey: ' + error.message)).then(() => {
trackPageView({ documentTitle: `Survey for ${nren} (${year})` });
})
}, []);
if (!surveyModel) {
......@@ -95,7 +100,7 @@ function SurveyContainerComponent({ loadFrom }) {
try {
const response = await fetch(
'/api/response/save/' + year + '/' + nren,
{method: "POST", headers: { "Content-Type": "application/json; charset=utf-8"}, body: JSON.stringify(saveData)}
{ method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(saveData) }
);
const json = await response.json();
if (!response.ok) {
......@@ -162,7 +167,7 @@ function SurveyContainerComponent({ loadFrom }) {
}
},
'startEdit': async () => {
const response = await fetch('/api/response/lock/' + year + '/' + nren, {method: "POST"});
const response = await fetch('/api/response/lock/' + year + '/' + nren, { method: "POST" });
const json = await response.json();
if (!response.ok) {
toast("Failed starting edit: " + json['message']);
......
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment