@segment/analytics-browser-actions-hubspot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@segment/analytics-browser-actions-hubspot",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "main": "./dist/cjs",
6
+ "module": "./dist/esm",
7
+ "scripts": {
8
+ "build": "yarn build:esm && yarn build:cjs",
9
+ "build:cjs": "tsc --module commonjs --outDir ./dist/cjs",
10
+ "build:esm": "tsc --outDir ./dist/esm"
11
+ },
12
+ "typings": "./dist/esm",
13
+ "dependencies": {
14
+ "@segment/actions-core": "^3.71.0",
15
+ "@segment/browser-destination-runtime": "^1.0.0"
16
+ },
17
+ "peerDependencies": {
18
+ "@segment/analytics-next": "*"
19
+ }
20
+ }
@@ -0,0 +1,70 @@
1
+ import { Subscription } from '@segment/browser-destination-runtime/types'
2
+ import { Analytics, Context } from '@segment/analytics-next'
3
+ import hubspotDestination, { destination } from '../index'
4
+ import nock from 'nock'
5
+
6
+ const subscriptions: Subscription[] = [
7
+ {
8
+ partnerAction: 'trackCustomBehavioralEvent',
9
+ name: 'Track Custom Behavioral Event',
10
+ enabled: true,
11
+ subscribe: 'type = "track"',
12
+ mapping: {
13
+ event_name: {
14
+ '@path': '$.event'
15
+ }
16
+ }
17
+ }
18
+ ]
19
+
20
+ describe('Hubspot Web (Actions)', () => {
21
+ beforeEach(() => {
22
+ nock('https://js.hs-scripts.com/').get('/12345.js').reply(200, "window._hsq = '๐Ÿ‡บ๐Ÿ‡ธ'")
23
+ nock('https://js-eu1.hs-scripts.com/').get('/12345.js').reply(200, "window._hsq = '๐Ÿ‡ช๐Ÿ‡บ'")
24
+ nock('https://https://js.hsforms.net').get('forms/v2.js').reply(200, "window.hbspt = {forms: '1232'}")
25
+ })
26
+ test('loads hubspot analytics with just a HubID', async () => {
27
+ const [event] = await hubspotDestination({
28
+ portalId: '12345',
29
+ subscriptions
30
+ })
31
+
32
+ jest.spyOn(destination, 'initialize')
33
+
34
+ await event.load(Context.system(), {} as Analytics)
35
+ expect(destination.initialize).toHaveBeenCalled()
36
+ expect(window._hsq).toEqual('๐Ÿ‡บ๐Ÿ‡ธ')
37
+ expect(window.hbspt).toBeUndefined()
38
+ })
39
+
40
+ test('loads hubspot analytics with EU script', async () => {
41
+ const [event] = await hubspotDestination({
42
+ portalId: '12345',
43
+ enableEuropeanDataCenter: true,
44
+ subscriptions
45
+ })
46
+
47
+ jest.spyOn(destination, 'initialize')
48
+
49
+ await event.load(Context.system(), {} as Analytics)
50
+ expect(destination.initialize).toHaveBeenCalled()
51
+
52
+ expect(window._hsq).toEqual('๐Ÿ‡ช๐Ÿ‡บ')
53
+ expect(window.hbspt).toBeUndefined()
54
+ })
55
+
56
+ test('loads hubspot forms SDK', async () => {
57
+ const [event] = await hubspotDestination({
58
+ portalId: '12345',
59
+ loadFormsSDK: true,
60
+ subscriptions
61
+ })
62
+ jest.spyOn(destination, 'initialize')
63
+
64
+ await event.load(Context.system(), {} as Analytics)
65
+ expect(destination.initialize).toHaveBeenCalled()
66
+
67
+ expect(window._hsq).toEqual('๐Ÿ‡บ๐Ÿ‡ธ')
68
+ expect(window.hbspt).toBeDefined()
69
+ })
70
+ })
@@ -0,0 +1,24 @@
1
+ // Generated file. DO NOT MODIFY IT BY HAND.
2
+
3
+ export interface Settings {
4
+ /**
5
+ * The Hub ID of your HubSpot account.
6
+ */
7
+ portalId: string
8
+ /**
9
+ * Enable this option if you would like Segment to load the HubSpot SDK for EU data residency.
10
+ */
11
+ enableEuropeanDataCenter?: boolean
12
+ /**
13
+ * Enable this option to fire a `trackPageView` HubSpot event immediately after each Segment `identify` call to flush the data to HubSpot immediately.
14
+ */
15
+ flushIdentifyImmediately?: boolean
16
+ /**
17
+ * Format the event names for custom behavioral event automatically to standard HubSpot format (`pe<HubID>_event_name`).
18
+ */
19
+ formatCustomBehavioralEventNames?: boolean
20
+ /**
21
+ * Enable this option if you would like Segment to automatically load the HubSpot Forms SDK onto your site.
22
+ */
23
+ loadFormsSDK?: boolean
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ import type { Settings } from './generated-types'
2
+ import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types'
3
+ import { browserDestination } from '@segment/browser-destination-runtime/shim'
4
+
5
+ import trackCustomBehavioralEvent from './trackCustomBehavioralEvent'
6
+
7
+ import trackPageView from './trackPageView'
8
+
9
+ import upsertContact from './upsertContact'
10
+ import type { Hubspot } from './types'
11
+ import { defaultValues } from '@segment/actions-core'
12
+
13
+ declare global {
14
+ interface Window {
15
+ _hsq: any
16
+ hbspt: any
17
+ }
18
+ }
19
+
20
+ // Switch from unknown to the partner SDK client types
21
+ export const destination: BrowserDestinationDefinition<Settings, Hubspot> = {
22
+ name: 'Hubspot Web (Actions)',
23
+ slug: 'actions-hubspot-web',
24
+ mode: 'device',
25
+ presets: [
26
+ {
27
+ name: 'Track Custom Behavioral Event',
28
+ subscribe: 'type = "track"',
29
+ partnerAction: 'trackCustomBehavioralEvent',
30
+ mapping: defaultValues(trackCustomBehavioralEvent.fields)
31
+ },
32
+ {
33
+ name: 'Upsert Contact',
34
+ subscribe: 'type = "identify"',
35
+ partnerAction: 'upsertContact',
36
+ mapping: defaultValues(upsertContact.fields)
37
+ },
38
+ {
39
+ name: 'Track Page View',
40
+ subscribe: 'type = "page"',
41
+ partnerAction: 'trackPageView',
42
+ mapping: defaultValues(trackPageView.fields)
43
+ }
44
+ ],
45
+ settings: {
46
+ portalId: {
47
+ description: 'The Hub ID of your HubSpot account.',
48
+ label: 'Hub ID',
49
+ type: 'string',
50
+ required: true
51
+ },
52
+ enableEuropeanDataCenter: {
53
+ description: 'Enable this option if you would like Segment to load the HubSpot SDK for EU data residency.',
54
+ label: 'Enable the European Data Center SDK.',
55
+ type: 'boolean',
56
+ required: false
57
+ },
58
+ flushIdentifyImmediately: {
59
+ description: 'Enable this option to fire a `trackPageView` HubSpot event immediately after each Segment `identify` call to flush the data to HubSpot immediately.',
60
+ label: 'Flush Identify Calls Immediately',
61
+ type: 'boolean',
62
+ required: false
63
+ },
64
+ formatCustomBehavioralEventNames: {
65
+ description: 'Format the event names for custom behavioral event automatically to standard HubSpot format (`pe<HubID>_event_name`).',
66
+ label: 'Format Custom Behavioral Event Names',
67
+ type: 'boolean',
68
+ required: false,
69
+ default: true
70
+ },
71
+ loadFormsSDK: {
72
+ description: 'Enable this option if you would like Segment to automatically load the HubSpot Forms SDK onto your site.',
73
+ label: 'Load Forms SDK',
74
+ type: 'boolean',
75
+ required: false,
76
+ default: false
77
+ }
78
+ },
79
+
80
+ initialize: async ({ settings }, deps) => {
81
+ const scriptPath = settings.enableEuropeanDataCenter
82
+ ? `https://js-eu1.hs-scripts.com/${settings.portalId}.js`
83
+ : `https://js.hs-scripts.com/${settings.portalId}.js`
84
+
85
+ const formsScriptPath = settings.enableEuropeanDataCenter
86
+ ? 'https://js-eu1.hsforms.net/forms/v2.js'
87
+ : 'https://js.hsforms.net/forms/v2.js'
88
+
89
+ await deps.loadScript(scriptPath)
90
+ if (settings.loadFormsSDK) {
91
+ await deps.loadScript(formsScriptPath)
92
+ }
93
+ await deps.resolveWhen(
94
+ () =>
95
+ !!(window._hsq && window._hsq.push !== Array.prototype.push) &&
96
+ (!settings.loadFormsSDK || !!(window.hbspt && window.hbspt.forms)),
97
+ 100
98
+ )
99
+ return window._hsq
100
+ },
101
+
102
+ actions: {
103
+ trackCustomBehavioralEvent,
104
+ trackPageView,
105
+ upsertContact
106
+ }
107
+ }
108
+
109
+ export default browserDestination(destination)
@@ -0,0 +1,124 @@
1
+ import { Analytics, Context } from '@segment/analytics-next'
2
+ import { Subscription } from '@segment/browser-destination-runtime'
3
+ import hubspotDestination, { destination } from '../../index'
4
+ import { Hubspot } from '../../types'
5
+
6
+ const subscriptions: Subscription[] = [
7
+ {
8
+ partnerAction: 'trackCustomBehavioralEvent',
9
+ name: 'Track Custom Behavioral Event',
10
+ enabled: true,
11
+ subscribe: 'type = "track"',
12
+ mapping: {
13
+ name: {
14
+ '@path': '$.event'
15
+ },
16
+ properties: {
17
+ '@path': '$.properties'
18
+ }
19
+ }
20
+ }
21
+ ]
22
+
23
+ describe('Hubspot.trackCustomBehavioralEvent', () => {
24
+ const settings = {
25
+ portalId: '1234',
26
+ formatCustomBehavioralEventNames: true
27
+ }
28
+
29
+ let mockHubspot: Hubspot
30
+ let trackCustomBehavioralEvent: any
31
+ beforeEach(async () => {
32
+ jest.restoreAllMocks()
33
+
34
+ const [trackCustomBehavioralEventPlugin] = await hubspotDestination({
35
+ ...settings,
36
+ subscriptions
37
+ })
38
+ trackCustomBehavioralEvent = trackCustomBehavioralEventPlugin
39
+
40
+ jest.spyOn(destination, 'initialize').mockImplementation(() => {
41
+ mockHubspot = {
42
+ push: jest.fn()
43
+ }
44
+ return Promise.resolve(mockHubspot)
45
+ })
46
+ await trackCustomBehavioralEvent.load(Context.system(), {} as Analytics)
47
+ })
48
+
49
+ test('maps custom traits correctly', async () => {
50
+ const context = new Context({
51
+ type: 'track',
52
+ event: 'purchased a ๐Ÿฑ',
53
+ properties: {
54
+ type: '๐Ÿฃ',
55
+ price: '$12.00',
56
+ currency: 'USD'
57
+ }
58
+ })
59
+ await trackCustomBehavioralEvent.track?.(context)
60
+
61
+ expect(mockHubspot.push).toHaveBeenCalledWith([
62
+ 'trackCustomBehavioralEvent',
63
+ { name: 'pe1234_purchased_a_๐Ÿฑ', properties: { currency: 'USD', price: '$12.00', type: '๐Ÿฃ' } }
64
+ ])
65
+ })
66
+
67
+ test('flattens nested object properties', async () => {
68
+ const context = new Context({
69
+ type: 'track',
70
+ event: 'purchased a ๐Ÿฑ',
71
+ properties: {
72
+ type: '๐Ÿฃ',
73
+ price: '$12.00',
74
+ currency: 'USD',
75
+ sides: {
76
+ item1: '๐Ÿง‰',
77
+ item2: '๐Ÿง‹',
78
+ 'auxilery Sauces': {
79
+ 'Soy Sauce': '๐Ÿถ'
80
+ }
81
+ }
82
+ }
83
+ })
84
+ await trackCustomBehavioralEvent.track?.(context)
85
+
86
+ expect(mockHubspot.push).toHaveBeenCalledWith([
87
+ 'trackCustomBehavioralEvent',
88
+ {
89
+ name: 'pe1234_purchased_a_๐Ÿฑ',
90
+ properties: {
91
+ currency: 'USD',
92
+ price: '$12.00',
93
+ type: '๐Ÿฃ',
94
+ sides_item1: '๐Ÿง‰',
95
+ sides_item2: '๐Ÿง‹',
96
+ sides_auxilery_sauces_soy_sauce: '๐Ÿถ'
97
+ }
98
+ }
99
+ ])
100
+ })
101
+
102
+ test('snake case spaces and dots', async () => {
103
+ const context = new Context({
104
+ type: 'track',
105
+ event: 'purchased a ๐Ÿฑ',
106
+ properties: {
107
+ type: '๐Ÿฃ',
108
+ price: '$12.00',
109
+ currency: 'USD',
110
+ 'type of fish': '๐ŸŸ',
111
+ 'brown.rice': false
112
+ }
113
+ })
114
+ await trackCustomBehavioralEvent.track?.(context)
115
+
116
+ expect(mockHubspot.push).toHaveBeenCalledWith([
117
+ 'trackCustomBehavioralEvent',
118
+ {
119
+ name: 'pe1234_purchased_a_๐Ÿฑ',
120
+ properties: { currency: 'USD', price: '$12.00', type: '๐Ÿฃ', type_of_fish: '๐ŸŸ', brown_rice: false }
121
+ }
122
+ ])
123
+ })
124
+ })
@@ -0,0 +1,14 @@
1
+ // Generated file. DO NOT MODIFY IT BY HAND.
2
+
3
+ export interface Payload {
4
+ /**
5
+ * The internal event name assigned by HubSpot. This can be found in your HubSpot account. If the "Format Custom Behavioral Event Names" setting is enabled, Segment will automatically convert your Segment event name into the expected HubSpot internal event name format.
6
+ */
7
+ name: string
8
+ /**
9
+ * A list of key-value pairs that describe the event.
10
+ */
11
+ properties?: {
12
+ [k: string]: unknown
13
+ }
14
+ }
@@ -0,0 +1,48 @@
1
+ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types'
2
+ import type { Settings } from '../generated-types'
3
+ import type { Payload } from './generated-types'
4
+ import type { Hubspot } from '../types'
5
+ import { flatten } from '../utils/flatten'
6
+
7
+ // Change from unknown to the partner SDK types
8
+ const action: BrowserActionDefinition<Settings, Hubspot, Payload> = {
9
+ title: 'Track Custom Behavioral Event',
10
+ description: 'Send a custom behavioral event to HubSpot.',
11
+ platform: 'web',
12
+ defaultSubscription: 'type = "track"',
13
+ fields: {
14
+ name: {
15
+ description: 'The internal event name assigned by HubSpot. This can be found in your HubSpot account. If the "Format Custom Behavioral Event Names" setting is enabled, Segment will automatically convert your Segment event name into the expected HubSpot internal event name format.',
16
+ label: 'Event Name',
17
+ type: 'string',
18
+ required: true,
19
+ default: {
20
+ '@path': '$.event'
21
+ }
22
+ },
23
+ properties: {
24
+ description: 'A list of key-value pairs that describe the event.',
25
+ label: 'Event Properties',
26
+ type: 'object',
27
+ required: false,
28
+ default: {
29
+ '@path': '$.properties'
30
+ }
31
+ }
32
+ },
33
+ perform: (_hsq, event) => {
34
+ let { name, properties } = event.payload
35
+
36
+ if (event.settings.formatCustomBehavioralEventNames) {
37
+ name = `pe${event.settings.portalId}_${name.replace(/[\s.]+/g, '_').toLocaleLowerCase()}`
38
+ }
39
+
40
+ // for custom properties, we will:
41
+ // remove any non-primitives, replace spaces and dots with underscores, then lowercase
42
+ properties = properties && flatten(properties, '', [], (key) => key.replace(/[\s.]+/g, '_').toLocaleLowerCase())
43
+
44
+ _hsq.push(['trackCustomBehavioralEvent', { name, properties }])
45
+ }
46
+ }
47
+
48
+ export default action
@@ -0,0 +1,72 @@
1
+ import { Analytics, Context } from '@segment/analytics-next'
2
+ import { Subscription } from '@segment/browser-destination-runtime'
3
+ import hubspotDestination, { destination } from '../../index'
4
+ import { Hubspot } from '../../types'
5
+
6
+ const subscriptions: Subscription[] = [
7
+ {
8
+ partnerAction: 'trackPageView',
9
+ name: 'Track Page View',
10
+ enabled: true,
11
+ subscribe: 'type = "page"',
12
+ mapping: {
13
+ path: {
14
+ '@path': '$.context.page.path'
15
+ }
16
+ }
17
+ }
18
+ ]
19
+
20
+ describe('Hubspot.trackPageView', () => {
21
+ const settings = {
22
+ portalId: '1234'
23
+ }
24
+
25
+ let mockHubspot: Hubspot
26
+ let trackPageViewEvent: any
27
+ beforeEach(async () => {
28
+ jest.restoreAllMocks()
29
+
30
+ const [trackPageViewEventPlugin] = await hubspotDestination({
31
+ ...settings,
32
+ subscriptions
33
+ })
34
+ trackPageViewEvent = trackPageViewEventPlugin
35
+
36
+ jest.spyOn(destination, 'initialize').mockImplementation(() => {
37
+ mockHubspot = {
38
+ push: jest.fn()
39
+ }
40
+ return Promise.resolve(mockHubspot)
41
+ })
42
+ await trackPageViewEvent.load(Context.system(), {} as Analytics)
43
+ })
44
+
45
+ test('call both trackPageView and setPath', async () => {
46
+ const context = new Context({
47
+ type: 'page',
48
+ name: 'Fried Chicken ๐Ÿ—',
49
+ category: 'Chicken Shop',
50
+ context: {
51
+ page: {
52
+ path: '/fried-chicken'
53
+ }
54
+ }
55
+ })
56
+ await trackPageViewEvent.track?.(context)
57
+ expect(mockHubspot.push).toHaveBeenCalledTimes(2)
58
+ expect(mockHubspot.push).toHaveBeenCalledWith(['trackPageView'])
59
+ expect(mockHubspot.push).toHaveBeenCalledWith(['setPath', '/fried-chicken'])
60
+ })
61
+
62
+ test('only calls trackPageView if no path is provided', async () => {
63
+ const context = new Context({
64
+ type: 'page',
65
+ name: 'Spicy Chicken Sandwich ๐ŸŒถ',
66
+ category: 'Chicken Shop'
67
+ })
68
+ await trackPageViewEvent.track?.(context)
69
+ expect(mockHubspot.push).toHaveBeenCalledTimes(1)
70
+ expect(mockHubspot.push).toHaveBeenCalledWith(['trackPageView'])
71
+ })
72
+ })
@@ -0,0 +1,8 @@
1
+ // Generated file. DO NOT MODIFY IT BY HAND.
2
+
3
+ export interface Payload {
4
+ /**
5
+ * The path of the current page. The set path will be treated as relative to the current domain being viewed.
6
+ */
7
+ path?: string
8
+ }
@@ -0,0 +1,28 @@
1
+ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types'
2
+ import type { Settings } from '../generated-types'
3
+ import { Hubspot } from '../types'
4
+ import type { Payload } from './generated-types'
5
+
6
+ const action: BrowserActionDefinition<Settings, Hubspot, Payload> = {
7
+ title: 'Track Page View',
8
+ description: 'Track the page view for the current page in HubSpot.',
9
+ defaultSubscription: 'type = "page"',
10
+ platform: 'web',
11
+ fields: {
12
+ path: {
13
+ description:
14
+ 'The path of the current page. The set path will be treated as relative to the current domain being viewed.',
15
+ label: 'Path String',
16
+ type: 'string',
17
+ required: false
18
+ }
19
+ },
20
+ perform: (_hsq, event) => {
21
+ if (event.payload.path) {
22
+ _hsq.push(['setPath', event.payload.path])
23
+ }
24
+ _hsq.push(['trackPageView'])
25
+ }
26
+ }
27
+
28
+ export default action
package/src/types.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type Hubspot = {
2
+ push: (event: unknown[]) => void
3
+ }
@@ -0,0 +1,207 @@
1
+ import { Analytics, Context } from '@segment/analytics-next'
2
+ import { Subscription } from '@segment/browser-destination-runtime'
3
+ import hubspotDestination, { destination } from '../../index'
4
+ import { Hubspot } from '../../types'
5
+
6
+ const subscriptions: Subscription[] = [
7
+ {
8
+ partnerAction: 'upsertContact',
9
+ name: 'Upsert Contact',
10
+ enabled: true,
11
+ subscribe: 'type = "identify"',
12
+ mapping: {
13
+ email: {
14
+ '@path': '$.traits.email'
15
+ },
16
+ custom_properties: {
17
+ '@path': '$.traits'
18
+ },
19
+ id: {
20
+ '@path': '$.userId'
21
+ },
22
+ company: {
23
+ '@path': '$.traits.company.name'
24
+ },
25
+ country: {
26
+ '@path': '$.traits.address.country'
27
+ },
28
+ state: {
29
+ '@path': '$.traits.address.state'
30
+ },
31
+ city: {
32
+ '@path': '$.traits.address.city'
33
+ },
34
+ address: {
35
+ '@path': '$.traits.address.street'
36
+ },
37
+ zip: {
38
+ '@path': '$.traits.address.postalCode'
39
+ }
40
+ }
41
+ }
42
+ ]
43
+
44
+ describe('Hubspot.upsertContact', () => {
45
+ const settings = {
46
+ portalId: '1234',
47
+ formatCustomBehavioralEventNames: true
48
+ }
49
+
50
+ let mockHubspot: Hubspot
51
+ let upsertContactEvent: any
52
+ beforeEach(async () => {
53
+ jest.restoreAllMocks()
54
+
55
+ const [upsertContactEventPlugin] = await hubspotDestination({
56
+ ...settings,
57
+ subscriptions
58
+ })
59
+ upsertContactEvent = upsertContactEventPlugin
60
+
61
+ jest.spyOn(destination, 'initialize').mockImplementation(() => {
62
+ mockHubspot = {
63
+ push: jest.fn()
64
+ }
65
+ return Promise.resolve(mockHubspot)
66
+ })
67
+ await upsertContactEvent.load(Context.system(), {} as Analytics)
68
+ })
69
+
70
+ test('does not call Hubspot if there is no email', async () => {
71
+ const context = new Context({
72
+ type: 'identify',
73
+ userId: '๐Ÿ‘ป',
74
+ traits: {
75
+ friendly: true
76
+ }
77
+ })
78
+
79
+ await upsertContactEvent.identify?.(context)
80
+ expect(mockHubspot.push).toHaveBeenCalledTimes(0)
81
+ })
82
+
83
+ test('Identifies the user to Hubspot when email is present', async () => {
84
+ const context = new Context({
85
+ type: 'identify',
86
+ userId: 'real_hubspot_tester',
87
+ traits: {
88
+ friendly: false,
89
+ email: 'real_hubspot_tester@jest_experts.com'
90
+ }
91
+ })
92
+
93
+ await upsertContactEvent.identify?.(context)
94
+ expect(mockHubspot.push).toHaveBeenCalledTimes(1)
95
+ expect(mockHubspot.push).toHaveBeenCalledWith([
96
+ 'identify',
97
+ { email: 'real_hubspot_tester@jest_experts.com', id: 'real_hubspot_tester', friendly: false }
98
+ ])
99
+ })
100
+
101
+ test('populates company info from the traits', async () => {
102
+ const context = new Context({
103
+ type: 'identify',
104
+ userId: 'mike',
105
+ traits: {
106
+ friendly: false,
107
+ email: 'mike_eh@lph.com',
108
+ company: {
109
+ id: '123',
110
+ name: 'Los Pollos Hermanos',
111
+ industry: 'Transportation',
112
+ employee_count: 128,
113
+ plan: 'startup'
114
+ }
115
+ }
116
+ })
117
+
118
+ await upsertContactEvent.identify?.(context)
119
+ expect(mockHubspot.push).toHaveBeenCalledTimes(1)
120
+ expect(mockHubspot.push).toHaveBeenCalledWith([
121
+ 'identify',
122
+ { email: 'mike_eh@lph.com', id: 'mike', friendly: false, company: 'Los Pollos Hermanos' }
123
+ ])
124
+ })
125
+
126
+ test('populates address info from the traits', async () => {
127
+ const context = new Context({
128
+ type: 'identify',
129
+ userId: 'mike',
130
+ traits: {
131
+ friendly: false,
132
+ email: 'mike_eh@lph.com',
133
+ address: {
134
+ street: '6th St',
135
+ city: 'San Francisco',
136
+ state: 'CA',
137
+ postalCode: '94103',
138
+ country: 'USA'
139
+ }
140
+ }
141
+ })
142
+
143
+ await upsertContactEvent.identify?.(context)
144
+ expect(mockHubspot.push).toHaveBeenCalledTimes(1)
145
+ expect(mockHubspot.push).toHaveBeenCalledWith([
146
+ 'identify',
147
+ {
148
+ email: 'mike_eh@lph.com',
149
+ id: 'mike',
150
+ friendly: false,
151
+ address: '6th St',
152
+ country: 'USA',
153
+ state: 'CA',
154
+ city: 'San Francisco',
155
+ zip: '94103'
156
+ }
157
+ ])
158
+ })
159
+
160
+ test('flattens nested traits', async () => {
161
+ const context = new Context({
162
+ type: 'identify',
163
+ userId: 'mike',
164
+ traits: {
165
+ friendly: false,
166
+ email: 'mike_eh@lph.com',
167
+ address: {
168
+ street: '6th St',
169
+ city: 'San Francisco',
170
+ state: 'CA',
171
+ postalCode: '94103',
172
+ country: 'USA'
173
+ },
174
+ equipment: {
175
+ type: '๐Ÿš˜',
176
+ color: 'red',
177
+ make: {
178
+ make: 'Tesla',
179
+ model: 'Model S',
180
+ year: 2019
181
+ }
182
+ }
183
+ }
184
+ })
185
+
186
+ await upsertContactEvent.identify?.(context)
187
+ expect(mockHubspot.push).toHaveBeenCalledTimes(1)
188
+ expect(mockHubspot.push).toHaveBeenCalledWith([
189
+ 'identify',
190
+ {
191
+ email: 'mike_eh@lph.com',
192
+ id: 'mike',
193
+ friendly: false,
194
+ address: '6th St',
195
+ country: 'USA',
196
+ state: 'CA',
197
+ city: 'San Francisco',
198
+ zip: '94103',
199
+ equipment_type: '๐Ÿš˜',
200
+ equipment_color: 'red',
201
+ equipment_make_make: 'Tesla',
202
+ equipment_make_model: 'Model S',
203
+ equipment_make_year: 2019
204
+ }
205
+ ])
206
+ })
207
+ })
@@ -0,0 +1,42 @@
1
+ // Generated file. DO NOT MODIFY IT BY HAND.
2
+
3
+ export interface Payload {
4
+ /**
5
+ * The contactโ€™s email. Email is used to uniquely identify contact records in HubSpot and create or update the contact accordingly.
6
+ */
7
+ email: string
8
+ /**
9
+ * A custom external ID that identifies the visitor.
10
+ */
11
+ id?: string
12
+ /**
13
+ * A list of key-value pairs that describe the contact. Please see [HubSpot`s documentation](https://knowledge.hubspot.com/account/prevent-contact-properties-update-through-tracking-code-api) for limitations in updating contact properties.
14
+ */
15
+ custom_properties?: {
16
+ [k: string]: unknown
17
+ }
18
+ /**
19
+ * The name of the company the contact is associated with.
20
+ */
21
+ company?: string
22
+ /**
23
+ * The name of the country the contact is associated with.
24
+ */
25
+ country?: string
26
+ /**
27
+ * The name of the state the contact is associated with.
28
+ */
29
+ state?: string
30
+ /**
31
+ * The name of the city the contact is associated with.
32
+ */
33
+ city?: string
34
+ /**
35
+ * The street address of the contact.
36
+ */
37
+ address?: string
38
+ /**
39
+ * The postal code of the contact.
40
+ */
41
+ zip?: string
42
+ }
@@ -0,0 +1,121 @@
1
+ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types'
2
+ import type { Settings } from '../generated-types'
3
+ import type { Payload } from './generated-types'
4
+ import { Hubspot } from '../types'
5
+ import { flatten } from '../utils/flatten'
6
+
7
+ const action: BrowserActionDefinition<Settings, Hubspot, Payload> = {
8
+ title: 'Upsert Contact',
9
+ description: 'Create or update a contact in HubSpot.',
10
+ defaultSubscription: 'type = "identify"',
11
+ platform: 'web',
12
+ fields: {
13
+ email: {
14
+ description:
15
+ 'The contactโ€™s email. Email is used to uniquely identify contact records in HubSpot and create or update the contact accordingly.',
16
+ label: 'Email Address',
17
+ type: 'string',
18
+ required: true,
19
+ default: {
20
+ '@path': '$.traits.email'
21
+ }
22
+ },
23
+ id: {
24
+ description: 'A custom external ID that identifies the visitor.',
25
+ label: 'External ID',
26
+ type: 'string',
27
+ required: false,
28
+ default: {
29
+ '@path': '$.userId'
30
+ }
31
+ },
32
+ custom_properties: {
33
+ description: 'A list of key-value pairs that describe the contact. Please see [HubSpot`s documentation](https://knowledge.hubspot.com/account/prevent-contact-properties-update-through-tracking-code-api) for limitations in updating contact properties.',
34
+ label: 'Custom Properties',
35
+ type: 'object',
36
+ required: false,
37
+ default: {
38
+ '@path': '$.traits'
39
+ }
40
+ },
41
+ company: {
42
+ description: 'The name of the company the contact is associated with.',
43
+ label: 'Company Name',
44
+ type: 'string',
45
+ required: false,
46
+ default: {
47
+ '@path': '$.traits.company.name'
48
+ }
49
+ },
50
+ country: {
51
+ description: 'The name of the country the contact is associated with.',
52
+ label: 'Country',
53
+ type: 'string',
54
+ required: false,
55
+ default: {
56
+ '@path': '$.traits.address.country'
57
+ }
58
+ },
59
+ state: {
60
+ description: 'The name of the state the contact is associated with.',
61
+ label: 'State',
62
+ type: 'string',
63
+ required: false,
64
+ default: {
65
+ '@path': '$.traits.address.state'
66
+ }
67
+ },
68
+ city: {
69
+ description: 'The name of the city the contact is associated with.',
70
+ label: 'City',
71
+ type: 'string',
72
+ required: false,
73
+ default: {
74
+ '@path': '$.traits.address.city'
75
+ }
76
+ },
77
+ address: {
78
+ description: 'The street address of the contact.',
79
+ label: 'Street Address',
80
+ type: 'string',
81
+ required: false,
82
+ default: {
83
+ '@path': '$.traits.address.street'
84
+ }
85
+ },
86
+ zip: {
87
+ description: 'The postal code of the contact.',
88
+ label: 'Postal Code',
89
+ type: 'string',
90
+ required: false,
91
+ default: {
92
+ '@if': {
93
+ exists: { '@path': '$.traits.address.postalCode' },
94
+ then: { '@path': '$.traits.address.postalCode' },
95
+ else: { '@path': '$.traits.address.postal_code' }
96
+ }
97
+ }
98
+ }
99
+ },
100
+ perform: (_hsq, event) => {
101
+ const payload = event.payload
102
+ if (!payload.email) {
103
+ return
104
+ }
105
+
106
+ // custom properties should be key-value pairs of strings, therefore, filtering out any non-primitive
107
+ const { custom_properties, ...rest } = payload
108
+ let flattenProperties
109
+ if (custom_properties) {
110
+ flattenProperties = flatten(custom_properties, '', ['address', 'company'])
111
+ }
112
+
113
+ _hsq.push(['identify', { ...flattenProperties, ...rest }])
114
+
115
+ if (event.settings.flushIdentifyImmediately) {
116
+ _hsq.push(['trackPageView'])
117
+ }
118
+ }
119
+ }
120
+
121
+ export default action
@@ -0,0 +1,30 @@
1
+ import { JSONPrimitive } from '@segment/actions-core'
2
+
3
+ export type Properties = {
4
+ [k: string]: unknown
5
+ }
6
+
7
+ type FlattenProperties = object & {
8
+ [k: string]: JSONPrimitive
9
+ }
10
+
11
+ export function flatten(
12
+ data: Properties,
13
+ prefix = '',
14
+ skipList: string[] = [],
15
+ keyTransformation = (input: string) => input
16
+ ): FlattenProperties {
17
+ let result: FlattenProperties = {}
18
+ for (const key in data) {
19
+ // skips flattening specific keys on the top level
20
+ if (!prefix && skipList.includes(key)) continue
21
+
22
+ if (typeof data[key] === 'object' && data[key] !== null) {
23
+ const flattened = flatten(data[key] as Properties, `${prefix}_${key}`, skipList, keyTransformation)
24
+ result = { ...result, ...flattened }
25
+ } else {
26
+ result[keyTransformation(`${prefix}_${key}`.replace(/^_/, ''))] = data[key] as JSONPrimitive
27
+ }
28
+ }
29
+ return result
30
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "baseUrl": "."
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["dist", "**/__tests__"]
9
+ }