feat: Embed - Introduce `prerender` instruction - Lightning fast popup experience (#11303)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Hariom Balhara 2023-10-10 08:40:04 +05:30 committed by GitHub
parent a53ea33168
commit 1456e2d4d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 543 additions and 237 deletions

View File

@ -1,81 +1,90 @@
import type { Page } from "@playwright/test";
export const createEmbedsFixture = (page: Page) => {
return async (calNamespace: string) => {
await page.addInitScript(
({ calNamespace }: { calNamespace: string }) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {};
document.addEventListener("DOMContentLoaded", function tryAddingListener() {
if (parent !== window) {
// Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialBodyVisibility = document.body.style.visibility;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialBodyBackground = document.body.style.background;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialValuesSet = true;
return;
}
return {
/**
* @deprecated Use gotoPlayground instead
*/
async addEmbedListeners(calNamespace: string) {
await page.addInitScript(
({ calNamespace }: { calNamespace: string }) => {
console.log("PlaywrightTest:", "Adding listener for __iframeReady on namespace:", calNamespace);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
let api = window.Cal;
window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {};
document.addEventListener("DOMContentLoaded", function tryAddingListener() {
if (parent !== window) {
// Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialBodyVisibility = document.body.style.visibility;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialBodyBackground = document.body.style.background;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialValuesSet = true;
return;
}
if (!api) {
setTimeout(tryAddingListener, 500);
return;
}
if (calNamespace) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
api = window.Cal.ns[calNamespace];
}
console.log("PlaywrightTest:", "Adding listener for __iframeReady");
if (!api) {
throw new Error(`namespace "${calNamespace}" not found`);
}
api("on", {
action: "*",
callback: (e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work
let api = window.Cal;
if (!api) {
console.log("PlaywrightTest:", "window.Cal not available yet, trying again");
setTimeout(tryAddingListener, 500);
return;
}
if (calNamespace) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const store = window.eventsFiredStoreForPlaywright;
const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] =
store[`${e.detail.type}-${e.detail.namespace}`] || []);
eventStore.push(e.detail);
},
//@ts-ignore
api = window.Cal.ns[calNamespace];
}
console.log("PlaywrightTest:", `Adding listener for __iframeReady on namespace:${calNamespace}`);
if (!api) {
throw new Error(`namespace "${calNamespace}" not found`);
}
api("on", {
action: "*",
callback: (e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const store = window.eventsFiredStoreForPlaywright;
const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] =
store[`${e.detail.type}-${e.detail.namespace}`] || []);
eventStore.push(e.detail);
},
});
});
});
},
{ calNamespace }
);
};
};
export const createGetActionFiredDetails = (page: Page) => {
return async ({ calNamespace, actionType }: { calNamespace: string; actionType: string }) => {
if (!page.isClosed()) {
return await page.evaluate(
({ actionType, calNamespace }) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`];
},
{ actionType, calNamespace }
{ calNamespace }
);
}
},
async getActionFiredDetails({ calNamespace, actionType }: { calNamespace: string; actionType: string }) {
if (!page.isClosed()) {
return await page.evaluate(
({ actionType, calNamespace }) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`];
},
{ actionType, calNamespace }
);
}
},
async gotoPlayground({ calNamespace, url }: { calNamespace: string; url: string }) {
await this.addEmbedListeners(calNamespace);
await page.goto(url);
},
};
};

View File

