From 3a705f063bcae99c7964495ff83ad9ce8d4eb5a3 Mon Sep 17 00:00:00 2001 From: shrouti1507 <60211312+shrouti1507@users.noreply.github.com> Date: Fri, 25 Oct 2024 11:06:21 +0530 Subject: [PATCH] feat: gainsight PX destination (#1852) (#1889) * feat: gainsight PX destination (#1852) * feat: gainsight PX destination * feat: Gainsight PX destination PR Feedback - changed name to use existing name * feat: gainsight PX destination PR feedback - Switched to use integration options instead of extra config object. Cleaned up identify * feat: gainsight PX destination PR feedback - Use integration config * feat: gainsight PX destination fixed typo on us2 datacenter URL * fix: updating config name to productTagKey and adding test cases * fix: added new test case * fix: added new test case * fix: fix imports * fix: fix destination name * fix: fix size limits * fix: fix build size * fix: fix build size --------- Co-authored-by: Nick Wolfe --- .../integrations/Gainsight_PX/constants.ts | 11 + .../integrations/client_server_name.js | 1 + .../config_to_integration_names.js | 1 + .../destDisplayNamesToFileNamesMap.ts | 3 + .../integrations/destinationNames.ts | 4 + .../integrations/integration_cname.js | 2 + .../integrations/Gainsight_PX/browser.test.js | 239 ++++++++++++++++++ .../src/integrations/Gainsight_PX/browser.js | 119 +++++++++ .../src/integrations/Gainsight_PX/index.js | 1 + .../Gainsight_PX/nativeSdkLoader.js | 29 +++ .../src/integrations/Gainsight_PX/utils.js | 17 ++ .../src/integrations/index.js | 2 + packages/analytics-js/.size-limit.mjs | 4 +- 13 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 packages/analytics-js-common/src/constants/integrations/Gainsight_PX/constants.ts create mode 100644 packages/analytics-js-integrations/__tests__/integrations/Gainsight_PX/browser.test.js create mode 100644 packages/analytics-js-integrations/src/integrations/Gainsight_PX/browser.js create mode 100644 packages/analytics-js-integrations/src/integrations/Gainsight_PX/index.js create mode 100644 packages/analytics-js-integrations/src/integrations/Gainsight_PX/nativeSdkLoader.js create mode 100644 packages/analytics-js-integrations/src/integrations/Gainsight_PX/utils.js diff --git a/packages/analytics-js-common/src/constants/integrations/Gainsight_PX/constants.ts b/packages/analytics-js-common/src/constants/integrations/Gainsight_PX/constants.ts new file mode 100644 index 000000000..53c4e4b2d --- /dev/null +++ b/packages/analytics-js-common/src/constants/integrations/Gainsight_PX/constants.ts @@ -0,0 +1,11 @@ +const DIR_NAME = 'Gainsight_PX'; +const NAME = 'GAINSIGHT_PX'; +const DISPLAY_NAME = 'Gainsight PX'; + +const DISPLAY_NAME_TO_DIR_NAME_MAP = { [DISPLAY_NAME]: DIR_NAME }; +const CNameMapping = { + [NAME]: NAME, + Gainsight_PX: NAME, +}; + +export { NAME, CNameMapping, DISPLAY_NAME_TO_DIR_NAME_MAP, DISPLAY_NAME, DIR_NAME }; diff --git a/packages/analytics-js-common/src/constants/integrations/client_server_name.js b/packages/analytics-js-common/src/constants/integrations/client_server_name.js index ecfd1e6a8..6ce842056 100644 --- a/packages/analytics-js-common/src/constants/integrations/client_server_name.js +++ b/packages/analytics-js-common/src/constants/integrations/client_server_name.js @@ -79,6 +79,7 @@ const clientToServerNames = { COMMANDBAR: 'CommandBar', NINETAILED: 'Ninetailed', XPIXEL: 'XPixel', + GAINSIGHT_PX: 'Gainsight PX', }; export { clientToServerNames }; diff --git a/packages/analytics-js-common/src/constants/integrations/config_to_integration_names.js b/packages/analytics-js-common/src/constants/integrations/config_to_integration_names.js index 8c87a975f..0b706d995 100644 --- a/packages/analytics-js-common/src/constants/integrations/config_to_integration_names.js +++ b/packages/analytics-js-common/src/constants/integrations/config_to_integration_names.js @@ -80,6 +80,7 @@ const configToIntNames = { COMMANDBAR: 'CommandBar', NINETAILED: 'Ninetailed', XPIXEL: 'XPixel', + GAINSIGHT_PX: 'Gainsight_PX', }; export { configToIntNames }; diff --git a/packages/analytics-js-common/src/constants/integrations/destDisplayNamesToFileNamesMap.ts b/packages/analytics-js-common/src/constants/integrations/destDisplayNamesToFileNamesMap.ts index 4a27a4413..e30ac4233 100644 --- a/packages/analytics-js-common/src/constants/integrations/destDisplayNamesToFileNamesMap.ts +++ b/packages/analytics-js-common/src/constants/integrations/destDisplayNamesToFileNamesMap.ts @@ -158,6 +158,8 @@ import { CommandBarDirectoryName, NinetailedDisplayName, NinetailedDirectoryName, + Gainsight_PXDisplayName, + Gainsight_PXDirectoryName, XPixelDisplayName, XPixelDirectoryName, } from './destinationNames'; @@ -243,6 +245,7 @@ const destDisplayNamesToFileNamesMap: Record = { [SpotifyPixelDisplayName]: SpotifyPixelDirectoryName, [CommandBarDisplayName]: CommandBarDirectoryName, [NinetailedDisplayName]: NinetailedDirectoryName, + [Gainsight_PXDisplayName]: Gainsight_PXDirectoryName, [XPixelDisplayName]: XPixelDirectoryName, }; diff --git a/packages/analytics-js-common/src/constants/integrations/destinationNames.ts b/packages/analytics-js-common/src/constants/integrations/destinationNames.ts index 0bde8e681..72767ad10 100644 --- a/packages/analytics-js-common/src/constants/integrations/destinationNames.ts +++ b/packages/analytics-js-common/src/constants/integrations/destinationNames.ts @@ -290,6 +290,10 @@ export { DISPLAY_NAME as NinetailedDisplayName, DIR_NAME as NinetailedDirectoryName, } from './Ninetailed/constants'; +export { + DISPLAY_NAME as Gainsight_PXDisplayName, + DIR_NAME as Gainsight_PXDirectoryName, +} from './Gainsight_PX/constants'; export { DISPLAY_NAME as XPixelDisplayName, DIR_NAME as XPixelDirectoryName, diff --git a/packages/analytics-js-common/src/constants/integrations/integration_cname.js b/packages/analytics-js-common/src/constants/integrations/integration_cname.js index 434f688fa..57ab51ec4 100644 --- a/packages/analytics-js-common/src/constants/integrations/integration_cname.js +++ b/packages/analytics-js-common/src/constants/integrations/integration_cname.js @@ -78,6 +78,7 @@ import { CNameMapping as SpotifyPixel } from './SpotifyPixel/constants'; import { CNameMapping as CommandBar } from './CommandBar/constants'; import { CNameMapping as Ninetailed } from './Ninetailed/constants'; import { CNameMapping as XPixel } from './XPixel/constants'; +import { CNameMapping as Gainsight_PX } from './Gainsight_PX/constants'; // for sdk side native integration identification // add a mapping from common names to index.js exported key names as identified by Rudder const commonNames = { @@ -162,6 +163,7 @@ const commonNames = { ...Sprig, ...SpotifyPixel, ...XPixel, + ...Gainsight_PX }; export { commonNames }; diff --git a/packages/analytics-js-integrations/__tests__/integrations/Gainsight_PX/browser.test.js b/packages/analytics-js-integrations/__tests__/integrations/Gainsight_PX/browser.test.js new file mode 100644 index 000000000..f0d315de6 --- /dev/null +++ b/packages/analytics-js-integrations/__tests__/integrations/Gainsight_PX/browser.test.js @@ -0,0 +1,239 @@ +import Gainsight_PX from '../../../src/integrations/Gainsight_PX/browser'; +import { loadNativeSdk } from '../../../src/integrations/Gainsight_PX/nativeSdkLoader'; +import { getDestinationOptions } from '../../../src/integrations/Gainsight_PX/utils'; + +// Mock the external dependencies +jest.mock('../../../src/integrations/Gainsight_PX/nativeSdkLoader'); +jest.mock('../../../src/integrations/Gainsight_PX/utils'); + +describe('Gainsight_PX', () => { + let gainsightPX; + let mockAnalytics; + let mockConfig; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Mock window.aptrinsic + window.aptrinsic = jest.fn(); + + // Mock analytics object + mockAnalytics = { + logLevel: 'debug', + getUserId: jest.fn(), + getUserTraits: jest.fn(), + getGroupId: jest.fn(), + getGroupTraits: jest.fn(), + loadOnlyIntegrations: {}, + }; + + // Mock config + mockConfig = { + productTagKey: 'test-product-key', + dataCenter: 'US', + }; + + gainsightPX = new Gainsight_PX(mockConfig, mockAnalytics); + }); + + describe('init', () => { + it('should load native SDK and initialize', () => { + getDestinationOptions.mockReturnValue({ someOption: 'value' }); + mockAnalytics.getUserId.mockReturnValue('test-user-id'); + mockAnalytics.getUserTraits.mockReturnValue({ name: 'Test User' }); + mockAnalytics.getGroupId.mockReturnValue('test-group-id'); + mockAnalytics.getGroupTraits.mockReturnValue({ plan: 'premium' }); + + gainsightPX.init(); + + expect(loadNativeSdk).toHaveBeenCalledWith('test-product-key', 'US', { someOption: 'value' }); + expect(window.aptrinsic).toHaveBeenCalledWith( + 'identify', + { id: 'test-user-id', name: 'Test User' }, + { id: 'test-group-id', plan: 'premium' } + ); + }); + + it('should not call identify if user ID is not present', () => { + mockAnalytics.getUserId.mockReturnValue(null); + + gainsightPX.init(); + + expect(loadNativeSdk).toHaveBeenCalled(); + expect(window.aptrinsic).not.toHaveBeenCalled(); + }); + }); + + describe('isLoaded', () => { + it('should return true when window.aptrinsic and window.aptrinsic.init exist', () => { + window.aptrinsic = { init: jest.fn() }; + expect(gainsightPX.isLoaded()).toBe(true); + }); + + it('should return false when window.aptrinsic does not exist', () => { + delete window.aptrinsic; + expect(gainsightPX.isLoaded()).toBe(false); + }); + + it('should return false when window.aptrinsic exists but init is missing', () => { + window.aptrinsic = {}; + expect(gainsightPX.isLoaded()).toBe(false); + }); + }); + + describe('isReady', () => { + it('should return the same value as isLoaded', () => { + window.aptrinsic = { init: jest.fn() }; + expect(gainsightPX.isReady()).toBe(gainsightPX.isLoaded()); + + delete window.aptrinsic; + expect(gainsightPX.isReady()).toBe(gainsightPX.isLoaded()); + }); + }); + + describe('identify', () => { + it('should call aptrinsic identify with user data', () => { + const rudderElement = { + message: { + userId: 'test-user-id', + context: { + traits: { name: 'Test User', email: 'test@example.com' }, + }, + }, + }; + + gainsightPX.identify(rudderElement); + + expect(window.aptrinsic).toHaveBeenCalledWith( + 'identify', + { id: 'test-user-id', name: 'Test User', email: 'test@example.com' }, + {} + ); + }); + + it('should not call aptrinsic identify if userId is missing', () => { + const rudderElement = { + message: { + context: { + traits: { name: 'Test User' }, + }, + }, + }; + + gainsightPX.identify(rudderElement); + + expect(window.aptrinsic).not.toHaveBeenCalled(); + }); + + test('Testing identify call with ID only', () => { + // call RudderStack function + gainsightPX.identify({ + message: { + userId: 'rudder01', + context: {} + } + }); + + // Confirm that it was translated to the appropriate PX call + expect(window.aptrinsic.mock.calls[0]).toEqual([ + 'identify', + { + id: 'rudder01' + }, + {} + ]); + }); + + }); + + describe('group', () => { + it('should call aptrinsic identify with group data', () => { + const rudderElement = { + message: { + userId: 'test-user-id', + groupId: 'test-group-id', + traits: { plan: 'premium' }, + context: { + traits: { name: 'Test User' }, + }, + }, + }; + + gainsightPX.group(rudderElement); + + expect(window.aptrinsic).toHaveBeenCalledWith( + 'identify', + { id: 'test-user-id', name: 'Test User' }, + { id: 'test-group-id', plan: 'premium' } + ); + }); + + it('should use anonymousId if groupId is not present', () => { + const rudderElement = { + message: { + anonymousId: 'anon-id', + traits: { plan: 'basic' }, + }, + }; + + gainsightPX.group(rudderElement); + + expect(window.aptrinsic).toHaveBeenCalledWith( + 'identify', + {}, + { id: 'anon-id', plan: 'basic' } + ); + }); + }); + + describe('track', () => { + it('should call aptrinsic track with event data', () => { + const rudderElement = { + message: { + event: 'Test Event', + properties: { category: 'test', value: 10 }, + }, + }; + + gainsightPX.track(rudderElement); + + expect(window.aptrinsic).toHaveBeenCalledWith( + 'track', + 'Test Event', + { category: 'test', value: 10 } + ); + }); + + it('should not call aptrinsic track if event name is missing', () => { + const rudderElement = { + message: { + properties: { category: 'test' }, + }, + }; + + gainsightPX.track(rudderElement); + + expect(window.aptrinsic).not.toHaveBeenCalled(); + }); + + test('Test for empty properties', () => { + // call RudderStack function + gainsightPX.track({ + message: { + context: {}, + event: 'event-name', + properties: {} + } + }); + + // Confirm that it was translated to the appropriate PX call + expect(window.aptrinsic.mock.calls[0]).toEqual([ + 'track', + 'event-name', + {} + ]); + }); + + }); +}); \ No newline at end of file diff --git a/packages/analytics-js-integrations/src/integrations/Gainsight_PX/browser.js b/packages/analytics-js-integrations/src/integrations/Gainsight_PX/browser.js new file mode 100644 index 000000000..b73f0eee4 --- /dev/null +++ b/packages/analytics-js-integrations/src/integrations/Gainsight_PX/browser.js @@ -0,0 +1,119 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable lines-between-class-members */ +import { + NAME, + DISPLAY_NAME, +} from '@rudderstack/analytics-js-common/constants/integrations/Gainsight_PX/constants'; +import Logger from '../../utils/logger'; +import { loadNativeSdk } from './nativeSdkLoader'; +import { getDestinationOptions } from './utils'; + +const logger = new Logger(DISPLAY_NAME); + +class Gainsight_PX { + constructor(config, analytics, destinationInfo) { + if (analytics.logLevel) { + logger.setLogLevel(analytics.logLevel); + } + this.analytics = analytics; + this.productKey = config.productTagKey; + this.dataCenter = !config.dataCenter ? 'US' : config.dataCenter; + this.name = NAME; + ({ + shouldApplyDeviceModeTransformation: this.shouldApplyDeviceModeTransformation, + propagateEventsUntransformedOnError: this.propagateEventsUntransformedOnError, + destinationId: this.destinationId, + } = destinationInfo ?? {}); + } + + init() { + const pxConfig = getDestinationOptions(this.analytics.loadOnlyIntegrations) || {}; + loadNativeSdk(this.productKey, this.dataCenter, pxConfig); + this.initializeMe(); + } + + initializeMe() { + const userId = this.analytics.getUserId(); + + // Only proceed with identify if user ID is defined + if (userId) { + const visitorObj = { id: userId, ...this.analytics.getUserTraits() }; + + const accountObj = { + id: this.analytics.getGroupId(), + ...this.analytics.getGroupTraits(), + }; + + window.aptrinsic('identify', visitorObj, accountObj); + } + } + + isLoaded() { + return !!(window.aptrinsic && window.aptrinsic.init); + } + + isReady() { + return this.isLoaded(); + } + + /* utility functions --- Ends here --- */ + + /* + * Gainsight_PX MAPPED FUNCTIONS :: identify, track, group + */ + + identify(rudderElement) { + let visitorObj = {}; + const accountObj = {}; + const { userId, context } = rudderElement.message; + const id = userId; + const userTraits = context?.traits || {}; + visitorObj = { + id, + ...userTraits, + }; + + if (!userId) { + return; + } + window.aptrinsic('identify', visitorObj, accountObj); + } + + /* + *Group call maps to an account for which visitor belongs. + *It is same as identify call + */ + group(rudderElement) { + let accountObj = {}; + let visitorObj = {}; + const { userId, traits, context, groupId, anonymousId } = rudderElement.message; + accountObj.id = groupId || anonymousId; + accountObj = { + ...accountObj, + ...traits, + }; + + const userTraits = context?.traits || {}; + if (userId) { + visitorObj = { + id: userId, + ...userTraits, + }; + } + + window.aptrinsic('identify',visitorObj, accountObj); + } + + // Custom Events + track(rudderElement) { + const { event, properties } = rudderElement.message; + if (!event) { + logger.error('Cannot send un-named custom event'); + return; + } + const props = properties; + window.aptrinsic('track', event, props); + } +} + +export default Gainsight_PX; diff --git a/packages/analytics-js-integrations/src/integrations/Gainsight_PX/index.js b/packages/analytics-js-integrations/src/integrations/Gainsight_PX/index.js new file mode 100644 index 000000000..ee75068ef --- /dev/null +++ b/packages/analytics-js-integrations/src/integrations/Gainsight_PX/index.js @@ -0,0 +1 @@ +export { default as Gainsight_PX } from './browser'; diff --git a/packages/analytics-js-integrations/src/integrations/Gainsight_PX/nativeSdkLoader.js b/packages/analytics-js-integrations/src/integrations/Gainsight_PX/nativeSdkLoader.js new file mode 100644 index 000000000..50af1a3f0 --- /dev/null +++ b/packages/analytics-js-integrations/src/integrations/Gainsight_PX/nativeSdkLoader.js @@ -0,0 +1,29 @@ +import { LOAD_ORIGIN } from '@rudderstack/analytics-js-common/v1.1/utils/constants'; + +function loadNativeSdk(productKey, dataCenter, pxConfig) { + let hostName = 'web-sdk.aptrinsic.com'; + switch (dataCenter) { + case 'EU': + hostName = 'web-sdk-eu.aptrinsic.com'; + break; + case 'US2': + hostName = 'web-sdk-us2.aptrinsic.com'; + break; + } + const sdkUrl= "https://" + hostName + "/api/aptrinsic.js"; + + (function(n,t,a,e, co){ + var i="aptrinsic"; + n[i]=n[i]||function(){ + (n[i].q=n[i].q||[]).push(arguments) + },n[i].p=e; + n[i].c=co; + var r=t.createElement("script"); + r.async=!0,r.src=a+"?a="+e; + r.setAttribute('data-loader', LOAD_ORIGIN); + var c=t.getElementsByTagName("script")[0]; + c.parentNode.insertBefore(r,c) + })(window, document, sdkUrl, productKey, pxConfig); +} + +export { loadNativeSdk }; diff --git a/packages/analytics-js-integrations/src/integrations/Gainsight_PX/utils.js b/packages/analytics-js-integrations/src/integrations/Gainsight_PX/utils.js new file mode 100644 index 000000000..0b54c0403 --- /dev/null +++ b/packages/analytics-js-integrations/src/integrations/Gainsight_PX/utils.js @@ -0,0 +1,17 @@ +import { + NAME, + DISPLAY_NAME, +} from '@rudderstack/analytics-js-common/constants/integrations/Gainsight_PX/constants'; + +/** + * Get destination specific options from integrations options + * By default, it will return options for the destination using its display name + * If display name is not present, it will return options for the destination using its name + * The fallback is only for backward compatibility with SDK versions < v1.1 + * @param {object} integrationsOptions Integrations options object + * @returns destination specific options + */ +const getDestinationOptions = integrationsOptions => + integrationsOptions && (integrationsOptions[DISPLAY_NAME] || integrationsOptions[NAME]); + +export { getDestinationOptions }; diff --git a/packages/analytics-js-integrations/src/integrations/index.js b/packages/analytics-js-integrations/src/integrations/index.js index 591fb539a..499c5daad 100644 --- a/packages/analytics-js-integrations/src/integrations/index.js +++ b/packages/analytics-js-integrations/src/integrations/index.js @@ -77,6 +77,7 @@ import * as SpotifyPixel from './SpotifyPixel'; import * as CommandBar from './CommandBar'; import * as Ninetailed from './Ninetailed'; import * as XPixel from './XPixel'; +import * as Gainsight_PX from './Gainsight_PX'; // the key names should match the destination.name value to keep parity everywhere // (config-plan name, native destination.name , exported integration name(this one below)) @@ -160,6 +161,7 @@ const integrations = { SPOTIFYPIXEL: SpotifyPixel.default, NINETAILED: Ninetailed.default, XPIXEL: XPixel.default, + GAINSIGHT_PX: Gainsight_PX.default, }; export { integrations }; diff --git a/packages/analytics-js/.size-limit.mjs b/packages/analytics-js/.size-limit.mjs index 651bdc86f..dc241cc4a 100644 --- a/packages/analytics-js/.size-limit.mjs +++ b/packages/analytics-js/.size-limit.mjs @@ -13,7 +13,7 @@ export default [ name: 'Core - Legacy - NPM (CJS)', path: 'dist/npm/legacy/cjs/index.cjs', import: '*', - limit: '48.5 KiB', + limit: '49 KiB', }, { name: 'Core - Legacy - NPM (UMD)', @@ -59,7 +59,7 @@ export default [ name: 'Core (Bundled) - Legacy - NPM (CJS)', path: 'dist/npm/legacy/bundled/cjs/index.cjs', import: '*', - limit: '48.5 KiB', + limit: '49 KiB', }, { name: 'Core (Bundled) - Legacy - NPM (UMD)',