From 9f7e3ff69654f51d770c7543c5e47814fbc7f3d6 Mon Sep 17 00:00:00 2001 From: Emmanuel Chambon Date: Mon, 11 Apr 2022 23:04:49 +0200 Subject: [PATCH 1/3] feat: add useGTM hook --- README.md | 7 ++ packages/use-gtm/.eslintrc.cjs | 10 ++ packages/use-gtm/.npmignore | 5 + packages/use-gtm/README.md | 82 +++++++++++++ packages/use-gtm/package.json | 32 +++++ .../__tests__/__snapshots__/index.tsx.snap | 59 +++++++++ packages/use-gtm/src/__tests__/index.tsx | 115 ++++++++++++++++++ packages/use-gtm/src/index.ts | 6 + packages/use-gtm/src/scripts.ts | 56 +++++++++ packages/use-gtm/src/types.ts | 10 ++ packages/use-gtm/src/useGTM.tsx | 81 ++++++++++++ 11 files changed, 463 insertions(+) create mode 100644 packages/use-gtm/.eslintrc.cjs create mode 100644 packages/use-gtm/.npmignore create mode 100644 packages/use-gtm/README.md create mode 100644 packages/use-gtm/package.json create mode 100644 packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap create mode 100644 packages/use-gtm/src/__tests__/index.tsx create mode 100644 packages/use-gtm/src/index.ts create mode 100644 packages/use-gtm/src/scripts.ts create mode 100644 packages/use-gtm/src/types.ts create mode 100644 packages/use-gtm/src/useGTM.tsx diff --git a/README.md b/README.md index 7deac1610..2a65cf51b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,13 @@ scaleway-lib is a set of NPM packages used at Scaleway. ![npm bundle size](https://img.shields.io/bundlephobia/min/@scaleway/use-segment) ![npm](https://img.shields.io/npm/v/@scaleway/use-segment) +- [`@scaleway/use-gtm`](./packages/use-gtm/README.md): + A tiny hook to handle gtm. + + ![npm](https://img.shields.io/npm/dm/@scaleway/use-gtm) + ![npm bundle size](https://img.shields.io/bundlephobia/min/@scaleway/use-gtm) + ![npm](https://img.shields.io/npm/v/@scaleway/use-gtm) + - [`@scaleway/use-i18n`](./packages/use-i18n/README.md): A tiny hook to handle i18n. diff --git a/packages/use-gtm/.eslintrc.cjs b/packages/use-gtm/.eslintrc.cjs new file mode 100644 index 000000000..a2dbbe3d7 --- /dev/null +++ b/packages/use-gtm/.eslintrc.cjs @@ -0,0 +1,10 @@ +const { join } = require('path') + +module.exports = { + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { packageDir: [__dirname, join(__dirname, '../../')] }, + ], + }, +} diff --git a/packages/use-gtm/.npmignore b/packages/use-gtm/.npmignore new file mode 100644 index 000000000..5600eef5f --- /dev/null +++ b/packages/use-gtm/.npmignore @@ -0,0 +1,5 @@ +**/__tests__/** +examples/ +src +.eslintrc.cjs +!.npmignore diff --git a/packages/use-gtm/README.md b/packages/use-gtm/README.md new file mode 100644 index 000000000..c76db3eb8 --- /dev/null +++ b/packages/use-gtm/README.md @@ -0,0 +1,82 @@ +# `@scaleway/use-gtm` + +## A tiny provider to handle Google Tag Manager in React + +## Install + +```bash +$ pnpm add @scaleway/use-gtm +``` + +## Usage + +### Basic + +```tsx +import GTMProvider, { useGTM } from '@scaleway/use-gtm' + +const Page = () => { + const { sendGTM } = useGTM() + + sendGTM?.({ + hello: 'world + }) + + return

Hello World

+} + +const App = () => ( + + + +) +``` + +### With injected events + +```tsx +import GTMProvider, { useGTM } from '@scaleway/use-gtm' + +const events = { + sampleEvent: (sendGTM?: SendGTM) => (message: string) => { + sendGTM?.({ + event: 'sampleEvent', + hello: message, + }) + } +} + +const Page = () => { + const { events } = useGTM() + + events.sampleEvent?.('world') + + return

Hello World

+} + +const App = () => ( + + + +) +``` + +### With global setter + +```tsx +import GTMProvider, { sendGTM } from '@scaleway/use-gtm' + +const Page = () => { + sendGTM?.({ + hello: 'world + }) + + return

Hello World

+} + +const App = () => ( + + + +) +``` diff --git a/packages/use-gtm/package.json b/packages/use-gtm/package.json new file mode 100644 index 000000000..2853d62b3 --- /dev/null +++ b/packages/use-gtm/package.json @@ -0,0 +1,32 @@ +{ + "name": "@scaleway/use-gtm", + "version": "0.1.0", + "description": "A small hook to handle gtm in a react app", + "keywords": [ + "react", + "reactjs", + "hooks", + "google", + "google tag manager", + "gtm" + ], + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "browser": { + "dist/index.js": "./dist/index.browser.js" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/scaleway/scaleway-lib", + "directory": "packages/use-gtm" + }, + "license": "MIT", + "peerDependencies": { + "react": "17.x" + } +} diff --git a/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap b/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap new file mode 100644 index 000000000..678dd3e00 --- /dev/null +++ b/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GTM hook Provider should call onLoadError if script fail to load 1`] = ` +"" +`; + +exports[`GTM hook Provider should load when id and environment is provided 1`] = ` +"" +`; + +exports[`GTM hook Provider should load when id is provided 1`] = ` +"" +`; + +exports[`GTM hook Provider should load with events when provided 1`] = ` +Array [ + Object { + "event": "gtm.js", + "gtm.start": 1649710634339, + }, + Object { + "event": "sampleEvent", + "extra": "test", + }, +] +`; diff --git a/packages/use-gtm/src/__tests__/index.tsx b/packages/use-gtm/src/__tests__/index.tsx new file mode 100644 index 000000000..c52c1f30f --- /dev/null +++ b/packages/use-gtm/src/__tests__/index.tsx @@ -0,0 +1,115 @@ +import { fireEvent } from '@testing-library/react' +import { renderHook } from '@testing-library/react-hooks' +import { ReactNode } from 'react' +import GTMProvider, { SendGTM, useGTM } from '..' +import { GTMProviderProps } from '../useGTM' + +const defaultEvents = { + sampleEvent: (sendGTM?: SendGTM) => (extraValue: string) => { + sendGTM?.({ + event: 'sampleEvent', + extra: extraValue, + }) + }, +} + +type DefaultEvents = typeof defaultEvents + +const wrapper = + ({ + id, + events, + environment, + onLoadError, + }: Omit, 'children'>) => + ({ children }: { children: ReactNode }) => + ( + + {children} + + ) + +describe('GTM hook', () => { + beforeEach(() => { + document.head.innerHTML = '' + window.dataLayer = undefined + jest.restoreAllMocks() + }) + + it('useGTM should not be defined without GTMProvider', () => { + const { result } = renderHook(() => useGTM()) + expect(() => { + expect(result.current).toBe(undefined) + }).toThrow(Error('useGTM must be used within a GTMProvider')) + }) + + it('Provider should call onLoadError if script fail to load', () => { + renderHook(() => useGTM(), { + wrapper: wrapper({ + id: 'testId', + }), + }) + + expect(document.head.innerHTML).toMatchSnapshot() + }) + + it('Provider should load when id is provided', () => { + renderHook(() => useGTM(), { + wrapper: wrapper({ + id: 'testId', + }), + }) + + expect(document.head.innerHTML).toMatchSnapshot() + }) + + it('Provider should load when id and environment is provided', () => { + renderHook(() => useGTM(), { + wrapper: wrapper({ + environment: { + auth: 'gtm', + preview: 'world', + }, + id: 'testId', + }), + }) + + expect(document.head.innerHTML).toMatchSnapshot() + }) + + it('Provider should load with events when provided', () => { + const { result } = renderHook(() => useGTM(), { + wrapper: wrapper({ + events: defaultEvents, + id: 'testId', + }), + }) + + expect(result.current.events.sampleEvent('test')).toBe(undefined) + expect(window.dataLayer).toMatchSnapshot() + // @ts-expect-error if type infering works this should be an error + expect(result.current.events.sampleEvent()).toBe(undefined) + }) + + it('Provider should load onLoadError when script fail to load', () => { + const onLoadError = jest.fn() + + renderHook(() => useGTM(), { + wrapper: wrapper({ + id: 'testId', + onLoadError, + }), + }) + + const script = document.querySelector( + `script[src="https://www.googletagmanager.com/gtm.js?id=testId"]`, + ) as Element + fireEvent.error(script) + expect(onLoadError).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/use-gtm/src/index.ts b/packages/use-gtm/src/index.ts new file mode 100644 index 000000000..1310442b9 --- /dev/null +++ b/packages/use-gtm/src/index.ts @@ -0,0 +1,6 @@ +import GTMProvider from './useGTM' + +export { useGTM, sendGTM } from './useGTM' +export { SendGTM } from './types' + +export default GTMProvider diff --git a/packages/use-gtm/src/scripts.ts b/packages/use-gtm/src/scripts.ts new file mode 100644 index 000000000..9124a9a5f --- /dev/null +++ b/packages/use-gtm/src/scripts.ts @@ -0,0 +1,56 @@ +import { GTMEnvironment } from './types' + +export const DATALAYER_NAME = 'dataLayer' +export const LOAD_ERROR_EVENT = 'gtm_loading_error' + +const flattenEnvironment = (environment?: GTMEnvironment) => + environment + ? `&${Object.entries(environment) + .filter(([, value]) => !!value) + .map(([key, value]) => `gtm_${key}=${value}`, '') + .join('&')}>m_cookies_win=x` + : '' + +const generateSnippets = (id: string, environment?: GTMEnvironment) => { + const env = flattenEnvironment(environment) + + return { + dataLayerInit: `window.${DATALAYER_NAME} = window.${DATALAYER_NAME} || [];`, + noScript: ``, + script: `(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),dl=l!='dataLayer'?'&l='+l:''; + j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl+'${env}'; + + j.addEventListener('error', function() { + var _ge = new CustomEvent('${LOAD_ERROR_EVENT}', { bubbles: true }); + d.dispatchEvent(_ge); + }); + + f.parentNode.insertBefore(j,f); + })(window,document,'script','${DATALAYER_NAME}','${id}');`, + } +} + +const generateScripts = (id: string, environment?: GTMEnvironment) => { + const { + dataLayerInit: dataLayerInitSnippet, + noScript: noScriptSnippet, + script: scriptSnippet, + } = generateSnippets(id, environment) + + const dataLayerInit = document.createElement('script') + dataLayerInit.innerHTML = dataLayerInitSnippet + const noScript = document.createElement('noscript') + noScript.innerHTML = noScriptSnippet + const script = document.createElement('script') + script.innerHTML = scriptSnippet + + return { + dataLayerInit, + noScript, + script, + } +} + +export default generateScripts diff --git a/packages/use-gtm/src/types.ts b/packages/use-gtm/src/types.ts new file mode 100644 index 000000000..b76df7c9a --- /dev/null +++ b/packages/use-gtm/src/types.ts @@ -0,0 +1,10 @@ +type PrimitiveType = string | number | boolean | null | undefined | Date +export type DataLayerEvent = Record +export type SendGTM = (event: DataLayerEvent) => void +export type GTMEnvironment = { + auth: string + preview?: string +} + +export type EventFunction = (...args: never[]) => void +export type Events = Record EventFunction> diff --git a/packages/use-gtm/src/useGTM.tsx b/packages/use-gtm/src/useGTM.tsx new file mode 100644 index 000000000..ef8720978 --- /dev/null +++ b/packages/use-gtm/src/useGTM.tsx @@ -0,0 +1,81 @@ +import { ReactNode, createContext, useContext, useEffect, useMemo } from 'react' +import generateScripts, { DATALAYER_NAME, LOAD_ERROR_EVENT } from './scripts' +import { DataLayerEvent, Events, GTMEnvironment, SendGTM } from './types' + +interface GTMContextInterface { + sendGTM: SendGTM | undefined + events: { [K in keyof T]: ReturnType } +} + +declare global { + interface Window { + dataLayer: DataLayerEvent[] | undefined + } +} + +export const sendGTM = (data: DataLayerEvent) => { + window?.[DATALAYER_NAME]?.push(data) +} + +const GTMContext = createContext(undefined) + +export function useGTM(): GTMContextInterface { + // @ts-expect-error Here we force cast the generic onto the useContext because the context is a + // global variable and cannot be generic + const context = useContext | undefined>(GTMContext) + if (context === undefined) { + throw new Error('useGTM must be used within a GTMProvider') + } + + return context +} + +export type GTMProviderProps = { + id: string + environment?: GTMEnvironment + children: ReactNode + onLoadError?: () => void + events?: T +} + +function GTMProvider({ + children, + id, + environment, + onLoadError, + events, +}: GTMProviderProps) { + useEffect(() => { + const { noScript, script, dataLayerInit } = generateScripts(id, environment) + + document.head.insertBefore(dataLayerInit, document.head.childNodes[0]) + document.head.insertBefore(script, document.head.childNodes[1]) + document.body.insertBefore(noScript, document.body.childNodes[0]) + + if (onLoadError) document.addEventListener(LOAD_ERROR_EVENT, onLoadError) + + return () => { + if (onLoadError) + document.removeEventListener(LOAD_ERROR_EVENT, onLoadError) + } + }, [environment, id, onLoadError]) + + const value = useMemo>(() => { + const curiedEvents = Object.entries(events || {}).reduce( + (acc, [eventName, eventFn]) => ({ + ...acc, + [eventName]: eventFn(sendGTM), + }), + {}, + ) as { [K in keyof T]: ReturnType } + + return { + events: curiedEvents, + sendGTM, + } + }, [events]) + + return {children} +} + +export default GTMProvider From a66028a67f8fdf5c651ca0f36591711e6fa22a8d Mon Sep 17 00:00:00 2001 From: Emmanuel Chambon Date: Mon, 11 Apr 2022 23:07:06 +0200 Subject: [PATCH 2/3] fix: export as type --- packages/use-gtm/package.json | 2 +- packages/use-gtm/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/use-gtm/package.json b/packages/use-gtm/package.json index 2853d62b3..4cb951339 100644 --- a/packages/use-gtm/package.json +++ b/packages/use-gtm/package.json @@ -1,6 +1,6 @@ { "name": "@scaleway/use-gtm", - "version": "0.1.0", + "version": "1.0.0", "description": "A small hook to handle gtm in a react app", "keywords": [ "react", diff --git a/packages/use-gtm/src/index.ts b/packages/use-gtm/src/index.ts index 1310442b9..0d40b4680 100644 --- a/packages/use-gtm/src/index.ts +++ b/packages/use-gtm/src/index.ts @@ -1,6 +1,6 @@ import GTMProvider from './useGTM' export { useGTM, sendGTM } from './useGTM' -export { SendGTM } from './types' +export type { SendGTM } from './types' export default GTMProvider From f3f442ad8ed9f8ae1e487ec96533fe22a881b2de Mon Sep 17 00:00:00 2001 From: Emmanuel Chambon Date: Mon, 11 Apr 2022 23:10:00 +0200 Subject: [PATCH 3/3] fix: correct date on test --- packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap | 2 +- packages/use-gtm/src/__tests__/index.tsx | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap b/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap index 678dd3e00..358a025c0 100644 --- a/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap +++ b/packages/use-gtm/src/__tests__/__snapshots__/index.tsx.snap @@ -49,7 +49,7 @@ exports[`GTM hook Provider should load with events when provided 1`] = ` Array [ Object { "event": "gtm.js", - "gtm.start": 1649710634339, + "gtm.start": 1618272000000, }, Object { "event": "sampleEvent", diff --git a/packages/use-gtm/src/__tests__/index.tsx b/packages/use-gtm/src/__tests__/index.tsx index c52c1f30f..6ac73a19f 100644 --- a/packages/use-gtm/src/__tests__/index.tsx +++ b/packages/use-gtm/src/__tests__/index.tsx @@ -1,5 +1,6 @@ import { fireEvent } from '@testing-library/react' import { renderHook } from '@testing-library/react-hooks' +import mockdate from 'mockdate' import { ReactNode } from 'react' import GTMProvider, { SendGTM, useGTM } from '..' import { GTMProviderProps } from '../useGTM' @@ -36,6 +37,11 @@ const wrapper = describe('GTM hook', () => { beforeEach(() => { + mockdate.set('4/13/2021') + }) + + afterEach(() => { + mockdate.reset() document.head.innerHTML = '' window.dataLayer = undefined jest.restoreAllMocks()