@ -8,7 +8,7 @@ import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createPaymentsFixture } from "../fixtures/payments";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers";
@ -19,8 +19,7 @@ export interface Fixtures {
users: ReturnType<typeof createUsersFixture>;
bookings: ReturnType<typeof createBookingsFixture>;
payments: ReturnType<typeof createPaymentsFixture>;
addEmbedListeners: ReturnType<typeof createEmbedsFixture>;
getActionFiredDetails: ReturnType<typeof createGetActionFiredDetails>;
embeds: ReturnType<typeof createEmbedsFixture>;
servers: ReturnType<typeof createServersFixture>;
prisma: typeof prisma;
emails?: API;
@ -36,7 +35,8 @@ declare global {
calNamespace: string,
// eslint-disable-next-line
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
expectedUrlDetails?: ExpectedUrlDetails
expectedUrlDetails?: ExpectedUrlDetails,
isPrendered?: boolean
): Promise<R>;
}
}
@ -58,14 +58,10 @@ export const test = base.extend<Fixtures>({
const payemntsFixture = createPaymentsFixture(page);
await use(payemntsFixture);
},
addEmbedListeners: async ({ page }, use) => {
embeds: async ({ page }, use) => {
const embedsFixture = createEmbedsFixture(page);
await use(embedsFixture);
},
getActionFiredDetails: async ({ page }, use) => {
const getActionFiredDetailsFixture = createGetActionFiredDetails(page);
await use(getActionFiredDetailsFixture);
},
servers: async ({}, use) => {
const servers = createServersFixture();
await use(servers);

View File

@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import type { Frame, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
@ -86,7 +86,7 @@ export async function waitFor(fn: () => Promise<unknown> | unknown, opts: { time
}
}
export async function selectFirstAvailableTimeSlotNextMonth(page: Page) {
export async function selectFirstAvailableTimeSlotNextMonth(page: Page | Frame) {
// Let current month dates fully render.
await page.click('[data-testid="incrementMonth"]');

View File

@ -1,3 +1,4 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["../../.eslintrc.js"],
rules: {

View File

@ -85,20 +85,30 @@
<span style="display: block"><a href="?color-scheme=dark">With Dark Color Scheme for the Page</a></span>
<span style="display: block"><a href="?nonResponsive">Non responsive version of this page here</a></span>
<span style="display: block"
><a href="?only=prerender-test">Go to Pre-render test page only</a><small></small
><a href="?only=prerender-test">Go to Prerender test page only</a><small></small
></span>
<span style="display: block"
><a href="?only=preload-test">Go to Preload test page only</a><small></small
></span>
<button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme</button>
<button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme</button>
<div>
<script>
if (only === "all" || only === "prerender-test") {
document.write(`
<button data-cal-namespace="prerendertestLightTheme" data-cal-config='{"theme":"light"}' data-cal-link="free?light&popup">Book with Free User[Light Theme]</button>
<button data-cal-namespace="e2ePrerenderLightTheme" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being preloaded. Assuming that it would take you some time to click this
>Corresponding Cal Link is being prerendered. Assuming that it would take you some time to click this
as you are reading this text, it would open up super fast[If you are running a production build on
local]. Try switching to slow 3G or create a custom Network configuration which is impossibly
slow</i
slow. This should be used if you know beforehand which type of embed is going to be opened.</i
>`);
}
if (only === "all" || only === "preload-test") {
document.write(`
<button data-cal-namespace="preloadTest" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being preloaded. That means that all the resources would be preloaded. This could be useful in preloading possible resources if you don't know before hand which type of embed you want to show</i
>`);
}
</script>
@ -110,6 +120,7 @@
<a href="?only=ns:floatingButton">Floating Popup</a>
<h2>Popup Examples</h2>
<button data-cal-namespace="e2ePopupLightTheme" data-cal-link="free" data-cal-config='{"theme":"light"}'>Book an event with Free[Light Theme]</button>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">
Book with Free User[Auto Theme]
</button>

View File

@ -24,7 +24,7 @@ document.addEventListener("click", (e) => {
const searchParams = new URL(document.URL).searchParams;
const only = searchParams.get("only");
const colorScheme = searchParams.get("color-scheme");
const prerender = searchParams.get("prerender");
if (colorScheme) {
document.documentElement.style.colorScheme = colorScheme;
}
@ -211,13 +211,25 @@ if (only === "all" || only === "ns:fifth") {
callback,
});
}
if (only === "all" || only === "prerender-test") {
Cal("init", "prerendertestLightTheme", {
Cal("init", "e2ePrerenderLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.prerendertestLightTheme("preload", {
calLink: "free",
Cal.ns.e2ePrerenderLightTheme("prerender", {
calLink: "free/30min",
type: "modal",
});
}
if (only === "all" || only === "preload-test") {
Cal("init", "preloadTest", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.preloadTest("preload", {
calLink: "free/30min",
});
}
@ -300,6 +312,11 @@ Cal("init", "popupDarkTheme", {
origin: "http://localhost:3000",
});
Cal("init", "e2ePopupLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupHideEventTypeDetails", {
debug: true,
origin: "http://localhost:3000",
@ -360,6 +377,12 @@ Cal("init", "routingFormDark", {
});
if (only === "all" || only == "ns:floatingButton") {
if (prerender == "true") {
Cal.ns.floatingButton("prerender", {
calLink: calLink || "pro",
type: "floatingButton",
});
}
Cal.ns.floatingButton("floatingButton", {
calLink: calLink || "pro",
config: {

View File

@ -56,9 +56,13 @@ export const getEmbedIframe = async ({
clearInterval(interval);
resolve(true);
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
console.log("Iframe Status:", !!iframe, !!iframe?.contentWindow, window.iframeReady);
console.log("Waiting for all three to be true:", {
iframeElement: iframe,
contentWindow: iframe?.contentWindow,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
iframeReady: window.iframeReady,
});
}
}, 500);

View File

@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
import { test } from "@calcom/web/playwright/lib/fixtures";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils";
import {
todo,
@ -18,9 +19,9 @@ async function bookFirstFreeUserEventThroughEmbed({
page,
getActionFiredDetails,
}: {
addEmbedListeners: Fixtures["addEmbedListeners"];
addEmbedListeners: Fixtures["embeds"]["addEmbedListeners"];
page: Page;
getActionFiredDetails: Fixtures["getActionFiredDetails"];
getActionFiredDetails: Fixtures["embeds"]["getActionFiredDetails"];
}) {
const embedButtonLocator = page.locator('[data-cal-link="free"]').first();
await page.goto("/");
@ -50,24 +51,16 @@ test.describe("Popup Tests", () => {
await deleteAllBookingsByEmail("embed-user@example.com");
});
test("should open embed iframe on click - Configured with light theme", async ({
page,
addEmbedListeners,
getActionFiredDetails,
}) => {
test("should open embed iframe on click - Configured with light theme", async ({ page, embeds }) => {
await deleteAllBookingsByEmail("embed-user@example.com");
const calNamespace = "e2ePopupLightTheme";
await embeds.gotoPlayground({ calNamespace, url: "/" });
const calNamespace = "prerendertestLightTheme";
await addEmbedListeners(calNamespace);
await page.goto("/?only=prerender-test");
let embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
expect(embedIframe).toBeFalsy();
await page.click(`[data-cal-namespace="${calNamespace}"]`);
await page.click('[data-cal-link="free?light&popup"]');
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/free",
});
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
@ -82,7 +75,10 @@ test.describe("Popup Tests", () => {
await deleteAllBookingsByEmail("embed-user@example.com");
});
test("should be able to reschedule", async ({ page, addEmbedListeners, getActionFiredDetails }) => {
test("should be able to reschedule", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const booking = await test.step("Create a booking", async () => {
return await bookFirstFreeUserEventThroughEmbed({
page,
@ -108,8 +104,7 @@ test.describe("Popup Tests", () => {
test("should open Routing Forms embed on click", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
@ -143,8 +138,7 @@ test.describe("Popup Tests", () => {
test.describe("Pro User - Configured in App with default setting of system theme", () => {
test("should open embed iframe according to system theme when no theme is configured through Embed API", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
@ -175,8 +169,7 @@ test.describe("Popup Tests", () => {
test("should open embed iframe according to system theme when configured with 'auto' theme using Embed API", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
@ -203,8 +196,7 @@ test.describe("Popup Tests", () => {
test("should open embed iframe(Booker Profile Page) with dark theme when configured with dark theme using Embed API", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
@ -227,8 +219,7 @@ test.describe("Popup Tests", () => {
test("should open embed iframe(Event Booking Page) with dark theme when configured with dark theme using Embed API", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
@ -250,4 +241,52 @@ test.describe("Popup Tests", () => {
});
});
});
test("prendered embed should be loaded and apply the config given to it", async ({ page, embeds }) => {
const calNamespace = "e2ePrerenderLightTheme";
const calLink = "/free/30min";
await embeds.gotoPlayground({ calNamespace, url: "/?only=prerender-test" });
await expectPrerenderedIframe({ calNamespace, calLink, embeds, page });
await page.click(`[data-cal-namespace="${calNamespace}"]`);
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
// eslint-disable-next-line playwright/no-conditional-in-test
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
await selectFirstAvailableTimeSlotNextMonth(embedIframe);
await expect(embedIframe.locator('[name="name"]')).toHaveValue("Preloaded Prefilled");
await expect(embedIframe.locator('[name="email"]')).toHaveValue("preloaded-prefilled@example.com");
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: calLink,
});
});
});
async function expectPrerenderedIframe({
page,
calNamespace,
calLink,
embeds,
}: {
page: Page;
calNamespace: string;
calLink: string;
embeds: Fixtures["embeds"];
}) {
const prerenderedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
if (!prerenderedIframe) {
throw new Error("Prerendered iframe not found");
}
await expect(prerenderedIframe).toBeEmbedCalLink(
calNamespace,
embeds.getActionFiredDetails,
{
pathname: calLink,
},
true
);
}

View File

@ -7,8 +7,7 @@ import { bookFirstEvent, deleteAllBookingsByEmail, getEmbedIframe, todo } from "
test.describe("Inline Iframe", () => {
test("Inline Iframe - Configured with Dark Theme", async ({
page,
getActionFiredDetails,
addEmbedListeners,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
await addEmbedListeners("");

View File

@ -41,6 +41,21 @@ export class ModalBox extends HTMLElement {
this.dispatchEvent(event);
}
hideIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
iframe.style.visibility = "hidden";
}
}
showIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
// Don't use visibility visible as that will make the iframe visible even when the modal is closed
iframe.style.visibility = "";
}
}
getLoaderElement() {
this.assertHasShadowRoot();
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
@ -68,10 +83,14 @@ export class ModalBox extends HTMLElement {
return;
}
if (newValue == "loaded") {
this.getLoaderElement().style.display = "none";
} else if (newValue === "started") {
if (newValue === "loading") {
this.open();
this.hideIframe();
this.getLoaderElement().style.display = "block";
} else if (newValue == "loaded" || newValue === "reopening") {
this.open();
this.showIframe();
this.getLoaderElement().style.display = "none";
} else if (newValue == "closed") {
this.close();
} else if (newValue === "failed") {
@ -79,6 +98,8 @@ export class ModalBox extends HTMLElement {
this.getErrorElement().style.display = "inline-block";
const errorString = getErrorString(this.dataset.errorCode);
this.getErrorElement().innerText = errorString;
} else if (newValue === "prerendering") {
this.close();
}
}

View File

@ -1,3 +1,4 @@
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState, useCallback } from "react";
@ -7,6 +8,29 @@ import type { EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, E
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
const enum EMBED_IFRAME_STATE {
NOT_INITIALIZED,
INITIALIZED,
}
/**
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
*/
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
iframeAttrs?: Record<string, string> & {
id?: string;
};
// TODO: It should have a dedicated prefill prop
// prefill: {},
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
// ui: {layout; theme}
layout?: BookerLayouts;
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
"ui.color-scheme"?: string;
theme?: EmbedThemeConfig;
};
declare global {
interface Window {
@ -17,10 +41,34 @@ declare global {
};
}
}
/**
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
*/
const embedStore = {
// Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available
router: {
setNextRouter(nextRouter: ReturnType<typeof useRouter>) {
this.nextRouter = nextRouter;
// Empty the queue after running push on nextRouter. This is important because setNextRouter is be called multiple times
this.queue.forEach((url) => {
nextRouter.push(url);
this.queue.splice(0, 1);
});
},
nextRouter: null as null | ReturnType<typeof useRouter>,
queue: [] as string[],
goto(url: string) {
if (this.nextRouter) {
this.nextRouter.push(url.toString());
} else {
this.queue.push(url);
}
},
},
state: EMBED_IFRAME_STATE.NOT_INITIALIZED,
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {} as EmbedStyles | undefined,
nonStyles: {} as EmbedNonStylesConfig | undefined,
@ -148,6 +196,8 @@ const useUrlChange = (callback: (newUrl: string) => void) => {
const pathname = currentFullUrl?.pathname ?? "";
const searchParams = currentFullUrl?.searchParams ?? null;
const lastKnownUrl = useRef(`${pathname}?${searchParams}`);
const router = useRouter();
embedStore.router.setNextRouter(router);
useEffect(() => {
const newUrl = `${pathname}?${searchParams}`;
if (lastKnownUrl.current !== newUrl) {
@ -340,9 +390,28 @@ const methods = {
}
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
unhideBody();
sdkActionManager?.fire("linkReady", {});
if (!isPrerendering()) {
sdkActionManager?.fire("linkReady", {});
}
});
},
connect: function connect(queryObject: PrefillAndIframeAttrsConfig) {
const currentUrl = new URL(document.URL);
const searchParams = currentUrl.searchParams;
searchParams.delete("preload");
for (const [key, value] of Object.entries(queryObject)) {
if (value === undefined) {
continue;
}
if (value instanceof Array) {
value.forEach((val) => searchParams.append(key, val));
} else {
searchParams.set(key, value as string);
}
}
connectPreloadedEmbed({ url: currentUrl });
},
};
export type InterfaceWithParent = {
@ -451,58 +520,71 @@ if (isBrowser) {
};
actOnColorScheme(embedStore.uiConfig.colorScheme);
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
log("Initializing embed-iframe");
// HACK
const pageStatus = window.CalComPageStatus;
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
}
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
log(detail);
messageParent(detail);
});
window.addEventListener("message", (e) => {
const data: Message = e.data;
if (!data) {
return;
}
const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") {
interfaceWithParent[method]?.(data.arg as never);
}
});
document.addEventListener("click", (e) => {
if (!e.target || !(e.target instanceof Node)) {
return;
}
const mainElement =
document.getElementsByClassName("main")[0] ||
document.getElementsByTagName("main")[0] ||
document.documentElement;
if (e.target.contains(mainElement)) {
sdkActionManager?.fire("__closeIframe", {});
}
});
if (!pageStatus || pageStatus == "200") {
keepParentInformedAboutDimensionChanges();
sdkActionManager?.fire("__iframeReady", {});
} else
sdkActionManager?.fire("linkFailed", {
code: pageStatus,
msg: "Problem loading the link",
data: {
url: document.URL,
},
});
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
}
window.addEventListener("message", (e) => {
const data: Message = e.data;
if (!data) {
return;
}
const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") {
interfaceWithParent[method]?.(data.arg as never);
}
});
document.addEventListener("click", (e) => {
if (!e.target || !(e.target instanceof Node)) {
return;
}
const mainElement =
document.getElementsByClassName("main")[0] ||
document.getElementsByTagName("main")[0] ||
document.documentElement;
if (e.target.contains(mainElement)) {
sdkActionManager?.fire("__closeIframe", {});
}
});
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
log(detail);
messageParent(detail);
});
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
initializeAndSetupEmbed();
} else {
log(`Preloaded scenario - Skipping initialization and setup`);
}
}
function initializeAndSetupEmbed() {
sdkActionManager?.fire("__iframeReady", {});
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
log("Embed Iframe already initialized");
return;
}
embedStore.state = EMBED_IFRAME_STATE.INITIALIZED;
log("Initializing embed-iframe");
// HACK
const pageStatus = window.CalComPageStatus;
if (!pageStatus || pageStatus == "200") {
keepParentInformedAboutDimensionChanges();
} else
sdkActionManager?.fire("linkFailed", {
code: pageStatus,
msg: "Problem loading the link",
data: {
url: document.URL,
},
});
}
function runAllUiSetters(uiConfig: UiConfig) {
@ -517,3 +599,22 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
}
document.documentElement.style.colorScheme = colorScheme;
}
/**
* Apply configurations to the preloaded page and then ask parent to show the embed
* url has the config as params
*/
function connectPreloadedEmbed({ url }: { url: URL }) {
// TODO: Use a better way to detect that React has initialized. Currently, we are using setTimeout which is a hack.
const MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES = 700;
// It can be fired before React has initialized, so use embedStore.router(which is a nextRouter wrapper that supports a queue)
embedStore.router.goto(url.toString());
setTimeout(() => {
// Firing this event would stop the loader and show the embed
sdkActionManager?.fire("linkReady", {});
}, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES);
}
const isPrerendering = () => {
return new URL(document.URL).searchParams.get("prerender") === "true";
};

View File

@ -2,13 +2,14 @@
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe";
import css from "./embed.css";
import { SdkActionManager } from "./sdk-action-manager";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import allCss from "./tailwind.generated.css?inline";
import type { UiConfig, EmbedThemeConfig, BookerLayouts } from "./types";
import type { UiConfig } from "./types";
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never;
export type Message = {
@ -151,34 +152,14 @@ type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
export type Instruction = SingleInstruction | SingleInstruction[];
export type InstructionQueue = Instruction[];
/**
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
*/
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
iframeAttrs?: Record<string, string> & {
id?: string;
};
// TODO: It should have a dedicated prefill prop
// prefill: {},
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
// ui: {layout; theme}
layout?: BookerLayouts;
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
"ui.color-scheme"?: string;
theme?: EmbedThemeConfig;
};
export class Cal {
iframe?: HTMLIFrameElement;
__config: Config;
modalBox!: Element;
modalBox?: Element;
inlineEl!: Element;
inlineEl?: Element;
namespace: string;
@ -190,6 +171,8 @@ export class Cal {
api: CalApi;
isPerendering?: boolean;
static actionsManagers: Record<Namespace, SdkActionManager>;
static getQueryObject(config: PrefillAndIframeAttrsConfig) {
@ -389,6 +372,9 @@ export class Cal {
});
this.actionManager.on("__routeChanged", () => {
if (!this.inlineEl) {
return;
}
const { top, height } = this.inlineEl.getBoundingClientRect();
// Try to readjust and scroll into view if more than 25% is hidden.
// Otherwise we assume that user might have positioned the content appropriately already
@ -398,6 +384,10 @@ export class Cal {
});
this.actionManager.on("linkReady", () => {
if (this.isPerendering) {
// Absolute check to ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed would showup without any user action
return;
}
this.modalBox?.setAttribute("state", "loaded");
this.inlineEl?.setAttribute("loading", "done");
});
@ -418,6 +408,8 @@ export class Cal {
class CalApi {
cal: Cal;
static initializedNamespaces = [] as string[];
modalUid?: string;
preloadedModalUid?: string;
constructor(cal: Cal) {
this.cal = cal;
}
@ -563,41 +555,71 @@ class CalApi {
modal({
calLink,
config = {},
uid,
__prerender = false,
}: {
calLink: string;
config?: PrefillAndIframeAttrsConfig;
uid?: string | number;
calOrigin?: string;
__prerender?: boolean;
}) {
uid = uid || 0;
const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0";
const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid;
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
existingModalEl.setAttribute("state", "started");
return;
const containerEl = document.body;
this.cal.isPerendering = !!__prerender;
if (__prerender) {
// Add preload query param
config.prerender = "true";
}
const queryObject = withColorScheme(Cal.getQueryObject(config), containerEl);
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
if (isConnectingToPreloadedModal) {
this.cal.doInIframe({
method: "connect",
arg: queryObject,
});
this.modalUid = uid;
existingModalEl.setAttribute("state", "loading");
return;
} else {
existingModalEl.setAttribute("state", "reopening");
return;
}
}
if (__prerender) {
this.preloadedModalUid = uid;
}
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
config.embedType = "modal";
const containerEl = document.body;
const iframe = this.cal.createIframe({
calLink,
queryObject: withColorScheme(Cal.getQueryObject(config), containerEl),
});
let iframe = null;
if (!iframe) {
iframe = this.cal.createIframe({
calLink,
queryObject,
});
}
iframe.style.borderRadius = "8px";
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
this.cal.modalBox.appendChild(iframe);
if (__prerender) {
this.cal.modalBox.setAttribute("state", "prerendering");
}
this.handleClose();
containerEl.appendChild(template.content);
}
@ -605,7 +627,7 @@ class CalApi {
private handleClose() {
// A request, to close from the iframe, should close the modal
this.cal.actionManager.on("__closeIframe", () => {
this.cal.modalBox.setAttribute("state", "closed");
this.cal.modalBox?.setAttribute("state", "closed");
});
}
@ -642,8 +664,24 @@ class CalApi {
}) {
this.cal.actionManager.off(action, callback);
}
preload({ calLink }: { calLink: string }) {
/**
*
* type is provided and prerenderIframe not set. We would assume prerenderIframe to be true
* type is provided and prerenderIframe set to false. We would ignore the type and preload assets only
* type is not provided and prerenderIframe set to true. We would throw error as we don't know what to prerender
* type is not provided and prerenderIframe set to false. We would preload assets only
*/
preload({
calLink,
type,
options = {},
}: {
calLink: string;
type?: "modal" | "floatingButton";
options?: {
prerenderIframe?: boolean;
};
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
@ -652,17 +690,58 @@ class CalApi {
type: "string",
required: true,
},
type: {
type: "string",
required: false,
},
options: {
type: Object,
required: false,
},
},
});
const iframe = document.body.appendChild(document.createElement("iframe"));
const config = this.cal.getConfig();
let api: GlobalCalWithoutNs = globalCal;
const namespace = this.cal.namespace;
if (namespace) {
api = globalCal.ns[namespace];
}
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
urlInstance.searchParams.set("prerender", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
if (!api) {
throw new Error(`Namespace ${namespace} isn't defined`);
}
const config = this.cal.getConfig();
let prerenderIframe = options.prerenderIframe;
if (type && prerenderIframe === undefined) {
prerenderIframe = true;
}
if (!type && prerenderIframe) {
throw new Error("You should provide 'type'");
}
if (prerenderIframe) {
if (type === "modal" || type === "floatingButton") {
this.cal.isPerendering = true;
this.modal({
calLink,
calOrigin: config.calOrigin,
__prerender: true,
});
} else {
console.warn("Ignoring - full preload for inline embed and instead preloading assets only");
preloadAssetsForCalLink({ calLink, config });
}
} else {
preloadAssetsForCalLink({ calLink, config });
}
}
prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) {
this.preload({
calLink,
type,
});
}
ui(uiConfig: UiConfig) {
@ -755,7 +834,6 @@ document.addEventListener("click", (e) => {
return;
}
const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now()));
const namespace = targetEl.dataset.calNamespace;
const configString = targetEl.dataset.calConfig || "";
const calOrigin = targetEl.dataset.calOrigin || "";
@ -779,7 +857,6 @@ document.addEventListener("click", (e) => {
api("modal", {
calLink: path,
config,
uid: modalUniqueId,
calOrigin,
});
});
@ -812,3 +889,14 @@ function getEmbedApiFn(ns: string) {
}
return api;
}
function preloadAssetsForCalLink({ config, calLink }: { config: Config; calLink: string }) {
const iframe = document.body.appendChild(document.createElement("iframe"));
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
urlInstance.searchParams.set("preload", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
}

View File

@ -6,8 +6,7 @@ import { test } from "@calcom/web/playwright/lib/fixtures";
test.describe("Inline Embed", () => {
test("should verify that the iframe got created with correct URL", async ({
page,
getActionFiredDetails,
addEmbedListeners,
embeds: { getActionFiredDetails, addEmbedListeners },
}) => {
//TODO: Do it with page.goto automatically
await addEmbedListeners("");

View File

@ -159,7 +159,8 @@ expect.extend({
//TODO: Move it to testUtil, so that it doesn't need to be passed
// eslint-disable-next-line
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
expectedUrlDetails: ExpectedUrlDetails = {}
expectedUrlDetails: ExpectedUrlDetails = {},
isPrerendered?: boolean
) {
if (!iframe || !iframe.url) {
return {
@ -169,14 +170,7 @@ expect.extend({
}
const u = new URL(iframe.url());
const frameElement = await iframe.frameElement();
if (!(await frameElement.isVisible())) {
return {
pass: false,
message: () => `Expected iframe to be visible`,
};
}
const pathname = u.pathname;
const expectedPathname = `${expectedUrlDetails.pathname}/embed`;
if (expectedPathname && expectedPathname !== pathname) {
@ -206,20 +200,41 @@ expect.extend({
};
}
}
let iframeReadyCheckInterval;
const frameElement = await iframe.frameElement();
if (isPrerendered) {
if (await frameElement.isVisible()) {
return {
pass: false,
message: () => `Expected prerender iframe to be not visible`,
};
}
return {
pass: true,
message: () => `is prerendered`,
};
}
const iframeReadyEventDetail = await new Promise(async (resolve) => {
iframeReadyCheckInterval = setInterval(async () => {
const iframeReadyCheckInterval = setInterval(async () => {
const iframeReadyEventDetail = await getActionFiredDetails({
calNamespace,
actionType: "linkReady",
});
if (iframeReadyEventDetail) {
clearInterval(iframeReadyCheckInterval);
resolve(iframeReadyEventDetail);
}
}, 500);
});
clearInterval(iframeReadyCheckInterval);
if (!(await frameElement.isVisible())) {
return {
pass: false,
message: () => `Expected iframe to be visible`,
};
}
//At this point we know that window.initialBodyVisibility would be set as DOM would already have been ready(because linkReady event can only fire after that)
const {