diff --git a/packages/analytics-js-integrations/__tests__/integrations/Mixpanel/browser.test.js b/packages/analytics-js-integrations/__tests__/integrations/Mixpanel/browser.test.js index 0680aeef2..5e86a9031 100644 --- a/packages/analytics-js-integrations/__tests__/integrations/Mixpanel/browser.test.js +++ b/packages/analytics-js-integrations/__tests__/integrations/Mixpanel/browser.test.js @@ -1,24 +1,24 @@ /* eslint-disable no-underscore-dangle */ import { Mixpanel } from '../../../src/integrations/Mixpanel'; -describe('Mixpanel init tests', () => { - let mixpanel; +beforeEach(() => { + window.mixpanel = {}; - beforeEach(() => { - window.mixpanel = {}; + // Add a dummy script as it is required by the init script + const scriptElement = document.createElement('script'); + scriptElement.type = 'text/javascript'; + scriptElement.id = 'dummyScript'; + const headElements = document.getElementsByTagName('head'); + headElements[0].insertBefore(scriptElement, headElements[0].firstChild); +}); - // Add a dummy script as it is required by the init script - const scriptElement = document.createElement('script'); - scriptElement.type = 'text/javascript'; - scriptElement.id = 'dummyScript'; - const headElements = document.getElementsByTagName('head'); - headElements[0].insertBefore(scriptElement, headElements[0].firstChild); - }); +afterEach(() => { + // Reset DOM to original state + document.getElementById('dummyScript')?.remove(); +}); - afterEach(() => { - // Reset DOM to original state - document.getElementById('dummyScript')?.remove(); - }); +describe('Init tests', () => { + let mixpanel; test('Persistence type is missing', () => { mixpanel = new Mixpanel({ persistence: 'none' }, { logLevel: 'debug' }); @@ -70,3 +70,48 @@ describe('Mixpanel init tests', () => { }); }); }); + +describe('Page tests', () => { + let mixpanel; + test('should return a custom generated event name when useUserDefinedPageEventName setting is enabled and event template is provided', () => { + mixpanel = new Mixpanel( + { + useUserDefinedPageEventName: true, + userDefinedPageEventTemplate: 'Viewed {{ category }} {{ name }} page', + }, + { logLevel: 'debug' }, + ); + mixpanel.init(); + window.mixpanel.track = jest.fn(); + mixpanel.page({ + message: { + name: 'Doc', + properties: { category: 'Integration' }, + }, + }); + expect(window.mixpanel.track.mock.calls[0][0]).toEqual('Viewed Integration Doc page'); + }); + + test('should throw an error when useUserDefinedPageEventName setting is enabled and event template is not provided', () => { + mixpanel = new Mixpanel( + { + useUserDefinedPageEventName: true, + }, + { logLevel: 'debug' }, + ); + mixpanel.init(); + window.mixpanel.track = jest.fn(); + try { + mixpanel.page({ + message: { + name: 'Doc', + properties: { category: 'Integration' }, + }, + }); + } catch (error) { + expect(error).toEqual( + 'Event name template is not configured. Please provide a valid value for the `Page Event Name Template` in the destination dashboard.', + ); + } + }); +}); diff --git a/packages/analytics-js-integrations/__tests__/integrations/Mixpanel/util.test.js b/packages/analytics-js-integrations/__tests__/integrations/Mixpanel/util.test.js index 6815c5fe4..88c305e99 100644 --- a/packages/analytics-js-integrations/__tests__/integrations/Mixpanel/util.test.js +++ b/packages/analytics-js-integrations/__tests__/integrations/Mixpanel/util.test.js @@ -7,7 +7,8 @@ import { mapTraits, filterSetOnceTraits, unset, - formatTraits + formatTraits, + generatePageCustomEventName, } from '../../../src/integrations/Mixpanel/util'; describe('parseConfigArray', () => { @@ -217,12 +218,9 @@ describe('mapTraits', () => { }); }); - describe('filterSetOnceTraits', () => { - // Should return an object with setTraits, setOnce, email, and username keys when given valid outgoingTraits and setOnceProperties inputs it('should return an object with setTraits, setOnce, email, and username keys', () => { - const outgoingTraits = { email: 'test@example.com', firstName: 'John', @@ -233,10 +231,8 @@ describe('filterSetOnceTraits', () => { }; const setOnceProperties = ['email', 'username']; - const result = filterSetOnceTraits(outgoingTraits, setOnceProperties); - expect(result).toHaveProperty('setTraits'); expect(result).toHaveProperty('setOnce'); expect(result).toHaveProperty('email'); @@ -245,7 +241,6 @@ describe('filterSetOnceTraits', () => { // Should correctly extract and remove setOnceProperties from the outgoingTraits object it('should correctly extract and remove setOnceProperties', () => { - const outgoingTraits = { email: 'test@example.com', firstName: 'John', @@ -256,10 +251,8 @@ describe('filterSetOnceTraits', () => { }; const setOnceProperties = ['email', 'username']; - const result = filterSetOnceTraits(outgoingTraits, setOnceProperties); - expect(result.setTraits).not.toHaveProperty('email'); expect(result.setTraits).not.toHaveProperty('username'); expect(result.setOnce).toHaveProperty('email', 'test@example.com'); @@ -268,7 +261,6 @@ describe('filterSetOnceTraits', () => { // Should correctly handle cases where setOnceProperties are not present in the outgoingTraits object it('should correctly handle cases where setOnceProperties are not present', () => { - const outgoingTraits = { firstName: 'John', lastName: 'Doe', @@ -277,11 +269,9 @@ describe('filterSetOnceTraits', () => { }; const setOnceProperties = ['email', 'username']; - const result = filterSetOnceTraits(outgoingTraits, setOnceProperties); console.log(result); - expect(result).toHaveProperty('setTraits'); expect(result).toHaveProperty('setOnce'); expect(result).toHaveProperty('email'); @@ -290,21 +280,17 @@ describe('filterSetOnceTraits', () => { // Should correctly handle cases where the outgoingTraits object is empty it('should return an object with empty setTraits and setOnce properties when given an empty outgoingTraits object', () => { - const outgoingTraits = {}; const setOnceProperties = ['email', 'username']; - const result = filterSetOnceTraits(outgoingTraits, setOnceProperties); - expect(result.setTraits).toEqual({}); expect(result.setOnce).toEqual({}); }); // Should correctly handle cases where setOnceProperties are present in the outgoingTraits object but have non-string values it('should exclude non-string setOnceProperties from the setOnce property in the result object', () => { - const outgoingTraits = { email: 'test@example.com', firstName: 'John', @@ -317,10 +303,8 @@ describe('filterSetOnceTraits', () => { }; const setOnceProperties = ['email', 'username', 'age', 'gender']; - const result = filterSetOnceTraits(outgoingTraits, setOnceProperties); - expect(result.setOnce).toEqual({ age: 25, gender: 'male', @@ -338,7 +322,6 @@ describe('filterSetOnceTraits', () => { // Should not modify the original outgoingTraits object it('should not modify the original outgoingTraits object', () => { - const outgoingTraits = { email: 'test@example.com', firstName: 'John', @@ -349,10 +332,8 @@ describe('filterSetOnceTraits', () => { }; const setOnceProperties = ['email', 'username']; - filterSetOnceTraits(outgoingTraits, setOnceProperties); - expect(outgoingTraits).toEqual({ email: 'test@example.com', firstName: 'John', @@ -365,7 +346,6 @@ describe('filterSetOnceTraits', () => { // Should correctly handle cases where setOnceProperties contain nested properties it('should correctly handle cases where setOnceProperties contain nested properties', () => { - const outgoingTraits = { email: 'test@example.com', firstName: 'John', @@ -381,10 +361,8 @@ describe('filterSetOnceTraits', () => { }; const setOnceProperties = ['address.street', 'address.city']; - const result = filterSetOnceTraits(outgoingTraits, setOnceProperties); - expect(result.setTraits).toEqual({ email: 'test@example.com', firstName: 'John', @@ -406,7 +384,6 @@ describe('filterSetOnceTraits', () => { }); describe('unset', () => { - // Can unset a property at the top level of an object it('should unset a property at the top level of an object', () => { const obj = { name: 'John', age: 30 }; @@ -442,9 +419,7 @@ describe('unset', () => { }); }); - describe('formatTraits', () => { - // Extracts defined traits from message and sets them as outgoing traits it('should extract defined traits from message and set them as outgoing traits', () => { // Arrange @@ -457,10 +432,9 @@ describe('formatTraits', () => { phone: '1234567890', name: 'John Doe', customField1: 'value1', - customField2: 'value2' - } - } - + customField2: 'value2', + }, + }, }; const setOnceProperties = ['firstName']; @@ -474,12 +448,57 @@ describe('formatTraits', () => { email: 'test@example.com', name: 'John Doe', customField1: 'value1', - customField2: 'value2' + customField2: 'value2', }); expect(result.setOnce).toEqual({ - firstName: 'John' + firstName: 'John', }); }); }); +describe('generatePageCustomEventName', () => { + it('should generate a custom event name when userDefinedEventTemplate contains event template and message object is provided', () => { + let message = { name: 'Doc', properties: { category: 'Integration' } }; + const userDefinedEventTemplate = 'Viewed {{ category }} {{ name }} page'; + let expected = 'Viewed Integration Doc page'; + let result = generatePageCustomEventName(message, userDefinedEventTemplate); + expect(result).toBe(expected); + + message = { name: true, properties: { category: 0 } }; + expected = 'Viewed 0 true page'; + result = generatePageCustomEventName(message, userDefinedEventTemplate); + expect(result).toBe(expected); + }); + + it('should generate a custom event name when userDefinedEventTemplate contains event template and category or name is missing in message object', () => { + const message = { name: 'Doc', properties: { category: undefined } }; + const userDefinedEventTemplate = 'Viewed {{ category }} {{ name }} page someKeyword'; + const expected = 'Viewed Doc page someKeyword'; + const result = generatePageCustomEventName(message, userDefinedEventTemplate); + expect(result).toBe(expected); + }); + + it('should generate a custom event name when userDefinedEventTemplate contains only category or name placeholder and message object is provided', () => { + const message = { name: 'Doc', properties: { category: 'Integration' } }; + const userDefinedEventTemplate = 'Viewed {{ name }} page'; + const expected = 'Viewed Doc page'; + const result = generatePageCustomEventName(message, userDefinedEventTemplate); + expect(result).toBe(expected); + }); + it('should return the userDefinedEventTemplate when it does not contain placeholder {{}}', () => { + const message = { name: 'Index' }; + const userDefinedEventTemplate = 'Viewed a Home page'; + const expected = 'Viewed a Home page'; + const result = generatePageCustomEventName(message, userDefinedEventTemplate); + expect(result).toBe(expected); + }); + + it('should return a event name when message object is not provided/empty', () => { + const message = {}; + const userDefinedEventTemplate = 'Viewed {{ category }} {{ name }} page someKeyword'; + const expected = 'Viewed page someKeyword'; + const result = generatePageCustomEventName(message, userDefinedEventTemplate); + expect(result).toBe(expected); + }); +}); diff --git a/packages/analytics-js-integrations/src/integrations/Mixpanel/browser.js b/packages/analytics-js-integrations/src/integrations/Mixpanel/browser.js index 8d984e889..104c24a11 100644 --- a/packages/analytics-js-integrations/src/integrations/Mixpanel/browser.js +++ b/packages/analytics-js-integrations/src/integrations/Mixpanel/browser.js @@ -15,6 +15,7 @@ import { parseConfigArray, inverseObjectArrays, getConsolidatedPageCalls, + generatePageCustomEventName, } from './util'; import { loadNativeSdk } from './nativeSdkLoader'; @@ -63,6 +64,8 @@ class Mixpanel { destinationId: this.destinationId, } = destinationInfo ?? {}); this.ignoreDnt = config.ignoreDnt || false; + this.useUserDefinedPageEventName = config.useUserDefinedPageEventName || false; + this.userDefinedPageEventTemplate = config.userDefinedPageEventTemplate; } init() { @@ -181,7 +184,24 @@ class Mixpanel { * @param {*} rudderElement */ page(rudderElement) { - const { name, properties } = rudderElement.message; + const { properties } = rudderElement.message; + + if (this.useUserDefinedPageEventName) { + if (!this.userDefinedPageEventTemplate) { + logger.error( + 'Event name template is not configured. Please provide a valid value for the `Page Event Name Template` in the destination dashboard.', + ); + return; + } + const eventName = generatePageCustomEventName( + rudderElement.message, + this.userDefinedPageEventTemplate, + ); + window.mixpanel.track(eventName, properties); + return; + } + + const { name } = rudderElement.message; const { category } = properties; // consolidated Page Calls if (this.consolidatedPageCalls) { diff --git a/packages/analytics-js-integrations/src/integrations/Mixpanel/util.js b/packages/analytics-js-integrations/src/integrations/Mixpanel/util.js index 2cb7b2af5..e6ce2d0fe 100644 --- a/packages/analytics-js-integrations/src/integrations/Mixpanel/util.js +++ b/packages/analytics-js-integrations/src/integrations/Mixpanel/util.js @@ -4,7 +4,7 @@ import get from 'get-value'; import { DISPLAY_NAME } from '@rudderstack/analytics-js-common/constants/integrations/Mixpanel/constants'; import Logger from '../../utils/logger'; -import { getDefinedTraits, extractCustomFields } from '../../utils/utils'; +import { getDefinedTraits, extractCustomFields, isDefinedAndNotNull } from '../../utils/utils'; const logger = new Logger(DISPLAY_NAME); @@ -38,7 +38,7 @@ const traitAliases = { /** * Removes a property from an object based on a given property path. - * + * * @param {object} obj - The object from which the property needs to be removed. * @param {string} propertyPath - The path of the property to be removed, using dot notation. * @returns {undefined} - This function does not return anything. @@ -54,7 +54,7 @@ const traitAliases = { * } * } * }; - * + * * unset(obj, 'person.address.city'); * Output: { person: { name: 'John', age: 30, address: { state: 'NY' } } } */ @@ -82,7 +82,7 @@ function filterSetOnceTraits(outgoingTraits, setOnceProperties) { // Step 1: find the k-v pairs of setOnceProperties in traits and contextTraits - setOnceProperties.forEach((propertyPath) => { + setOnceProperties.forEach(propertyPath => { const pathSegments = propertyPath.split('.'); const propName = pathSegments[pathSegments.length - 1]; @@ -239,6 +239,40 @@ const getConsolidatedPageCalls = config => ? config.consolidatedPageCalls : true; +/** + * Generates a custom event name for a page or screen. + * + * @param {Object} message - The message object + * @param {string} userDefinedEventTemplate - The user-defined event template to be used for generating the event name. + * @throws {ConfigurationError} If the event template is missing. + * @returns {string} The generated custom event name. + * @example + * const userDefinedEventTemplate = "Viewed {{ category }} {{ name }} Page"; + * const message = {name: 'Home', properties: {category: 'Index'}}; + * output: "Viewed Index Home Page" + */ +const generatePageCustomEventName = (message, userDefinedEventTemplate) => { + let eventName = userDefinedEventTemplate; + + if (isDefinedAndNotNull(message.properties?.category)) { + // Replace {{ category }} with actual values + eventName = eventName.replace(/{{\s*category\s*}}/g, message.properties.category); + } else { + // find {{ category }} surrounded by whitespace characters and replace it with a single whitespace character + eventName = eventName.replace(/\s{{\s*category\s*}}\s/g, ' '); + } + + if (isDefinedAndNotNull(message.name)) { + // Replace {{ name }} with actual values + eventName = eventName.replace(/{{\s*name\s*}}/g, message.name); + } else { + // find {{ name }} surrounded by whitespace characters and replace it with a single whitespace character + eventName = eventName.replace(/\s{{\s*name\s*}}\s/g, ' '); + } + + return eventName; +}; + export { mapTraits, unionArrays, @@ -249,5 +283,6 @@ export { inverseObjectArrays, getConsolidatedPageCalls, filterSetOnceTraits, - unset + unset, + generatePageCustomEventName, };