diff --git a/apps/web/playwright/fixtures/embeds.ts b/apps/web/playwright/fixtures/embeds.ts index 35e33ae21a..5c867dcfbe 100644 --- a/apps/web/playwright/fixtures/embeds.ts +++ b/apps/web/playwright/fixtures/embeds.ts @@ -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); + }, }; }; diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 3d8fb05490..2c9cb71216 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -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; bookings: ReturnType; payments: ReturnType; - addEmbedListeners: ReturnType; - getActionFiredDetails: ReturnType; + embeds: ReturnType; servers: ReturnType; prisma: typeof prisma; emails?: API; @@ -36,7 +35,8 @@ declare global { calNamespace: string, // eslint-disable-next-line getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise, - expectedUrlDetails?: ExpectedUrlDetails + expectedUrlDetails?: ExpectedUrlDetails, + isPrendered?: boolean ): Promise; } } @@ -58,14 +58,10 @@ export const test = base.extend({ 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); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 7279d39d8f..f401dca0f9 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -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, 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"]'); diff --git a/packages/embeds/.eslintrc.js b/packages/embeds/.eslintrc.js index 59165894d4..62e45fcb34 100644 --- a/packages/embeds/.eslintrc.js +++ b/packages/embeds/.eslintrc.js @@ -1,3 +1,4 @@ +/** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["../../.eslintrc.js"], rules: { diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index d62d51ee2a..c4c28ca0ce 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -85,20 +85,30 @@ With Dark Color Scheme for the Page Non responsive version of this page here Go to Pre-render test page onlyGo to Prerender test page only + Go to Preload test page only
@@ -110,6 +120,7 @@ Floating Popup

Popup Examples

+ diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index 6f66de976f..a9776cf934 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -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: { diff --git a/packages/embeds/embed-core/playwright/lib/testUtils.ts b/packages/embeds/embed-core/playwright/lib/testUtils.ts index 41927b7666..23a5fc996c 100644 --- a/packages/embeds/embed-core/playwright/lib/testUtils.ts +++ b/packages/embeds/embed-core/playwright/lib/testUtils.ts @@ -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); diff --git a/packages/embeds/embed-core/playwright/tests/action-based.e2e.ts b/packages/embeds/embed-core/playwright/tests/action-based.e2e.ts index 223b80d81e..7bf0e5fa3c 100644 --- a/packages/embeds/embed-core/playwright/tests/action-based.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/action-based.e2e.ts @@ -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 + ); +} diff --git a/packages/embeds/embed-core/playwright/tests/inline.e2e.ts b/packages/embeds/embed-core/playwright/tests/inline.e2e.ts index 6109aeb53b..7c32ceefaf 100644 --- a/packages/embeds/embed-core/playwright/tests/inline.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/inline.e2e.ts @@ -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(""); diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts index 57a6dd5da9..f462fef425 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts @@ -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(".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(); } } diff --git a/packages/embeds/embed-core/src/embed-iframe.ts b/packages/embeds/embed-core/src/embed-iframe.ts index 7671baf5b1..bb5cb8c655 100644 --- a/packages/embeds/embed-core/src/embed-iframe.ts +++ b/packages/embeds/embed-core/src/embed-iframe.ts @@ -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>; type setNonStylesConfig = React.Dispatch>; +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> & { + // TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app. + iframeAttrs?: Record & { + 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) { + 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, + 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"; +}; diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index cc051bd542..df9f27e2e5 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -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, ...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> & { - // TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app. - iframeAttrs?: Record & { - 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; 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 = ``; - 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"; +} diff --git a/packages/embeds/embed-react/playwright/tests/basic.e2e.ts b/packages/embeds/embed-react/playwright/tests/basic.e2e.ts index 8eabd97e2c..3ecffad394 100644 --- a/packages/embeds/embed-react/playwright/tests/basic.e2e.ts +++ b/packages/embeds/embed-react/playwright/tests/basic.e2e.ts @@ -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(""); diff --git a/playwright.config.ts b/playwright.config.ts index 0317a1b6f7..86a5c6593f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, - 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 {