From c2685425b8a2b6c40ce2f79a5a0afc2b2022760d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dcaro=20Azevedo?= Date: Thu, 9 Sep 2021 14:23:21 -0300 Subject: [PATCH] feat(store-sdk): custom events, tests and docs (#937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * docs: improve readability and fix erros on code Co-authored-by: Larícia Mota * chore: improve error message from useAnalyticsEvent Co-authored-by: Larícia Mota * chore: moving event samples to fixture file Co-authored-by: Emerson Laurentino Co-authored-by: Larícia Mota --- packages/store-sdk/docs/analytics/README.md | 103 ++++++++++++++++++ .../src/analytics/sendAnalyticsEvent.ts | 12 +- .../src/analytics/useAnalyticsEvent.ts | 22 ++-- .../src/analytics/{index.ts => wrap.ts} | 57 ++++++---- packages/store-sdk/src/index.ts | 10 +- .../analytics/__fixtures__/EventSamples.ts | 46 ++++++++ .../test/analytics/sendAnalyticsEvent.test.ts | 55 ++++++++++ .../test/analytics/useAnalyticsEvent.test.ts | 44 ++++++++ .../store-sdk/test/analytics/wrap.test.ts | 33 ++++++ 9 files changed, 342 insertions(+), 40 deletions(-) create mode 100644 packages/store-sdk/docs/analytics/README.md rename packages/store-sdk/src/analytics/{index.ts => wrap.ts} (65%) create mode 100644 packages/store-sdk/test/analytics/__fixtures__/EventSamples.ts create mode 100644 packages/store-sdk/test/analytics/sendAnalyticsEvent.test.ts create mode 100644 packages/store-sdk/test/analytics/useAnalyticsEvent.test.ts create mode 100644 packages/store-sdk/test/analytics/wrap.test.ts diff --git a/packages/store-sdk/docs/analytics/README.md b/packages/store-sdk/docs/analytics/README.md new file mode 100644 index 0000000000..4ba7cf684f --- /dev/null +++ b/packages/store-sdk/docs/analytics/README.md @@ -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 +} +``` + +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) + }, []) + + return +} +``` + +### 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) + + /* ... */ +} +``` diff --git a/packages/store-sdk/src/analytics/sendAnalyticsEvent.ts b/packages/store-sdk/src/analytics/sendAnalyticsEvent.ts index bb049b6d91..8e3eba36d6 100644 --- a/packages/store-sdk/src/analytics/sendAnalyticsEvent.ts +++ b/packages/store-sdk/src/analytics/sendAnalyticsEvent.ts @@ -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) { diff --git a/packages/store-sdk/src/analytics/useAnalyticsEvent.ts b/packages/store-sdk/src/analytics/useAnalyticsEvent.ts index b499e6df75..8fd1984888 100644 --- a/packages/store-sdk/src/analytics/useAnalyticsEvent.ts +++ b/packages/store-sdk/src/analytics/useAnalyticsEvent.ts @@ -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 - -export const useAnalyticsEvent = (handler: AnalyticsEventHandler) => { +export const useAnalyticsEvent = ( + 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] diff --git a/packages/store-sdk/src/analytics/index.ts b/packages/store-sdk/src/analytics/wrap.ts similarity index 65% rename from packages/store-sdk/src/analytics/index.ts rename to packages/store-sdk/src/analytics/wrap.ts index a96a967d8b..4347acf09f 100644 --- a/packages/store-sdk/src/analytics/index.ts +++ b/packages/store-sdk/src/analytics/wrap.ts @@ -40,34 +40,47 @@ export type AnalyticsEvent = | SignupEvent | ShareEvent -export type WrappedAnalyticsEvent = { +export interface UnknownEvent { + type: string + data: unknown +} + +export type WrappedAnalyticsEventData = 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 { type: 'AnalyticsEvent' - data: T + data: WrappedAnalyticsEventData } export const STORE_EVENT_PREFIX = 'store:' +export const ANALYTICS_EVENT_TYPE = 'AnalyticsEvent' -export const wrap = ( +export const wrap = ( event: T -): WrappedAnalyticsEvent => ({ - type: 'AnalyticsEvent', - data: { - ...event, - type: `${STORE_EVENT_PREFIX}${event.type}`, - }, -}) +): WrappedAnalyticsEvent => + ({ + type: ANALYTICS_EVENT_TYPE, + data: { + ...event, + type: `${STORE_EVENT_PREFIX}${event.type}`, + }, + } as WrappedAnalyticsEvent) -export const unwrap = ( +export const unwrap = ( event: WrappedAnalyticsEvent -) => { - 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 } diff --git a/packages/store-sdk/src/index.ts b/packages/store-sdk/src/index.ts index 271fba67b7..b3ef804c9a 100644 --- a/packages/store-sdk/src/index.ts +++ b/packages/store-sdk/src/index.ts @@ -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 diff --git a/packages/store-sdk/test/analytics/__fixtures__/EventSamples.ts b/packages/store-sdk/test/analytics/__fixtures__/EventSamples.ts new file mode 100644 index 0000000000..5e7bed3785 --- /dev/null +++ b/packages/store-sdk/test/analytics/__fixtures__/EventSamples.ts @@ -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 = { + 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 = { + type: 'AnalyticsEvent', + data: { + type: 'store:add_to_cart', + data: { + items: [{ item_id: 'PRODUCT_ID' }], + }, + }, +} diff --git a/packages/store-sdk/test/analytics/sendAnalyticsEvent.test.ts b/packages/store-sdk/test/analytics/sendAnalyticsEvent.test.ts new file mode 100644 index 0000000000..e00e2da12c --- /dev/null +++ b/packages/store-sdk/test/analytics/sendAnalyticsEvent.test.ts @@ -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(CUSTOM_EVENT_SAMPLE) + + expect(postMessageSpy).toHaveBeenCalled() + expect(postMessageSpy).toHaveBeenCalledWith( + WRAPPED_CUSTOM_EVENT_SAMPLE, + origin + ) + }) +}) diff --git a/packages/store-sdk/test/analytics/useAnalyticsEvent.test.ts b/packages/store-sdk/test/analytics/useAnalyticsEvent.test.ts new file mode 100644 index 0000000000..76b452deaa --- /dev/null +++ b/packages/store-sdk/test/analytics/useAnalyticsEvent.test.ts @@ -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() + }) +}) diff --git a/packages/store-sdk/test/analytics/wrap.test.ts b/packages/store-sdk/test/analytics/wrap.test.ts new file mode 100644 index 0000000000..a67945042a --- /dev/null +++ b/packages/store-sdk/test/analytics/wrap.test.ts @@ -0,0 +1,33 @@ +import { wrap, unwrap } from '../../src/analytics/wrap' +import { ADD_TO_CART_SAMPLE } from './__fixtures__/EventSamples' + +describe('wrap and unwrap functions', () => { + it('wrap function wraps event with AnalyticsEvent type', () => { + const { type } = wrap(ADD_TO_CART_SAMPLE) + + expect(type).toBe('AnalyticsEvent') + }) + + it('wrap function prefixes the event type', () => { + const wrappedEvent = wrap(ADD_TO_CART_SAMPLE) + const { type: wrappedEventType } = wrappedEvent.data + const { type: originalType } = ADD_TO_CART_SAMPLE + + expect(wrappedEventType).toBe(`store:${originalType}`) + }) + + it('wrap function preserves all event data but the type', () => { + const wrappedEvent = wrap(ADD_TO_CART_SAMPLE) + const { type: _, ...wrappedEventRest } = wrappedEvent.data + const { type: __, ...originalEventRest } = ADD_TO_CART_SAMPLE + + expect(wrappedEventRest).toStrictEqual(originalEventRest) + }) + + it('unwrap function preserves the original event', () => { + const wrappedEvent = wrap(ADD_TO_CART_SAMPLE) + const unwrappedEvent = unwrap(wrappedEvent) ?? {} + + expect(unwrappedEvent).toStrictEqual(ADD_TO_CART_SAMPLE) + }) +})