Skip to content

Commit

Permalink
EventsSDK: Support Google Tag Manager (#118)
Browse files Browse the repository at this point in the history
In order to support Google Tag Manager, we will export a new function
just specifically for sending Events API requests from it. This also
adds a convertTypesGTM function which is used to convert booleans and
numbers represented as strings to their correct types. This is unit
tested in convertTypes.test.ts.

J=https://yexttest.atlassian.net/browse/FUS-6055
TEST=auto, unit

---------

Co-authored-by: Ethan Jaffee <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Nov 15, 2023
1 parent f44c8e4 commit fd43132
Show file tree
Hide file tree
Showing 8 changed files with 321 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
| Function | Description |
| --- | --- |
| [analytics(config)](./analytics.analytics.md) | The Yext Analytics Events SDK. Returns an AnalyticsEventService given an AnalyticsConfig. |
| [reportBrowserAnalytics()](./analytics.reportbrowseranalytics.md) | An alternative entry point for the Yext Analytics Events SDK, currently used by Google Tag Manager. This method reads the config and payload from the variable analyticsEventPayload stored in the global window object. The config and payload are then passed to the report function to be sent to the Yext Analytics Events API. |

## Interfaces

Expand Down
17 changes: 17 additions & 0 deletions docs/analytics.reportbrowseranalytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@yext/analytics](./analytics.md) &gt; [reportBrowserAnalytics](./analytics.reportbrowseranalytics.md)

## reportBrowserAnalytics() function

An alternative entry point for the Yext Analytics Events SDK, currently used by Google Tag Manager. This method reads the config and payload from the variable analyticsEventPayload stored in the global window object. The config and payload are then passed to the report function to be sent to the Yext Analytics Events API.

<b>Signature:</b>

```typescript
export declare function reportBrowserAnalytics(): Promise<string>;
```
<b>Returns:</b>

Promise&lt;string&gt;

3 changes: 3 additions & 0 deletions etc/analytics.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ export enum RegionEnum {
US = "us"
}

// @public
export function reportBrowserAnalytics(): Promise<string>;

// @public
export type VersionLabel = EnumOrString<VersionLabelEnum>;

Expand Down
70 changes: 70 additions & 0 deletions src/convertStringToValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { EventPayload } from './EventPayload';

// Define the list of possibly numerical properties to check and convert
const propertiesToCheck = [
'sessionTrackingEnabled',
'versionNumber',
'siteUid',
'count',
'entity',
'amount',
'latitude',
'longitude',
'timestamp',
'bot',
'internalUser',
'isDirectAnswer'
];

export function convertStringToValue(
data: Record<string, unknown>
): EventPayload {
// Recursive function to traverse and convert nested objects
function recursiveConversion(obj: Record<string, unknown>) {
for (const property in obj) {
if (propertiesToCheck.includes(property)) {
// Check if the property is in the list of properties to convert,
// and if it's a string that represents a number, convert it to a number.
// If it's a boolean string, convert it to a boolean.
if (!isNaN(Number(obj[property]))) {
obj[property] = Number(obj[property]);
} else if (obj[property] === 'true' || obj[property] === 'false') {
obj[property] = obj[property] === 'true';
}
}
// If the property is an object, recursively call the function on it
else if (typeof obj[property] === 'object') {
recursiveConversion(obj[property] as Record<string, unknown>);
}
}
}

// Copy the input data to avoid modifying the original
const result: Record<string, unknown> = { ...data };
// Start the recursive conversion
recursiveConversion(result);

// Handle customValues separately as it's nested properties can have a user defined key name
if (result.customValues && typeof result.customValues === 'object') {
convertCustomValues(result.customValues as Record<string, unknown>);
}

// Return the modified result
return {
...result,
action: result.action as EventPayload['action']
};
}

// Function to convert custom values within customValues object
function convertCustomValues(obj: Record<string, unknown>) {
for (const key in obj) {
if (
obj.hasOwnProperty(key) &&
typeof obj[key] === 'string' &&
!isNaN(Number(obj[key]))
) {
obj[key] = Number(obj[key]);
}
}
}
41 changes: 41 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AnalyticsConfig } from './AnalyticsConfig';
import { AnalyticsEventReporter } from './AnalyticsEventReporter';
import { AnalyticsEventService } from './AnalyticsEventService';
import { convertStringToValue } from './convertStringToValue';

/**
* The Yext Analytics Events SDK.
Expand All @@ -11,6 +12,39 @@ export function analytics(config: AnalyticsConfig): AnalyticsEventService {
return new AnalyticsEventReporter(config);
}

/**
* An alternative entry point for the Yext Analytics Events SDK, currently used by Google Tag Manager.
* This method reads the config and payload from the variable analyticsEventPayload stored
* in the global window object. The config and payload are then passed to the report function to be sent
* to the Yext Analytics Events API.
* @public
*/
export function reportBrowserAnalytics(): Promise<string> {
const gtmPayload = window['analyticsEventPayload'];
let response: Promise<string>;
if (gtmPayload) {
const config = gtmPayload[0][1] as Record<string, unknown>;
const data = gtmPayload[1][1] as Record<string, unknown>;
if (config) {
// User text input inside of Google Tag Manager defaults to a String type for all fields.
// However, the Events API expects true boolean and number types for certain fields.
// The below convertStringToValue method calls take care of converting the String types
// to the correct one's for the config and payload objects.
const correctedConfig = convertStringToValue(config);
const correctedData = convertStringToValue(data);
const reporter = new AnalyticsEventReporter(
correctedConfig as AnalyticsConfig
);
response = reporter.report(correctedData);
} else {
response = Promise.reject('No config found in payload.');
}
} else {
response = Promise.reject('No payload found.');
}
return response;
}

