From 24cc3a65f09f668dbbeedcbd9866f7247e2fea2f Mon Sep 17 00:00:00 2001 From: mikkojamG Date: Fri, 24 Nov 2023 13:27:09 +0200 Subject: [PATCH] feat: add HDS CookieModal and Matomo tracking HAUKI-483 --- .env | 7 +- cypress/index.d.ts | 1 + cypress/integration/App.spec.ts | 1 + .../integration/CopyResourcePeriods.spec.ts | 2 + cypress/integration/ExceptionOpeningHours.ts | 1 + cypress/integration/Holidays.ts | 1 + cypress/integration/NormalOpeningHours.ts | 1 + cypress/integration/User.spec.ts | 2 + cypress/support/commands.ts | 9 + docker-compose.yml | 6 +- package.json | 9 +- src/App.tsx | 477 ++++++++++-------- .../cookie-consent/CookieConsent.test.tsx | 46 ++ .../cookie-consent/CookieConsent.tsx | 17 + .../cookie-consent/hooks/useCookieConsent.ts | 79 +++ src/components/footer/HaukiFooter.tsx | 1 + src/components/main/Main.tsx | 30 +- src/components/matomo/MatomoTracker.test.ts | 45 ++ src/components/matomo/MatomoTracker.ts | 128 +++++ src/components/matomo/constants.ts | 4 + .../matomo/hooks/useMatomo.test.tsx | 52 ++ src/components/matomo/hooks/useMatomo.ts | 17 + src/components/matomo/matomo-context.tsx | 20 + src/components/matomo/types.ts | 5 + src/globals.d.ts | 6 + src/language/en.json | 5 +- src/language/fi.json | 5 +- src/language/sv.json | 5 +- src/pages/CookieManagement.tsx | 19 + src/setupTests.ts | 13 + yarn.lock | 20 + 31 files changed, 791 insertions(+), 243 deletions(-) create mode 100644 src/components/cookie-consent/CookieConsent.test.tsx create mode 100644 src/components/cookie-consent/CookieConsent.tsx create mode 100644 src/components/cookie-consent/hooks/useCookieConsent.ts create mode 100644 src/components/matomo/MatomoTracker.test.ts create mode 100644 src/components/matomo/MatomoTracker.ts create mode 100644 src/components/matomo/constants.ts create mode 100644 src/components/matomo/hooks/useMatomo.test.tsx create mode 100644 src/components/matomo/hooks/useMatomo.ts create mode 100644 src/components/matomo/matomo-context.tsx create mode 100644 src/components/matomo/types.ts create mode 100644 src/pages/CookieManagement.tsx diff --git a/.env b/.env index 39fb11d32..adf125a51 100644 --- a/.env +++ b/.env @@ -1,6 +1,9 @@ -API_URL="" +API_URL="http://localhost:8000" USE_AXE="" SENTRY_DSN="" SENTRY_ENV="" -REACT_APP_VERSION="" FEEDBACK_EMAILS="" +MATOMO_SRC_URL="" +MATOMO_URL_BASE="" +MATOMO_SITE_ID="" +MATOMO_ENABLED=false diff --git a/cypress/index.d.ts b/cypress/index.d.ts index 10e6d884f..4ed1c8eb6 100644 --- a/cypress/index.d.ts +++ b/cypress/index.d.ts @@ -41,5 +41,6 @@ declare namespace Cypress { hours: string; minutes: string; }) => Chainable; + acceptAllCookies(): void; } } diff --git a/cypress/integration/App.spec.ts b/cypress/integration/App.spec.ts index 21ca8e85b..dc9eb6ab8 100644 --- a/cypress/integration/App.spec.ts +++ b/cypress/integration/App.spec.ts @@ -5,6 +5,7 @@ describe('Open aukiolot app', () => { beforeEach(() => { cy.visitResourcePageAsAuthenticatedUser(Cypress.env('resourceId')); + cy.acceptAllCookies(); }); it('Contains correct page title', () => { diff --git a/cypress/integration/CopyResourcePeriods.spec.ts b/cypress/integration/CopyResourcePeriods.spec.ts index 18b5980d3..fcc3b96aa 100644 --- a/cypress/integration/CopyResourcePeriods.spec.ts +++ b/cypress/integration/CopyResourcePeriods.spec.ts @@ -8,6 +8,8 @@ describe('User visits resource page with target resource', () => { Cypress.env('resourceId'), Cypress.env('targetResourceId') ); + + cy.acceptAllCookies(); }); it('Contains copy section title and copy button', () => { diff --git a/cypress/integration/ExceptionOpeningHours.ts b/cypress/integration/ExceptionOpeningHours.ts index 045e04044..1e8659a5c 100644 --- a/cypress/integration/ExceptionOpeningHours.ts +++ b/cypress/integration/ExceptionOpeningHours.ts @@ -3,6 +3,7 @@ describe('User adds a new exception date period', () => { beforeEach(() => { cy.visitResourcePageAsAuthenticatedUser(Cypress.env('resourceId')); + cy.acceptAllCookies(); }); it('Users successfully adds a new exception opening hours', () => { diff --git a/cypress/integration/Holidays.ts b/cypress/integration/Holidays.ts index 7067b0da2..d3bd079f5 100644 --- a/cypress/integration/Holidays.ts +++ b/cypress/integration/Holidays.ts @@ -4,6 +4,7 @@ describe('User adds a new holiday opening period', () => { beforeEach(() => { cy.visitResourcePageAsAuthenticatedUser(Cypress.env('resourceId')); cy.intercept('POST', '/v1/date_period/').as('saveHoliday'); + cy.acceptAllCookies(); }); it('Users successfully adds a new holiday opening hours', () => { diff --git a/cypress/integration/NormalOpeningHours.ts b/cypress/integration/NormalOpeningHours.ts index 33d9a8780..86a8667f2 100644 --- a/cypress/integration/NormalOpeningHours.ts +++ b/cypress/integration/NormalOpeningHours.ts @@ -3,6 +3,7 @@ describe('User adds a new opening period', () => { beforeEach(() => { cy.visitResourcePageAsAuthenticatedUser(Cypress.env('resourceId')); + cy.acceptAllCookies(); }); it('Users successfully adds a new opening hours', () => { diff --git a/cypress/integration/User.spec.ts b/cypress/integration/User.spec.ts index 6eb51bf1d..e9fc2967d 100644 --- a/cypress/integration/User.spec.ts +++ b/cypress/integration/User.spec.ts @@ -3,6 +3,7 @@ describe('Unauthenticated user', () => { beforeEach(() => { cy.visitResourcePageAsUnauthenticatedUser(Cypress.env('resourceId')); + cy.acceptAllCookies(); }); it('Is redirected to unauthenticated page', () => { @@ -13,6 +14,7 @@ describe('Unauthenticated user', () => { describe('Authenticated user', () => { beforeEach(() => { cy.visitResourcePageAsAuthenticatedUser(Cypress.env('resourceId')); + cy.acceptAllCookies(); }); it('should logout permanently on application close', () => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 0f44e7a3e..3ac9eb188 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -222,3 +222,12 @@ Cypress.Commands.add( cy.get(`#${id}-minutes`).type(minutes); } ); + +Cypress.Commands.add('acceptAllCookies', () => { + const cookieConsentModal = cy.get('#cookie-consent-content'); + const acceptAllButton = cookieConsentModal.get( + 'button[data-testid="cookie-consent-approve-button"]' + ); + + acceptAllButton.click(); +}); diff --git a/docker-compose.yml b/docker-compose.yml index eb221bf83..eab4b8d27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,12 @@ -version: "3.7" +version: '3.7' services: app: + platform: linux/amd64 container_name: hauki-admin-ui build: context: . + env_file: + - .env + - .env.local ports: - '3000:8000' diff --git a/package.json b/package.json index 8e183714d..a069b5f16 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "cypress-axe": "^0.12.1", "date-fns": "2.29.3", "date-holidays": "^3.16.1", + "dotenv-cli": "^7.3.0", "enzyme": "^3.11.0", "eslint-config-airbnb-typescript-prettier": "^5.0.0", "hds-core": "^3.5.0", @@ -47,10 +48,10 @@ "typescript": "4.4.3" }, "scripts": { - "start": "REACT_APP_VERSION=$npm_package_version SENTRY_ENV=${SENTRY_ENV:=local} API_URL=${API_URL:=http://localhost:8000} USE_AXE=${USE_AXE:=true} ./scripts/env.sh && cp env-config.js ./public/ && BROWSER=none react-scripts start", - "start-with-test-api": "REACT_APP_VERSION=$npm_package_version SENTRY_ENV=local API_URL=https://hauki-api.test.hel.ninja USE_AXE=${USE_AXE:=true} ./scripts/env.sh && cp env-config.js ./public/ && react-scripts start", - "start-with-new-test-api": "REACT_APP_VERSION=$npm_package_version SENTRY_ENV=local API_URL=https://hauki-api.test.hel.ninja USE_AXE=${USE_AXE:=true} ./scripts/env.sh && cp env-config.js ./public/ && react-scripts start", - "build": "REACT_APP_VERSION=$npm_package_version react-scripts build", + "start": "dotenv -c -v REACT_APP_VERSION=$npm_package_version -- ./scripts/env.sh && cp env-config.js ./public/ && BROWSER=none react-scripts start", + "start-with-test-api": "dotenv -c -v REACT_APP_VERSION=$npm_package_version SENTRY_ENV=local API_URL=https://hauki-api.test.hel.ninja USE_AXE=${USE_AXE:=true} -- ./scripts/env.sh && cp env-config.js ./public/ && react-scripts start", + "start-with-new-test-api": "dotenv -c -v REACT_APP_VERSION=$npm_package_version SENTRY_ENV=local API_URL=https://hauki-api.test.hel.ninja USE_AXE=${USE_AXE:=true} -- ./scripts/env.sh && cp env-config.js ./public/ && react-scripts start", + "build": "dotenv -c -v REACT_APP_VERSION=$npm_package_version -- react-scripts build", "test": "react-scripts test --env=jest-environment-jsdom-sixteen", "test:cov": "react-scripts test --env=jest-environment-jsdom-sixteen --coverage", "test-cypress": "start-server-and-test start http://localhost:3000 cypress-run-chrome", diff --git a/src/App.tsx b/src/App.tsx index f8e3e5aa3..9da0d6f34 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,10 @@ import EditHolidaysPage from './pages/EditHolidaysPage'; import AddExceptionOpeningHoursPage from './pages/AddExceptionOpeningHoursPage'; import EditExceptionOpeningHoursPage from './pages/EditExceptionOpeningHoursPage'; import { SelectedDatePeriodsProvider } from './common/selectedDatePeriodsContext/SelectedDatePeriodsContext'; +import MatomoTracker from './components/matomo/MatomoTracker'; +import MatomoContext from './components/matomo/matomo-context'; +import CookieConsent from './components/cookie-consent/CookieConsent'; +import CookieManagement from './pages/CookieManagement'; type OptionalAuthTokens = AuthTokens | undefined; @@ -94,240 +98,265 @@ const App = (): JSX.Element => { [authTokens, clearAuth] ); + const matomoTracker = useMemo( + () => + new MatomoTracker({ + urlBase: window?.ENV?.MATOMO_URL_BASE, + siteId: window?.ENV?.MATOMO_SITE_ID, + srcUrl: window?.ENV?.MATOMO_SRC_URL, + enabled: window?.ENV?.MATOMO_ENABLED, + configurations: { + setDoNotTrack: true, + }, + }), + [] + ); + return (
- - - - - -
-

Etusivu

-
-
-
- - -
-

Kohdetta ei löydy

-

- Kohdetta ei löytynyt. Yritä myöhemmin uudestaan. Ongelman - toistuessa ota yhteys sivuston ylläpitoon. Teidät on - automaattisesti kirjattu ulos. -

-
-
-
- - -
-

Puutteelliset tunnukset

-
-
-
- - -
-

Puuttuvat tunnukset

-
-
-
- ) => { - const { id, childId } = match.params; + + - return ( - id && ( - -
- - - -
-
- ) - ); - }} - /> - ) => { - const { id } = match.params; + + + + + +
+

Etusivu

+
+
+
+ + +
+

Kohdetta ei löydy

+

+ Kohdetta ei löytynyt. Yritä myöhemmin uudestaan. + Ongelman toistuessa ota yhteys sivuston ylläpitoon. + Teidät on automaattisesti kirjattu ulos. +

+
+
+
+ + +
+

Puutteelliset tunnukset

+
+
+
+ + +
+

Puuttuvat tunnukset

+
+
+
+ + +
+ +
+
+
+ ) => { + const { id, childId } = match.params; - return ( - id && ( - -
- - - -
-
- ) - ); - }} - /> - ) => { - const { id } = match.params; + return ( + id && ( + +
+ + + +
+
+ ) + ); + }} + /> + ) => { + const { id } = match.params; - return ( - id && ( - <> - -
- -
- - ) - ); - }} - /> - ) => { - const { id, datePeriodId } = match.params; + return ( + id && ( + +
+ + + +
+
+ ) + ); + }} + /> + ) => { + const { id } = match.params; - return ( - id && - datePeriodId && ( - <> - -
- -
- - ) - ); - }} - /> - ) => { - const { id } = match.params; + return ( + id && ( + <> + +
+ +
+ + ) + ); + }} + /> + ) => { + const { id, datePeriodId } = match.params; - return ( - id && ( - <> - -
- -
- - ) - ); - }} - /> - ) => { - const { id } = match.params; + return ( + id && + datePeriodId && ( + <> + +
+ +
+ + ) + ); + }} + /> + ) => { + const { id } = match.params; + + return ( + id && ( + <> + +
+ +
+ + ) + ); + }} + /> + ) => { + const { id } = match.params; - return ( - id && ( - <> - -
- -
- - ) - ); - }} - /> - ) => { - const { id, datePeriodId } = match.params; + return ( + id && ( + <> + +
+ +
+ + ) + ); + }} + /> + ) => { + const { id, datePeriodId } = match.params; - return ( - id && - datePeriodId && ( - <> - -
- -
- - ) - ); - }} - /> -
-
-
+ return ( + id && + datePeriodId && ( + <> + +
+ +
+ + ) + ); + }} + /> +
+
+
+
); diff --git a/src/components/cookie-consent/CookieConsent.test.tsx b/src/components/cookie-consent/CookieConsent.test.tsx new file mode 100644 index 000000000..ce27693f7 --- /dev/null +++ b/src/components/cookie-consent/CookieConsent.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AppContext } from '../../App-context'; +import CookieConsent from './CookieConsent'; +import { Language } from '../../common/lib/types'; +import MatomoContext from '../matomo/matomo-context'; +import MatomoTracker from '../matomo/MatomoTracker'; + +describe('CookieConsent', () => { + it('should render cookie modal', async () => { + global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + + const setLanguageMock = jest.fn(); + const mockMatomoTracker = new MatomoTracker({ + urlBase: 'https://www.test.fi/', + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + }); + + render( + + + + + + ); + + const buttons = await screen.findAllByRole('button'); + const changeLanguageButton = buttons[0]; + + userEvent.click(changeLanguageButton); + + const enOption = await screen.findByRole('link', { name: 'English (EN)' }); + + userEvent.click(enOption); + + expect(setLanguageMock).toHaveBeenCalled(); + }); +}); diff --git a/src/components/cookie-consent/CookieConsent.tsx b/src/components/cookie-consent/CookieConsent.tsx new file mode 100644 index 000000000..d038c387c --- /dev/null +++ b/src/components/cookie-consent/CookieConsent.tsx @@ -0,0 +1,17 @@ +/* eslint-disable no-underscore-dangle */ +import { CookieModal } from 'hds-react'; +import React, { FC } from 'react'; +import { useAppContext } from '../../App-context'; +import useCookieConsent from './hooks/useCookieConsent'; + +const CookieConsent: FC = () => { + const { language, setLanguage } = useAppContext(); + const { config } = useCookieConsent({ + language, + setLanguage, + }); + + return ; +}; + +export default CookieConsent; diff --git a/src/components/cookie-consent/hooks/useCookieConsent.ts b/src/components/cookie-consent/hooks/useCookieConsent.ts new file mode 100644 index 000000000..9b2cc1280 --- /dev/null +++ b/src/components/cookie-consent/hooks/useCookieConsent.ts @@ -0,0 +1,79 @@ +/* eslint-disable no-underscore-dangle */ +import { useEffect, useState } from 'react'; +import { ContentSource } from 'hds-react'; +import { Language } from '../../../common/lib/types'; + +type Props = { + language: Language | undefined; + setLanguage: ((language: Language) => void) | undefined; + isModal?: boolean; +}; + +const useCookieConsent = ({ + language, + setLanguage, + isModal = true, +}: Props): { config: ContentSource } => { + const [currentLanguage, setCurrentLanguage] = useState( + language ?? Language.FI + ); + + useEffect(() => { + if (language) { + const newLanguage = + Language[language.toUpperCase() as keyof typeof Language]; + + if (newLanguage !== currentLanguage) { + setCurrentLanguage(newLanguage); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [language]); + + const onLanguageChange = (lang: string) => { + const newLanguage = Language[lang.toUpperCase() as keyof typeof Language]; + + setCurrentLanguage(newLanguage); + + if (setLanguage) { + setLanguage(newLanguage); + } + }; + + const config: ContentSource = { + siteName: 'Aukiolot', + currentLanguage, + optionalCookies: { + groups: [ + { + commonGroup: 'statistics', + cookies: [{ commonCookie: 'matomo' }], + }, + ], + }, + language: { onLanguageChange }, + onAllConsentsGiven: (consents) => { + if (consents.matomo) { + // start tracking + window._paq.push(['setConsentGiven']); + window._paq.push(['setCookieConsentGiven']); + } + }, + onConsentsParsed: (consents) => { + /* istanbul ignore next */ + if (consents.matomo === undefined) { + // tell matomo to wait for consent: + window._paq.push(['requireConsent']); + window._paq.push(['requireCookieConsent']); + } else if (consents.matomo === false) { + // tell matomo to forget conset + window._paq.push(['forgetConsentGiven']); + } + }, + focusTargetSelector: isModal ? `#main` : undefined, + }; + + return { config }; +}; + +export default useCookieConsent; diff --git a/src/components/footer/HaukiFooter.tsx b/src/components/footer/HaukiFooter.tsx index b4ece7c92..937315233 100644 --- a/src/components/footer/HaukiFooter.tsx +++ b/src/components/footer/HaukiFooter.tsx @@ -38,6 +38,7 @@ const HaukiFooter = (): JSX.Element => { target="_blank" label={t('Footer.ContentLicenseLink')} /> + diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 4f1552b4c..12499faa6 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -1,5 +1,8 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useEffect } from 'react'; import './Main.scss'; +import { useCookies } from 'hds-react'; +import { useLocation } from 'react-router-dom'; +import useMatomo from '../matomo/hooks/useMatomo'; type MainProps = { id: string; @@ -10,10 +13,25 @@ export function MainContainer({ children }: Partial): JSX.Element { return
{children}
; } -const Main = ({ id, children }: MainProps): JSX.Element => ( -
- {children} -
-); +const Main = ({ id, children }: MainProps): JSX.Element => { + const location = useLocation(); + const { getAllConsents } = useCookies(); + const { trackPageView } = useMatomo(); + + useEffect(() => { + if (getAllConsents().matomo) { + trackPageView({ + href: window.location.href, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getAllConsents, location.pathname, location.search]); + + return ( +
+ {children} +
+ ); +}; export default Main; diff --git a/src/components/matomo/MatomoTracker.test.ts b/src/components/matomo/MatomoTracker.test.ts new file mode 100644 index 000000000..abba3ec7d --- /dev/null +++ b/src/components/matomo/MatomoTracker.test.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-underscore-dangle */ +import MatomoTracker, { MatomoTrackerOptions } from './MatomoTracker'; + +describe('MatomoTracker', () => { + it('should initialise window._paq', () => { + window._paq = []; + + const intance = new MatomoTracker({ + urlBase: 'https://www.test.fi/', + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + configurations: { + foo: 'bar', + testArray: ['testArrayItem1', 'testArrayItem2'], + testNoValue: undefined, + }, + }); + + expect(intance).toBeTruthy(); + expect(window._paq).toEqual([ + ['setTrackerUrl', 'https://www.test.fi/matomo.php'], + ['setSiteId', 'test123'], + ['foo', 'bar'], + ['testArray', 'testArrayItem1', 'testArrayItem2'], + ['testNoValue'], + ['enableLinkTracking', true], + ]); + }); + + it('should throw error if urlBase missing', () => { + expect( + () => new MatomoTracker({ siteId: 'test123' } as MatomoTrackerOptions) + ).toThrowError(); + }); + + it('should throw error if siteId missing', () => { + expect( + () => + new MatomoTracker({ + urlBase: 'https://www.test.fi', + } as MatomoTrackerOptions) + ).toThrowError(); + }); +}); diff --git a/src/components/matomo/MatomoTracker.ts b/src/components/matomo/MatomoTracker.ts new file mode 100644 index 000000000..0d00e10da --- /dev/null +++ b/src/components/matomo/MatomoTracker.ts @@ -0,0 +1,128 @@ +/* eslint-disable no-underscore-dangle */ +import { TRACK_TYPES } from './constants'; + +export type MatomoTrackerOptions = { + urlBase: string; + siteId: string; + srcUrl: string; + trackerUrl?: string; + enabled: boolean; + linkTracking?: boolean; + configurations?: { + [key: string]: string | string[] | boolean | undefined; + }; +}; + +export type CustomDimension = { + id: number; + value: string; +}; + +export type TrackPageViewParams = { + documentTitle?: string; + href?: string | Location; + customDimensions?: boolean | CustomDimension[]; +}; + +export type TrackParams = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any[]; +} & TrackPageViewParams; + +class MatomoTracker { + constructor(userOptions: MatomoTrackerOptions) { + 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, + srcUrl, + trackerUrl = 'matomo.php', + enabled = true, + linkTracking = true, + configurations = {}, + }: MatomoTrackerOptions) { + if (typeof window === 'undefined') { + return; + } + + window._paq = window._paq || []; + + if (window._paq.length !== 0) { + return; + } + + if (!enabled) { + return; + } + + this.pushInstruction('setTrackerUrl', `${urlBase}${trackerUrl}`); + this.pushInstruction('setSiteId', siteId); + + Object.entries(configurations).forEach(([name, instructions]) => { + if (instructions instanceof Array) { + this.pushInstruction(name, ...instructions); + } else if (instructions === undefined) { + this.pushInstruction(name); + } else { + this.pushInstruction(name, instructions); + } + }); + + this.enableLinkTracking(linkTracking); + + 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 = `${urlBase}${srcUrl}`; + + if (scripts?.parentNode) { + scripts?.parentNode.insertBefore(scriptElement, scripts); + } + } + + enableLinkTracking(active: boolean): void { + this.pushInstruction('enableLinkTracking', active); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pushInstruction(name: string, ...args: any[]): this { + if (typeof window !== 'undefined') { + window._paq.push([name, ...args]); + } + + return this; + } + + trackPageView(params?: TrackPageViewParams): void { + this.track({ data: [TRACK_TYPES.TRACK_VIEW], ...params }); + } + + track({ + data = [], + documentTitle = document.title, + href, + }: TrackParams): void { + if (data.length) { + this.pushInstruction('setCustomUrl', href ?? window.location.href); + this.pushInstruction('setDocumentTitle', documentTitle); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.pushInstruction(...(data as [string, ...any[]])); + } + } +} + +export default MatomoTracker; diff --git a/src/components/matomo/constants.ts b/src/components/matomo/constants.ts new file mode 100644 index 000000000..a75c675de --- /dev/null +++ b/src/components/matomo/constants.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export, @typescript-eslint/naming-convention +export const TRACK_TYPES = { + TRACK_VIEW: 'trackPageView', +}; diff --git a/src/components/matomo/hooks/useMatomo.test.tsx b/src/components/matomo/hooks/useMatomo.test.tsx new file mode 100644 index 000000000..3377c6ad0 --- /dev/null +++ b/src/components/matomo/hooks/useMatomo.test.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { render } from '@testing-library/react'; +import * as MatomoTracker from '../MatomoTracker'; +import { MatomoProvider } from '../matomo-context'; +import useMatomo from './useMatomo'; + +describe('useMatomo', () => { + const MockedComponent = () => { + const { trackPageView } = useMatomo(); + + useEffect(() => { + trackPageView({ href: 'https://www.hel.fi' }); + }, [trackPageView]); + + return
MockedComponent
; + }; + + it('should trackPageView', () => { + const trackPageViewMock = jest.fn(); + + jest.spyOn(MatomoTracker, 'default').mockImplementation( + () => + ({ + trackPageView: trackPageViewMock, + } as unknown as MatomoTracker.default) + ); + + // eslint-disable-next-line new-cap + const instance = new MatomoTracker.default({ + urlBase: 'https://www.hel.fi', + siteId: 'test123', + srcUrl: 'test.js', + enabled: true, + }); + + const MockProvider = () => { + return ( + + + + ); + }; + + expect(MatomoTracker.default).toHaveBeenCalled(); + + render(); + + expect(trackPageViewMock).toHaveBeenCalledWith({ + href: 'https://www.hel.fi', + }); + }); +}); diff --git a/src/components/matomo/hooks/useMatomo.ts b/src/components/matomo/hooks/useMatomo.ts new file mode 100644 index 000000000..7b9272e8d --- /dev/null +++ b/src/components/matomo/hooks/useMatomo.ts @@ -0,0 +1,17 @@ +import { useCallback, useContext } from 'react'; +import MatomoContext from '../matomo-context'; +import { TrackPageViewParams } from '../MatomoTracker'; +import { MatomoTrackerInstance } from '../types'; + +function useMatomo(): MatomoTrackerInstance { + const instance = useContext(MatomoContext); + + const trackPageView = useCallback( + (params?: TrackPageViewParams) => instance?.trackPageView(params), + [instance] + ); + + return { trackPageView }; +} + +export default useMatomo; diff --git a/src/components/matomo/matomo-context.tsx b/src/components/matomo/matomo-context.tsx new file mode 100644 index 000000000..7292ca385 --- /dev/null +++ b/src/components/matomo/matomo-context.tsx @@ -0,0 +1,20 @@ +import React, { createContext } from 'react'; +import { MatomoTrackerInstance } from './types'; + +export type MatomoProviderProps = { + children?: React.ReactNode; + value: MatomoTrackerInstance; +}; + +const MatomoContext = createContext(null); + +export const MatomoProvider: React.FC = ({ + children, + value, +}) => { + const Context = MatomoContext; + + return {children}; +}; + +export default MatomoContext; diff --git a/src/components/matomo/types.ts b/src/components/matomo/types.ts new file mode 100644 index 000000000..9306f1755 --- /dev/null +++ b/src/components/matomo/types.ts @@ -0,0 +1,5 @@ +import MatomoTracker from './MatomoTracker'; + +export type MatomoTrackerInstance = { + trackPageView: MatomoTracker['trackPageView']; +}; diff --git a/src/globals.d.ts b/src/globals.d.ts index e449ce1c7..2d3d94834 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -6,5 +6,11 @@ interface Window { SENTRY_ENV: string; REACT_APP_VERSION: string; FEEDBACK_EMAILS: string; + MATOMO_SRC_URL: string; + MATOMO_URL_BASE: string; + MATOMO_SITE_ID: string; + MATOMO_ENABLED: boolean; }; + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any + _paq: [string, ...any[]][]; } diff --git a/src/language/en.json b/src/language/en.json index cd4b7dcb6..e7db72e6d 100644 --- a/src/language/en.json +++ b/src/language/en.json @@ -111,6 +111,7 @@ "BackToTopLabel": "Back to top", "AccessibilityStatementLink": "Accessibility statement", "AccessibilityStatementLinkUrl": "https://kaupunkialustana.hel.fi/aukiolosovellus/accessibility-statement", - "ContentLicenseLink": "Content license CC BY 4.0" + "ContentLicenseLink": "Content license CC BY 4.0", + "CookiesLink": "Cookie settings" } -} \ No newline at end of file +} diff --git a/src/language/fi.json b/src/language/fi.json index 418140de9..e07a119ec 100644 --- a/src/language/fi.json +++ b/src/language/fi.json @@ -112,6 +112,7 @@ "BackToTopLabel": "Takaisin ylös", "AccessibilityStatementLink": "Saavutettavuusseloste", "AccessibilityStatementLinkUrl": "https://kaupunkialustana.hel.fi/aukiolosovellus/saavutettavuusseloste", - "ContentLicenseLink": "Sisältölisenssi CC BY 4.0" + "ContentLicenseLink": "Sisältölisenssi CC BY 4.0", + "CookiesLink": "Evästeasetukset" } -} \ No newline at end of file +} diff --git a/src/language/sv.json b/src/language/sv.json index d4fed596f..f1dcebcd3 100644 --- a/src/language/sv.json +++ b/src/language/sv.json @@ -112,6 +112,7 @@ "BackToTopLabel": "Tillbaka till toppen", "AccessibilityStatementLink": "Tillgänglighetsutlåtande", "AccessibilityStatementLinkUrl": "https://kaupunkialustana.hel.fi/aukiolosovellus/tillganglighetsutlatande", - "ContentLicenseLink": "Innehållslicens CC BY 4.0" + "ContentLicenseLink": "Innehållslicens CC BY 4.0", + "CookiesLink": "Cookie -inställningar" } -} \ No newline at end of file +} diff --git a/src/pages/CookieManagement.tsx b/src/pages/CookieManagement.tsx new file mode 100644 index 000000000..a8b6b8a1a --- /dev/null +++ b/src/pages/CookieManagement.tsx @@ -0,0 +1,19 @@ +/* eslint-disable no-underscore-dangle */ +import React from 'react'; +import { CookiePage } from 'hds-react'; +import { useAppContext } from '../App-context'; + +import useCookieConsent from '../components/cookie-consent/hooks/useCookieConsent'; + +const CookieManagement = (): JSX.Element => { + const { language, setLanguage } = useAppContext(); + const { config } = useCookieConsent({ + language, + setLanguage, + isModal: false, + }); + + return ; +}; + +export default CookieManagement; diff --git a/src/setupTests.ts b/src/setupTests.ts index 68945fd37..b86f28476 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -18,3 +18,16 @@ console.error = (msg: any, ...optionalParams: any[]) => { originalError(msg, ...optionalParams) ); }; + +window.ENV = { + REACT_APP_VERSION: '', + API_URL: '', + USE_AXE: '', + SENTRY_DSN: '', + SENTRY_ENV: '', + FEEDBACK_EMAILS: '', + MATOMO_SRC_URL: '', + MATOMO_URL_BASE: 'test', + MATOMO_SITE_ID: 'test123', + MATOMO_ENABLED: false, +}; diff --git a/yarn.lock b/yarn.lock index d1949c5d1..19c48642f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5786,16 +5786,36 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +dotenv-cli@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/dotenv-cli/-/dotenv-cli-7.3.0.tgz#21e33e7944713001677658d68856063968edfbd2" + integrity sha512-314CA4TyK34YEJ6ntBf80eUY+t1XaFLyem1k9P0sX1gn30qThZ5qZr/ZwE318gEnzyYP9yj9HJk6SqwE0upkfw== + dependencies: + cross-spawn "^7.0.3" + dotenv "^16.3.0" + dotenv-expand "^10.0.0" + minimist "^1.2.6" + dotenv-expand@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== +dotenv-expand@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-10.0.0.tgz#12605d00fb0af6d0a592e6558585784032e4ef37" + integrity sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A== + dotenv@8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== +dotenv@^16.3.0: + version "16.3.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" + integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== + downshift@6.0.6: version "6.0.6" resolved "https://registry.yarnpkg.com/downshift/-/downshift-6.0.6.tgz#82aee8e2e260d7ad99df8a0969bd002dd523abe8"