Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: simplify GTM logic + unmount on permission removal #1047

Merged
merged 17 commits into from
Nov 22, 2022
Merged
125 changes: 60 additions & 65 deletions src/services/analytics/TagManager.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,89 @@
// Based on https://github.com/alinemorelli/react-gtm
import Cookies from 'js-cookie'

import { IS_PRODUCTION } from '@/config/constants'

type DataLayer = Record<string, unknown>

export type TagManagerArgs = {
/**
* GTM id, must be something like GTM-000000.
*/
// GTM id, e.g. GTM-000000
gtmId: string
/**
* Used to set environments.
*/
auth?: string | undefined
/**
* Used to set environments, something like env-00.
*/
preview?: string | undefined
/**
* Object that contains all of the information that you want to pass to Google Tag Manager.
*/
dataLayer?: DataLayer | undefined
}

export const DATA_LAYER_NAME = 'dataLayer'

export const _getRequiredGtmArgs = ({ gtmId, dataLayer = undefined, auth = '', preview = '' }: TagManagerArgs) => {
return {
gtmId,
dataLayer,
auth: auth ? `&gtm_auth=${auth}` : '',
preview: preview ? `&gtm_preview=${preview}` : '',
}
// GTM authetication key
auth: string
// GTM environment, e.g. env-00.
preview: string
// Object that contains all of the information that you want to pass to GTM
dataLayer?: DataLayer
}

// Initialization scripts

export const _getGtmScript = (args: TagManagerArgs) => {
const { gtmId, auth, preview } = _getRequiredGtmArgs(args)
const DATA_LAYER_NAME = 'dataLayer'

const script = document.createElement('script')
const TagManager = {
// `jest.spyOn` is not possible if outside of `TagManager`
_getScript: ({ gtmId, auth, preview }: TagManagerArgs) => {
const script = document.createElement('script')

const gtmScript = `
const gtmScript = `
(function (w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + '${auth}${preview}&gtm_cookies_win=x';
f.parentNode.insertBefore(j, f);
})(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');`
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl + '&gtm_auth=${auth}&gtm_preview=${preview}&gtm_cookies_win=x';
f.parentNode.insertBefore(j, f);
})(window, document, 'script', '${DATA_LAYER_NAME}', '${gtmId}');`

script.innerHTML = gtmScript
script.innerHTML = gtmScript

return script
}

// Data layer scripts
return script
},
isInitialized: () => {
const GTM_SCRIPT = 'https://www.googletagmanager.com/gtm.js'

export const _getGtmDataLayerScript = (dataLayer: DataLayer) => {
const script = document.createElement('script')
return !!document.querySelector(`[src^="${GTM_SCRIPT}"]`)
Comment on lines +41 to +44
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it work if we checked that window.dataLayer exists instead? It would be a bit simpler than querying the DOM.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would sooner check the DOM. We could manually push to window.dataLayer and then this would return true when GTM isn't initialised.

},
initialize: (args: TagManagerArgs) => {
if (TagManager.isInitialized()) {
return
}

const gtmDataLayerScript = `
window.${DATA_LAYER_NAME} = window.${DATA_LAYER_NAME} || [];
window.${DATA_LAYER_NAME}.push(${JSON.stringify(dataLayer)})`
// Initialize dataLayer (with configuration)
window[DATA_LAYER_NAME] = args.dataLayer ? [args.dataLayer] : []

script.innerHTML = gtmDataLayerScript
const script = TagManager._getScript(args)

return script
}
// Initialize GTM. This pushes the default dataLayer event:
// { "gtm.start": new Date().getTime(), event: "gtm.js" }
document.head.insertBefore(script, document.head.childNodes[0])
},
dataLayer: (dataLayer: DataLayer) => {
if (!TagManager.isInitialized()) {
return
}

const TagManager = {
initialize: (args: TagManagerArgs) => {
const { dataLayer } = _getRequiredGtmArgs(args)
window[DATA_LAYER_NAME].push(dataLayer)

if (dataLayer) {
const gtmDataLayerScript = _getGtmDataLayerScript(dataLayer)
document.head.appendChild(gtmDataLayerScript)
if (!IS_PRODUCTION) {
console.info('[GTM] -', dataLayer)
}

const gtmScript = _getGtmScript(args)
document.head.insertBefore(gtmScript, document.head.childNodes[0])
},
dataLayer: (dataLayer: DataLayer) => {
if (window[DATA_LAYER_NAME]) {
return window[DATA_LAYER_NAME].push(dataLayer)
disable: () => {
if (!TagManager.isInitialized()) {
return
}

const gtmDataLayerScript = _getGtmDataLayerScript(dataLayer)
document.head.insertBefore(gtmDataLayerScript, document.head.childNodes[0])
const GTM_COOKIE_LIST = ['_ga', '_gat', '_gid']

GTM_COOKIE_LIST.forEach((cookie) => {
Cookies.remove(cookie, {
path: '/',
domain: `.${location.host.split('.').slice(-2).join('.')}`,
})
})

// Injected script will remain in memory until new session
location.reload()
},
}

Expand Down
174 changes: 122 additions & 52 deletions src/services/analytics/__tests__/TagManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,160 @@
import TagManager, { _getGtmDataLayerScript, _getGtmScript, _getRequiredGtmArgs } from '../TagManager'
import Cookies from 'js-cookie'

const MOCK_ID = 'GTM-123456'
import * as gtm from '../TagManager'

describe('TagManager', () => {
beforeEach(() => {
delete window.dataLayer
})
const { default: TagManager } = gtm

describe('getRequiredGtmArgs', () => {
it('should assign default arguments', () => {
const result1 = _getRequiredGtmArgs({ gtmId: MOCK_ID })
const MOCK_ID = 'GTM-123456'
const MOCK_AUTH = 'key123'
const MOCK_PREVIEW = 'env-0'

expect(result1).toStrictEqual({
gtmId: MOCK_ID,
dataLayer: undefined,
auth: '',
preview: '',
})
jest.mock('js-cookie', () => ({
remove: jest.fn(),
}))

const result2 = _getRequiredGtmArgs({ gtmId: MOCK_ID, auth: 'abcdefg', preview: 'env-1' })
describe('TagManager', () => {
const originalLocation = window.location

// Mock `location.reload`
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
reload: jest.fn(),
},
})
})

expect(result2).toStrictEqual({
gtmId: MOCK_ID,
dataLayer: undefined,
auth: '&gtm_auth=abcdefg',
preview: '&gtm_preview=env-1',
})
// Remove mock
afterAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})

describe('getGtmScript', () => {
it('should use the id', () => {
const script1 = _getGtmScript({ gtmId: MOCK_ID })
// Clear GTM between tests
afterEach(() => {
document.head.innerHTML = ''
delete window.dataLayer
})

describe('TagManager._getScript', () => {
it('should use the id, auth and preview', () => {
const script1 = TagManager._getScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })

expect(script1.innerHTML).toContain(MOCK_ID)
expect(script1.innerHTML).toContain(`&gtm_auth=${MOCK_AUTH}`)
expect(script1.innerHTML).toContain(`&gtm_preview=${MOCK_PREVIEW}`)
expect(script1.innerHTML).toContain('dataLayer')
})
})

it('should use the auth and preview if present', () => {
const script1 = _getGtmScript({
gtmId: MOCK_ID,
})

expect(script1.innerHTML).not.toContain('&gtm_auth')
expect(script1.innerHTML).not.toContain('&gtm_preview')

const script2 = _getGtmScript({
gtmId: MOCK_ID,
auth: 'abcdefg',
preview: 'env-1',
})

expect(script2.innerHTML).toContain('&gtm_auth=abcdefg&gtm_preview=env-1')
describe('TagManager.isInitialized', () => {
it('should return false if no script is found', () => {
expect(TagManager.isInitialized()).toBe(false)
})
})

describe('getGtmDataLayerScript', () => {
it('should use the `dataLayer` for the script', () => {
const dataLayerScript = _getGtmDataLayerScript({
gtmId: MOCK_ID,
dataLayer: { foo: 'bar' },
})
it('should return true if a script is found', () => {
TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })

expect(dataLayerScript.innerHTML).toContain('{"foo":"bar"}')
expect(TagManager.isInitialized()).toBe(true)
})
})

describe('TagManager.initialize', () => {
it('should initialize TagManager', () => {
TagManager.initialize({ gtmId: MOCK_ID })
TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })

expect(document.head.childNodes).toHaveLength(2)

// Script added by `TagManager._getScript`
// @ts-expect-error
expect(document.head.childNodes[0].src).toBe(
`https://www.googletagmanager.com/gtm.js?id=${MOCK_ID}&gtm_auth=${MOCK_AUTH}&gtm_preview=${MOCK_PREVIEW}&gtm_cookies_win=x`,
)

// Manually added script
expect(document.head.childNodes[1]).toStrictEqual(
TagManager._getScript({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW }),
)

expect(window.dataLayer).toHaveLength(1)
expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) })
})

