diff --git a/packages/app-store/BookingPageTagManager.test.tsx b/packages/app-store/BookingPageTagManager.test.tsx new file mode 100644 index 0000000000..1e55a05999 --- /dev/null +++ b/packages/app-store/BookingPageTagManager.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, cleanup } from "@testing-library/react"; +import { vi } from "vitest"; + +import BookingPageTagManager, { handleEvent } from "./BookingPageTagManager"; + +// NOTE: We don't intentionally mock appStoreMetadata as that also tests config.json and generated files for us for no cost. If it becomes a pain in future, we could just start mocking it. + +vi.mock("next/script", () => { + return { + default: ({ ...props }) => { + return
; + }, + }; +}); + +const windowProps: string[] = []; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function setOnWindow(prop: any, value: any) { + window[prop] = value; + windowProps.push(prop); +} + +afterEach(() => { + windowProps.forEach((prop) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + delete window[prop]; + }); + windowProps.splice(0); + cleanup(); +}); + +describe("BookingPageTagManager", () => { + it("GTM App when enabled should have its scripts added with appropriate trackingID and $pushEvent replacement", () => { + const GTM_CONFIG = { + enabled: true, + trackingId: "GTM-123", + }; + render( + + ); + const scripts = screen.getAllByTestId("cal-analytics-app-gtm"); + const trackingScript = scripts[0]; + const pushEventScript = scripts[1]; + expect(trackingScript.innerHTML).toContain(GTM_CONFIG.trackingId); + expect(pushEventScript.innerHTML).toContain("cal_analytics_app__gtm"); + }); + + it("GTM App when disabled should not have its scripts added", () => { + const GTM_CONFIG = { + enabled: false, + trackingId: "GTM-123", + }; + render( + + ); + const scripts = screen.queryAllByTestId("cal-analytics-app-gtm"); + expect(scripts.length).toBe(0); + }); + + it("should not add scripts for an app that doesnt have tag defined(i.e. non-analytics app)", () => { + render( + + ); + const scripts = screen.queryAllByTestId("cal-analytics-app-zoomvideo"); + expect(scripts.length).toBe(0); + }); + + it("should not crash for an app that doesnt exist", () => { + render( + + ); + const scripts = screen.queryAllByTestId("cal-analytics-app-zoomvideo"); + expect(scripts.length).toBe(0); + }); +}); + +describe("handleEvent", () => { + it("should not push internal events to analytics apps", () => { + expect( + handleEvent({ + detail: { + // Internal event + type: "__abc", + }, + }) + ).toBe(false); + + expect( + handleEvent({ + detail: { + // Not an internal event + type: "_abc", + }, + }) + ).toBe(true); + }); + + it("should call the function on window with the event name and data", () => { + const pushEventXyz = vi.fn(); + const pushEventAnything = vi.fn(); + const pushEventRandom = vi.fn(); + const pushEventNotme = vi.fn(); + + setOnWindow("cal_analytics_app__xyz", pushEventXyz); + setOnWindow("cal_analytics_app__anything", pushEventAnything); + setOnWindow("cal_analytics_app_random", pushEventRandom); + setOnWindow("cal_analytics_notme", pushEventNotme); + + handleEvent({ + detail: { + type: "abc", + key: "value", + }, + }); + + expect(pushEventXyz).toHaveBeenCalledWith({ + name: "abc", + data: { + key: "value", + }, + }); + + expect(pushEventAnything).toHaveBeenCalledWith({ + name: "abc", + data: { + key: "value", + }, + }); + + expect(pushEventRandom).toHaveBeenCalledWith({ + name: "abc", + data: { + key: "value", + }, + }); + + expect(pushEventNotme).not.toHaveBeenCalled(); + }); + + it("should not error if accidentally the value is not a function", () => { + const pushEventNotAfunction = "abc"; + const pushEventAnything = vi.fn(); + setOnWindow("cal_analytics_app__notafun", pushEventNotAfunction); + setOnWindow("cal_analytics_app__anything", pushEventAnything); + + handleEvent({ + detail: { + type: "abc", + key: "value", + }, + }); + + // No error for cal_analytics_app__notafun and pushEventAnything is called + expect(pushEventAnything).toHaveBeenCalledWith({ + name: "abc", + data: { + key: "value", + }, + }); + }); +}); diff --git a/packages/app-store/BookingPageTagManager.tsx b/packages/app-store/BookingPageTagManager.tsx index e8349e80f7..b8dcefba97 100644 --- a/packages/app-store/BookingPageTagManager.tsx +++ b/packages/app-store/BookingPageTagManager.tsx @@ -2,28 +2,93 @@ import Script from "next/script"; import { getEventTypeAppData } from "@calcom/app-store/_utils/getEventTypeAppData"; import { appStoreMetadata } from "@calcom/app-store/bookerAppsMetaData"; +import type { Tag } from "@calcom/app-store/types"; +import { sdkActionManager } from "@calcom/lib/sdk-event"; +import type { AppMeta } from "@calcom/types/App"; import type { appDataSchemas } from "./apps.schemas.generated"; +const PushEventPrefix = "cal_analytics_app_"; + +// AnalyticApp has appData.tag always set +type AnalyticApp = Omit & { + appData: Omit, "tag"> & { + tag: NonNullable["tag"]>; + }; +}; + +const getPushEventScript = ({ tag, appId }: { tag: Tag; appId: string }) => { + if (!tag.pushEventScript) { + return tag.pushEventScript; + } + + return { + ...tag.pushEventScript, + // In case of complex pushEvent implementations, we could think about exporting a pushEvent function from the analytics app maybe but for now this should suffice + content: tag.pushEventScript?.content?.replace("$pushEvent", `${PushEventPrefix}_${appId}`), + }; +}; + +function getAnalyticsApps(eventType: Parameters[0]) { + return Object.entries(appStoreMetadata).reduce( + (acc, entry) => { + const [appId, app] = entry; + const eventTypeAppData = getEventTypeAppData(eventType, appId as keyof typeof appDataSchemas); + + if (!eventTypeAppData || !app.appData?.tag) { + return acc; + } + + acc[appId] = { + meta: app as AnalyticApp, + eventTypeAppData: eventTypeAppData, + }; + return acc; + }, + {} as Record< + string, + { + meta: AnalyticApp; + eventTypeAppData: ReturnType; + } + > + ); +} + +export function handleEvent(event: { detail: Record & { type: string } }) { + const { type: name, ...data } = event.detail; + // Don't push internal events to analytics apps + // They are meant for internal use like helping embed make some decisions + if (name.startsWith("__")) { + return false; + } + + Object.entries(window).forEach(([prop, value]) => { + if (!prop.startsWith(PushEventPrefix) || typeof value !== "function") { + return; + } + // Find the pushEvent if defined by the analytics app + const pushEvent = window[prop as keyof typeof window]; + + pushEvent({ + name, + data, + }); + }); + + return true; +} + export default function BookingPageTagManager({ eventType, }: { eventType: Parameters[0]; }) { + const analyticsApps = getAnalyticsApps(eventType); return ( <> - {Object.entries(appStoreMetadata).map(([appId, app]) => { - const tag = app.appData?.tag; - if (!tag) { - return null; - } - - const appData = getEventTypeAppData(eventType, appId as keyof typeof appDataSchemas); - - if (!appData) { - return null; - } - + {Object.entries(analyticsApps).map(([appId, { meta: app, eventTypeAppData }]) => { + const tag = app.appData.tag; const parseValue = (val: T): T => { if (!val) { return val; @@ -34,18 +99,19 @@ export default function BookingPageTagManager({ let matches; while ((matches = regex.exec(val))) { const variableName = matches[1]; - if (appData[variableName]) { + if (eventTypeAppData[variableName]) { // Replace if value is available. It can possible not be a template variable that just matches the regex. val = val.replace( new RegExp(`{${variableName}}`, "g"), - appData[variableName] + eventTypeAppData[variableName] ) as NonNullable; } } return val; }; - return tag.scripts.map((script, index) => { + const pushEventScript = getPushEventScript({ tag, appId }); + return tag.scripts.concat(pushEventScript ? [pushEventScript] : []).map((script, index) => { const parsedAttributes: NonNullable<(typeof tag.scripts)[number]["attrs"]> = {}; const attrs = script.attrs || {}; Object.entries(attrs).forEach(([name, value]) => { @@ -57,6 +123,7 @@ export default function BookingPageTagManager({ return (