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:
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
@@ -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 {
|
||||
|
||||
@@ -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", {});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user