export * from './AnalyticsConfig';
export * from './AnalyticsEventService';
export * from './Environment';
Expand All @@ -19,3 +53,10 @@ export * from './EventPayload';
export * from './EnumOrString';
export * from './Action';
export * from './VersionLabel';

declare global {
interface Window {
analyticsEventPayload?: GTMPayload;
}
}
type GTMPayload = Record<string, Record<string, unknown>>;
36 changes: 36 additions & 0 deletions src/test-gtm/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<!-- Google Tag Manager -->
<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;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-5XJN5FXJ');
</script>
<!-- End Google Tag Manager -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Analytics Test</title>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript
><iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-5XJN5FXJ"
height="0"
width="0"
style="display: none; visibility: hidden"
></iframe
></noscript>
<!-- End Google Tag Manager (noscript) -->
<h1>Analytics Test</h1>
<button type="button">Test</button>
</body>
</html>
115 changes: 115 additions & 0 deletions tests/convertStringToValue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { convertStringToValue } from '../src/convertStringToValue';

describe('convertTypesGTM Test', () => {
it('should convert string represented numbers to numerical type - standard case', () => {
const gtmPayload = {
action: 'ADD_TO_CART',
count: '5',
search: {
versionNumber: '5'
},
sites: {
siteUid: '5'
}
};

const result = convertStringToValue(gtmPayload);

expect(result).toEqual({
action: 'ADD_TO_CART',
count: 5,
search: {
versionNumber: 5
},
sites: {
siteUid: 5
}
});
});

it('should convert string represented numbers to numerical type - entity is Number', () => {
const gtmPayload = {
action: 'ADD_TO_CART',
entity: '1234'
};

const result = convertStringToValue(gtmPayload);

expect(result).toEqual({
action: 'ADD_TO_CART',
entity: 1234
});
});

it('should convert string represented numbers to numerical type - entity is string', () => {
const gtmPayload = {
action: 'ADD_TO_CART',
entity: 'myEntity'
};

const result = convertStringToValue(gtmPayload);

expect(result).toEqual({
action: 'ADD_TO_CART',
entity: 'myEntity'
});
});

it('should convert customValues properly', () => {
const gtmPayload = {
action: 'ADD_TO_CART',
customValues: {
myCustomVal: '5',
myCustomVal2: '1234'
}
};

const result = convertStringToValue(gtmPayload);

expect(result).toEqual({
action: 'ADD_TO_CART',
customValues: {
myCustomVal: 5,
myCustomVal2: 1234
}
});
});
it('should convert string represented booleans to boolean type', () => {
const gtmPayload = {
action: 'ADD_TO_CART',
bot: 'true',
internalUser: 'false',
search: {
versionNumber: '5',
isDirectAnswer: 'false'
}
};

const result = convertStringToValue(gtmPayload);

expect(result).toEqual({
action: 'ADD_TO_CART',
bot: true,
internalUser: false,
search: {
versionNumber: 5,
isDirectAnswer: false
}
});
});
it('test convert AnayticsConfig - should convert sessionTrackingEnabled', () => {
const analyticsConfig = {
key: 'apiKey',
sessionTrackingEnabled: 'false',
region: 'US'
};

const result = convertStringToValue(analyticsConfig);

expect(result).toEqual({
key: 'apiKey',
sessionTrackingEnabled: false,
region: 'US'
});
});
});
38 changes: 38 additions & 0 deletions tests/reportBrowserAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { reportBrowserAnalytics } from '../src/index';
import { AnalyticsEventReporter } from '../src/AnalyticsEventReporter'; // replace with actual import path

describe('reportBrowserAnalytics', () => {
let reportSpy: jest.SpyInstance;

beforeEach(() => {
// Mock window['analyticsEventPayload']
(global as any).window = {};

Check warning on line 9 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / test-e2e

Unexpected any. Specify a different type

Check warning on line 9 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / call_run_tests / tests (14.x)

Unexpected any. Specify a different type

Check warning on line 9 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / call_run_tests / tests (16.x)

Unexpected any. Specify a different type

// Mock report method
reportSpy = jest.spyOn(AnalyticsEventReporter.prototype, 'report');
reportSpy.mockResolvedValue('report');
});

afterEach(() => {
jest.resetAllMocks();
});

it('should return a promise that resolves when payload is present', async () => {
const mockPayload = [
['config', { key: 'apiKey' }],
['payload', { bot: false }]
];
(global as any).window['analyticsEventPayload'] = mockPayload;

Check warning on line 25 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / test-e2e

Unexpected any. Specify a different type

Check warning on line 25 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / call_run_tests / tests (14.x)

Unexpected any. Specify a different type

Check warning on line 25 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / call_run_tests / tests (16.x)

Unexpected any. Specify a different type

const result = await reportBrowserAnalytics();

expect(result).toBe('report');
expect(reportSpy).toHaveBeenCalled();
});

it('should return a promise that rejects when analyticeEvenPayload is not present in window', async () => {
(global as any).window['analyticsEventPayload'] = undefined;

Check warning on line 34 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / test-e2e

Unexpected any. Specify a different type

Check warning on line 34 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / call_run_tests / tests (14.x)

Unexpected any. Specify a different type

Check warning on line 34 in tests/reportBrowserAnalytics.test.ts

View workflow job for this annotation

GitHub Actions / call_run_tests / tests (16.x)

Unexpected any. Specify a different type

await expect(reportBrowserAnalytics()).rejects.toEqual('No payload found.');
});
});

0 comments on commit fd43132

Please sign in to comment.