feat: Add framework to send all events fired for embed to Analytics Apps (#15173)

* Send all events to analytics apps

* feat: Start sending all events fired for embed to Analytics Apps(GTM Support added)

* Add tests
This commit is contained in:
Hariom Balhara
2024-05-31 16:07:09 +00:00
committed by GitHub
parent 9e371ded90
commit 1730ef7d5d
11 changed files with 363 additions and 21 deletions
@@ -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 <div {...props} />;
},
};
});
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(
<BookingPageTagManager
eventType={{
metadata: {
apps: {
gtm: GTM_CONFIG,
},
},
price: 0,
currency: "USD",
}}
/>
);
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(
<BookingPageTagManager
eventType={{
metadata: {
apps: {
gtm: GTM_CONFIG,
},
},
price: 0,
currency: "USD",
}}
/>
);
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(
<BookingPageTagManager
eventType={{
metadata: {
apps: {
zoomvideo: {
enabled: true,
},
},
},
price: 0,
currency: "USD",
}}
/>
);
const scripts = screen.queryAllByTestId("cal-analytics-app-zoomvideo");
expect(scripts.length).toBe(0);
});
it("should not crash for an app that doesnt exist", () => {
render(
<BookingPageTagManager
eventType={{
metadata: {
apps: {
//@ts-expect-error Testing for non-existent app
nonexistentapp: {
enabled: true,
},
},
},
price: 0,
currency: "USD",
}}
/>
);
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",
},
});
});
});
+88 -15
View File
@@ -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<AppMeta, "appData"> & {
appData: Omit<NonNullable<AppMeta["appData"]>, "tag"> & {
tag: NonNullable<NonNullable<AppMeta["appData"]>["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<typeof getEventTypeAppData>[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<typeof getEventTypeAppData>;
}
>
);
}
export function handleEvent(event: { detail: Record<string, unknown> & { 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<typeof getEventTypeAppData>[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 = <T extends string | undefined>(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<T>;
}
}
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 (
<Script
data-testid={`cal-analytics-app-${appId}`}
src={parseValue(script.src)}
id={`${appId}-${index}`}
key={`${appId}-${index}`}
@@ -72,3 +139,9 @@ export default function BookingPageTagManager({
</>
);
}
if (typeof window !== "undefined") {
// Attach listener outside React as it has to be attached only once per page load
// Setup listener for all events to push to analytics apps
sdkActionManager?.on("*", handleEvent);
}
+4 -1
View File
@@ -16,7 +16,10 @@
{
"content": "(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','{TRACKING_ID}');"
}
]
],
"pushEventScript": {
"content": "function $pushEvent(event) {window.dataLayer.push({ event: event.name, ...event.data })}"
}
}
},
"isTemplate": false,
+1
View File
@@ -32,6 +32,7 @@ type AppScript = { attrs?: Record<string, string> } & { src?: string; content?:
export type Tag = {
scripts: AppScript[];
pushEventScript?: AppScript;
};
export interface InstallAppButtonProps {
@@ -22,6 +22,19 @@ export type EventDataMap = {
};
};
linkReady: Record<string, never>;
bookingSuccessfulV2: {
uid: string | undefined;
title: string | undefined;
startTime: string | undefined;
endTime: string | undefined;
eventTypeId: number | null | undefined;
status: string | undefined;
paymentRequired: boolean;
};
/**
* @deprecated Use `bookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well.
*/
bookingSuccessful: {
// TODO: Shouldn't send the entire booking and eventType objects, we should send specific fields from them.
booking: unknown;
@@ -35,6 +48,18 @@ export type EventDataMap = {
};
confirmed: boolean;
};
rescheduleBookingSuccessfulV2: {
uid: string | undefined;
title: string | undefined;
startTime: string | undefined;
endTime: string | undefined;
eventTypeId: number | null | undefined;
status: string | undefined;
paymentRequired: boolean;
};
/**
* @deprecated Use `rescheduleBookingSuccessfulV2` instead. We restrict the data heavily there, only sending what is absolutely needed and keeping it light as well. Plus, more importantly that can be documented well.
*/
rescheduleBookingSuccessful: {
booking: unknown;
eventType: unknown;
@@ -104,7 +104,15 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
? duration
: event.data?.length;
const eventPayload = {
uid: responseData.uid,
title: responseData.title,
startTime: responseData.startTime,
endTime: responseData.endTime,
eventTypeId: responseData.eventTypeId,
status: responseData.status,
paymentRequired: responseData.paymentRequired,
};
if (isRescheduling) {
sdkActionManager?.fire("rescheduleBookingSuccessful", {
booking: responseData,
@@ -118,6 +126,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
},
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
});
sdkActionManager?.fire("rescheduleBookingSuccessfulV2", eventPayload);
} else {
sdkActionManager?.fire("bookingSuccessful", {
booking: responseData,
@@ -131,6 +140,8 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
},
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
});
sdkActionManager?.fire("bookingSuccessfulV2", eventPayload);
}
if (paymentUid) {
@@ -3,6 +3,12 @@ import { post } from "@calcom/lib/fetch-wrapper";
import type { BookingCreateBody, BookingResponse } from "../types";
export const createBooking = async (data: BookingCreateBody) => {
const response = await post<BookingCreateBody, BookingResponse>("/api/book/event", data);
const response = await post<
Omit<BookingCreateBody, "startTime" | "endTime">,
BookingResponse & {
startTime: string;
endTime: string;
}
>("/api/book/event", data);
return response;
};
@@ -1731,8 +1731,8 @@ async function handler(
...newBooking.user,
email: null,
},
paymentRequired: false,
};
return {
...bookingResponse,
...luckyUserResponse,
@@ -2348,7 +2348,7 @@ async function handler(
req.statusCode = 201;
// TODO: Refactor better so this booking object is not passed
// all around and instead the individual fields are sent as args.
const bookingReponse = {
const bookingResponse = {
...booking,
user: {
...booking.user,
@@ -2357,9 +2357,10 @@ async function handler(
};
return {
...bookingReponse,
...bookingResponse,
...luckyUserResponse,
message: "Payment required",
paymentRequired: true,
paymentUid: payment?.uid,
paymentId: payment?.id,
};
@@ -2499,6 +2500,7 @@ async function handler(
...booking.user,
email: null,
},
paymentRequired: false,
};
return {
+4
View File
@@ -0,0 +1,4 @@
// We can't use sdkActionManager without embed because sdkActionManager needs embed namespace to be able to inform the correct embed namespace in parent
// We should plan to create 2 sdkActionManager instances and fire events on both of them if we want to remove dependency on embed
// Because right now(and in near future) we need only those events which are fired from embed, we are fine
export { sdkActionManager } from "@calcom/embed-core/embed-iframe";
@@ -41,6 +41,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
const date = dayjs(selectedDate).format("YYYY-MM-DD");
useEffect(() => {
// This event isn't processed by BookingPageTagManager because BookingPageTagManager hasn't loaded when it is fired. I think we should have a queue in fire method to handle this.
sdkActionManager?.fire("navigatedToBooker", {});
}, []);
+9
View File
@@ -120,6 +120,15 @@ const workspaces = packagedEmbedTestsOnly
setupFiles: ["packages/app-store/closecom/test/globals.ts"],
},
},
{
test: {
globals: true,
name: "@calcom/app-store-core",
include: ["packages/app-store/*.{test,spec}.[jt]s?(x)"],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
},
},
{
test: {
globals: true,