it('should not re-initialize the scripts if previously enabled', async () => {
const getScriptSpy = jest.spyOn(gtm.default, '_getScript')

TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })
TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW })

expect(getScriptSpy).toHaveBeenCalledTimes(1)
})

it('should push to the dataLayer if povided', () => {
TagManager.initialize({ gtmId: MOCK_ID, auth: MOCK_AUTH, preview: MOCK_PREVIEW, dataLayer: { test: '456' } })

expect(window.dataLayer).toHaveLength(2)
expect(window.dataLayer[0]).toStrictEqual({ test: '456' })
expect(window.dataLayer[1]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) })
})
})

describe('TagManager.dataLayer', () => {
it('should not push to the dataLayer if not initialized', () => {
TagManager.dataLayer({ test: '456' })

expect(window.dataLayer).toBeUndefined()
})

it('should push data to the dataLayer', () => {
TagManager.dataLayer({
test: '123',
expect(window.dataLayer).toBeUndefined()

TagManager.initialize({
gtmId: MOCK_ID,
auth: MOCK_AUTH,
preview: MOCK_PREVIEW,
})

expect(window.dataLayer).toHaveLength(1)
expect(window.dataLayer[0]).toStrictEqual({
expect(window.dataLayer[0]).toStrictEqual({ event: 'gtm.js', 'gtm.start': expect.any(Number) })

TagManager.dataLayer({
test: '123',
})

expect(window.dataLayer).toHaveLength(2)
expect(window.dataLayer[1]).toStrictEqual({ test: '123' })
})
})

describe('TagManager.disable', () => {
it('should not remove GA cookies and reload if not mounted', () => {
TagManager.disable()

expect(Cookies.remove).not.toHaveBeenCalled()

expect(global.location.reload).not.toHaveBeenCalled()
})
it('should remove GA cookies and reload if mounted', () => {
TagManager.initialize({
gtmId: MOCK_ID,
auth: MOCK_AUTH,
preview: MOCK_PREVIEW,
})

TagManager.disable()

const path = '/'
const domain = '.localhost'

expect(Cookies.remove).toHaveBeenCalledWith('_ga', { path, domain })
expect(Cookies.remove).toHaveBeenCalledWith('_gat', { path, domain })
expect(Cookies.remove).toHaveBeenCalledWith('_gid', { path, domain })

expect(global.location.reload).toHaveBeenCalled()
})
})
})
Loading