Skip to content
Open
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
20 changes: 10 additions & 10 deletions documentation-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@
"node": ">=22.12.0"
},
"devDependencies": {
"@biomejs/biome": "^2.5.1",
"@biomejs/biome": "^2.5.3",
"@chromatic-com/storybook": "^5.2.1",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@eslint/js": "^9.39.4",
"@radix-ui/react-icons": "^1.3.2",
"@rollup/plugin-commonjs": "^29.0.3",
Expand All @@ -80,18 +80,18 @@
"@storybook/react-vite": "^10.4.6",
"@tailwindcss/typography": "^0.5.20",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.20.0",
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.0",
"autoprefixer": "^10.5.2",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-storybook": "10.4.4",
"globals": "^15.15.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.15",
"postcss": "^8.5.16",
"postcss-fail-on-warn": "^0.2.1",
"rollup": "^4.62.2",
"rollup-plugin-peer-deps-external": "^2.2.4",
Expand All @@ -101,12 +101,12 @@
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.0",
"vite": "^8.0.16",
"vitest": "^4.1.9"
"typescript-eslint": "^8.63.0",
"vite": "^8.1.4",
"vitest": "^4.1.10"
},
"dependencies": {
"@quantinuum/quantinuum-ui": "^5.0.1",
"@quantinuum/quantinuum-ui": "^5.1.0",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"remeda": "^2.39.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CookieCategoryName, type CookieConsent } from '../gdpr/types'
import {
loadGoogleAnalytics,
setGoogleConsentDefault,
updateAnalyticsConsent,
} from './consent-mode'
import { GoogleAnalyticsWithConsent } from './GoogleAnalyticsWithConsent'
import { useCookieConsent } from '../gdpr/contexts/useCookieConsent'
import type { CookieConsentContextType } from '../gdpr/contexts/CookieConsentShared'

vi.mock('./consent-mode', () => ({
setGoogleConsentDefault: vi.fn(),
loadGoogleAnalytics: vi.fn(),
updateAnalyticsConsent: vi.fn(),
}))

vi.mock('../gdpr/contexts/useCookieConsent', () => ({
useCookieConsent: vi.fn(),
}))

const TEST_GA_ID = 'G-TEST12345'

const buildConsent = (analyticsGranted: boolean): CookieConsent => ({
[CookieCategoryName.Essential]: true,
[CookieCategoryName.Analytics]: analyticsGranted,
})

const mockConsent = (analyticsGranted: boolean) => {
vi.mocked(useCookieConsent).mockReturnValue({
consent: buildConsent(analyticsGranted),
} as CookieConsentContextType)
}

afterEach(() => {
vi.clearAllMocks()
})

describe('GoogleAnalyticsWithConsent', () => {
it('renders nothing', () => {
mockConsent(false)

const { container } = render(<GoogleAnalyticsWithConsent gaId={TEST_GA_ID} />)

expect(container.childNodes).toHaveLength(0)
})

it('sets the denied consent default before loading GA on mount', () => {
mockConsent(false)

render(<GoogleAnalyticsWithConsent gaId={TEST_GA_ID} />)

expect(setGoogleConsentDefault).toHaveBeenCalledTimes(1)
expect(loadGoogleAnalytics).toHaveBeenCalledWith(TEST_GA_ID)

// The default must be set before GA is configured.
const defaultOrder = vi.mocked(setGoogleConsentDefault).mock.invocationCallOrder[0]
const loadOrder = vi.mocked(loadGoogleAnalytics).mock.invocationCallOrder[0]
expect(defaultOrder).toBeLessThan(loadOrder)
})

it('keeps analytics denied while the visitor has not consented', () => {
mockConsent(false)

render(<GoogleAnalyticsWithConsent gaId={TEST_GA_ID} />)

expect(updateAnalyticsConsent).toHaveBeenCalledWith(false)
})

it('grants analytics consent when the visitor has consented', () => {
mockConsent(true)

render(<GoogleAnalyticsWithConsent gaId={TEST_GA_ID} />)

expect(updateAnalyticsConsent).toHaveBeenCalledWith(true)
})

it('upgrades consent when the visitor accepts after mounting', () => {
mockConsent(false)

const { rerender } = render(<GoogleAnalyticsWithConsent gaId={TEST_GA_ID} />)
expect(updateAnalyticsConsent).toHaveBeenLastCalledWith(false)

mockConsent(true)
rerender(<GoogleAnalyticsWithConsent gaId={TEST_GA_ID} />)

expect(updateAnalyticsConsent).toHaveBeenLastCalledWith(true)
// GA is loaded once; only the consent signal changes.
expect(setGoogleConsentDefault).toHaveBeenCalledTimes(1)
expect(loadGoogleAnalytics).toHaveBeenCalledTimes(1)
})

it('does nothing when no gaId is provided', () => {
mockConsent(false)

render(<GoogleAnalyticsWithConsent gaId="" />)

expect(setGoogleConsentDefault).not.toHaveBeenCalled()
expect(loadGoogleAnalytics).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client'

import { useEffect } from 'react'
import { useCookieConsent } from '../gdpr/contexts/useCookieConsent'
import { CookieCategoryName } from '../gdpr/types'
import {
loadGoogleAnalytics,
setGoogleConsentDefault,
updateAnalyticsConsent,
} from './consent-mode'

export type GoogleAnalyticsWithConsentProps = {
gaId: string
}

/**
* Loads Google Analytics (GA4) and drives it with Google Consent Mode v2.
*
* GA is always loaded, but consent defaults to `denied`, so GA sends anonymous
* cookieless pings (aggregate pageview counts, no tracking cookies) until the
* visitor grants the Analytics cookie category. When consent changes, a
* `consent update` upgrades or downgrades GA accordingly.
*
* This is framework-agnostic (no `next/script`), so it works in Next.js apps
* and in the plain-React sphinx injection alike. Render it inside a
* `CookieConsentProvider`.
*/
export function GoogleAnalyticsWithConsent({ gaId }: GoogleAnalyticsWithConsentProps) {
const { consent } = useCookieConsent()
const analyticsGranted = consent[CookieCategoryName.Analytics]

useEffect(() => {
if (!gaId) {
return
}

// The consent default must be set before GA's config call.
setGoogleConsentDefault()
loadGoogleAnalytics(gaId)
}, [gaId])

useEffect(() => {
updateAnalyticsConsent(analyticsGranted)
}, [analyticsGranted])

return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_GOOGLE_CONSENT,
ensureGtag,
loadGoogleAnalytics,
setGoogleConsentDefault,
updateAnalyticsConsent,
} from './consent-mode'

type AnalyticsWindow = Window & {
dataLayer?: unknown[]
gtag?: (...args: unknown[]) => void
}

const GA_SCRIPT_ID = 'quantinuum-google-analytics'
const TEST_GA_ID = 'G-TEST12345'

const analyticsWindow = window as AnalyticsWindow

/** Returns the recorded gtag command tuples pushed onto the dataLayer. */
const commands = (): unknown[][] => (analyticsWindow.dataLayer ?? []) as unknown[][]

/** Finds the first recorded command matching the given leading arguments. */
const findCommand = (...prefix: unknown[]): unknown[] | undefined =>
commands().find((entry) => prefix.every((value, index) => entry[index] === value))

beforeEach(() => {
// Each test starts from a pristine analytics environment.
delete analyticsWindow.dataLayer
delete analyticsWindow.gtag
document.getElementById(GA_SCRIPT_ID)?.remove()
})

afterEach(() => {
vi.restoreAllMocks()
})

describe('ensureGtag', () => {
it('creates the dataLayer and a gtag function that records commands', () => {
const gtag = ensureGtag()

expect(gtag).toBeTypeOf('function')
expect(analyticsWindow.dataLayer).toEqual([])

gtag?.('event', 'page_view')

expect(commands()).toEqual([['event', 'page_view']])
})

it('is idempotent and preserves an existing dataLayer and gtag', () => {
const first = ensureGtag()
first?.('js', 'seed')

const second = ensureGtag()

expect(second).toBe(first)
expect(commands()).toEqual([['js', 'seed']])
})
})

describe('setGoogleConsentDefault', () => {
it('defaults every signal to denied so GA runs in cookieless ping mode', () => {
setGoogleConsentDefault()

const consentDefault = findCommand('consent', 'default')
expect(consentDefault).toBeDefined()

const settings = consentDefault?.[2] as Record<string, unknown>
expect(settings).toMatchObject({
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500,
})
})

it('emits exactly the DEFAULT_GOOGLE_CONSENT signals plus wait_for_update', () => {
setGoogleConsentDefault()

const settings = findCommand('consent', 'default')?.[2] as Record<string, unknown>
expect(settings).toEqual({
...DEFAULT_GOOGLE_CONSENT,
wait_for_update: 500,
})
})

it('runs before any analytics config so the default applies to the first ping', () => {
setGoogleConsentDefault()
loadGoogleAnalytics(TEST_GA_ID)

const layer = commands()
const consentIndex = layer.findIndex(
(entry) => entry[0] === 'consent' && entry[1] === 'default'
)
const configIndex = layer.findIndex((entry) => entry[0] === 'config')

expect(consentIndex).toBeGreaterThanOrEqual(0)
expect(configIndex).toBeGreaterThan(consentIndex)
})

it('allows individual signals to be overridden', () => {
setGoogleConsentDefault({ analytics_storage: 'granted' })

const settings = findCommand('consent', 'default')?.[2] as Record<string, unknown>
expect(settings.analytics_storage).toBe('granted')
// Non-overridden signals remain denied.
expect(settings.ad_storage).toBe('denied')
})
})

describe('updateAnalyticsConsent', () => {
it('upgrades analytics_storage to granted when the visitor consents', () => {
updateAnalyticsConsent(true)

expect(findCommand('consent', 'update')?.[2]).toEqual({
analytics_storage: 'granted',
})
})

it('keeps analytics_storage denied when the visitor withholds consent', () => {
updateAnalyticsConsent(false)

expect(findCommand('consent', 'update')?.[2]).toEqual({
analytics_storage: 'denied',
})
})

it('does not touch advertising signals', () => {
updateAnalyticsConsent(true)

const settings = findCommand('consent', 'update')?.[2] as Record<string, unknown>
expect(settings).not.toHaveProperty('ad_storage')
expect(settings).not.toHaveProperty('ad_user_data')
expect(settings).not.toHaveProperty('ad_personalization')
})
})

describe('loadGoogleAnalytics', () => {
it('initialises GA and injects a single async gtag.js script', () => {
loadGoogleAnalytics(TEST_GA_ID)

expect(findCommand('js')).toBeDefined()
expect(findCommand('config', TEST_GA_ID)).toBeDefined()

const script = document.getElementById(GA_SCRIPT_ID) as HTMLScriptElement | null
expect(script).not.toBeNull()
expect(script?.async).toBe(true)
expect(script?.src).toBe(`https://www.googletagmanager.com/gtag/js?id=${TEST_GA_ID}`)
})

it('does not append a second script when called repeatedly', () => {
loadGoogleAnalytics(TEST_GA_ID)
loadGoogleAnalytics(TEST_GA_ID)

expect(document.querySelectorAll(`#${GA_SCRIPT_ID}`)).toHaveLength(1)
})

it('updates the script src when the GA id changes', () => {
const otherGaId = 'G-OTHER67890'
loadGoogleAnalytics(TEST_GA_ID)
loadGoogleAnalytics(otherGaId)

const script = document.getElementById(GA_SCRIPT_ID) as HTMLScriptElement | null
expect(document.querySelectorAll(`#${GA_SCRIPT_ID}`)).toHaveLength(1)
expect(script?.src).toBe(`https://www.googletagmanager.com/gtag/js?id=${otherGaId}`)
})
})
Loading