diff --git a/.changeset/few-yaks-scream.md b/.changeset/few-yaks-scream.md new file mode 100644 index 00000000..5811e99e --- /dev/null +++ b/.changeset/few-yaks-scream.md @@ -0,0 +1,23 @@ +--- +"@ima/plugin-analytic-fb-pixel": major +"@ima/plugin-analytic-google": major +--- + +Update to new version of @ima/plugin-analytic + +- **What?** + - Update to new version of [@ima/plugin-analytic](https://github.com/seznam/IMA.js-plugins/tree/master/packages/plugin-analytic), which doesn't save `config` argument to class variable anymore. + - Config was moved to first position in dependencies list + - Removed `defaultDependencies` export. + - Typescriptization + - Property `_fbq` is now protected (`#fbq`). + - Removed property `_id` as it was not used anywhere. +- **Why?** + - Adding dependencies to subclasses is easier (no need to copy all dependencies, more info in @ima/plugin-analytic [CHANGELOG](https://github.com/seznam/IMA.js-plugins/blob/master/packages/plugin-analytic/CHANGELOG.md#600)) + - `defaultDependencies` was weird pattern, and we want to get rid of it +- **How?** + - If you extend `FacebookPixelAnalytic` or `GoogleAnalytics4` you need to move `config` parameter to the first position, when calling its `constructor`. + - Replace use of `defaultDependencies` by `$dependencies` property of given class plugin class. + - Replace `_fbq` by `#fbq`. + + **!!** Use only with **@ima/plugin-analytic@6.0.0** or newer. **!!** diff --git a/.changeset/pink-coats-rest.md b/.changeset/pink-coats-rest.md new file mode 100644 index 00000000..04ccaf69 --- /dev/null +++ b/.changeset/pink-coats-rest.md @@ -0,0 +1,75 @@ +--- +"@ima/plugin-analytic": major +--- + +Removed config from constructor of `AbstractAnalytic` + +- **What?** + - Removed `defaultDependencies` from plugin. + - Removed config from constructor of `AbstractAnalytic` + - Properties `_loaded`, `_scriptLoader`, `_dispatcher` and method `_afterLoadCallback` are now protected. + (`#loaded`, `#scriptLoader`, `#dispatcher`, `#afterLoadCallback`) + - New method `_isLoaded`. +- **Why?** + - `defaultDependencies` was weird pattern, and we want to get rid of it + - To be able to use spread operator for dependencies in constructor of classes which extends `AbstractAnalytic`. + Until now, we had to repeat all arguments from `AbstractAnalytic` constructor if we wanted to access `config` parameter, which is very common use-case. + Also, now we can work with types in TypeScript more easily. + - To clear the interface of `AbstractAnalytic`. +- **How?** + - Replace use of `defaultDependencies` by `AbstractAnalytic.$dependencies` + - Classes, which extends `AbstractAnalytic` needs to save given config argument on their own. + But you can use rest operator now. + + Therefore, this: + ```javascript + class MyClass extends AbstractAnalytic { + // Even here we were forced to copy dependencies from AbstractAnalytic to specify settings (last value in the array) + static get $dependencies() { + return [ + NonAbstractAnalyticParam, + ScriptLoaderPlugin, + '$Window', + '$Dispatcher', + '$Settings.plugin.analytic.myClass', + ]; + } + + constructor(nonAbstractAnalyticParam, scriptLoader, window, dispatcher, config) { + super(scriptLoader, window, dispatcher, config); + + this._nonAbstractAnalyticParam = nonAbstractAnalyticParam; + + this._id = config.id; // due to this line we were forced to copy all arguments of AbstractAnalytic + + // ... + } + } + ``` + ...can be rewritten to this: + ```javascript + class MyClass extends AbstractAnalytic { + // now we can define only added dependencies and use spread for the rest + static get $dependencies() { + return [ + NonAbstractAnalyticParam, + '$Settings.plugin.analytic.myClass', + ...AbstractAnalytic.$dependencies + ]; + } + + constructor(nonAbstractAnalyticParam, config, ...rest) { + super(...rest); + + this._nonAbstractAnalyticParam = nonAbstractAnalyticParam; + + this._config = config; + + this._id = config.id; + + // ... + } + } + ``` + - Replace use of `_scriptLoader`, `_dispatcher` and `_afterLoadCallback` to `#scriptLoader`, `#dispatcher` and `#afterLoadCallback`. + Check if script is loaded by calling new method `_isLoaded()`. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 302b23b1..3544c7d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4453,6 +4453,12 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, + "node_modules/@types/facebook-pixel": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/facebook-pixel/-/facebook-pixel-0.0.30.tgz", + "integrity": "sha512-zg/T6dmkcyzX4rj4MjnEUdijPjF7Fj1xw8CmYxz5MeoUuYU0iDbTL8qNdcbSTTkRrPTCtq3Db002Use4ikoNmw==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -4462,6 +4468,11 @@ "@types/node": "*" } }, + "node_modules/@types/gtag.js": { + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.19.tgz", + "integrity": "sha512-KHoDzrf9rSd0mooKN576PjExpdk/XRrNu4RQnmigsScSTSidwyOUe9kDrHz9UPKjiBrx2QEsSkexbJSgS0j72w==" + }, "node_modules/@types/http-proxy": { "version": "1.17.14", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", @@ -21656,6 +21667,9 @@ "dependencies": { "@ima/plugin-analytic": "^5.0.2" }, + "devDependencies": { + "@types/facebook-pixel": "^0.0.30" + }, "peerDependencies": { "@ima/core": ">=18.0.0", "@ima/plugin-script-loader": ">=3.1.1" @@ -21667,7 +21681,8 @@ "license": "MIT", "dependencies": { "@ima/plugin-analytic": "^5.0.2", - "@ima/plugin-script-loader": "^4.0.0" + "@ima/plugin-script-loader": "^4.0.0", + "@types/gtag.js": "^0.0.19" }, "peerDependencies": { "@ima/core": ">=18.0.0", diff --git a/packages/plugin-analytic-fb-pixel/package.json b/packages/plugin-analytic-fb-pixel/package.json index 98b86ece..9aae09a8 100644 --- a/packages/plugin-analytic-fb-pixel/package.json +++ b/packages/plugin-analytic-fb-pixel/package.json @@ -39,5 +39,8 @@ "peerDependencies": { "@ima/core": ">=18.0.0", "@ima/plugin-script-loader": ">=3.1.1" + }, + "devDependencies": { + "@types/facebook-pixel": "^0.0.30" } } diff --git a/packages/plugin-analytic-fb-pixel/src/FacebookPixelAnalytic.js b/packages/plugin-analytic-fb-pixel/src/FacebookPixelAnalytic.js deleted file mode 100644 index a033b528..00000000 --- a/packages/plugin-analytic-fb-pixel/src/FacebookPixelAnalytic.js +++ /dev/null @@ -1,235 +0,0 @@ -import { AbstractAnalytic, defaultDependencies } from '@ima/plugin-analytic'; - -const FB_ROOT_VARIABLE = 'fbq'; - -/** - * Facebook Pixel Helper. - * - * @class - */ -export default class FacebookPixelAnalytic extends AbstractAnalytic { - /** @type {import('@ima/core').Dependencies} */ - static get $dependencies() { - return [...defaultDependencies, '$Settings.plugin.analytic.fbPixel']; - } - - /** - * Creates a Facebook Pixel Helper instance. - * - * @function Object() { [native code] } - * @param {import('@ima/plugin-script-loader').ScriptLoaderPlugin} scriptLoader - * @param {import('@ima/core').Window} window - * @param {import('@ima/core').Dispatcher} dispatcher - * @param {object} config - */ - constructor(scriptLoader, window, dispatcher, config) { - super(scriptLoader, window, dispatcher, config); - - this._analyticScriptName = 'fb_pixel'; - this._analyticScriptUrl = '//connect.facebook.net/en_US/fbevents.js'; - - /** - * An identifier for Facebook Pixel. - * - * @type {string} - */ - this._id = null; - - /** - * A main function of Facebook Pixel. - * - * @type {Function} - */ - this._fbq = null; - } - - /** - * Gets the identifier for Facebook Pixel. - * - * @returns {string} The identifier for Facebook Pixel. - */ - getId() { - switch (typeof this._config.id) { - case 'number': - return String(this._config.id); - case 'string': - return this._config.id; - default: - throw new TypeError( - 'A Facebook Pixel identifier should be a number/string.' - ); - } - } - - /** - * Hits an event. - * - * @override - * @param {string} eventName Name of the event. - * @param {object} [eventData] Data attached to the event. - * @returns {boolean} TRUE when event has been hit; otherwise FALSE. - */ - hit(eventName, eventData = null) { - try { - if (!this._fbq) { - throw new Error( - 'Initialize the FacebookPixelHelper instance before calling hit() method.' - ); - } else if (typeof eventName !== 'string' || !eventName) { - throw new TypeError( - 'Parameter eventName of hit() method is required and should be a string.' - ); - } else if (typeof eventData !== 'object') { - throw new TypeError( - 'Parameter eventData of hit() method should be an object.' - ); - } - } catch (error) { - this._processError(error); - - return false; - } - - if (!eventData) { - this._fbq('track', eventName); - } else { - this._fbq('track', eventName, eventData); - } - - return true; - } - - /** - * Hits a page view event (optionally with page view data). - * - * @override - * @param {object} [viewContentData] Page view data (containing path etc.). - * @returns {boolean} TRUE when event has been hit; otherwise FALSE. - */ - hitPageView(viewContentData = null) { - try { - if (!this._fbq) { - throw new Error( - 'Initialize the FacebookPixelHelper instance before calling hitPageView() method.' - ); - } else if (typeof viewContentData !== 'object') { - throw new TypeError( - 'Parameter data of hitPageView() method should be an object.' - ); - } - } catch (error) { - this._processError(error); - - return false; - } - - let hitResult = this.hit('PageView'); - - if (!hitResult) { - return false; - } else if (viewContentData) { - return this.hit('ViewContent', viewContentData); - } - - return true; - } - - /** - * Hits a search event (optionally with page name or other event data). - * - * @param {string|object} [queryOrData] Search query / event data. - * @returns {boolean} TRUE when event has been hit; otherwise FALSE. - */ - hitSearch(queryOrData = null) { - try { - if (!this._fbq) { - throw new Error( - 'Initialize the FacebookPixelHelper instance before calling hitSearch() method.' - ); - } else if (['string', 'object'].indexOf(typeof queryOrData) === -1) { - throw new TypeError( - 'Parameter queryOrData of hitSearch() method should be a string or an object.' - ); - } - } catch (error) { - this._processError(error); - - return false; - } - - let eventData; - - if (typeof queryOrData === 'string' && queryOrData) { - eventData = { search_string: queryOrData }; - } else { - eventData = queryOrData; - } - - if (!eventData) { - return this.hit('Search'); - } else { - return this.hit('Search', eventData); - } - } - - /** - * @override - * @inheritdoc - */ - _configuration() { - const clientWindow = this._window.getWindow(); - - if ( - this.isEnabled() || - !clientWindow[FB_ROOT_VARIABLE] || - typeof clientWindow[FB_ROOT_VARIABLE] !== 'function' - ) { - return; - } - - this._enable = true; - - this._fbq = window[FB_ROOT_VARIABLE]; - this._fbq('init', this.getId()); - } - - /** - * @override - * @inheritdoc - */ - _createGlobalDefinition(window) { - if (window[FB_ROOT_VARIABLE]) { - return; - } - - const fbAnalytic = (window[FB_ROOT_VARIABLE] = function () { - fbAnalytic.callMethod - ? fbAnalytic.callMethod.apply(fbAnalytic, arguments) - : fbAnalytic.queue.push(arguments); - }); - - if (!window['_' + FB_ROOT_VARIABLE]) { - window['_' + FB_ROOT_VARIABLE] = fbAnalytic; - } - - fbAnalytic.push = fbAnalytic; - fbAnalytic.loaded = false; - fbAnalytic.version = '2.0'; - fbAnalytic.queue = []; - - this._fbq = fbAnalytic; - - this._configuration(); - } - - /** - * Processes an error. - * - * @param {Error|TypeError|string} error An error to be processed. - */ - _processError(error) { - if ($Debug && error) { - console.error(error); - } - } -} diff --git a/packages/plugin-analytic-fb-pixel/src/FacebookPixelAnalytic.ts b/packages/plugin-analytic-fb-pixel/src/FacebookPixelAnalytic.ts new file mode 100644 index 00000000..358fdc97 --- /dev/null +++ b/packages/plugin-analytic-fb-pixel/src/FacebookPixelAnalytic.ts @@ -0,0 +1,257 @@ +import type { Dependencies } from '@ima/core'; +import { AbstractAnalytic } from '@ima/plugin-analytic'; + +const FB_ROOT_VARIABLE = 'fbq'; + +/* + * Whole code of method _createGlobalDefinition is implementation of initialization code of Facebook Pixel from their documentation. + * Not everything used there is typed on the internet (at least I didn't find it), therefore it is typed by us here. + */ +interface FbAnalytic { + callMethod: (...params: unknown[]) => void; + queue: any[]; + push: FbAnalytic; + loaded: boolean; + version: string; +} + +type FbAnalyiticExtended = facebook.Pixel.Event & FbAnalytic; + +export type AnalyticFBPixelSettings = { + id: string | null; +}; + +/** + * Facebook Pixel Helper. + * + * @class + */ +export class FacebookPixelAnalytic extends AbstractAnalytic { + #config: AnalyticFBPixelSettings; + // A main function of Facebook Pixel. + #fbq: facebook.Pixel.Event | null; + + static get $dependencies(): Dependencies { + return [ + '$Settings.plugin.analytic.fbPixel', + ...AbstractAnalytic.$dependencies, + ]; + } + + /** + * Creates a Facebook Pixel Helper instance. + */ + constructor( + config: AnalyticFBPixelSettings, + ...rest: ConstructorParameters + ) { + super(...rest); + + this._analyticScriptName = 'fb_pixel'; + this._analyticScriptUrl = '//connect.facebook.net/en_US/fbevents.js'; + + this.#config = config; + + this.#fbq = null; + } + + _applyPurposeConsents() { + /* this implementation of FB pixel doesn't work with consents */ + } + + /** + * Gets the identifier for Facebook Pixel. + * + * @returns The identifier for Facebook Pixel. + */ + getId() { + switch (typeof this.#config.id) { + case 'number': + return String(this.#config.id); + case 'string': + return this.#config.id; + default: + throw new TypeError( + 'A Facebook Pixel identifier should be a number/string.' + ); + } + } + + /** + * Hits an event. + * + * @override + * @param eventName Name of the event. + * @param eventData Data attached to the event. + * @returns TRUE when event has been hit; otherwise FALSE. + */ + hit(eventName: string, eventData: Record | null = null) { + try { + if (!this.#fbq) { + throw new Error( + 'Initialize the FacebookPixelHelper instance before calling hit() method.' + ); + } else if (typeof eventName !== 'string' || !eventName) { + throw new TypeError( + 'Parameter eventName of hit() method is required and should be a string.' + ); + } else if (typeof eventData !== 'object') { + throw new TypeError( + 'Parameter eventData of hit() method should be an object.' + ); + } + } catch (error) { + this._processError( + error as Parameters[0] + ); + + return false; + } + + if (!eventData) { + this.#fbq('track', eventName); + } else { + this.#fbq('track', eventName, eventData); + } + + return true; + } + + /** + * Hits a page view event (optionally with page view data). + * + * @override + * @param Page view data (containing path etc.). + * @returns TRUE when event has been hit; otherwise FALSE. + */ + hitPageView(viewContentData: Record | null = null) { + try { + if (!this.#fbq) { + throw new Error( + 'Initialize the FacebookPixelHelper instance before calling hitPageView() method.' + ); + } else if (typeof viewContentData !== 'object') { + throw new TypeError( + 'Parameter data of hitPageView() method should be an object.' + ); + } + } catch (error) { + this._processError( + error as Parameters[0] + ); + + return false; + } + + const hitResult = this.hit('PageView'); + + if (!hitResult) { + return false; + } else if (viewContentData) { + return this.hit('ViewContent', viewContentData); + } + + return true; + } + + /** + * Hits a search event (optionally with page name or other event data). + * + * @param Search query / event data. + * @param queryOrData + * @returns TRUE when event has been hit; otherwise FALSE. + */ + hitSearch(queryOrData: Record | string | null = null) { + try { + if (!this.#fbq) { + throw new Error( + 'Initialize the FacebookPixelHelper instance before calling hitSearch() method.' + ); + } else if (['string', 'object'].indexOf(typeof queryOrData) === -1) { + throw new TypeError( + 'Parameter queryOrData of hitSearch() method should be a string or an object.' + ); + } + } catch (error) { + this._processError( + error as Parameters[0] + ); + + return false; + } + + let eventData: Record | null; + + if (typeof queryOrData === 'string' && queryOrData) { + eventData = { search_string: queryOrData }; + } else { + return this.hit('Search'); + } + + return this.hit('Search', eventData); + } + + /** + * @override + * @inheritdoc + */ + _configuration() { + // _configuration is only called on client, therefore window is defined + const clientWindow = this._window.getWindow()!; + + if ( + this.isEnabled() || + !clientWindow[FB_ROOT_VARIABLE] || + typeof clientWindow[FB_ROOT_VARIABLE] !== 'function' + ) { + return; + } + + this._enable = true; + + this.#fbq = clientWindow[FB_ROOT_VARIABLE]; + this.#fbq!('init', this.getId()); + } + + /** + * @override + * @inheritdoc + */ + _createGlobalDefinition(window: globalThis.Window) { + if (window[FB_ROOT_VARIABLE]) { + return; + } + + const fbAnalytic = (window[FB_ROOT_VARIABLE] = function ( + ...rest: unknown[] + ) { + fbAnalytic.callMethod + ? fbAnalytic.callMethod(...rest) + : fbAnalytic.queue.push(...rest); + }) as FbAnalyiticExtended; + + if (!window[`_${FB_ROOT_VARIABLE}`]) { + window[`_${FB_ROOT_VARIABLE}`] = fbAnalytic; + } + + fbAnalytic.push = fbAnalytic; + fbAnalytic.loaded = false; + fbAnalytic.version = '2.0'; + fbAnalytic.queue = []; + + this.#fbq = fbAnalytic; + + this._configuration(); + } + + /** + * Processes an error. + * + * @param error An error to be processed. + */ + _processError(error: Error | TypeError | string) { + if ($Debug && error) { + console.error(error); + } + } +} diff --git a/packages/plugin-analytic-fb-pixel/src/__tests__/mainSpec.js b/packages/plugin-analytic-fb-pixel/src/__tests__/mainSpec.js index a7955173..73290eef 100644 --- a/packages/plugin-analytic-fb-pixel/src/__tests__/mainSpec.js +++ b/packages/plugin-analytic-fb-pixel/src/__tests__/mainSpec.js @@ -1,11 +1,7 @@ -import * as Main from '../main'; +import { FacebookPixelAnalytic } from '../main'; describe('Main', () => { it('should export FacebookPixelAnalytic', () => { - expect(typeof Main.FacebookPixelAnalytic).toBe('function'); - }); - - it('should export defaultDependencies', () => { - expect(Array.isArray(Main.defaultDependencies)).toBe(true); + expect(typeof FacebookPixelAnalytic).toBe('function'); }); }); diff --git a/packages/plugin-analytic-fb-pixel/src/main.ts b/packages/plugin-analytic-fb-pixel/src/main.ts index c1986261..26cb58a8 100644 --- a/packages/plugin-analytic-fb-pixel/src/main.ts +++ b/packages/plugin-analytic-fb-pixel/src/main.ts @@ -1,8 +1,9 @@ import { pluginLoader } from '@ima/core'; -import FacebookPixelAnalytic from './FacebookPixelAnalytic'; - -const defaultDependencies = FacebookPixelAnalytic.$dependencies; +import { + FacebookPixelAnalytic, + type AnalyticFBPixelSettings, +} from './FacebookPixelAnalytic'; pluginLoader.register('@ima/plugin-analytic-google', () => ({ initSettings: () => ({ @@ -19,4 +20,4 @@ pluginLoader.register('@ima/plugin-analytic-google', () => ({ })); export type { PluginAnalyticFBPixelSettings } from './types'; -export { FacebookPixelAnalytic, defaultDependencies }; +export { FacebookPixelAnalytic, type AnalyticFBPixelSettings }; diff --git a/packages/plugin-analytic-fb-pixel/src/types.ts b/packages/plugin-analytic-fb-pixel/src/types.ts index 5a9cc0bb..e4d2f627 100644 --- a/packages/plugin-analytic-fb-pixel/src/types.ts +++ b/packages/plugin-analytic-fb-pixel/src/types.ts @@ -1,7 +1,14 @@ +import type { AnalyticFBPixelSettings } from './FacebookPixelAnalytic'; + +declare global { + interface Window { + fbq: facebook.Pixel.Event; + _fbq: facebook.Pixel.Event; + } +} + export interface PluginAnalyticFBPixelSettings { - fbPixel: { - id: string | null; - }; + fbPixel: AnalyticFBPixelSettings; } declare module '@ima/core' { diff --git a/packages/plugin-analytic-google/package.json b/packages/plugin-analytic-google/package.json index c0487dbd..8d59ee9b 100644 --- a/packages/plugin-analytic-google/package.json +++ b/packages/plugin-analytic-google/package.json @@ -34,7 +34,8 @@ "license": "MIT", "dependencies": { "@ima/plugin-analytic": "^5.0.2", - "@ima/plugin-script-loader": "^4.0.0" + "@ima/plugin-script-loader": "^4.0.0", + "@types/gtag.js": "^0.0.19" }, "peerDependencies": { "@ima/core": ">=18.0.0", diff --git a/packages/plugin-analytic-google/src/GoogleAnalytics4.js b/packages/plugin-analytic-google/src/GoogleAnalytics4.js deleted file mode 100644 index 85576211..00000000 --- a/packages/plugin-analytic-google/src/GoogleAnalytics4.js +++ /dev/null @@ -1,155 +0,0 @@ -import { AbstractAnalytic, defaultDependencies } from '@ima/plugin-analytic'; - -const GTAG_ROOT_VARIABLE = 'gtag'; - -/** - * Google analytic 4 class - */ -export default class GoogleAnalytics4 extends AbstractAnalytic { - /** @type {import('@ima/core').Dependencies} */ - static get $dependencies() { - return [...defaultDependencies, '$Settings.plugin.analytic.google4']; - } - - set _ga4Script(value) { - const clientWindow = this._window.getWindow(); - - clientWindow[GTAG_ROOT_VARIABLE] = value; - } - - get _ga4Script() { - const clientWindow = this._window.getWindow(); - - return clientWindow[GTAG_ROOT_VARIABLE]; - } - - /** - * Initializes the Google Analytics 4 plugin. - * - * @param {import('@ima/plugin-script-loader').ScriptLoaderPlugin} scriptLoader - * @param {import('@ima/core').Window} window - * @param {import('@ima/core').Dispatcher} dispatcher - * @param {Object} config - */ - constructor(scriptLoader, window, dispatcher, config) { - super(scriptLoader, window, dispatcher, config); - - this._analyticScriptName = 'google_analytics_4'; - - this._analyticScriptUrl = `https://www.googletagmanager.com/gtag/js?id=${this._config.service}`; - - this._consentSettings = this._config.consentSettings; - } - /** - * Hits custom event of given with given data - * - * @param {string} eventName custom event name - * @param {Object} eventData custom event data - */ - hit(eventName, eventData) { - if (!this.isEnabled()) { - return; - } - - this._ga4Script('event', eventName, eventData); - } - - /** - * Hit page view event to analytic with defined data. - * - * @override - * @param {Object} pageData - * @param {Object} customDimensions - */ - hitPageView(pageData) { - if (!this.isEnabled()) { - return; - } - - this._ga4Script('event', 'page_view', this._getPageViewData(pageData)); - } - - /** - * Updates user consents in Google Analytics script - * - * @param {Object} purposeConsents Purpose Consents of TCModel, see: https://www.npmjs.com/package/@iabtcf/core#tcmodel - */ - updateConsent(purposeConsents) { - this._applyPurposeConsents(purposeConsents); - - this._ga4Script('consent', 'update', { - ...this._consentSettings, - }); - } - - /** - * @override - * @inheritdoc - */ - _applyPurposeConsents(purposeConsents) { - if (purposeConsents && typeof purposeConsents === 'object') { - if (purposeConsents['1']) { - this._consentSettings.analytics_storage = 'granted'; - } else { - this._consentSettings.analytics_storage = 'denied'; - } - } - } - - /** - * @override - * @inheritdoc - */ - _configuration() { - if ( - this.isEnabled() || - !this._ga4Script || - typeof this._ga4Script !== 'function' - ) { - return; - } - - this._enable = true; - - this._ga4Script('consent', 'default', { - ...this._consentSettings, - wait_for_update: this._config.waitForUpdateTimeout, - }); - - this._ga4Script('js', new Date()); - - this._ga4Script('config', this._config.service, { - send_page_view: false, - }); - } - - /** - * Returns page view data derived from pageData param. - * - * @param {Object} pageData - * @returns {Object} pageViewData - */ - _getPageViewData(pageData) { - return { - page: pageData.path, - location: this._window.getUrl(), - title: document.title || '', - }; - } - - /** - * @override - * @inheritdoc - */ - _createGlobalDefinition() { - const window = this._window.getWindow(); - - window.dataLayer = window.dataLayer || []; - - this._ga4Script = function () { - window.dataLayer.push(arguments); - }; - - this._configuration(); - } -} diff --git a/packages/plugin-analytic-google/src/GoogleAnalytics4.ts b/packages/plugin-analytic-google/src/GoogleAnalytics4.ts new file mode 100644 index 00000000..ff6e26b1 --- /dev/null +++ b/packages/plugin-analytic-google/src/GoogleAnalytics4.ts @@ -0,0 +1,173 @@ +import type { Dependencies } from '@ima/core'; +import { AbstractAnalytic } from '@ima/plugin-analytic'; + +const GTAG_ROOT_VARIABLE = 'gtag'; + +type ConsentSettings = { + ad_storage?: 'denied' | 'granted'; + analytics_storage?: 'denied' | 'granted'; + personalization_storage?: 'denied' | 'granted'; +}; + +export type AnalyticGoogleSettings = { + consentSettings?: ConsentSettings; + service: string; + waitForUpdateTimeout?: number; +}; + +/** + * Google analytic 4 class + */ +export class GoogleAnalytics4 extends AbstractAnalytic { + #config: AnalyticGoogleSettings; + _consentSettings?: ConsentSettings; + + static get $dependencies(): Dependencies { + return [ + '$Settings.plugin.analytic.google4', + ...AbstractAnalytic.$dependencies, + ]; + } + + set _ga4Script(value) { + const clientWindow = this._window.getWindow()!; + + clientWindow[GTAG_ROOT_VARIABLE] = value; + } + + get _ga4Script() { + const clientWindow = this._window.getWindow()!; + + return clientWindow[GTAG_ROOT_VARIABLE]; + } + + get config() { + return this.#config; + } + + /** + * Initializes the Google Analytics 4 plugin. + */ + constructor( + config: AnalyticGoogleSettings, + ...rest: ConstructorParameters + ) { + super(...rest); + + this.#config = config; + + this._analyticScriptName = 'google_analytics_4'; + + this._analyticScriptUrl = `https://www.googletagmanager.com/gtag/js?id=${this.config.service}`; + + this._consentSettings = this.config.consentSettings; + } + /** + * Hits custom event of given with given data + * + * @param eventName custom event name + * @param eventData custom event data + */ + hit(eventName: string, eventData: Record) { + if (!this.isEnabled()) { + return; + } + + this._ga4Script('event', eventName, eventData); + } + + /** + * Hit page view event to analytic with defined data. + * @param pageData + */ + hitPageView(pageData: Record) { + if (!this.isEnabled()) { + return; + } + + this._ga4Script('event', 'page_view', this._getPageViewData(pageData)); + } + + /** + * Updates user consents in Google Analytics script + * + * @param purposeConsents Purpose Consents of TCModel, see: https://www.npmjs.com/package/@iabtcf/core#tcmodel + */ + updateConsent(purposeConsents: Record) { + this._applyPurposeConsents(purposeConsents); + + this._ga4Script('consent', 'update', { + ...this._consentSettings, + }); + } + + /** + * @override + * @inheritdoc + */ + _applyPurposeConsents(purposeConsents: Record) { + if ( + purposeConsents && + typeof purposeConsents === 'object' && + this._consentSettings + ) { + if (purposeConsents['1']) { + this._consentSettings.analytics_storage = 'granted'; + } else { + this._consentSettings.analytics_storage = 'denied'; + } + } + } + + /** + * @override + * @inheritdoc + */ + _configuration() { + if ( + this.isEnabled() || + !this._ga4Script || + typeof this._ga4Script !== 'function' + ) { + return; + } + + this._enable = true; + + this._ga4Script('consent', 'default', { + ...this._consentSettings, + wait_for_update: this.config.waitForUpdateTimeout, + }); + + this._ga4Script('js', new Date()); + + this._ga4Script('config', this.config.service, { + send_page_view: false, + }); + } + + /** + * Returns page view data derived from pageData param. + */ + _getPageViewData(pageData: Record) { + return { + page: pageData.path, + location: this._window.getUrl(), + title: document.title || '', + }; + } + + /** + * @override + * @inheritdoc + */ + _createGlobalDefinition(window: globalThis.Window) { + window.dataLayer = window.dataLayer || []; + + this._ga4Script = function (...rest: unknown[]) { + window.dataLayer.push(...rest); + }; + + this._configuration(); + } +} diff --git a/packages/plugin-analytic-google/src/__tests__/GoogleAnalytics4Spec.js b/packages/plugin-analytic-google/src/__tests__/GoogleAnalytics4Spec.js index 0627ac07..7848effe 100644 --- a/packages/plugin-analytic-google/src/__tests__/GoogleAnalytics4Spec.js +++ b/packages/plugin-analytic-google/src/__tests__/GoogleAnalytics4Spec.js @@ -2,7 +2,7 @@ import { Dispatcher, Window } from '@ima/core'; import { ScriptLoaderPlugin } from '@ima/plugin-script-loader'; import { toMockedInstance } from 'to-mock'; -import GoogleAnalytics4 from '../GoogleAnalytics4'; +import { GoogleAnalytics4 } from '../GoogleAnalytics4'; describe('GoogleAnalytics4', () => { const settings = { @@ -29,10 +29,10 @@ describe('GoogleAnalytics4', () => { beforeEach(() => { googleAnalytics4 = new GoogleAnalytics4( + settings, scriptLoader, window, - dispatcher, - settings + dispatcher ); }); diff --git a/packages/plugin-analytic-google/src/__tests__/mainSpec.js b/packages/plugin-analytic-google/src/__tests__/mainSpec.js index a43e49aa..e556b8fb 100644 --- a/packages/plugin-analytic-google/src/__tests__/mainSpec.js +++ b/packages/plugin-analytic-google/src/__tests__/mainSpec.js @@ -1,11 +1,7 @@ -import * as Main from '../main'; +import { GoogleAnalytics4 } from '../main'; describe('Main', () => { it('should export GoogleAnalytics4', () => { - expect(typeof Main.GoogleAnalytics4).toBe('function'); - }); - - it('should export googleAnalytics4DefaultDependencies', () => { - expect(Array.isArray(Main.googleAnalytics4DefaultDependencies)).toBe(true); + expect(typeof GoogleAnalytics4).toBe('function'); }); }); diff --git a/packages/plugin-analytic-google/src/main.ts b/packages/plugin-analytic-google/src/main.ts index c8e91484..795f3efc 100644 --- a/packages/plugin-analytic-google/src/main.ts +++ b/packages/plugin-analytic-google/src/main.ts @@ -1,8 +1,9 @@ import { pluginLoader } from '@ima/core'; -import GoogleAnalytics4 from './GoogleAnalytics4.js'; - -const googleAnalytics4DefaultDependencies = GoogleAnalytics4.$dependencies; +import { + GoogleAnalytics4, + type AnalyticGoogleSettings, +} from './GoogleAnalytics4'; pluginLoader.register('@ima/plugin-analytic-google', () => ({ initSettings: () => ({ @@ -25,4 +26,4 @@ pluginLoader.register('@ima/plugin-analytic-google', () => ({ })); export type { PluginAnalyticGoogleSettings } from './types'; -export { GoogleAnalytics4, googleAnalytics4DefaultDependencies }; +export { GoogleAnalytics4, type AnalyticGoogleSettings }; diff --git a/packages/plugin-analytic-google/src/types.ts b/packages/plugin-analytic-google/src/types.ts index d618bfca..53b020b0 100644 --- a/packages/plugin-analytic-google/src/types.ts +++ b/packages/plugin-analytic-google/src/types.ts @@ -1,13 +1,14 @@ +import type { AnalyticGoogleSettings } from './GoogleAnalytics4'; + +declare global { + interface Window { + gtag: Gtag.Gtag; + dataLayer: unknown[]; + } +} + export interface PluginAnalyticGoogleSettings { - google4: { - consentSettings?: { - ad_storage?: 'denied' | 'granted'; - analytics_storage?: 'denied' | 'granted'; - personalization_storage?: 'denied' | 'granted'; - }; - service: string; - waitForUpdateTimeout?: number; - }; + google4: AnalyticGoogleSettings; } declare module '@ima/core' { diff --git a/packages/plugin-analytic/src/AbstractAnalytic.ts b/packages/plugin-analytic/src/AbstractAnalytic.ts index 5c386aa0..8cf3e8af 100644 --- a/packages/plugin-analytic/src/AbstractAnalytic.ts +++ b/packages/plugin-analytic/src/AbstractAnalytic.ts @@ -3,29 +3,25 @@ import { ScriptLoaderPlugin } from '@ima/plugin-script-loader'; import { Events as AnalyticEvents } from './Events'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface AbstractAnalyticSettings {} - // @property purposeConsents Purpose Consents of TCModel, see: https://www.npmjs.com/package/@iabtcf/core#tcmodel export type InitConfig = Record & { - purposeConsents: Record; + purposeConsents?: Record; }; /** * Abstract analytic class */ -export default abstract class AbstractAnalytic { - _scriptLoader: ScriptLoaderPlugin; +export abstract class AbstractAnalytic { + #scriptLoader: ScriptLoaderPlugin; + #dispatcher: Dispatcher; + // If flag has value true then analytic script was loaded. + #loaded = false; _window: Window; - _dispatcher: Dispatcher; - _config: AbstractAnalyticSettings; _analyticScriptName: string | null = null; - //Analytic script url. + // Analytic script url. _analyticScriptUrl: string | null = null; - //If flag has value true then analytic is enabled to hit events. + // If flag has value true then analytic is enabled to hit events. _enable = false; - //If flag has value true then analytic script was loaded. - _loaded = false; static get $dependencies(): Dependencies { return [ScriptLoaderPlugin, '$Window', '$Dispatcher']; @@ -34,16 +30,13 @@ export default abstract class AbstractAnalytic { constructor( scriptLoader: ScriptLoaderPlugin, window: Window, - dispatcher: Dispatcher, - config: AbstractAnalyticSettings + dispatcher: Dispatcher ) { - this._scriptLoader = scriptLoader; + this.#scriptLoader = scriptLoader; this._window = window; - this._dispatcher = dispatcher; - - this._config = config; + this.#dispatcher = dispatcher; } /** @@ -53,9 +46,10 @@ export default abstract class AbstractAnalytic { * @param initConfig * @param initConfig.purposeConsents Purpose Consents of TCModel, see: https://www.npmjs.com/package/@iabtcf/core#tcmodel */ - init(initConfig: InitConfig) { + init(initConfig?: InitConfig) { if (!this.isEnabled() && this._window.isClient()) { - const window = this._window.getWindow() as globalThis.Window; + // we are on client, therefore window is defined + const window = this._window.getWindow()!; if (initConfig?.purposeConsents) { this._applyPurposeConsents(initConfig.purposeConsents); @@ -67,25 +61,23 @@ export default abstract class AbstractAnalytic { /** * Load analytic script, configure analytic and execute deferred hits. - * - * @returns {Promise} */ load() { if (this._window.isClient()) { - if (this._loaded) { + if (this.isLoaded()) { return Promise.resolve(true); } if (!this._analyticScriptUrl) { - this._afterLoadCallback(); + this.#afterLoadCallback(); return Promise.resolve(true); } - return this._scriptLoader + return this.#scriptLoader .load(this._analyticScriptUrl)! .then(() => { - this._afterLoadCallback(); + this.#afterLoadCallback(); return true; }) @@ -101,13 +93,9 @@ export default abstract class AbstractAnalytic { * Applies Purpose Consents to respect GDPR, see https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework * * @abstract - * @param _purposeConsents Purpose Consents of TCModel, see: https://www.npmjs.com/package/@iabtcf/core#tcmodel + * @param purposeConsents Purpose Consents of TCModel, see: https://www.npmjs.com/package/@iabtcf/core#tcmodel */ - _applyPurposeConsents(_purposeConsents: Record) { - throw new Error( - 'The applyPurposeConsents() method is abstract and must be overridden.' - ); - } + abstract _applyPurposeConsents(purposeConsents: Record): void; /** * Returns true if analytic is enabled. @@ -116,28 +104,28 @@ export default abstract class AbstractAnalytic { return this._enable; } + /** + * Returns true if analytic is loaded. + * @protected + */ + isLoaded() { + return this.#loaded; + } + /** * Hit event to analytic with defined data. If analytic is not configured then * defer hit to storage. * * @abstract - * @param _data */ - hit(_data: Record) { - throw new Error('The hit() method is abstract and must be overridden.'); - } + abstract hit(...args: unknown[]): void; /** * Hit page view event to analytic for defined page data. * * @abstract - * @param _pageData */ - hitPageView(_pageData: Record) { - throw new Error( - 'The hitPageView() method is abstract and must be overridden.' - ); - } + abstract hitPageView(...args: unknown[]): void; /** * Configuration analytic. The analytic must be enabled after configuration. @@ -145,30 +133,18 @@ export default abstract class AbstractAnalytic { * @abstract * @protected */ - _configuration() { - throw new Error( - 'The _configuration() method is abstract and must be overridden.' - ); - } + abstract _configuration(): void; /** * Creates global definition for analytics script. * * @abstract * @protected - * @param _window */ - _createGlobalDefinition(_window: globalThis.Window) { - throw new Error( - 'The _createGlobalDefinition() method is abstract and must be overridden.' - ); - } + abstract _createGlobalDefinition(window: globalThis.Window): void; - /** - * @protected - */ - _afterLoadCallback() { - this._loaded = true; + #afterLoadCallback() { + this.#loaded = true; this._configuration(); this._fireLifecycleEvent(AnalyticEvents.LOADED); } @@ -178,6 +154,6 @@ export default abstract class AbstractAnalytic { * @param eventType */ _fireLifecycleEvent(eventType: AnalyticEvents) { - this._dispatcher.fire(eventType, { type: this._analyticScriptName }, true); + this.#dispatcher.fire(eventType, { type: this._analyticScriptName }, true); } } diff --git a/packages/plugin-analytic/src/__tests__/AbstractAnalyticSpec.js b/packages/plugin-analytic/src/__tests__/AbstractAnalyticSpec.js index d8d6368c..2e4c73b0 100644 --- a/packages/plugin-analytic/src/__tests__/AbstractAnalyticSpec.js +++ b/packages/plugin-analytic/src/__tests__/AbstractAnalyticSpec.js @@ -2,10 +2,19 @@ import { Window, Dispatcher } from '@ima/core'; import { ScriptLoaderPlugin } from '@ima/plugin-script-loader'; import { toMockedInstance } from 'to-mock'; -import AbstractAnalytic from '../AbstractAnalytic'; +import { AbstractAnalytic } from '../AbstractAnalytic'; import { Events as AnalyticEvents } from '../Events'; describe('AbstractAnalytic', () => { + // Abstract methods must be implemented to be testable and monitored by jest.spyOn + class DummyAnalytic extends AbstractAnalytic { + _applyPurposeConsents() {} + hit() {} + hitPageView() {} + _configuration() {} + _createGlobalDefinition() {} + } + let abstractAnalytic = null; const _windowMock = toMockedInstance(Window, { @@ -21,11 +30,7 @@ describe('AbstractAnalytic', () => { }); beforeEach(() => { - abstractAnalytic = new AbstractAnalytic( - scriptLoader, - _windowMock, - dispatcher - ); + abstractAnalytic = new DummyAnalytic(scriptLoader, _windowMock, dispatcher); abstractAnalytic._analyticScriptName = 'dummy'; abstractAnalytic._analyticScriptUrl = 'http://example.net/script.js'; diff --git a/packages/plugin-analytic/src/main.ts b/packages/plugin-analytic/src/main.ts index c156dbdb..9592f8bb 100644 --- a/packages/plugin-analytic/src/main.ts +++ b/packages/plugin-analytic/src/main.ts @@ -1,9 +1,9 @@ import './types'; -import type { InitConfig as AbstractAnalyticInitConfig } from './AbstractAnalytic'; -import AbstractAnalytic from './AbstractAnalytic'; +import { + AbstractAnalytic, + type InitConfig as AbstractAnalyticInitConfig, +} from './AbstractAnalytic'; import { Events } from './Events'; -const defaultDependencies = AbstractAnalytic.$dependencies; - export type { AbstractAnalyticInitConfig }; -export { Events, AbstractAnalytic, defaultDependencies }; +export { Events, AbstractAnalytic };