From c4e226b3f79d01c8f4adc73a4331841154776f6f Mon Sep 17 00:00:00 2001 From: Daniel Bento Date: Fri, 29 Dec 2023 11:58:40 +0000 Subject: [PATCH] feat: add support to google consent mode for `v1` --- .../src/analytics/integrations/GA4/GA4.js | 32 ++++++- .../src/analytics/integrations/GTM/GTM.js | 5 + .../analytics/integrations/GTM/constants.js | 2 + .../src/analytics/integrations/GTM/gtmTag.js | 4 +- .../react/src/analytics/integrations/index.js | 7 +- .../ConsentManagementProtocol.js | 80 ++++++++++++++++ .../ConsentManagementProtocol.test.js | 95 +++++++++++++++++++ .../ConsentManagementProtocol.test.js.snap | 31 ++++++ .../shared/ConsentManagementProtocol/index.js | 4 + .../analytics/integrations/shared/index.js | 6 ++ 10 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/ConsentManagementProtocol.js create mode 100644 packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/__tests__/ConsentManagementProtocol.test.js create mode 100644 packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/__tests__/__snapshots__/ConsentManagementProtocol.test.js.snap create mode 100644 packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/index.js create mode 100644 packages/react/src/analytics/integrations/shared/index.js diff --git a/packages/react/src/analytics/integrations/GA4/GA4.js b/packages/react/src/analytics/integrations/GA4/GA4.js index 44c544495..ea44af99c 100644 --- a/packages/react/src/analytics/integrations/GA4/GA4.js +++ b/packages/react/src/analytics/integrations/GA4/GA4.js @@ -20,6 +20,7 @@ import { integrations, utils, } from '@farfetch/blackout-core/analytics'; +import { consentManagementProtocol } from '..'; import { DATA_TEST_SELECTOR, DEFAULT_DATA_LAYER_NAME, @@ -76,7 +77,7 @@ class GA4 extends integrations.Integration { */ constructor(options, loadData, analytics) { super(options, loadData, analytics); - this.initialize(options); + this.initialize(options, loadData); this.onSetUser(loadData); } @@ -87,8 +88,9 @@ class GA4 extends integrations.Integration { * Initializes member variables from options and tries to initialize Google Analytics 4. * * @param {object} options - Options passed for the GA4 integration. + * @param {object} loadData - Analytics's load event data. */ - initialize(options) { + initialize(options, loadData) { this.optionsValidationResultsMap = validateFields(options); this.measurementId = options[OPTION_MEASUREMENT_ID]; @@ -123,9 +125,35 @@ class GA4 extends integrations.Integration { true, ); + const dataLayer = get( + this.options, + OPTION_DATA_LAYER_NAME, + DEFAULT_DATA_LAYER_NAME, + ); + consentManagementProtocol.addIntegration(loadData?.consent, dataLayer); this.loadGtagScript(options); } + /** + * Sets the consent object. + * This method is called by analytics whenever the consent changes, so there's no need to validate if it has changed or not. + * + * @param {object} consent - Object to be written on the dataLayer. + * + * @returns {GA4} This allows chaining of class methods. + */ + setConsent(consent) { + const dataLayer = get( + this.options, + OPTION_DATA_LAYER_NAME, + DEFAULT_DATA_LAYER_NAME, + ); + + consentManagementProtocol.setConsents(consent, dataLayer); + + return this; + } + /** * Send page events to GA4. * diff --git a/packages/react/src/analytics/integrations/GTM/GTM.js b/packages/react/src/analytics/integrations/GTM/GTM.js index e41c299a9..8b38b9118 100644 --- a/packages/react/src/analytics/integrations/GTM/GTM.js +++ b/packages/react/src/analytics/integrations/GTM/GTM.js @@ -22,6 +22,7 @@ import { DATA_LAYER_CONSENT_EVENT, DATA_LAYER_CONTEXT_EVENT, DATA_LAYER_SET_USER_EVENT, + GTM_DATA_LAYER, GTM_LABEL_PREFIX, GTM_TYPE_ERROR_PREFIX, INVALID_FUNCTION_ERROR_SUFFIX, @@ -32,6 +33,7 @@ import { SET_USER_KEY, SET_USER_TYPE, } from './constants'; +import { consentManagementProtocol } from '..'; import { utils as coreUtils } from '@farfetch/blackout-core/analytics'; import { getContextParameters, getUserParameters } from './utils'; import { Integration } from '@farfetch/blackout-core/analytics/integrations'; @@ -107,6 +109,8 @@ class GTM extends Integration { * @param {object} loadData - Analytics's load event data. */ initialize(options, loadData) { + consentManagementProtocol.addIntegration(loadData?.consent, GTM_DATA_LAYER); + this.runGTMScript(options); this.setConsent(loadData.consent); this.setContext(loadData.context); @@ -150,6 +154,7 @@ class GTM extends Integration { * @returns {GTM} This allows chaining of class methods. */ setConsent(consent) { + consentManagementProtocol.setConsents(consent, GTM_DATA_LAYER); this.write({ consent, event: this.consentKey, diff --git a/packages/react/src/analytics/integrations/GTM/constants.js b/packages/react/src/analytics/integrations/GTM/constants.js index c50b35b39..92ae4143d 100644 --- a/packages/react/src/analytics/integrations/GTM/constants.js +++ b/packages/react/src/analytics/integrations/GTM/constants.js @@ -18,3 +18,5 @@ export const INVALID_FUNCTION_ERROR_SUFFIX = export const CONSENT_TYPE = 'consent'; export const CONTEXT_TYPE = 'context'; export const SET_USER_TYPE = 'user'; + +export const GTM_DATA_LAYER = 'dataLayer'; diff --git a/packages/react/src/analytics/integrations/GTM/gtmTag.js b/packages/react/src/analytics/integrations/GTM/gtmTag.js index 9a55eeea8..edd627d8f 100644 --- a/packages/react/src/analytics/integrations/GTM/gtmTag.js +++ b/packages/react/src/analytics/integrations/GTM/gtmTag.js @@ -1,5 +1,7 @@ /* eslint-disable */ +import { GTM_DATA_LAYER } from './constants'; + /** * Script from google tag manager. * @@ -18,4 +20,4 @@ export default containerId => j.async = true; j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f); - })(window, document, 'script', 'dataLayer', containerId); + })(window, document, 'script', GTM_DATA_LAYER, containerId); diff --git a/packages/react/src/analytics/integrations/index.js b/packages/react/src/analytics/integrations/index.js index cdd6e234c..d612289a3 100644 --- a/packages/react/src/analytics/integrations/index.js +++ b/packages/react/src/analytics/integrations/index.js @@ -3,7 +3,12 @@ import { integrations } from '@farfetch/blackout-core/analytics'; const { Integration } = integrations; export { Integration }; -export { AnalyticsConstants } from './shared/constants'; + +export { + AnalyticsConstants, + ConsentManagementProtocol, + consentManagementProtocol, +} from './shared'; export { default as AnalyticsApi } from './AnalyticsApi'; export { default as AnalyticsService } from './AnalyticsService'; diff --git a/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/ConsentManagementProtocol.js b/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/ConsentManagementProtocol.js new file mode 100644 index 000000000..b21a6765f --- /dev/null +++ b/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/ConsentManagementProtocol.js @@ -0,0 +1,80 @@ +/* CMP - Consent Management Protocol */ + +export class ConsentManagementProtocol { + constructor() { + this.dataLayers = []; + this.enabled = true; + this.mappingConsentCategories = this.defaultMappings(); + } + + addIntegration(consent, dataLayer) { + if (this.enabled) { + this.dataLayers.push(dataLayer); + this.gtag(dataLayer, consent); + } + } + + disable() { + this.enabled = false; + } + + defaultMappings() { + return { + ad_storage: [], + ad_user_data: [], + ad_personalization: [], + analytics_storage: [], + }; + } + + setCustomCustomCategoriesMapping(customMapping) { + this.mappingConsentCategories = customMapping; + } + + getConsentValues(consents) { + // Dealing with null or undefined consent values + const consentList = consents || {}; + + return Object.keys(this.mappingConsentCategories).reduce( + (result, consentKey) => ({ + ...result, + [consentKey]: this.mappingConsentCategories[consentKey]?.every( + consent => consentList[consent], + ) + ? 'granted' + : 'deny', + }), + {}, + ); + } + + setConsents(consents, dataLayer) { + if (this.enabled) { + const dataLayers = dataLayer ? [dataLayer] : this.dataLayers; + dataLayers.forEach(layer => this.gtag(layer, consents, 'update')); + } + } + + gtag(dataLayer, consent, command = 'default') { + if (this.enabled && typeof window !== 'undefined') { + // @ts-ignore + window[dataLayer] = window[dataLayer] || []; + + /** + * + */ + function internalGtag() { + // @ts-ignore + // eslint-disable-next-line + window[dataLayer].push(arguments); + } + + // @ts-ignore + internalGtag('consent', command, this.getConsentValues(consent)); + } + } +} + +const instance = new ConsentManagementProtocol(); + +export default instance; diff --git a/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/__tests__/ConsentManagementProtocol.test.js b/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/__tests__/ConsentManagementProtocol.test.js new file mode 100644 index 000000000..fd001da8a --- /dev/null +++ b/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/__tests__/ConsentManagementProtocol.test.js @@ -0,0 +1,95 @@ +import { consentManagementProtocol } from '../index.js'; + +describe('ConsentManagementProtocol', () => { + let instance; + let spyGtag; + const mockConsent = {}; + + beforeEach(() => { + instance = consentManagementProtocol; + spyGtag = jest.spyOn(instance, 'gtag'); + spyGtag.mockClear(); + window['dataLayer'] = []; + }); + + it('should add integrations and initialize data layers', () => { + instance.addIntegration(mockConsent, 'dataLayer'); + instance.addIntegration(mockConsent, 'ga4DataLayer'); + + expect(instance['dataLayers']).toEqual(['dataLayer', 'ga4DataLayer']); + + expect(window['dataLayer']).toMatchSnapshot(); + }); + + it('should set custom consent categories mapping and initialize a data layer; only 1 consent should be granted', () => { + const customMappings = { + ad_storage: ['consent1'], + ad_user_data: ['consent1', 'consent2'], + ad_personalization: [], + analytics_storage: ['consent3'], + }; + + instance.setCustomCustomCategoriesMapping(customMappings); + + instance.addIntegration(mockConsent, 'dataLayer'); + + expect(instance['mappingConsentCategories']).toEqual(customMappings); + + expect(window['dataLayer']).toMatchSnapshot(); + }); + + it('should correctly process consent values', () => { + instance.setCustomCustomCategoriesMapping({ + ad_storage: ['consent1'], + ad_user_data: ['consent2'], + ad_personalization: [], + analytics_storage: ['consent3'], + }); + + const mockConsentWithValues = { + consent1: true, + consent2: true, + consent3: false, + }; + + const consentValues = instance['getConsentValues'](mockConsentWithValues); + + expect(consentValues).toEqual({ + ad_storage: 'granted', + ad_user_data: 'granted', + ad_personalization: 'granted', + analytics_storage: 'deny', + }); + }); + + it('should set consents for specific or all data layers', () => { + const spyGtag = jest.spyOn(instance, 'gtag'); + + instance['dataLayers'] = ['dataLayer1', 'dataLayer2']; + + instance.setConsents(mockConsent, 'dataLayer'); + expect(spyGtag).toHaveBeenCalledWith('dataLayer', mockConsent, 'update'); + + expect(spyGtag).not.toHaveBeenCalledWith( + 'dataLayer1', + mockConsent, + 'update', + ); + + instance.setConsents(mockConsent); + + expect(spyGtag).toHaveBeenCalledWith('dataLayer1', mockConsent, 'update'); + }); + + it('should not be fill the datalayer when consent mode is disabled', () => { + instance['dataLayers'] = ['dataLayer1', 'dataLayer2']; + instance.setConsents(mockConsent, 'dataLayer'); + expect(spyGtag).toHaveBeenCalled(); + + spyGtag.mockClear(); + instance.disable(); + + instance.setConsents(mockConsent, 'dataLayer'); + expect(spyGtag).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/__tests__/__snapshots__/ConsentManagementProtocol.test.js.snap b/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/__tests__/__snapshots__/ConsentManagementProtocol.test.js.snap new file mode 100644 index 000000000..8c9df487e --- /dev/null +++ b/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/__tests__/__snapshots__/ConsentManagementProtocol.test.js.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConsentManagementProtocol should add integrations and initialize data layers 1`] = ` +Array [ + Arguments [ + "consent", + "default", + Object { + "ad_personalization": "granted", + "ad_storage": "granted", + "ad_user_data": "granted", + "analytics_storage": "granted", + }, + ], +] +`; + +exports[`ConsentManagementProtocol should set custom consent categories mapping and initialize a data layer; only 1 consent should be granted 1`] = ` +Array [ + Arguments [ + "consent", + "default", + Object { + "ad_personalization": "granted", + "ad_storage": "deny", + "ad_user_data": "deny", + "analytics_storage": "deny", + }, + ], +] +`; diff --git a/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/index.js b/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/index.js new file mode 100644 index 000000000..f1a437700 --- /dev/null +++ b/packages/react/src/analytics/integrations/shared/ConsentManagementProtocol/index.js @@ -0,0 +1,4 @@ +export { + ConsentManagementProtocol, + default as consentManagementProtocol, +} from './ConsentManagementProtocol'; diff --git a/packages/react/src/analytics/integrations/shared/index.js b/packages/react/src/analytics/integrations/shared/index.js new file mode 100644 index 000000000..7fabc9b41 --- /dev/null +++ b/packages/react/src/analytics/integrations/shared/index.js @@ -0,0 +1,6 @@ +export { + ConsentManagementProtocol, + consentManagementProtocol, +} from './ConsentManagementProtocol'; + +export { AnalyticsConstants } from './constants';