From 4ab30cbde11f1bb61b503f5c43be5be10aeabb9b Mon Sep 17 00:00:00 2001 From: "Juan P. Prieto" Date: Wed, 11 Jan 2023 15:39:59 -0800 Subject: [PATCH 1/2] add analytic-utils tests and refactor --- packages/react/src/analytics-errors-utils.ts | 9 -- ...ema-custom-storefront-customer-tracking.ts | 21 ++-- ...ics-schema-trekkie-storefront-page-view.ts | 26 +++-- packages/react/src/analytics-utils.test.ts | 108 ++++++++++++++++++ packages/react/src/analytics-utils.ts | 70 +++++++++--- packages/react/src/analytics.tsx | 3 +- 6 files changed, 192 insertions(+), 45 deletions(-) delete mode 100644 packages/react/src/analytics-errors-utils.ts create mode 100644 packages/react/src/analytics-utils.test.ts diff --git a/packages/react/src/analytics-errors-utils.ts b/packages/react/src/analytics-errors-utils.ts deleted file mode 100644 index 7ea30242..00000000 --- a/packages/react/src/analytics-errors-utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function errorIfServer(fnName: string): boolean { - if (!window) { - console.error( - `${fnName} should only be used within the useEffect callback or event handlers` - ); - return true; - } - return false; -} diff --git a/packages/react/src/analytics-schema-custom-storefront-customer-tracking.ts b/packages/react/src/analytics-schema-custom-storefront-customer-tracking.ts index 56de9c63..57e238ef 100644 --- a/packages/react/src/analytics-schema-custom-storefront-customer-tracking.ts +++ b/packages/react/src/analytics-schema-custom-storefront-customer-tracking.ts @@ -6,12 +6,7 @@ import { ShopifyAnalyticsProduct, } from './analytics-types.js'; import {AnalyticsPageType, ShopifyAppSource} from './analytics-constants.js'; -import { - addDataIf, - schemaWrapper, - stripGId, - stripId, -} from './analytics-utils.js'; +import {addDataIf, schemaWrapper, parseGid} from './analytics-utils.js'; import {buildUUID} from './cookies-utils.js'; const SCHEMA_ID = 'custom_storefront_customer_tracking/1.0'; @@ -102,6 +97,8 @@ export function addToCart( payload: ShopifyAnalyticsPayload ): ShopifyMonorailPayload[] { const addToCartPayload = payload as ShopifyAddToCartPayload; + const cartToken = parseGid(addToCartPayload.cartId); + const cart_token = cartToken?.id ? `${cartToken.id}` : null; return [ schemaWrapper( SCHEMA_ID, @@ -109,7 +106,7 @@ export function addToCart( { event_name: PRODUCT_ADDED_TO_CART_EVENT_NAME, customerId: addToCartPayload.customerId, - cart_token: stripId(addToCartPayload.cartId), + cart_token, total_value: addToCartPayload.totalValue, products: formatProductPayload(addToCartPayload.products), }, @@ -122,6 +119,10 @@ export function addToCart( function formatPayload( payload: ShopifyAnalyticsPayload ): ShopifyMonorailPayload { + const shop_id = + typeof payload.shopId === 'string' + ? parseGid(payload.shopId).id + : payload.shopId; return { source: payload.shopifyAppSource || ShopifyAppSource.headless, hydrogenSubchannelId: payload.storefrontId || '0', @@ -139,7 +140,7 @@ function formatPayload( navigation_type: payload.navigationType, navigation_api: payload.navigationApi, - shop_id: stripGId(payload.shopId), + shop_id, currency: payload.currency, }; } @@ -152,8 +153,8 @@ function formatProductPayload(products?: ShopifyAnalyticsProduct[]) { variant_gid: p.variant_gid, category: p.category, sku: p.sku, - product_id: stripGId(p.product_gid), - variant_id: stripGId(p.variant_gid), + product_id: parseGid(p.product_gid).id, + variant_id: parseGid(p.variant_gid).id, }, { product_gid: p.product_gid, diff --git a/packages/react/src/analytics-schema-trekkie-storefront-page-view.ts b/packages/react/src/analytics-schema-trekkie-storefront-page-view.ts index cb4c0d55..4787776c 100644 --- a/packages/react/src/analytics-schema-trekkie-storefront-page-view.ts +++ b/packages/react/src/analytics-schema-trekkie-storefront-page-view.ts @@ -4,21 +4,18 @@ import { ShopifyMonorailPayload, } from './analytics-types.js'; import {ShopifyAppId} from './analytics-constants.js'; -import { - addDataIf, - schemaWrapper, - stripGId, - getResourceType, -} from './analytics-utils.js'; +import {addDataIf, schemaWrapper, parseGid} from './analytics-utils.js'; import {buildUUID} from './cookies-utils.js'; const SCHEMA_ID = 'trekkie_storefront_page_view/1.4'; -const oxygenDomain = 'myshopify.dev'; +const OXYGEN_DOMAIN = 'myshopify.dev'; export function pageView( payload: ShopifyAnalyticsPayload ): ShopifyMonorailPayload[] { const pageViewPayload = payload as ShopifyPageViewPayload; + const {id, resource} = parseGid(pageViewPayload.resourceId); + const resourceType = resource ? resource.toLowerCase() : undefined; return [ schemaWrapper( SCHEMA_ID, @@ -26,8 +23,8 @@ export function pageView( { pageType: pageViewPayload.pageType, customerId: pageViewPayload.customerId, - resourceType: getResourceType(pageViewPayload.resourceId), - resourceId: stripGId(pageViewPayload.resourceId), + resourceType, + resourceId: id, }, formatPayload(pageViewPayload) ) @@ -38,6 +35,10 @@ export function pageView( function formatPayload( payload: ShopifyAnalyticsPayload ): ShopifyMonorailPayload { + const shopId = + typeof payload.shopId === 'string' + ? parseGid(payload.shopId).id + : payload.shopId; return { appClientId: payload.shopifyAppSource ? ShopifyAppId[payload.shopifyAppSource] @@ -57,15 +58,18 @@ function formatPayload( referrer: payload.referrer, title: payload.title, - shopId: stripGId(payload.shopId), + shopId, currency: payload.currency, contentLanguage: payload.acceptedLanguage || 'en', }; } function isMerchantRequest(url: string): boolean { + if (typeof url !== 'string') { + return false; + } const hostname = new URL(url).hostname; - if (hostname.indexOf(oxygenDomain) !== -1 || hostname === 'localhost') { + if (hostname.indexOf(OXYGEN_DOMAIN) !== -1 || hostname === 'localhost') { return true; } return false; diff --git a/packages/react/src/analytics-utils.test.ts b/packages/react/src/analytics-utils.test.ts new file mode 100644 index 00000000..2f1f7a4d --- /dev/null +++ b/packages/react/src/analytics-utils.test.ts @@ -0,0 +1,108 @@ +import {parseGid, addDataIf, schemaWrapper} from './analytics-utils.js'; + +describe('analytic-utils', () => { + describe('parseGid', () => { + it('returns the id and resource type from a gid', () => { + const {id, resource} = parseGid('gid://shopify/Order/123'); + expect(id).toBe('123'); + expect(resource).toBe('Order'); + }); + + it('returns null if the gid is not a string', () => { + //@ts-expect-error - testing invalid input + const {id, resource} = parseGid(123); + expect(id).toBe(null); + expect(resource).toBe(null); + }); + + it('returns null if the gid is not a valid gid', () => { + const {id, resource} = parseGid('gid://shopify/Order'); + expect(id).toBe(null); + expect(resource).toBe(null); + }); + + it('returns the id and resource type from a gid with a query string', () => { + const {id, resource} = parseGid('gid://shopify/Order/123?namespace=123'); + expect(id).toBe('123'); + expect(resource).toBe('Order'); + }); + + it('returns the id and resource type from a gid with a query string and a fragment', () => { + const {id, resource} = parseGid( + 'gid://shopify/Order/123?namespace=123#fragment' + ); + expect(id).toBe('123'); + expect(resource).toBe('Order'); + }); + + it('returns null if the resource is missing', () => { + const {id, resource} = parseGid('gid://shopify//123'); + expect(id).toBe(null); + expect(resource).toBe(null); + }); + }); + + describe('addDataIf', () => { + it('adds the key value pair when the value is truthy', () => { + const data = {foo: 'bar'}; + const formattedData = {}; + + expect(addDataIf(data, formattedData)).toEqual({foo: 'bar'}); + }); + + it('does not add the key value pair when the value is falsy', () => { + const data = {foo: null, bazz: ''}; + const formattedData = {}; + + expect(addDataIf(data, formattedData)).toEqual({}); + }); + + it('does not add the key value pair when the value is an empty string', () => { + const data = {foo: ''}; + const formattedData = {}; + + expect(addDataIf(data, formattedData)).toEqual({}); + }); + + it('returns and empty object if the key value pairs are not an object', () => { + const data = 'foo'; + const formattedData = {}; + + //@ts-expect-error passing-and-invalid-type-for-testing + expect(addDataIf(data, formattedData)).toEqual({}); + }); + }); + + describe('schemaWrapper', () => { + it('returns a Shopify Monorail payload from a Shopify Analytics payload and a schema ID', () => { + const payload = {foo: 'bar'}; + const schemaId = '123'; + + expect(schemaWrapper(schemaId, payload)).toEqual({ + schema_id: '123', + payload: {foo: 'bar'}, + metadata: {event_created_at_ms: expect.any(Number)}, + }); + }); + + it('throws an error if the schema ID is not a string', () => { + const payload = {foo: 'bar'}; + const schemaId = 123; + + //@ts-expect-error passing-and-invalid-type-for-testing + expect(() => schemaWrapper(schemaId, payload)).toThrow( + '`schemaId` must be a string' + ); + }); + + it('throws an error if the payload is not an object', () => { + const payload = 'foo'; + const schemaId = '123'; + + //@ts-expect-error passing-and-invalid-type-for-testing + expect(() => schemaWrapper(schemaId, payload)).toThrow( + '`payload` must be an object' + ); + }); + }); +}); diff --git a/packages/react/src/analytics-utils.ts b/packages/react/src/analytics-utils.ts index 4fc228ec..291857f0 100644 --- a/packages/react/src/analytics-utils.ts +++ b/packages/react/src/analytics-utils.ts @@ -1,6 +1,18 @@ import type {ShopifyMonorailPayload} from './analytics-types.js'; -export function schemaWrapper(schemaId: string, payload: unknown) { +/** + * Builds a Shopify Monorail payload from a Shopify Analytics payload and a schema ID. + * @param payload - The payload to format + * @param schemaId - The schema ID to use + * @returns The formatted payload + **/ +export function schemaWrapper(schemaId: string, payload: object) { + if (typeof schemaId !== 'string') { + throw new Error('`schemaId` must be a string'); + } + if (typeof payload !== 'object' || payload === null) { + throw new Error('`payload` must be an object'); + } return { schema_id: schemaId, payload, @@ -10,19 +22,43 @@ export function schemaWrapper(schemaId: string, payload: unknown) { }; } -export function stripGId(text: number | string | undefined): number { - if (typeof text === 'number') return text; - return parseInt(stripId(text || '')); -} - -export function stripId(text = ''): string { - return text.substring(text.lastIndexOf('/') + 1); +/** + * Parses global id (gid) and returns the resource type and id. + * @see https://shopify.dev/api/usage/gids + * @param gid - A shopify GID (string) + * @returns \{ id: string, resource: string \} + * + * @example + * ```ts + * const {id, resource} = parseGid('gid://shopify/Order/123') + * // => id = '123', resource = 'Order' + * ``` + **/ +export function parseGid(gid: string | undefined): { + id: string | null; + resource: string | null; +} { + if (typeof gid !== 'string') return {id: null, resource: null}; + const matches = gid.match(/^gid:\/\/.hopify\/(\w+)\/(\d+)/); + if (!matches || matches.length === 1) { + return {id: null, resource: null}; + } + return {id: matches[2] ?? null, resource: matches[1] ?? null}; } +/** + * Filters properties from an object and returns a new object with only the properties that have a truthy value. + * @param keyValuePairs - An object of key-value pairs + * @param formattedData - An object which will hold the truthy values + * @returns The formatted object + **/ export function addDataIf( keyValuePairs: ShopifyMonorailPayload, formattedData: ShopifyMonorailPayload ): ShopifyMonorailPayload { + if (typeof keyValuePairs !== 'object') { + return {}; + } Object.entries(keyValuePairs).forEach(([key, value]) => { if (value) { formattedData[key] = value; @@ -31,9 +67,17 @@ export function addDataIf( return formattedData; } -export function getResourceType(text = ''): string { - return text - .substring(0, text.lastIndexOf('/')) - .replace(/.*shopify\//, '') - .toLowerCase(); +/** + * Utility that errors if a function is called on the server. + * @param fnName - The name of the function + * @returns A boolean + **/ +export function errorIfServer(fnName: string): boolean { + if (!window) { + console.error( + `${fnName} should only be used within the useEffect callback or event handlers` + ); + return true; + } + return false; } diff --git a/packages/react/src/analytics.tsx b/packages/react/src/analytics.tsx index 56d31dd2..7be34bf5 100644 --- a/packages/react/src/analytics.tsx +++ b/packages/react/src/analytics.tsx @@ -5,7 +5,7 @@ import type { ShopifyMonorailPayload, } from './analytics-types.js'; import {AnalyticsEventName} from './analytics-constants.js'; -import {errorIfServer} from './analytics-errors-utils.js'; +import {errorIfServer} from './analytics-utils.js'; import {getShopifyCookies} from './cookies-utils.js'; import * as TrekkieStorefrontPageView from './analytics-schema-trekkie-storefront-page-view.js'; @@ -128,7 +128,6 @@ function getNavigationTypeLegacy() { performance?.navigation?.type !== undefined ) { // https://developer.mozilla.org/en-US/docs/Web/API/Performance/navigation - const rawType = performance.navigation.type; switch (rawType) { case PerformanceNavigation.TYPE_NAVIGATE: From 2165281d061700edb67106049e2e5215c9190334 Mon Sep 17 00:00:00 2001 From: "Juan P. Prieto" Date: Wed, 11 Jan 2023 15:58:06 -0800 Subject: [PATCH 2/2] default to returning the parsed id as an int --- packages/react/src/analytics-utils.test.ts | 6 +++--- packages/react/src/analytics-utils.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/react/src/analytics-utils.test.ts b/packages/react/src/analytics-utils.test.ts index 2f1f7a4d..c05f2d2e 100644 --- a/packages/react/src/analytics-utils.test.ts +++ b/packages/react/src/analytics-utils.test.ts @@ -4,7 +4,7 @@ describe('analytic-utils', () => { describe('parseGid', () => { it('returns the id and resource type from a gid', () => { const {id, resource} = parseGid('gid://shopify/Order/123'); - expect(id).toBe('123'); + expect(id).toBe(123); expect(resource).toBe('Order'); }); @@ -23,7 +23,7 @@ describe('analytic-utils', () => { it('returns the id and resource type from a gid with a query string', () => { const {id, resource} = parseGid('gid://shopify/Order/123?namespace=123'); - expect(id).toBe('123'); + expect(id).toBe(123); expect(resource).toBe('Order'); }); @@ -31,7 +31,7 @@ describe('analytic-utils', () => { const {id, resource} = parseGid( 'gid://shopify/Order/123?namespace=123#fragment' ); - expect(id).toBe('123'); + expect(id).toBe(123); expect(resource).toBe('Order'); }); diff --git a/packages/react/src/analytics-utils.ts b/packages/react/src/analytics-utils.ts index 291857f0..7abb3899 100644 --- a/packages/react/src/analytics-utils.ts +++ b/packages/react/src/analytics-utils.ts @@ -26,16 +26,16 @@ export function schemaWrapper(schemaId: string, payload: object) { * Parses global id (gid) and returns the resource type and id. * @see https://shopify.dev/api/usage/gids * @param gid - A shopify GID (string) - * @returns \{ id: string, resource: string \} + * @returns \{ id: number, resource: string \} * * @example * ```ts * const {id, resource} = parseGid('gid://shopify/Order/123') - * // => id = '123', resource = 'Order' + * // => id = 123, resource = 'Order' * ``` **/ export function parseGid(gid: string | undefined): { - id: string | null; + id: number | null; resource: string | null; } { if (typeof gid !== 'string') return {id: null, resource: null}; @@ -43,7 +43,9 @@ export function parseGid(gid: string | undefined): { if (!matches || matches.length === 1) { return {id: null, resource: null}; } - return {id: matches[2] ?? null, resource: matches[1] ?? null}; + const id = matches[2] ? parseInt(matches[2], 10) : null; + const resource = matches[1] ? matches[1] : null; + return {id, resource}; } /**