Skip to content

Commit da7ee52

Browse files
authored
feat: add useGTM hook (#716)
* feat: add useGTM hook * fix: export as type * fix: correct date on test
1 parent 573e748 commit da7ee52

File tree

11 files changed

+469
-0
lines changed

11 files changed

+469
-0
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ scaleway-lib is a set of NPM packages used at Scaleway.
7272
![npm bundle size](https://img.shields.io/bundlephobia/min/@scaleway/use-segment)
7373
![npm](https://img.shields.io/npm/v/@scaleway/use-segment)
7474

75+
- [`@scaleway/use-gtm`](./packages/use-gtm/README.md):
76+
A tiny hook to handle gtm.
77+
78+
![npm](https://img.shields.io/npm/dm/@scaleway/use-gtm)
79+
![npm bundle size](https://img.shields.io/bundlephobia/min/@scaleway/use-gtm)
80+
![npm](https://img.shields.io/npm/v/@scaleway/use-gtm)
81+
7582
- [`@scaleway/use-i18n`](./packages/use-i18n/README.md):
7683
A tiny hook to handle i18n.
7784

packages/use-gtm/.eslintrc.cjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const { join } = require('path')
2+
3+
module.exports = {
4+
rules: {
5+
'import/no-extraneous-dependencies': [
6+
'error',
7+
{ packageDir: [__dirname, join(__dirname, '../../')] },
8+
],
9+
},
10+
}

packages/use-gtm/.npmignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
**/__tests__/**
2+
examples/
3+
src
4+
.eslintrc.cjs
5+
!.npmignore

packages/use-gtm/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# `@scaleway/use-gtm`
2+
3+
## A tiny provider to handle Google Tag Manager in React
4+
5+
## Install
6+
7+
```bash
8+
$ pnpm add @scaleway/use-gtm
9+
```
10+
11+
## Usage
12+
13+
### Basic
14+
15+
```tsx
16+
import GTMProvider, { useGTM } from '@scaleway/use-gtm'
17+
18+
const Page = () => {
19+
const { sendGTM } = useGTM()
20+
21+
sendGTM?.({
22+
hello: 'world
23+
})
24+
25+
return <p>Hello World</p>
26+
}
27+
28+
const App = () => (
29+
<GTMProvider id="testId">
30+
<Page />
31+
</GTMProvider>
32+
)
33+
```
34+
35+
### With injected events
36+
37+
```tsx
38+
import GTMProvider, { useGTM } from '@scaleway/use-gtm'
39+
40+
const events = {
41+
sampleEvent: (sendGTM?: SendGTM) => (message: string) => {
42+
sendGTM?.({
43+
event: 'sampleEvent',
44+
hello: message,
45+
})
46+
}
47+
}
48+
49+
const Page = () => {
50+
const { events } = useGTM()
51+
52+
events.sampleEvent?.('world')
53+
54+
return <p>Hello World</p>
55+
}
56+
57+
const App = () => (
58+
<GTMProvider id="testId">
59+
<Page />
60+
</GTMProvider>
61+
)
62+
```
63+
64+
### With global setter
65+
66+
```tsx
67+
import GTMProvider, { sendGTM } from '@scaleway/use-gtm'
68+
69+
const Page = () => {
70+
sendGTM?.({
71+
hello: 'world
72+
})
73+
74+
return <p>Hello World</p>
75+
}
76+
77+
const App = () => (
78+
<GTMProvider id="testId">
79+
<Page />
80+
</GTMProvider>
81+
)
82+
```

packages/use-gtm/package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@scaleway/use-gtm",
3+
"version": "1.0.0",
4+
"description": "A small hook to handle gtm in a react app",
5+
"keywords": [
6+
"react",
7+
"reactjs",
8+
"hooks",
9+
"google",
10+
"google tag manager",
11+
"gtm"
12+
],
13+
"type": "module",
14+
"main": "dist/index.js",
15+
"module": "dist/index.js",
16+
"types": "dist/index.d.ts",
17+
"browser": {
18+
"dist/index.js": "./dist/index.browser.js"
19+
},
20+
"publishConfig": {
21+
"access": "public"
22+
},
23+
"repository": {
24+
"type": "git",
25+
"url": "https://github.com/scaleway/scaleway-lib",
26+
"directory": "packages/use-gtm"
27+
},
28+
"license": "MIT",
29+
"peerDependencies": {
30+
"react": "17.x"
31+
}
32+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`GTM hook Provider should call onLoadError if script fail to load 1`] = `
4+
"<script src=\\"https://www.googletagmanager.com/gtm.js?id=testId\\"></script><script>window.dataLayer = window.dataLayer || [];</script><script>(function(w,d,s,l,i){w[l]=w[l]||[];
5+
w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
6+
var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
7+
j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'';
8+
9+
j.addEventListener('error', function() {
10+
var _ge = new CustomEvent('gtm_loading_error', { bubbles: true });
11+
d.dispatchEvent(_ge);
12+
});
13+
14+
f.parentNode.insertBefore(j,f);
15+
})(window,document,'script','dataLayer','testId');</script>"
16+
`;
17+
18+
exports[`GTM hook Provider should load when id and environment is provided 1`] = `
19+
"<script src=\\"https://www.googletagmanager.com/gtm.js?id=testId&amp;gtm_auth=gtm&amp;gtm_preview=world&amp;gtm_cookies_win=x\\"></script><script>window.dataLayer = window.dataLayer || [];</script><script>(function(w,d,s,l,i){w[l]=w[l]||[];
20+
w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
21+
var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
22+
j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'&gtm_auth=gtm&gtm_preview=world&gtm_cookies_win=x';
23+
24+
j.addEventListener('error', function() {
25+
var _ge = new CustomEvent('gtm_loading_error', { bubbles: true });
26+
d.dispatchEvent(_ge);
27+
});
28+
29+
f.parentNode.insertBefore(j,f);
30+
})(window,document,'script','dataLayer','testId');</script>"
31+
`;
32+
33+
exports[`GTM hook Provider should load when id is provided 1`] = `
34+
"<script src=\\"https://www.googletagmanager.com/gtm.js?id=testId\\"></script><script>window.dataLayer = window.dataLayer || [];</script><script>(function(w,d,s,l,i){w[l]=w[l]||[];
35+
w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
36+
var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
37+
j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'';
38+
39+
j.addEventListener('error', function() {
40+
var _ge = new CustomEvent('gtm_loading_error', { bubbles: true });
41+
d.dispatchEvent(_ge);
42+
});
43+
44+
f.parentNode.insertBefore(j,f);
45+
})(window,document,'script','dataLayer','testId');</script>"
46+
`;
47+
48+
exports[`GTM hook Provider should load with events when provided 1`] = `
49+
Array [
50+
Object {
51+
"event": "gtm.js",
52+
"gtm.start": 1618272000000,
53+
},
54+
Object {
55+
"event": "sampleEvent",
56+
"extra": "test",
57+
},
58+
]
59+
`;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { fireEvent } from '@testing-library/react'
2+
import { renderHook } from '@testing-library/react-hooks'
3+
import mockdate from 'mockdate'
4+
import { ReactNode } from 'react'
5+
import GTMProvider, { SendGTM, useGTM } from '..'
6+
import { GTMProviderProps } from '../useGTM'
7+
8+
const defaultEvents = {
9+
sampleEvent: (sendGTM?: SendGTM) => (extraValue: string) => {
10+
sendGTM?.({
11+
event: 'sampleEvent',
12+
extra: extraValue,
13+
})
14+
},
15+
}
16+
17+
type DefaultEvents = typeof defaultEvents
18+
19+
const wrapper =
20+
({
21+
id,
22+
events,
23+
environment,
24+
onLoadError,
25+
}: Omit<GTMProviderProps<DefaultEvents>, 'children'>) =>
26+
({ children }: { children: ReactNode }) =>
27+
(
28+
<GTMProvider
29+
id={id}
30+
events={events}
31+
environment={environment}
32+
onLoadError={onLoadError}
33+
>
34+
{children}
35+
</GTMProvider>
36+
)
37+
38+
describe('GTM hook', () => {
39+
beforeEach(() => {
40+
mockdate.set('4/13/2021')
41+
})
42+
43+
afterEach(() => {
44+
mockdate.reset()
45+
document.head.innerHTML = ''
46+
window.dataLayer = undefined
47+
jest.restoreAllMocks()
48+
})
49+
50+
it('useGTM should not be defined without GTMProvider', () => {
51+
const { result } = renderHook(() => useGTM())
52+
expect(() => {
53+
expect(result.current).toBe(undefined)
54+
}).toThrow(Error('useGTM must be used within a GTMProvider'))
55+
})
56+
57+
it('Provider should call onLoadError if script fail to load', () => {
58+
renderHook(() => useGTM<DefaultEvents>(), {
59+
wrapper: wrapper({
60+
id: 'testId',
61+
}),
62+
})
63+
64+
expect(document.head.innerHTML).toMatchSnapshot()
65+
})
66+
67+
it('Provider should load when id is provided', () => {
68+
renderHook(() => useGTM<DefaultEvents>(), {
69+
wrapper: wrapper({
70+
id: 'testId',
71+
}),
72+
})
73+
74+
expect(document.head.innerHTML).toMatchSnapshot()
75+
})
76+
77+
it('Provider should load when id and environment is provided', () => {
78+
renderHook(() => useGTM<DefaultEvents>(), {
79+
wrapper: wrapper({
80+
environment: {
81+
auth: 'gtm',
82+
preview: 'world',
83+
},
84+
id: 'testId',
85+
}),
86+
})
87+
88+
expect(document.head.innerHTML).toMatchSnapshot()
89+
})
90+
91+
it('Provider should load with events when provided', () => {
92+
const { result } = renderHook(() => useGTM<DefaultEvents>(), {
93+
wrapper: wrapper({
94+
events: defaultEvents,
95+
id: 'testId',
96+
}),
97+
})
98+
99+
expect(result.current.events.sampleEvent('test')).toBe(undefined)
100+
expect(window.dataLayer).toMatchSnapshot()
101+
// @ts-expect-error if type infering works this should be an error
102+
expect(result.current.events.sampleEvent()).toBe(undefined)
103+
})
104+
105+
it('Provider should load onLoadError when script fail to load', () => {
106+
const onLoadError = jest.fn()
107+
108+
renderHook(() => useGTM<DefaultEvents>(), {
109+
wrapper: wrapper({
110+
id: 'testId',
111+
onLoadError,
112+
}),
113+
})
114+
115+
const script = document.querySelector(
116+
`script[src="https://www.googletagmanager.com/gtm.js?id=testId"]`,
117+
) as Element
118+
fireEvent.error(script)
119+
expect(onLoadError).toHaveBeenCalledTimes(1)
120+
})
121+
})

packages/use-gtm/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import GTMProvider from './useGTM'
2+
3+
export { useGTM, sendGTM } from './useGTM'
4+
export type { SendGTM } from './types'
5+
6+
export default GTMProvider

packages/use-gtm/src/scripts.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { GTMEnvironment } from './types'
2+
3+
export const DATALAYER_NAME = 'dataLayer'
4+
export const LOAD_ERROR_EVENT = 'gtm_loading_error'
5+
6+
const flattenEnvironment = (environment?: GTMEnvironment) =>
7+
environment
8+
? `&${Object.entries(environment)
9+
.filter(([, value]) => !!value)
10+
.map(([key, value]) => `gtm_${key}=${value}`, '')
11+
.join('&')}&gtm_cookies_win=x`
12+
: ''
13+
14+
const generateSnippets = (id: string, environment?: GTMEnvironment) => {
15+
const env = flattenEnvironment(environment)
16+
17+
return {
18+
dataLayerInit: `window.${DATALAYER_NAME} = window.${DATALAYER_NAME} || [];`,
19+
noScript: `<iframe src="https://www.googletagmanager.com/ns.html?id=${id}${env}" height="0" width="0" style="display:none;visibility:hidden" id="tag-manager"></iframe>`,
20+
script: `(function(w,d,s,l,i){w[l]=w[l]||[];
21+
w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
22+
var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
23+
j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'${env}';
24+
25+
j.addEventListener('error', function() {
26+
var _ge = new CustomEvent('${LOAD_ERROR_EVENT}', { bubbles: true });
27+
d.dispatchEvent(_ge);
28+
});
29+
30+
f.parentNode.insertBefore(j,f);
31+
})(window,document,'script','${DATALAYER_NAME}','${id}');`,
32+
}
33+
}
34+
35+
const generateScripts = (id: string, environment?: GTMEnvironment) => {
36+
const {
37+
dataLayerInit: dataLayerInitSnippet,
38+
noScript: noScriptSnippet,
39+
script: scriptSnippet,
40+
} = generateSnippets(id, environment)
41+
42+
const dataLayerInit = document.createElement('script')
43+
dataLayerInit.innerHTML = dataLayerInitSnippet
44+
const noScript = document.createElement('noscript')
45+
noScript.innerHTML = noScriptSnippet
46+
const script = document.createElement('script')
47+
script.innerHTML = scriptSnippet
48+
49+
return {
50+
dataLayerInit,
51+
noScript,
52+
script,
53+
}
54+
}
55+
56+
export default generateScripts

0 commit comments

Comments
 (0)