Skip to content

Commit

Permalink
feat(store-sdk): custom events, tests and docs (#937)
Browse files Browse the repository at this point in the history
* fix: general wrapper types for analytics and removed unecessary code from unwrap function

* chore: renaming analytics index file to wrap

* chore: adding wrap and unwrap functions tests

* chore: adding tests to analytics layer

* feat: allows sending custom analytics events

* feat: add docs to analytics

* chore: add test for custom event

* docs: adding link to GA4 spec

* chore: removing tsdx incompatible code

* fix: change title name to analytics

* docs: correct grammar

Co-authored-by: Emerson Laurentino <emersonlaurentino@hotmail.com>

* docs: improve readability and fix erros on code

Co-authored-by: Larícia Mota <laricia.mota@vtex.com.br>

* chore: improve error message from useAnalyticsEvent

Co-authored-by: Larícia Mota <laricia.mota@vtex.com.br>

* chore: moving event samples to fixture file

Co-authored-by: Emerson Laurentino <emersonlaurentino@hotmail.com>
Co-authored-by: Larícia Mota <laricia.mota@vtex.com.br>
  • Loading branch information
3 people authored Sep 9, 2021
1 parent dcc05e9 commit c268542
Show file tree
Hide file tree
Showing 9 changed files with 342 additions and 40 deletions.
103 changes: 103 additions & 0 deletions packages/store-sdk/docs/analytics/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
## Analytics

The analytics module lets you manage analytics events based on [Google Analytics 4 (GA4) data model](https://developers.google.com/analytics/devguides/collection/ga4/reference/events). The events are wrapped and then sent over standard `postMessage` calls, which share the event only with the website's origin. The events are received via event listeners. It also supports sending and receiving custom events as the types on the helper functions can be overridden.

### Sending events

Analytics events can be sent by using the `sendAnalyticsEvent` function and it's especially useful to send common ecommerce events such as `add_to_cart`. It enforces standard GA4 events via type check and IntelliSense suggestions, but this behavior can be altered via overriding the function's types.

To fire a standard GA4 event:
```tsx
import { useCallback } from 'react'
import { sendAnalyticsEvent } from '@vtex/store-sdk'

const MyComponent = () => {
const addToCartCallback = useCallback(() => {
/* ... */

const addToCartEvent = {
type: 'add_to_cart',
data: {
items: [
/* ... */
]
}
}

sendAnalyticsEvent(addToCartEvent)
}, [])

return <button onClick={addToCartCallback}>Add to cart</button>
}
```

For custom events, define the type of the event and override the default type via `sendAnalyticsEvent` generics. Your custom event has to have a type and a data field of any kind.

To fire a custom event:

```tsx
import { useCallback } from 'react'
import { sendAnalyticsEvent } from '@vtex/store-sdk'

interface CustomEvent {
type: 'custom_event',
data: {
customProperty?: string
}
}

const MyComponent = () => {
const customEventCallback = useCallback(() => {
/* ... */

const customEvent = {
type: 'custom_event',
data: {
customProperty: 'value'
}
}

sendAnalyticsEvent<CustomEvent>(customEvent)
}, [])

return <button onClick={customEventCallback}>Press here</button>
}
```

### Receiving events

It's possible to receive analytics events by using the `useAnalyticsEvent` hook. It accepts a handler that will be called every time an event sent by `sendAnalyticsEvent` arrives. For that reason, it can fire both for standard GA4 events and for custom events that a library or a component might be sending. To help users be aware of that possibility, the event received by the handler is, by default, typed as `UnknownEvent`. You can assume it has another type by simply typing the callback function as you wish, but be careful with the unexpected events that might come to this handler.

To use the `useAnalyticsEvent` hook:

```tsx
import { useAnalyticsEvent } from '@vtex/store-sdk'
import type { AnalyticsEvent } from '@vtex/store-sdk'

/**
* Notice that we typed it as AnalyticsEvent, but there may be events that are not from this type.
*
* Since we're dealing with it on a switch and we are providing an empty default clause,
* we're not gonna have issues receiving custom events sent by other components or libraries.
*/
function handler(event: AnalyticsEvent) {
switch(event.type) {
case 'add_to_cart': {
/* ... */
}

/* ... */

default: {
/* ... */
}
}
}

// In your component:
const MyComponent = () => {
useAnalyticsEvent(handler)

/* ... */
}
```
12 changes: 9 additions & 3 deletions packages/store-sdk/src/analytics/sendAnalyticsEvent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { wrap } from '.'
import type { AnalyticsEvent } from '.'
import { wrap } from './wrap'
import type { UnknownEvent, AnalyticsEvent } from './wrap'

export const sendAnalyticsEvent = (event: AnalyticsEvent) => {
export const sendAnalyticsEvent = <
K extends UnknownEvent = AnalyticsEvent,
/** This generic is here so users get the IntelliSense for event type options from AnalyticsEvent */
T extends K = K
>(
event: T
) => {
try {
window.postMessage(wrap(event), window.origin)
} catch (e) {
Expand Down
22 changes: 10 additions & 12 deletions packages/store-sdk/src/analytics/useAnalyticsEvent.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
import { useCallback, useEffect } from 'react'

import { unwrap } from '.'
import type { AnalyticsEvent } from '.'
import { ANALYTICS_EVENT_TYPE, unwrap } from './wrap'
import type { UnknownEvent } from './wrap'

export type AnalyticsEventHandler = (
event: AnalyticsEvent
) => void | PromiseLike<void>

export const useAnalyticsEvent = (handler: AnalyticsEventHandler) => {
export const useAnalyticsEvent = <T extends UnknownEvent = UnknownEvent>(
handler: (event: T) => unknown
) => {
const callback = useCallback(
(message: MessageEvent) => {
try {
const maybeEvent = unwrap(message.data ?? {})

if (maybeEvent) {
handler(maybeEvent)
if (message.data.type !== ANALYTICS_EVENT_TYPE) {
return
}

handler(unwrap(message.data))
} catch (err) {
console.error('Some bad happened while running Analytics handler')
console.error('Something went wrong while running Analytics handler')
}
},
[handler]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,34 +40,47 @@ export type AnalyticsEvent =
| SignupEvent
| ShareEvent

export type WrappedAnalyticsEvent<T extends AnalyticsEvent> = {
export interface UnknownEvent {
type: string
data: unknown
}

export type WrappedAnalyticsEventData<T extends UnknownEvent> = Omit<
T,
'type'
> & {
// Sadly tsdx doesn't support typescript 4.x yet. We should change this type to this when it does:
// type: `store:${T['type']}`
type: string
}

export interface WrappedAnalyticsEvent<T extends UnknownEvent> {
type: 'AnalyticsEvent'
data: T
data: WrappedAnalyticsEventData<T>
}

export const STORE_EVENT_PREFIX = 'store:'
export const ANALYTICS_EVENT_TYPE = 'AnalyticsEvent'

export const wrap = <T extends AnalyticsEvent>(
export const wrap = <T extends UnknownEvent>(
event: T
): WrappedAnalyticsEvent<T> => ({
type: 'AnalyticsEvent',
data: {
...event,
type: `${STORE_EVENT_PREFIX}${event.type}`,
},
})
): WrappedAnalyticsEvent<T> =>
({
type: ANALYTICS_EVENT_TYPE,
data: {
...event,
type: `${STORE_EVENT_PREFIX}${event.type}`,
},
} as WrappedAnalyticsEvent<T>)

export const unwrap = <T extends AnalyticsEvent>(
export const unwrap = <T extends UnknownEvent>(
event: WrappedAnalyticsEvent<T>
) => {
if (event.type === 'AnalyticsEvent') {
return {
...event.data,
type: event.type.startsWith(STORE_EVENT_PREFIX)
? event.type.slice(STORE_EVENT_PREFIX.length, event.type.length)
: event.type,
}
}

return null
): T => {
return {
...event.data,
type: event.data.type.slice(
STORE_EVENT_PREFIX.length,
event.data.type.length
),
} as T
}
10 changes: 7 additions & 3 deletions packages/store-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,14 @@ export type {
PromotionItem,
CurrencyCode,
} from './analytics/events/common'
export type { AnalyticsEvent, WrappedAnalyticsEvent } from './analytics/index'
export { STORE_EVENT_PREFIX } from './analytics/index'
export type {
AnalyticsEvent,
WrappedAnalyticsEvent,
WrappedAnalyticsEventData,
UnknownEvent,
} from './analytics/wrap'
export { STORE_EVENT_PREFIX, ANALYTICS_EVENT_TYPE } from './analytics/wrap'
export { sendAnalyticsEvent } from './analytics/sendAnalyticsEvent'
export type { AnalyticsEventHandler } from './analytics/useAnalyticsEvent'
export { useAnalyticsEvent } from './analytics/useAnalyticsEvent'

// Faceted Search
Expand Down
46 changes: 46 additions & 0 deletions packages/store-sdk/test/analytics/__fixtures__/EventSamples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { AddToCartEvent } from '../../../src/analytics/events/add_to_cart'
import type { WrappedAnalyticsEvent } from '../../../src/analytics/wrap'

export interface CustomEvent {
type: 'custom_event'
data: {
customDataProperty: string
}
customProperty: string
}

export const CUSTOM_EVENT_SAMPLE: CustomEvent = {
type: 'custom_event',
data: {
customDataProperty: 'value',
},
customProperty: 'value',
}

export const WRAPPED_CUSTOM_EVENT_SAMPLE: WrappedAnalyticsEvent<CustomEvent> = {
type: 'AnalyticsEvent',
data: {
type: 'store:custom_event',
data: {
customDataProperty: 'value',
},
customProperty: 'value',
},
}

export const ADD_TO_CART_SAMPLE: AddToCartEvent = {
type: 'add_to_cart',
data: {
items: [{ item_id: 'PRODUCT_ID' }],
},
}

export const WRAPPED_ADD_TO_CART_SAMPLE: WrappedAnalyticsEvent<AddToCartEvent> = {
type: 'AnalyticsEvent',
data: {
type: 'store:add_to_cart',
data: {
items: [{ item_id: 'PRODUCT_ID' }],
},
},
}
55 changes: 55 additions & 0 deletions packages/store-sdk/test/analytics/sendAnalyticsEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { sendAnalyticsEvent } from '../../src/analytics/sendAnalyticsEvent'
import type { CustomEvent } from './__fixtures__/EventSamples'
import {
CUSTOM_EVENT_SAMPLE,
ADD_TO_CART_SAMPLE,
WRAPPED_CUSTOM_EVENT_SAMPLE,
WRAPPED_ADD_TO_CART_SAMPLE,
} from './__fixtures__/EventSamples'

const noop = () => {}
const origin = 'http://localhost:8080/'

describe('sendAnalyticsEvent', () => {
afterEach(() => {
jest.restoreAllMocks()
})

it('window.postMessage is called with correct params', () => {
const postMessageSpy = jest
.spyOn(window, 'postMessage')
.mockImplementation(noop)

Object.defineProperty(window, 'origin', {
writable: false,
value: origin,
})

sendAnalyticsEvent(ADD_TO_CART_SAMPLE)

expect(postMessageSpy).toHaveBeenCalled()
expect(postMessageSpy).toHaveBeenCalledWith(
WRAPPED_ADD_TO_CART_SAMPLE,
origin
)
})

it('sendAnalyticsEvent is able to send custom events', () => {
const postMessageSpy = jest
.spyOn(window, 'postMessage')
.mockImplementation(noop)

Object.defineProperty(window, 'origin', {
writable: false,
value: origin,
})

sendAnalyticsEvent<CustomEvent>(CUSTOM_EVENT_SAMPLE)

expect(postMessageSpy).toHaveBeenCalled()
expect(postMessageSpy).toHaveBeenCalledWith(
WRAPPED_CUSTOM_EVENT_SAMPLE,
origin
)
})
})
44 changes: 44 additions & 0 deletions packages/store-sdk/test/analytics/useAnalyticsEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { renderHook } from '@testing-library/react-hooks'

import { useAnalyticsEvent } from '../../src/analytics/useAnalyticsEvent'
import { wrap } from '../../src/analytics/wrap'
import { ADD_TO_CART_SAMPLE } from './__fixtures__/EventSamples'

describe('useAnalyticsEvent', () => {
afterEach(() => {
jest.restoreAllMocks()
})

it('useAnalyticsEvent calls handler with correct params when an AnalyticsEvent is fired', async () => {
const handler = jest.fn()

jest.spyOn(window, 'addEventListener').mockImplementation((_, fn) => {
if (typeof fn === 'function') {
fn(new MessageEvent('message', { data: wrap(ADD_TO_CART_SAMPLE) }))
}
})

renderHook(() => useAnalyticsEvent(handler))

expect(handler).toHaveBeenCalled()
expect(handler).toHaveBeenCalledWith(ADD_TO_CART_SAMPLE)
})

it('useAnalyticsEvent ignores events that are not AnalyticsEvent', async () => {
const handler = jest.fn()

jest.spyOn(window, 'addEventListener').mockImplementation((_, fn) => {
if (typeof fn === 'function') {
fn(
new MessageEvent('message', {
data: { ...wrap(ADD_TO_CART_SAMPLE), type: 'OtherEventType' },
})
)
}
})

renderHook(() => useAnalyticsEvent(handler))

expect(handler).not.toHaveBeenCalled()
})
})
Loading

0 comments on commit c268542

Please sign in to comment.